photo-of-the-day master
authorGeorgios Kontaxis <redacted>
Fri, 26 Sep 2025 22:08:25 +0000 (18:08 -0400)
committerGeorgios Kontaxis <redacted>
Sat, 27 Sep 2025 00:47:12 +0000 (20:47 -0400)
.gitignore [new file with mode: 0644]
README.md
photo-of-the-day.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..9af4b79
--- /dev/null
@@ -0,0 +1 @@
+cache.sqlite3
index e9e46053c37baeee021e8fed7626ee3ea379cedc..ce5b5df3ae6809b2b62dc05fac280187886365f5 100644 (file)
--- 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 (file)
index 0000000..ed587f6
--- /dev/null
@@ -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))
+
git clone https://git.99rst.org/PROJECT