# Standard library imports. from pathlib import Path from urllib.parse import urlencode from functools import cmp_to_key # Django imports. from django.http import HttpResponseNotFound from django.conf import settings from django.contrib.auth.decorators import login_required from django.shortcuts import render, redirect # Project imports. from .utils import make_thumbnail, is_image_file from PIL import Image import datetime ########################################################################################### # Constants. # ########################################################################################### SORT_OPTIONS = [ ("abc", "Alphabetical A-Z"), ("cba", "Alphabetical Z-A"), ("old", "Creation date old to new"), ("new", "Creation date new to old"), ("recent", "Modification date most recent"), ("tnecer", "Modification date least recent"), ] SORT_LABELS = dict(SORT_OPTIONS) ########################################################################################### # Helper functions. # ########################################################################################### def normalize_sort(sort_value): return sort_value if sort_value in SORT_LABELS else "abc" def normalize_theme(theme_value): return theme_value if theme_value in ("dark", "light") else "dark" def get_creation_timestamp(path_obj): try: stat_data = path_obj.stat() return getattr(stat_data, "st_birthtime", stat_data.st_ctime) except OSError: return 0 def get_modification_timestamp(path_obj): try: return path_obj.stat().st_mtime except OSError: return 0 def build_query(search_text, sort_key, theme): query = {"sort": sort_key, "theme": theme} if search_text != "": query["search"] = search_text return query def append_query(url, query_dict): if len(query_dict) == 0: return url return url + "?" + urlencode(query_dict) def gallery_url(path_obj=None, is_dir=False, query_dict=None): if query_dict is None: query_dict = {} if path_obj is None: base_url = "/gallery/" else: path_text = str(path_obj).replace("\\", "/") base_url = "/gallery/" + path_text if is_dir and not base_url.endswith("/"): base_url += "/" return append_query(base_url, query_dict) def do_recursive_search(start_path, query): """ Gets all images and sub-directories inside the start_path whose name matches the given query, and then joins the results recursively by iterating in each sub-directory. """ try: entries = [entry for entry in start_path.iterdir() if not entry.is_symlink()] except OSError: return [], [] all_subdirs = sorted( [entry for entry in entries if entry.is_dir()], key=lambda item: (item.name.lower(), str(item).lower()), ) subdirs = [entry for entry in all_subdirs if query.lower() in entry.name.lower()] images = sorted( [ entry for entry in entries if entry.is_file() and is_image_file(entry) and query.lower() in entry.name.lower() ], key=lambda item: (item.name.lower(), str(item).lower()), ) for subdir in all_subdirs: rec_subdirs, rec_images = do_recursive_search(subdir, query.lower()) subdirs.extend(rec_subdirs) images.extend(rec_images) return subdirs, images def sort_images(images, sort_key): def compare(img_a, img_b): name_a = img_a.name.lower() name_b = img_b.name.lower() rel_a = str(img_a.relative_to(settings.GALLERY_ROOT)).lower() rel_b = str(img_b.relative_to(settings.GALLERY_ROOT)).lower() if sort_key == "abc": if name_a < name_b: return -1 if name_a > name_b: return 1 elif sort_key == "cba": if name_a > name_b: return -1 if name_a < name_b: return 1 elif sort_key == "old": created_a = get_creation_timestamp(img_a) created_b = get_creation_timestamp(img_b) if created_a < created_b: return -1 if created_a > created_b: return 1 elif sort_key == "new": created_a = get_creation_timestamp(img_a) created_b = get_creation_timestamp(img_b) if created_a > created_b: return -1 if created_a < created_b: return 1 elif sort_key == "recent": modified_a = get_modification_timestamp(img_a) modified_b = get_modification_timestamp(img_b) if modified_a > modified_b: return -1 if modified_a < modified_b: return 1 elif sort_key == "tnecer": modified_a = get_modification_timestamp(img_a) modified_b = get_modification_timestamp(img_b) if modified_a < modified_b: return -1 if modified_a > modified_b: return 1 if name_a < name_b: return -1 if name_a > name_b: return 1 if rel_a < rel_b: return -1 if rel_a > rel_b: return 1 return 0 return sorted(images, key=cmp_to_key(compare)) def build_breadcrumbs(path_text, query_dict): breadcrumbs = [{"label": "Gallery", "path": gallery_url(None, True, query_dict)}] if path_text == "": return breadcrumbs segments = Path(path_text).parts current = Path("") for segment in segments: current = current.joinpath(segment) breadcrumbs.append( {"label": segment, "path": gallery_url(current, True, query_dict)} ) return breadcrumbs def build_sort_options(request, search_text, sort_key, theme): options = [] for option_key, label in SORT_OPTIONS: query = {"sort": option_key, "theme": theme} if search_text != "": query["search"] = search_text options.append( { "key": option_key, "label": label, "url": append_query(request.path, query), "is_active": option_key == sort_key, } ) return options def get_first_image_thumbnail_url(subdir): try: images = sorted( [ entry for entry in subdir.iterdir() if entry.is_file() and not entry.is_symlink() and is_image_file(entry) ], key=lambda item: ( item.name.lower(), str(item.relative_to(settings.GALLERY_ROOT)).lower(), ), ) except OSError: return None if len(images) == 0: return None first_image = images[0] try: make_thumbnail(first_image) rel_path = first_image.relative_to(settings.GALLERY_ROOT) thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path) if thumb_path.exists(): return "/thumbs/" + str(rel_path).replace("\\", "/") except Exception: pass return None ########################################################################################### # View functions. # ########################################################################################### @login_required def index(request): return redirect("gallery_view_root") def _render_directory(request, path_text, full_path): """ Renders the gallery view related to directories, be it the contents of an actual directory in the file system, or logical gallery directories like search result pages. """ search_text = request.GET.get("search", "").strip() sort_key = normalize_sort(request.GET.get("sort", "abc")) theme = normalize_theme(request.GET.get("theme", "dark")) query_state = build_query(search_text, sort_key, theme) try: current_entries = [ entry for entry in full_path.iterdir() if not entry.is_symlink() ] except OSError: return HttpResponseNotFound("Not found") current_subdirs = sorted( [entry for entry in current_entries if entry.is_dir()], key=lambda item: ( item.name.lower(), str(item.relative_to(settings.GALLERY_ROOT)).lower(), ), ) if search_text == "": images = [ entry for entry in current_entries if entry.is_file() and is_image_file(entry) ] else: _, images = do_recursive_search(full_path, search_text) images = sort_images(images, sort_key) image_data = [] for image in images: rel_path = image.relative_to(settings.GALLERY_ROOT) image_url = gallery_url(rel_path, False, query_state) thumbnail = None try: make_thumbnail(image) thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path) if thumb_path.exists(): thumbnail = "/thumbs/" + str(rel_path).replace("\\", "/") except Exception: pass image_data.append( {"path": image_url, "name": image.name, "thumbnail": thumbnail} ) subdir_data = [] for subdir in current_subdirs: rel_path = subdir.relative_to(settings.GALLERY_ROOT) subdir_data.append( { "path": gallery_url(rel_path, True, query_state), "name": subdir.name, "thumbnail": get_first_image_thumbnail_url(subdir), } ) next_theme = "light" if theme == "dark" else "dark" theme_query = {"sort": sort_key, "theme": next_theme} if search_text != "": theme_query["search"] = search_text search_action_query = {"sort": sort_key, "theme": theme} context = { "path": path_text, "search_text": search_text, "theme": theme, "sort_key": sort_key, "sort_label": SORT_LABELS[sort_key], "sort_options": build_sort_options(request, search_text, sort_key, theme), "breadcrumbs": build_breadcrumbs(path_text, query_state), "images": image_data, "subdirs": subdir_data, "theme_toggle_url": append_query(request.path, theme_query), "search_action_url": append_query(request.path, search_action_query), } return render(request, "gallery_view.html", context) def _render_image(request, path_text, full_path): """ Renders the view corresponding to an image file. """ # Preserve query state (sort, search, theme) similar to gallery view search_text = request.GET.get("search", "").strip() sort_key = normalize_sort(request.GET.get("sort", "abc")) theme = normalize_theme(request.GET.get("theme", "dark")) query_state = build_query(search_text, sort_key, theme) image = Path("/imgs/").joinpath(path_text) img_dir = full_path.parent try: entries = [ entry for entry in img_dir.iterdir() if entry.is_file() and not entry.is_symlink() and is_image_file(entry) ] except OSError: return HttpResponseNotFound("Not found") # Sort siblings according to requested sort mode images_sorted = sort_images(entries, sort_key) # Find index of current image try: index = next(i for i, p in enumerate(images_sorted) if p.name == full_path.name) except StopIteration: return HttpResponseNotFound("Not found") prev_path = images_sorted[index - 1] if index > 0 else None next_path = images_sorted[index + 1] if index < len(images_sorted) - 1 else None # Helper to produce thumb url for a Path or None def thumb_for(path_obj): if path_obj is None: return None try: make_thumbnail(path_obj) rel = path_obj.relative_to(settings.GALLERY_ROOT) thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel) if thumb_path.exists(): return "/thumbs/" + str(rel).replace("\\", "/") except Exception: pass return None # Build URLs (preserving query state) prev_url = None next_url = None if prev_path is not None: rel = prev_path.relative_to(settings.GALLERY_ROOT) prev_url = gallery_url(rel, False, query_state) if next_path is not None: rel = next_path.relative_to(settings.GALLERY_ROOT) next_url = gallery_url(rel, False, query_state) # Back (directory) and Home (root) links and thumbnails dir_rel = None try: # derive directory path text relative to gallery root dir_rel = full_path.parent.relative_to(settings.GALLERY_ROOT) dir_text = str(dir_rel).replace("\\", "/") except Exception: dir_text = "" back_url = gallery_url( Path(dir_text) if dir_text != "" else None, True, query_state ) back_thumb = get_first_image_thumbnail_url(full_path.parent) home_url = gallery_url(None, True, query_state) home_thumb = get_first_image_thumbnail_url(settings.GALLERY_ROOT) # Prev/next thumbnails prev_thumb = thumb_for(prev_path) next_thumb = thumb_for(next_path) # Image metadata width = height = None filesize = None created_ts = None modified_ts = None try: img_file = full_path with Image.open(img_file) as im: width, height = im.size except Exception: pass try: stat = full_path.stat() filesize = stat.st_size created_ts = getattr(stat, "st_birthtime", stat.st_ctime) modified_ts = stat.st_mtime except Exception: pass def human_size(bytes_val): if bytes_val is None: return None kb = 1024.0 if bytes_val < kb * 1024: return f"{bytes_val / kb:.2f} KB" return f"{bytes_val / (kb * kb):.2f} MB" def fmt_ts(ts): if ts is None: return None try: return datetime.datetime.fromtimestamp(ts).strftime("%c") except Exception: return None # Breadcrumbs: include directory breadcrumbs then append file name as label-only # build_breadcrumbs expects a path_text representing directories only dir_path_text = dir_text if dir_text != "" else "" breadcrumbs = build_breadcrumbs(dir_path_text, query_state) breadcrumbs.append({"label": full_path.name, "path": None}) next_theme = "light" if theme == "dark" else "dark" theme_query = {"sort": sort_key, "theme": next_theme} if search_text != "": theme_query["search"] = search_text context = { "image_path": image, # keep legacy prev/next names for tests "prev": prev_path.name if prev_path is not None else None, "next": next_path.name if next_path is not None else None, # new richer values "prev_url": prev_url, "next_url": next_url, "prev_thumb": prev_thumb, "next_thumb": next_thumb, "back_url": back_url, "back_thumb": back_thumb, "home_url": home_url, "home_thumb": home_thumb, "image_meta": { "filename": full_path.name, "width": width, "height": height, "filesize": human_size(filesize), "created": fmt_ts(created_ts), "modified": fmt_ts(modified_ts), }, "breadcrumbs": breadcrumbs, "theme": theme, "sort_key": sort_key, "sort_label": SORT_LABELS[sort_key], "sort_options": build_sort_options(request, search_text, sort_key, theme), "theme_toggle_url": append_query(request.path, theme_query), } return render(request, "image_view.html", context) @login_required def gallery_view(request, path=None): """ Shows a list of subdirectories and image files inside the given path. The path should be inside the GALLERY_ROOT path, otherwise a 404 error will be thrown. """ root = settings.GALLERY_ROOT.resolve() try: candidate = root.joinpath(path).resolve() if path is not None else root candidate.relative_to(root) except Exception: return HttpResponseNotFound("Not found") if not candidate.exists(): return HttpResponseNotFound("Not found") path_text = path if path is not None else "" if candidate.is_dir(): return _render_directory(request, path_text, candidate) return _render_image(request, path_text, candidate)