yaffo 0.0.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yaffo/__init__.py ADDED
File without changes
yaffo/__main__.py ADDED
@@ -0,0 +1,144 @@
1
+ """Entry point for `python -m yaffo` and the packaged (PyInstaller) app.
2
+
3
+ One frozen binary plays three roles, selected by the YAFFO_ROLE env var, because a
4
+ PyInstaller app's `sys.executable` is the app itself and its bootloader ignores
5
+ `-m`/`-c` — so a child process is started by re-executing this same entry with a
6
+ role set, not by invoking a module path:
7
+
8
+ - (unset) "web": serve the Flask app via waitress (a production WSGI server, not
9
+ the Flask dev server), run migrations, and supervise the host + watcher children.
10
+ - "host": run the task-queue host (which itself spawns workers via multiprocessing).
11
+ - "watcher": run the filesystem watcher.
12
+
13
+ `multiprocessing.freeze_support()` must run first: in a spawned task worker the
14
+ bootloader/freeze_support intercept startup and run the worker, never reaching main().
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import atexit
19
+ import multiprocessing
20
+ import os
21
+ import subprocess
22
+ import sys
23
+ import threading
24
+ import webbrowser
25
+
26
+ HOST = "127.0.0.1"
27
+ PORT = 5001
28
+ WEB_THREADS = 8
29
+ WORKERS = max(2, (os.cpu_count() or 4) - 1)
30
+ RECYCLE = 100
31
+
32
+
33
+ def _child_cmd() -> list[str]:
34
+ """Command to re-launch this entry for a child role. Frozen: just the app
35
+ binary (args ignored; role travels via env). Dev: `python -m yaffo`."""
36
+ if getattr(sys, "frozen", False):
37
+ return [sys.executable]
38
+ return [sys.executable, "-m", "yaffo"]
39
+
40
+
41
+ def _start_background() -> list[subprocess.Popen]:
42
+ from yaffo.logging_config import get_logger
43
+
44
+ logger = get_logger(__name__, "webapp")
45
+ procs: list[subprocess.Popen] = []
46
+ for role in ("host", "watcher"):
47
+ try:
48
+ procs.append(subprocess.Popen(_child_cmd(), env={**os.environ, "YAFFO_ROLE": role}))
49
+ logger.info(f"started {role}")
50
+ except Exception:
51
+ logger.exception(f"failed to start {role}")
52
+ return procs
53
+
54
+
55
+ def _stop_background(procs: list[subprocess.Popen]) -> None:
56
+ for proc in procs:
57
+ if proc.poll() is None:
58
+ proc.terminate()
59
+
60
+
61
+ def _run_menubar(procs: list[subprocess.Popen], url: str) -> None:
62
+ """Run a macOS menu-bar item on the main thread (the AppKit run loop that gives
63
+ the app a face and keeps it alive). 'Quit' tears down the host/watcher children
64
+ so nothing is orphaned — the failure mode of the headless, faceless build."""
65
+ import rumps
66
+
67
+ from yaffo.common import RESOURCES_DIR
68
+
69
+ icon = RESOURCES_DIR / "branding" / "menubar.png"
70
+ icon_kwargs = {"icon": str(icon), "template": False} if icon.exists() else {"title": "📷"}
71
+
72
+ class YaffoApp(rumps.App):
73
+ def __init__(self) -> None:
74
+ super().__init__("Yaffo", quit_button=None, **icon_kwargs)
75
+ self.menu = [
76
+ rumps.MenuItem("Open Yaffo", callback=lambda _: webbrowser.open(url)),
77
+ None,
78
+ rumps.MenuItem("Quit Yaffo", callback=self._quit),
79
+ ]
80
+
81
+ def _quit(self, _) -> None:
82
+ _stop_background(procs)
83
+ rumps.quit_application()
84
+
85
+ YaffoApp().run()
86
+
87
+
88
+ def _run_web() -> None:
89
+ from waitress import serve
90
+ from yaffo.app import create_app
91
+ from yaffo.logging_config import get_logger
92
+ from yaffo.scripts.db.migrate import run_migrations
93
+
94
+ logger = get_logger(__name__, "webapp")
95
+ run_migrations()
96
+ procs = _start_background()
97
+ atexit.register(_stop_background, procs)
98
+
99
+ app = create_app()
100
+ url = f"http://{HOST}:{PORT}"
101
+ threading.Timer(1.5, lambda: webbrowser.open(url)).start()
102
+
103
+ try:
104
+ import rumps # noqa: F401 (probe: present in the bundle, absent in plain dev)
105
+ except Exception:
106
+ # No menu bar (e.g. Linux / a bare dev run): serve on the main thread.
107
+ logger.info(f"serving Yaffo at {url} (no menu bar)")
108
+ serve(app, host=HOST, port=PORT, threads=WEB_THREADS)
109
+ return
110
+
111
+ logger.info(f"serving Yaffo at {url} (menu bar)")
112
+ threading.Thread(
113
+ target=serve, args=(app,),
114
+ kwargs={"host": HOST, "port": PORT, "threads": WEB_THREADS},
115
+ daemon=True,
116
+ ).start()
117
+ _run_menubar(procs, url)
118
+
119
+
120
+ def _run_host() -> None:
121
+ from yaffo.taskq.host import main as host_main
122
+
123
+ host_main()
124
+
125
+
126
+ def _run_watcher() -> None:
127
+ from yaffo.background_tasks.watcher import main as watcher_main
128
+
129
+ watcher_main()
130
+
131
+
132
+ def main() -> None:
133
+ role = os.environ.get("YAFFO_ROLE", "web")
134
+ if role == "host":
135
+ _run_host()
136
+ elif role == "watcher":
137
+ _run_watcher()
138
+ else:
139
+ _run_web()
140
+
141
+
142
+ if __name__ == "__main__":
143
+ multiprocessing.freeze_support()
144
+ main()
yaffo/app.py ADDED
@@ -0,0 +1,79 @@
1
+ import os
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from flask import Flask
7
+ from yaffo import themes
8
+ from yaffo.db import db
9
+ from yaffo.common import DB_PATH
10
+ from yaffo.distance_units import supported_distance_unit_options
11
+ from yaffo.i18n import init_i18n, select_locale, supported_locale_options, text_direction
12
+ from yaffo.logging_config import get_logger
13
+ from yaffo.template_filters import init_template_filters
14
+ from yaffo.routes.init_routes import init_routes
15
+
16
+ logger = get_logger(__name__, 'webapp')
17
+
18
+ def create_app(db_path: Path = DB_PATH, config: Optional[dict] = None):
19
+ app = Flask(__name__)
20
+
21
+ # Configure werkzeug logger to use our logging system
22
+ werkzeug_logger = logging.getLogger('werkzeug')
23
+ werkzeug_logger.setLevel(logging.ERROR)
24
+
25
+ # Set Flask app logger to use our webapp logger
26
+ app.logger.handlers = logger.handlers
27
+ app.logger.setLevel(logger.level)
28
+
29
+ logger.info("Starting Photo Organizer application")
30
+ app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
31
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
32
+ app.config["SQLALCHEMY_ECHO"] = os.environ.get("SQLALCHEMY_ECHO", "").lower() == "true"
33
+ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
34
+ app.config["SESSION_COOKIE_SECURE"] = False
35
+ app.config["SESSION_COOKIE_HTTPONLY"] = True
36
+
37
+ app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
38
+ app.config['SESSION_TYPE'] = 'filesystem' # or 'redis', 'memcached', etc.
39
+ app.config['SESSION_PERMANENT'] = True
40
+ #app.config['SESSION_USE_SIGNER'] = True
41
+
42
+ # Caller overrides (e.g. tests) win over the defaults above.
43
+ if config:
44
+ app.config.update(config)
45
+
46
+ db.init_app(app)
47
+ init_i18n(app)
48
+
49
+ # Make url_map available in all templates
50
+ @app.context_processor
51
+ def inject_url_map():
52
+ return {'url_map': app.url_map}
53
+
54
+ @app.context_processor
55
+ def inject_theme():
56
+ return {'theme': themes.get_theme()}
57
+
58
+ @app.context_processor
59
+ def inject_i18n():
60
+ locale = select_locale()
61
+ return {
62
+ "current_locale": locale,
63
+ "distance_unit_options": supported_distance_unit_options(),
64
+ "supported_locales": supported_locale_options(),
65
+ "text_direction": text_direction(locale),
66
+ }
67
+
68
+ # Register template filters
69
+
70
+ init_template_filters(app)
71
+ init_routes(app)
72
+ return app
73
+
74
+ if __name__ == "__main__":
75
+ app = create_app()
76
+ # threaded so a long streaming response (e.g. the index-photos scan) doesn't block
77
+ # the page's other requests on the single-user dev server. Port 5001, not 5000 —
78
+ # macOS AirPlay Receiver (Control Center) binds *:5000 and answers with a 403.
79
+ app.run(debug=True, threaded=True, port=5001)
yaffo/common.py ADDED
@@ -0,0 +1,69 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from platformdirs import user_cache_dir, user_data_dir
4
+ import os
5
+
6
+ app_author = "Jason Turan"
7
+ version = "0.0.1"
8
+ app_name = "yaffo"
9
+
10
+ # Media-type discriminator values. Defined here (a dependency-free leaf module) so
11
+ # both yaffo.common and yaffo.db.models can expose them without an import cycle;
12
+ # models re-exports these, so `from yaffo.db.models import MEDIA_TYPE_*` still works.
13
+ MEDIA_TYPE_PHOTO = "photo"
14
+ MEDIA_TYPE_VIDEO = "video"
15
+
16
+ # Containers that play inline in the browser's <video> (H.264/HEVC in MP4/MOV/M4V).
17
+ PLAYABLE_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v"}
18
+ # All cataloged video. The non-playable containers (avi/mkv/wmv/flv) are still
19
+ # indexed for metadata + poster + faces (exiftool/ffmpeg handle them); the detail
20
+ # view offers "open externally" instead of an inline player. See docs/development/video.md.
21
+ VIDEO_EXTENSIONS = PLAYABLE_VIDEO_EXTENSIONS | {".avi", ".mkv", ".wmv", ".flv"}
22
+ PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".png", ".heic"}
23
+ MEDIA_EXTENSIONS = PHOTO_EXTENSIONS | VIDEO_EXTENSIONS
24
+
25
+
26
+ def media_type_for_path(path: Path) -> str:
27
+ """The MEDIA_TYPE_* a file belongs to, from its suffix. Defaults to photo for
28
+ anything that isn't a known video extension (the import path only ever sees
29
+ files already filtered to MEDIA_EXTENSIONS)."""
30
+ return MEDIA_TYPE_VIDEO if path.suffix.lower() in VIDEO_EXTENSIONS else MEDIA_TYPE_PHOTO
31
+
32
+
33
+ def is_browser_playable_video(path: "Path | str") -> bool:
34
+ """Whether a video plays inline in an HTML5 <video> (a container-level check).
35
+ Non-playable containers are cataloged but opened in an external player."""
36
+ return Path(path).suffix.lower() in PLAYABLE_VIDEO_EXTENSIONS
37
+
38
+ # Where the DB, thumbnails, temp/trash, and logs live. Set YAFFO_DATA_DIR to
39
+ # override (invoke sets it to ~/Pictures for dev). Otherwise default to the OS
40
+ # per-user data dir, so an installed app gets its own home (the photo library
41
+ # itself is configured in-app, independent of this).
42
+ if os.environ.get("YAFFO_DATA_DIR"):
43
+ data_dir = Path(os.environ["YAFFO_DATA_DIR"])
44
+ else:
45
+ data_dir = Path(user_data_dir(app_name, app_author))
46
+
47
+ ROOT_DIR = Path(data_dir)
48
+ # Ensure the DB/log home exists before anything writes into it (logging opens a
49
+ # file here at import time, ahead of any migration step).
50
+ ROOT_DIR.mkdir(parents=True, exist_ok=True)
51
+
52
+ DB_PATH = ROOT_DIR / f"{app_name}.db"
53
+ QUEUE_DB_PATH = ROOT_DIR / f"{app_name}-queue.db"
54
+
55
+ # Downloaded vision models (CLIP ONNX encoders) cache here on first use, like
56
+ # InsightFace's ~/.insightface — kept out of the photo library / data dir.
57
+ MODEL_CACHE_DIR = Path(user_cache_dir(app_name, app_author))
58
+
59
+ # Read-only assets shipped with the source tree and bundled into the app. When
60
+ # frozen by PyInstaller they live under sys._MEIPASS (the spec adds `resources`
61
+ # there); in dev `parents[1]` is the repo root, where `resources/` sits beside the
62
+ # `yaffo/` package. Models, when pre-bundled by the build script, live under
63
+ # BUNDLED_MODELS_DIR; loaders prefer them and fall back to a network download.
64
+ if getattr(sys, "frozen", False):
65
+ BUNDLE_ROOT = Path(sys._MEIPASS) # type: ignore[attr-defined]
66
+ else:
67
+ BUNDLE_ROOT = Path(__file__).resolve().parents[1]
68
+ RESOURCES_DIR = BUNDLE_ROOT / "resources"
69
+ BUNDLED_MODELS_DIR = RESOURCES_DIR / "models"
yaffo/config.py ADDED
@@ -0,0 +1,154 @@
1
+ """Optional user config, read once at startup from ``<data dir>/config.toml``.
2
+
3
+ TOML (not YAML/JSON) because it's the Python-conventional config format: the stdlib
4
+ ``tomllib`` reads it with no dependency, it supports comments, and the project
5
+ already uses TOML (pyproject.toml). Edit the file and restart the app to apply.
6
+
7
+ On startup the file is migrated to the current template: new sections/keys (and their
8
+ documentation comments) are added while the user's set values are preserved. The
9
+ template is the source of truth for structure + comments + defaults, so we re-render
10
+ it with the user's overrides substituted in rather than round-tripping through a dict
11
+ (``tomllib`` can read but not write TOML, and would drop every comment).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import tomllib
18
+ from typing import Any
19
+
20
+ from yaffo.common import ROOT_DIR
21
+
22
+ CONFIG_PATH = ROOT_DIR / "config.toml"
23
+
24
+ _TEMPLATE = """\
25
+ # Yaffo configuration. Edit a value and restart the app to apply it.
26
+
27
+ [logging]
28
+ # Verbosity of the log files (yaffo.log, background_tasks.log).
29
+ # One of: DEBUG, INFO, WARNING, ERROR
30
+ level = "INFO"
31
+ # Max size (MB) of each rolling log file before it rotates, and how many old files
32
+ # to keep.
33
+ max_file_mb = 5
34
+ backup_count = 3
35
+ # How many recent page/theme/automation generation runs to keep under model_logs/.
36
+ max_model_log_runs = 50
37
+
38
+ [database]
39
+ # SQLite durability vs. write speed (PRAGMA synchronous), paired with WAL.
40
+ # NORMAL = fast; the last transaction can be lost on an OS/power/disk failure
41
+ # (never corruption). The default — best for bulk photo indexing.
42
+ # FULL = fsync on every commit; survives OS/power/disk failures with no lost
43
+ # writes, at a write-throughput cost.
44
+ # One of: NORMAL, FULL
45
+ synchronous = "NORMAL"
46
+
47
+ [tasks]
48
+ # Background worker processes: more = more parallel indexing, more CPU/RAM. Defaults
49
+ # to the CPU count when unset; a `--workers` CLI flag still overrides this.
50
+ # workers = 4
51
+ # Recycle (restart) a worker after this many tasks, to bound slow leaks.
52
+ recycle = 100
53
+
54
+ [ai]
55
+ # Caps per AI generation run (page/theme/automation builder), to bound cost.
56
+ # max_iterations: tool-use loop steps before the run stops.
57
+ # max_output_tokens: tokens per model call — shared with the thinking budget, so
58
+ # setting it too low risks responses getting cut off mid-generation.
59
+ max_iterations = 25
60
+ max_output_tokens = 64000
61
+ """
62
+
63
+
64
+ _SECTION_RE = re.compile(r"^\s*\[([A-Za-z0-9_.-]+)\]\s*$")
65
+ # A `key = value` line, optionally commented (e.g. the `# workers = 4` default).
66
+ _KEY_RE = re.compile(r"^(\s*)#?\s*([A-Za-z_][\w-]*)\s*=\s*(.*)$")
67
+
68
+
69
+ def _toml_value(value: Any) -> str:
70
+ """Render a Python scalar as a TOML literal (covers the value types this config
71
+ uses: str, int, float, bool)."""
72
+ if isinstance(value, bool):
73
+ return "true" if value else "false"
74
+ if isinstance(value, (int, float)):
75
+ return str(value)
76
+ escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
77
+ return f'"{escaped}"'
78
+
79
+
80
+ def _render(user: dict[str, Any]) -> str:
81
+ """Render _TEMPLATE (the canonical structure + comments + defaults), substituting
82
+ any value the user has set so their overrides survive the migration. A commented
83
+ default (e.g. `# workers = 4`) is activated when the user has set that key. Pure
84
+ doc-comment lines and unknown user keys are left untouched / dropped."""
85
+ section: str | None = None
86
+ out: list[str] = []
87
+ for line in _TEMPLATE.splitlines():
88
+ sm = _SECTION_RE.match(line)
89
+ if sm:
90
+ section = sm.group(1)
91
+ out.append(line)
92
+ continue
93
+ km = _KEY_RE.match(line)
94
+ if km and section and section in user and km.group(2) in user[section]:
95
+ indent, key = km.group(1), km.group(2)
96
+ out.append(f"{indent}{key} = {_toml_value(user[section][key])}")
97
+ continue
98
+ out.append(line)
99
+ return "\n".join(out) + "\n"
100
+
101
+
102
+ def _atomic_write(text: str) -> None:
103
+ """Write config.toml via a temp file + rename so concurrently-starting processes
104
+ (flask, watcher, host, spawn workers all import this) never see a torn file."""
105
+ tmp = CONFIG_PATH.with_suffix(".toml.tmp")
106
+ tmp.write_text(text)
107
+ os.replace(tmp, CONFIG_PATH)
108
+
109
+
110
+ def _load() -> dict[str, Any]:
111
+ if not CONFIG_PATH.exists():
112
+ try:
113
+ _atomic_write(_TEMPLATE) # seed a documented default on first run
114
+ except OSError:
115
+ pass
116
+ return tomllib.loads(_TEMPLATE)
117
+
118
+ try:
119
+ existing = CONFIG_PATH.read_text()
120
+ user = tomllib.loads(existing)
121
+ except (OSError, tomllib.TOMLDecodeError):
122
+ # Leave an unreadable/corrupt file alone (don't clobber something the user
123
+ # could still fix); fall back to code defaults via get()/get_int().
124
+ return {}
125
+
126
+ # Migrate: re-render the template with the user's values, rewriting only when the
127
+ # result actually differs (so a settled config doesn't get rewritten every launch).
128
+ merged_text = _render(user)
129
+ if merged_text != existing:
130
+ try:
131
+ _atomic_write(merged_text)
132
+ except OSError:
133
+ pass
134
+ return tomllib.loads(merged_text)
135
+
136
+
137
+ _config = _load()
138
+
139
+
140
+ def get(section: str, key: str, default: Any = None) -> Any:
141
+ """Read config[section][key], falling back to default if absent."""
142
+ return _config.get(section, {}).get(key, default)
143
+
144
+
145
+ def get_int(section: str, key: str, default: int, minimum: int = 1) -> int:
146
+ """Read an integer config value, falling back to `default` when absent, the wrong
147
+ type, or below `minimum` — so a typo in config.toml can't feed a nonsensical value
148
+ (e.g. 0 workers, a negative retention) into the system."""
149
+ raw = get(section, key, default)
150
+ try:
151
+ value = int(raw)
152
+ except (TypeError, ValueError):
153
+ return default
154
+ return value if value >= minimum else default
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from flask_babel import lazy_gettext
6
+ from sqlalchemy.exc import OperationalError
7
+
8
+ from yaffo.db import db
9
+ from yaffo.db.models import ApplicationSettings
10
+
11
+ DISTANCE_UNIT_SETTING = "distance_unit"
12
+ DISTANCE_UNIT_MILES = "mi"
13
+ DISTANCE_UNIT_KILOMETERS = "km"
14
+ DEFAULT_DISTANCE_UNIT = DISTANCE_UNIT_MILES
15
+ KILOMETERS_PER_MILE = 1.609344
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class DistanceUnitOption:
20
+ code: str
21
+ label: str
22
+
23
+
24
+ def supported_distance_unit_options() -> list[DistanceUnitOption]:
25
+ return [
26
+ DistanceUnitOption(code=DISTANCE_UNIT_MILES, label=lazy_gettext("Miles")),
27
+ DistanceUnitOption(code=DISTANCE_UNIT_KILOMETERS, label=lazy_gettext("Kilometers")),
28
+ ]
29
+
30
+
31
+ def normalize_distance_unit(unit: str | None) -> str | None:
32
+ if not unit:
33
+ return None
34
+ normalized = unit.strip().lower()
35
+ return normalized if normalized in {DISTANCE_UNIT_MILES, DISTANCE_UNIT_KILOMETERS} else None
36
+
37
+
38
+ def get_saved_distance_unit(session=None) -> str:
39
+ session = session or db.session
40
+ try:
41
+ row = session.query(ApplicationSettings).filter_by(name=DISTANCE_UNIT_SETTING).first()
42
+ except OperationalError:
43
+ return DEFAULT_DISTANCE_UNIT
44
+ return normalize_distance_unit(row.value if row else None) or DEFAULT_DISTANCE_UNIT
45
+
46
+
47
+ def set_distance_unit(unit: str) -> bool:
48
+ normalized = normalize_distance_unit(unit)
49
+ if normalized is None:
50
+ return False
51
+ row = db.session.query(ApplicationSettings).filter_by(name=DISTANCE_UNIT_SETTING).first()
52
+ if row is None:
53
+ db.session.add(ApplicationSettings(name=DISTANCE_UNIT_SETTING, type="string", value=normalized))
54
+ else:
55
+ row.value = normalized
56
+ db.session.commit()
57
+ return True
58
+
59
+
60
+ def kilometers_per_unit(unit: str) -> float:
61
+ return {
62
+ DISTANCE_UNIT_MILES: KILOMETERS_PER_MILE,
63
+ DISTANCE_UNIT_KILOMETERS: 1.0,
64
+ }[normalize_distance_unit(unit) or DEFAULT_DISTANCE_UNIT]
65
+
66
+
67
+ def distance_to_kilometers(value: float, unit: str) -> float:
68
+ return value * kilometers_per_unit(unit)
69
+
70
+
71
+ def kilometers_to_distance(value_kilometers: float, unit: str) -> float:
72
+ return value_kilometers / kilometers_per_unit(unit)