From: Georgios Kontaxis Date: Fri, 26 Sep 2025 22:08:25 +0000 (-0400) Subject: photo-of-the-day X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;p=photo-of-the-day.git photo-of-the-day --- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9af4b79 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +cache.sqlite3 diff --git a/README.md b/README.md index e9e4605..ce5b5df 100644 --- a/README.md +++ b/README.md @@ -1 +1,41 @@ # photo-of-the-day + +``` +usage: photo-of-the-day.py [-h] [--database DATABASE] [--verbose] [--debug] [photosDirectory ...] + +Find photos and return the path of one at random. + +positional arguments: + photosDirectory Directories containing photos. Needed for initial discovery and a cache refresh. + +options: + -h, --help show this help message and exit + --database, -db DATABASE + SQLite3 filename to use for the discovery cache. + --verbose, -v Output information on photo discovery and selection. + --debug, -vv Output debug information on photo discovery and selection. +``` + +``` +# Either of the following commands will initialize a database +# named 'cache.sqlite3' in the current directory. +python3 photo-of-the-day.py -v --database ./cache.sqlite3 /Volumes/Photos/2025 +python3 photo-of-the-day.py /Volumes/Photos/2025 + +# Assuming an initialized database, the following command +# will return the path of a photo at random. +python3 photo-of-the-day.py + +# Assuming an initialized database, the following command +# will discover additional photos and add their path to cache. +python3 photo-of-the-day.py /Volumes/Photos/2026/2026.01 + +# Assuming an initialized database containing paths /Volumes/Photos/2025 +# and /Volumes/Photos/2026, the following command will purge it from all +# photos under the former prefix and perform discovery anew. +# Photos with the latter prefix will not be affected and no new photos +# will be discovered respectively. +# This is how the cache should be refreshed when new photos are added +# to the file system. +python3 photo-of-the-day.py /Volumes/Photos/2025 +``` diff --git a/photo-of-the-day.py b/photo-of-the-day.py new file mode 100644 index 0000000..ed587f6 --- /dev/null +++ b/photo-of-the-day.py @@ -0,0 +1,170 @@ +#!/usr/bin/python3 -u + +# kontaxis 2025-09-26 + +import argparse +import os +import random +import re +import sqlite3 +import sys +import time + +class photoOfTheDay: + debug = False + verbose = False + + _connCursor = None + + def __init__(self, dbPath, debug=True, verbose=True): + if not dbPath: + return None + + self.debug = debug + self.verbose = verbose + + conn = sqlite3.connect(dbPath) + debug and conn.set_trace_callback(print) + conn.row_factory = sqlite3.Row + conn.text_factory = str + self._connCursor = conn.cursor() + + def __db_is_null(self): + c = self._connCursor + c.execute("SELECT name FROM sqlite_master " + "WHERE type='table' AND name='version'") + hasVersion = c.fetchone() + if hasVersion: + return False + return True + + def __initialize_db_if_needed(self): + if not self.__db_is_null(): + return + + self.debug and print("DEBUG: Will initialize the database") + + c = self._connCursor + c.execute("CREATE TABLE version (epoch integer);") + c.execute('INSERT INTO version VALUES(:epoch)', { + "epoch": str(int(time.time())) + }) + + c.execute("CREATE TABLE tree (" + "parent text NOT NULL, child text NOT NULL UNIQUE);") + c.execute("CREATE INDEX tree_parent ON tree (parent);") + c.execute("CREATE UNIQUE INDEX tree_tuple ON tree (parent, child);") + + c.execute("CREATE TABLE photos (" + "prefix text NOT NULL, name text NOT NULL);") + c.execute("CREATE INDEX photos_prefix ON photos (prefix);") + c.execute("CREATE UNIQUE INDEX photos_tuple ON photos (prefix, name);") + + c.connection.commit() + + def __prune_db(self, prefix): + self.debug and \ + print("DEBUG: Cache will forget {prefix}".format(prefix = prefix)) + + c = self._connCursor + c.execute("SELECT child FROM tree where parent=:me", { "me": prefix }) + for link in c.fetchall(): + self.__prune_db(link["child"]) + + c.execute("DELETE FROM tree WHERE parent=:me", { "me": prefix }) + c.execute("DELETE FROM photos WHERE prefix=:me", { "me": prefix }) + + def __find_photos(self, targetPath): + if not targetPath: + return + + self.__initialize_db_if_needed() + + c = self._connCursor + + for dirpath, dirnames, filenames in os.walk(targetPath, topdown=True): + self.debug and \ + print("DEBUG: " + "{me} has {ndirs} directories and {nfiles} files".format( + me = dirpath, + ndirs = len(dirnames), + nfiles = len(filenames))) + + self.__prune_db(dirpath) + + for dirname in dirnames: + path = os.path.join(dirpath, dirname) + c.execute("INSERT INTO tree VALUES(:parent, :child)", + { "parent": dirpath, "child": path }) + + photos = [] + for filename in filenames: + match = re.search(r"\.(jpg)$", filename, re.IGNORECASE) + if not match: + continue + photos.append((dirpath, filename)) + if photos: + self.verbose and \ + print("{me} has {nphotos} matching photos".format( + me = dirpath, nphotos = len(photos))) + c.executemany("INSERT INTO photos VALUES(?,?)", photos) + + c.connection.commit() + + def fetch(self, targetPaths: []): + if targetPaths: + for targetPath in targetPaths: + self.__find_photos(targetPath) + + if self.__db_is_null(): + self.debug and print("DEBUG: Database is uninitialized") + return None + + c = self._connCursor + c.execute("SELECT count(*) as cnt FROM photos") + totalPhotos = c.fetchone()["cnt"] + if totalPhotos < 1: + self.debug and print("DEBUG: Database has zero photos") + return None + + randomPhotoIndex = random.randrange(0, totalPhotos) + self.debug and print("DEBUG: " + "Will return photo at index {index}. " + "Total photos: {totalPhotos}".format( + index = randomPhotoIndex, + totalPhotos = totalPhotos)) + + c.execute("SELECT prefix, name FROM photos LIMIT 1 OFFSET :offset", + { "offset": randomPhotoIndex }) + photo = c.fetchone() + return os.path.join(photo["prefix"], photo["name"]) + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser( + description="Find photos and return the path of one at random.") + + parser.add_argument("--database", "-db", + default = "cache.sqlite3", + help = "SQLite3 filename to use for the discovery cache.") + + parser.add_argument("photosDirectory", nargs='*', + help = "Directories containing photos. " + "Needed for initial discovery and a cache refresh.") + + parser.add_argument("--verbose", "-v", + action="store_const", const=True, default=False, + help = "Output information on photo discovery and selection.") + + parser.add_argument("--debug", "-vv", + action="store_const", const=True, default=False, + help = "Output debug information on photo discovery and selection.") + + args = parser.parse_args() + + photo_db = photoOfTheDay(dbPath = args.database, + debug = args.debug, + verbose = args.verbose) + print(photo_db.fetch(args.photosDirectory)) +