4 Commits

Author SHA1 Message Date
cfe1585c39 added exception to log 2025-10-27 15:05:10 +01:00
57b6b56388 fixed TypeError 2025-10-27 12:06:14 +01:00
2c6f8fbcb7 move thumbdir declare 2025-10-27 11:12:03 +01:00
30eb1ed8df added metadata dataclass 2025-10-27 09:51:00 +01:00
5 changed files with 221 additions and 61 deletions

View File

@@ -1 +1 @@
2.8.6 2.9.0

View File

@@ -37,8 +37,8 @@ NOT_LIST = ["*/Galleries/*", "Archives"]
args = parse_arguments(VERSION) args = parse_arguments(VERSION)
lock_file = os.path.join(args.root_directory, ".lock") LOCKFILE = os.path.join(args.root_directory, ".lock")
if os.path.exists(lock_file): if os.path.exists(LOCKFILE):
print("Another instance of this program is running.") print("Another instance of this program is running.")
sys.exit() sys.exit()
else: else:
@@ -168,12 +168,13 @@ def main(args) -> None:
""" """
Main function to process images and generate a static image hosting website. Main function to process images and generate a static image hosting website.
""" """
thumbnails: list[tuple[str, str, str, bool]] = [] thumbnails: list[tuple[str, str, str]] = []
args, raw = init_globals(args, RAW_EXTENSIONS) args, raw = init_globals(args, RAW_EXTENSIONS)
thumbdir = os.path.join(args.root_directory, ".thumbnails")
try: try:
Path(lock_file).touch() Path(LOCKFILE).touch()
logger.info("starting builder", extra={"version": VERSION, "arguments": args}) logger.info("starting builder", extra={"version": VERSION, "arguments": args})
logger.info("getting logo from sorogon.eu") logger.info("getting logo from sorogon.eu")
@@ -195,10 +196,10 @@ def main(args) -> None:
logger.warning("reread metadata flag is set to true, all image metadata will be reread") logger.warning("reread metadata flag is set to true, all image metadata will be reread")
if args.regenerate_thumbnails: if args.regenerate_thumbnails:
logger.warning("regenerate thumbnails flag is set to true, all thumbnails will be regenerated") logger.warning("regenerate thumbnails flag is set to true, all thumbnails will be regenerated")
if os.path.exists(os.path.join(args.root_directory, ".thumbnails")): if os.path.exists(thumbdir):
logger.info("removing old thumbnails folder") logger.info("removing old thumbnails folder")
shutil.rmtree(os.path.join(args.root_directory, ".thumbnails")) shutil.rmtree(thumbdir)
os.makedirs(os.path.join(args.root_directory, ".thumbnails"), exist_ok=True) os.makedirs(thumbdir, exist_ok=True)
copy_static_files(args) copy_static_files(args)
icons(args) icons(args)
@@ -229,8 +230,11 @@ def main(args) -> None:
dynamic_ncols=True, dynamic_ncols=True,
): ):
pass pass
except Exception as e:
logger.critical("an unhandled exception occurred: %s", str(e), exc_info=True)
print(f"An unhandled exception occurred: {str(e)}")
finally: finally:
os.remove(lock_file) os.remove(LOCKFILE)
logger.info("finished builder", extra={"version": VERSION}) logger.info("finished builder", extra={"version": VERSION})

View File

@@ -95,7 +95,8 @@ def css_color_to_hex(css_color: str) -> str:
# Helper function to convert HSL tuple to RGB tuple # Helper function to convert HSL tuple to RGB tuple
def hsl_to_rgb(hsl: tuple[int, float, float]) -> tuple[int, int, int]: def hsl_to_rgb(hsl: tuple[int, float, float]) -> tuple[int, int, int]:
logger.debug("converting hsl tuple to rgb tuple", extra={"hsl": hsl}) logger.debug("converting hsl tuple to rgb tuple", extra={"hsl": hsl})
return tuple(round(c * 255) for c in colorsys.hls_to_rgb(hsl[0] / 360, hsl[1] / 100, hsl[2] / 100)) r, g, b = colorsys.hls_to_rgb(hsl[0] / 360, hsl[1] / 100, hsl[2] / 100)
return (round(r * 255), round(g * 255), round(b * 255))
# Regular expression pattern to match CSS colors # Regular expression pattern to match CSS colors
color_pattern = re.compile( color_pattern = re.compile(

View File

@@ -0,0 +1,157 @@
from dataclasses import dataclass
from typing import List, Optional, Any, Dict, TypeVar, Callable, Type, cast
T = TypeVar("T")
def from_int(x: Any) -> int:
assert isinstance(x, int) and not isinstance(x, bool)
return x
def from_str(x: Any) -> str:
assert isinstance(x, str)
return x
def from_list(f: Callable[[Any], T], x: Any) -> List[T]:
assert isinstance(x, list)
return [f(y) for y in x]
def from_none(x: Any) -> Any:
assert x is None
return x
def from_union(fs, x):
for f in fs:
try:
return f(x)
except AssertionError:
pass
assert False
def to_class(c: Type[T], x: Any) -> dict:
assert isinstance(x, c)
return cast(Any, x).to_dict()
def from_dict(f: Callable[[Any], T], x: Any) -> Dict[str, T]:
assert isinstance(x, dict)
return {k: f(v) for (k, v) in x.items()}
def from_native_dict(f: Callable[[Any], T], x: Any) -> Dict[Any, T]:
assert isinstance(x, dict)
return x
@dataclass
class ImageMetadata:
w: Optional[int]
h: Optional[int]
tags: List[str]
exifdata: Optional[Dict[str, Any]]
xmp: Optional[Dict[str, Any]]
src: str
msrc: str
name: str
title: str
tiff: Optional[str] = None
raw: Optional[str] = None
@staticmethod
def from_dict(obj: Any) -> "ImageMetadata":
assert isinstance(obj, dict)
w = from_union([from_int, from_none], obj.get("w"))
h = from_union([from_int, from_none], obj.get("h"))
tags = from_list(from_str, obj.get("tags"))
exifdata = from_union([lambda x: from_native_dict(dict, x), from_none], obj.get("exifdata"))
xmp = from_union([lambda x: from_native_dict(dict, x), from_none], obj.get("xmp"))
src = from_str(obj.get("src"))
msrc = from_str(obj.get("msrc"))
name = from_str(obj.get("name"))
title = from_str(obj.get("title"))
tiff = from_union([from_str, from_none], obj.get("tiff"))
raw = from_union([from_str, from_none], obj.get("raw"))
return ImageMetadata(w, h, tags, exifdata, xmp, src, msrc, name, title, tiff, raw)
def to_dict(self) -> dict:
result: dict = {}
if self.w is not None:
result["w"] = from_union([from_int, from_none], self.w)
if self.h is not None:
result["h"] = from_union([from_int, from_none], self.h)
result["tags"] = from_list(from_str, self.tags)
result["src"] = from_str(self.src)
result["msrc"] = from_str(self.msrc)
result["name"] = from_str(self.name)
result["title"] = from_str(self.title)
if self.tiff is not None:
result["tiff"] = from_union([from_str, from_none], self.tiff)
if self.raw is not None:
result["raw"] = from_union([from_str, from_none], self.raw)
if self.exifdata is not None:
result["exifdata"] = from_union([lambda x: from_native_dict(dict, x), from_none], self.exifdata)
if self.xmp is not None:
result["xmp"] = from_union([lambda x: from_native_dict(dict, x), from_none], self.xmp)
return result
@dataclass
class SubfolderMetadata:
url: str
name: str
metadata: Optional[str] = None
thumb: Optional[str] = None
@staticmethod
def from_dict(obj: Any) -> "SubfolderMetadata":
assert isinstance(obj, dict)
url = from_str(obj.get("url"))
name = from_str(obj.get("name"))
metadata = from_union([from_none, from_str], obj.get("metadata"))
thumb = from_union([from_none, from_str], obj.get("thumb"))
return SubfolderMetadata(url, name, metadata, thumb)
def to_dict(self) -> dict:
result: dict = {}
result["url"] = from_str(self.url)
result["name"] = from_str(self.name)
result["metadata"] = from_union([from_none, from_str], self.metadata)
result["thumb"] = from_union([from_none, from_str], self.thumb)
return result
@dataclass
class Metadata:
images: Dict[str, ImageMetadata]
subfolders: Optional[List[SubfolderMetadata]] = None
@staticmethod
def from_dict(obj: Any) -> "Metadata":
assert isinstance(obj, dict)
images = from_dict(ImageMetadata.from_dict, obj.get("images"))
subfolders = from_union([lambda x: from_list(SubfolderMetadata.from_dict, x), from_none], obj.get("subfolders"))
return Metadata(images, subfolders)
def to_dict(self) -> dict:
result: dict = {}
result["images"] = from_dict(lambda x: to_class(ImageMetadata, x), self.images)
if self.subfolders is not None:
result["subfolders"] = from_union([lambda x: from_list(lambda x: to_class(SubfolderMetadata, x), x), from_none], self.subfolders)
return result
def sort(self, reverse=False) -> "Metadata":
self.images = {key: self.images[key] for key in sorted(self.images, reverse=reverse)}
return self
def top_level_from_dict(s: Any) -> Metadata:
return Metadata.from_dict(s)
def top_level_to_dict(x: Metadata) -> Any:
return to_class(Metadata, x)

View File

@@ -17,6 +17,7 @@ from bs4 import BeautifulSoup
from modules.logger import logger from modules.logger import logger
from modules import cclicense from modules import cclicense
from modules.argumentparser import Args from modules.argumentparser import Args
from modules.datatypes.metadata import Metadata, ImageMetadata, SubfolderMetadata
# Constants for file paths and exclusions # Constants for file paths and exclusions
if __package__ is None: if __package__ is None:
@@ -73,7 +74,7 @@ def getxmp(strbuffer: str) -> dict[str, Any]:
return {get_name(root.tag): get_value(root)} return {get_name(root.tag): get_value(root)}
def initialize_metadata(folder: str) -> dict[str, dict[str, int]]: def initialize_metadata(folder: str) -> Metadata:
""" """
Initializes the metadata JSON file if it doesn't exist. Initializes the metadata JSON file if it doesn't exist.
@@ -128,10 +129,10 @@ def initialize_metadata(folder: str) -> dict[str, dict[str, int]]:
if "title" not in v: if "title" not in v:
metadata["images"][k]["title"] = v["name"] metadata["images"][k]["title"] = v["name"]
return metadata return Metadata.from_dict(metadata)
def update_metadata(metadata: dict[str, dict[str, Any]], folder: str) -> None: def update_metadata(metadata: Metadata, folder: str) -> None:
""" """
Updates the metadata JSON file. Updates the metadata JSON file.
@@ -143,14 +144,14 @@ def update_metadata(metadata: dict[str, dict[str, Any]], folder: str) -> None:
if metadata: if metadata:
with open(metadata_path, "w", encoding="utf-8") as metadatafile: with open(metadata_path, "w", encoding="utf-8") as metadatafile:
logger.info("writing metadata file", extra={"file": metadata_path}) logger.info("writing metadata file", extra={"file": metadata_path})
metadatafile.write(json.dumps(metadata, indent=4)) metadatafile.write(json.dumps(metadata.to_dict(), indent=4))
else: else:
if os.path.exists(metadata_path): if os.path.exists(metadata_path):
logger.info("deleting empty metadata file", extra={"file": metadata_path}) logger.info("deleting empty metadata file", extra={"file": metadata_path})
os.remove(metadata_path) os.remove(metadata_path)
def get_image_info(item: str, folder: str) -> dict[str, Any]: def get_image_info(item: str, folder: str) -> ImageMetadata:
""" """
Extracts image information and EXIF data. Extracts image information and EXIF data.
@@ -172,7 +173,7 @@ def get_image_info(item: str, folder: str) -> dict[str, Any]:
except UnidentifiedImageError: except UnidentifiedImageError:
logger.error("cannot identify image file", extra={"file": file}) logger.error("cannot identify image file", extra={"file": file})
print(f"cannot identify image file: {file}") print(f"cannot identify image file: {file}")
return {"w": None, "h": None, "tags": None, "exifdata": None, "xmp": None} return ImageMetadata(w=None, h=None, tags=[], exifdata=None, xmp=None, src="", msrc="", name="", title="")
if exif: if exif:
logger.info("extracting EXIF data", extra={"file": file}) logger.info("extracting EXIF data", extra={"file": file})
ifd = exif.get_ifd(ExifTags.IFD.Exif) ifd = exif.get_ifd(ExifTags.IFD.Exif)
@@ -253,9 +254,11 @@ def get_image_info(item: str, folder: str) -> dict[str, Any]:
tags = get_tags(sidecarfile) tags = get_tags(sidecarfile)
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
if None in tags: if None in tags: # type: ignore
tags.remove(None) tags.remove(None) # type: ignore
return {"w": width, "h": height, "tags": tags, "exifdata": exifdata, "xmp": xmp} if not isinstance(tags, list):
tags = []
return ImageMetadata(w=width, h=height, tags=tags, exifdata=exifdata, xmp=xmp, src="", msrc="", name="", title="")
def nested_dict(): def nested_dict():
@@ -331,12 +334,12 @@ def get_tags(sidecarfile: str) -> list[str]:
pass pass
except KeyError: except KeyError:
pass pass
if None in tags: if None in tags: # type: ignore
tags.remove(None) tags.remove(None) # type: ignore
return tags return tags # type: ignore
def process_image(item: str, folder: str, _args: Args, baseurl: str, metadata: dict[str, dict[str, int]], raw: list[str]) -> dict[str, Any]: def process_image(item: str, folder: str, _args: Args, baseurl: str, metadata: Metadata, raw: list[str]) -> tuple[ImageMetadata, Metadata]:
""" """
Processes an image and prepares its data for the HTML template. Processes an image and prepares its data for the HTML template.
@@ -353,24 +356,21 @@ def process_image(item: str, folder: str, _args: Args, baseurl: str, metadata: d
""" """
extsplit = os.path.splitext(item) extsplit = os.path.splitext(item)
sidecarfile = os.path.join(folder, item + ".xmp") sidecarfile = os.path.join(folder, item + ".xmp")
if item not in metadata["images"] or _args.reread_metadata: if item not in metadata.images or _args.reread_metadata:
metadata["images"][item] = get_image_info(item, folder) metadata.images[item] = get_image_info(item, folder)
if _args.reread_sidecar and os.path.exists(sidecarfile): if _args.reread_sidecar and os.path.exists(sidecarfile):
logger.info("xmp sidecar file found", extra={"file": sidecarfile}) logger.info("xmp sidecar file found", extra={"file": sidecarfile})
try: try:
metadata["images"][item]["tags"] = get_tags(sidecarfile) metadata.images[item].tags = get_tags(sidecarfile)
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
image = { image = metadata.images[item]
"src": f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}", image.src = f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}"
"msrc": f"{_args.web_root_url}.thumbnails/{baseurl}{urllib.parse.quote(item)}.jpg", image.msrc = f"{_args.web_root_url}.thumbnails/{baseurl}{urllib.parse.quote(item)}.jpg"
"name": item, image.name = item
"w": metadata["images"][item]["w"], image.title = item
"h": metadata["images"][item]["h"],
"tags": metadata["images"][item]["tags"],
"title": item,
}
path = os.path.join(_args.root_directory, ".thumbnails", baseurl, item + ".jpg") path = os.path.join(_args.root_directory, ".thumbnails", baseurl, item + ".jpg")
if not os.path.exists(path) or _args.regenerate_thumbnails: if not os.path.exists(path) or _args.regenerate_thumbnails:
if os.path.exists(path): if os.path.exists(path):
@@ -383,17 +383,17 @@ def process_image(item: str, folder: str, _args: Args, baseurl: str, metadata: d
url = f"{_args.web_root_url}{baseurl}{urllib.parse.quote(extsplit[0])}{_raw}" url = f"{_args.web_root_url}{baseurl}{urllib.parse.quote(extsplit[0])}{_raw}"
if _raw in (".tif", ".tiff"): if _raw in (".tif", ".tiff"):
logger.info("tiff file found", extra={"file": file}) logger.info("tiff file found", extra={"file": file})
image["tiff"] = url image.tiff = url
else: else:
logger.info("raw file found", extra={"file": file, "extension": _raw}) logger.info("raw file found", extra={"file": file, "extension": _raw})
image["raw"] = url image.raw = url
metadata["images"][item].update(image) metadata.images[item] = image
return image, metadata return image, metadata
def generate_html(folder: str, title: str, _args: Args, raw: list[str], version: str, logo: str) -> list[str]: def generate_html(folder: str, title: str, _args: Args, raw: list[str], version: str, logo: str) -> set[str]:
""" """
Generates HTML content for a folder of images. Generates HTML content for a folder of images.
@@ -412,16 +412,16 @@ def generate_html(folder: str, title: str, _args: Args, raw: list[str], version:
items = sorted(os.listdir(folder)) items = sorted(os.listdir(folder))
contains_files = False contains_files = False
images = [] images: list[ImageMetadata] = []
subfolders = [] subfolders: list[SubfolderMetadata] = []
subfoldertags = set() subfoldertags: set[str] = set()
foldername = folder.removeprefix(_args.root_directory) foldername = folder.removeprefix(_args.root_directory)
foldername = f"{foldername}/" if foldername else "" foldername = f"{foldername}/" if foldername else ""
baseurl = urllib.parse.quote(foldername) baseurl = urllib.parse.quote(foldername)
gone = [item for item in metadata["images"] if item not in items] gone = [item for item in metadata.images if item not in items]
for gon in gone: for gon in gone:
del metadata["images"][gon] del metadata.images[gon]
create_thumbnail_folder(foldername, _args.root_directory) create_thumbnail_folder(foldername, _args.root_directory)
@@ -455,11 +455,11 @@ def generate_html(folder: str, title: str, _args: Args, raw: list[str], version:
if item == "LICENSE": if item == "LICENSE":
process_license(folder, item) process_license(folder, item)
metadata["subfolders"] = subfolders metadata.subfolders = subfolders
if _args.reverse_sort: if _args.reverse_sort:
metadata["images"] = {key: metadata["images"][key] for key in sorted(metadata["images"], reverse=True)} metadata.sort(reverse=True)
else: else:
metadata["images"] = {key: metadata["images"][key] for key in sorted(metadata["images"])} metadata.sort()
update_metadata(metadata, folder) update_metadata(metadata, folder)
if should_generate_html(images, contains_files, _args): if should_generate_html(images, contains_files, _args):
@@ -485,7 +485,7 @@ def create_thumbnail_folder(foldername: str, root_directory: str) -> None:
os.mkdir(thumbnails_path) os.mkdir(thumbnails_path)
def process_subfolder(item: str, folder: str, baseurl: str, subfolders: list[dict[str, str | None]], _args: Args, raw: list[str], version: str, logo: str) -> list[str]: def process_subfolder(item: str, folder: str, baseurl: str, subfolders: list[SubfolderMetadata], _args: Args, raw: list[str], version: str, logo: str) -> set[str]:
""" """
Processes a subfolder. Processes a subfolder.
@@ -513,10 +513,10 @@ def process_subfolder(item: str, folder: str, baseurl: str, subfolders: list[dic
if item not in _args.exclude_folders: if item not in _args.exclude_folders:
if not any(fnmatch.fnmatchcase(os.path.join(folder, item), exclude) for exclude in _args.exclude_folders): if not any(fnmatch.fnmatchcase(os.path.join(folder, item), exclude) for exclude in _args.exclude_folders):
subfolders.append({"url": subfolder_url, "name": item, "thumb": thumb, "metadata": f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}/.metadata.json"}) subfolders.append(SubfolderMetadata(url=subfolder_url, name=item, thumb=thumb, metadata=f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}/.metadata.json"))
return generate_html(os.path.join(folder, item), os.path.join(folder, item).removeprefix(_args.root_directory), _args, raw, version, logo) return generate_html(os.path.join(folder, item), os.path.join(folder, item).removeprefix(_args.root_directory), _args, raw, version, logo)
subfolders.append({"url": subfolder_url, "name": item, "thumb": thumb, "metadata": None}) subfolders.append(SubfolderMetadata(url=subfolder_url, name=item, thumb=thumb))
return [] return set()
def process_license(folder: str, item: str) -> None: def process_license(folder: str, item: str) -> None:
@@ -548,7 +548,7 @@ def process_info_file(folder: str, item: str) -> None:
info[urllib.parse.quote(folder)] = f.read() info[urllib.parse.quote(folder)] = f.read()
def should_generate_html(images: list[dict[str, Any]], contains_files, _args: Args) -> bool: def should_generate_html(images: list[ImageMetadata], contains_files, _args: Args) -> bool:
""" """
Determines if HTML should be generated. Determines if HTML should be generated.
@@ -559,7 +559,7 @@ def should_generate_html(images: list[dict[str, Any]], contains_files, _args: Ar
Returns: Returns:
bool: True if HTML should be generated, False otherwise. bool: True if HTML should be generated, False otherwise.
""" """
return images or (_args.use_fancy_folders and not contains_files) or (_args.use_fancy_folders and _args.ignore_other_files) return bool(images) or (_args.use_fancy_folders and not contains_files) or (_args.use_fancy_folders and _args.ignore_other_files)
def format_html(html: str) -> str: def format_html(html: str) -> str:
@@ -568,8 +568,8 @@ def format_html(html: str) -> str:
def create_html_file( def create_html_file(
folder: str, title: str, foldername: str, images: list[dict[str, Any]], subfolders: list[dict[str, str]], _args: Args, version: str, logo: str, subfoldertags: list[str] folder: str, title: str, foldername: str, images: list[ImageMetadata], subfolders: list[SubfolderMetadata], _args: Args, version: str, logo: str, subfoldertags: set[str]
) -> list[str]: ) -> set[str]:
""" """
Creates the HTML file using the template. Creates the HTML file using the template.
@@ -602,15 +602,13 @@ def create_html_file(
alltags = set() alltags = set()
for img in images: for img in images:
if img["tags"]: if img.tags:
alltags.update(img["tags"]) alltags.update(img.tags)
alltags.update(set(subfoldertags)) alltags.update(subfoldertags)
folder_info = info.get(urllib.parse.quote(folder), "").split("\n") folder_info = info.get(urllib.parse.quote(folder), "").split("\n")
_info = [i for i in folder_info if len(i) > 1] if folder_info else None _info = [i for i in folder_info if len(i) > 1] if folder_info else None
if _args.reverse_sort:
images.sort(key=lambda i: i["name"], reverse=True)
folder_license = licens.get(urllib.parse.quote(folder), False) folder_license = licens.get(urllib.parse.quote(folder), False)
@@ -661,7 +659,7 @@ def create_html_file(
logger.info("writing formatted html file", extra={"path": html_file}) logger.info("writing formatted html file", extra={"path": html_file})
f.write(format_html(content)) f.write(format_html(content))
return sorted(alltags) return set(sorted(alltags))
def list_folder(folder: str, title: str, _args: Args, raw: list[str], version: str, logo: str) -> list[tuple[str, str, str]]: def list_folder(folder: str, title: str, _args: Args, raw: list[str], version: str, logo: str) -> list[tuple[str, str, str]]: