image-annex 0.1.2__tar.gz

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.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: image-annex
3
+ Version: 0.1.2
4
+ Summary: High-performance, Git-Annex powered photo archive with AWS S3.
5
+ Requires-Python: <4,>=3.9
6
+ Requires-Dist: aws-cdk-lib==2.202.0
7
+ Requires-Dist: constructs<11.0.0,>=10.0.0
8
+ Requires-Dist: pillow>=11.3.0
9
+ Requires-Dist: platformdirs>=4.4.0
10
+ Requires-Dist: psutil>=6.0.0
11
+ Requires-Dist: pydantic>=2.0.0
12
+ Requires-Dist: pyexiftool>=0.5.6
13
+ Requires-Dist: faster-whisper>=1.1.0
14
+ Requires-Dist: sentence-transformers>=3.0.0
15
+ Requires-Dist: sqlite-vec>=0.1.0
16
+ Requires-Dist: textual>=1.0.0
17
+ Requires-Dist: toml
18
+ Requires-Dist: torch>=2.0.0
@@ -0,0 +1,86 @@
1
+
2
+ # Image Annex
3
+
4
+ This project contains the AWS CDK infrastructure and client-side tooling for the Image Annex system, a bulletproof, deduplicating, and cost-effective photo archive.
5
+
6
+ The core concept is based on the blog post [Your Photos Are a Mess](blog-post.md), which details a workflow for using `git-annex` to manage a master photo repository backed by cloud storage.
7
+
8
+ ## Architecture
9
+
10
+ The system consists of two primary components:
11
+
12
+ 1. **Cloud Infrastructure (AWS CDK):** Defines an S3 bucket used as a `git-annex` special remote. It is managed via Python-based AWS CDK and deployed via GitHub Actions.
13
+ 2. **Client-Side Tooling:**
14
+ * `scripts/setup_client.py`: Automates the initialization of a local `git-annex` repository and configuration of the S3 remote.
15
+ * `scripts/hook.py`: A pre-commit hook that extracts EXIF metadata and generates local thumbnails upon `git commit`.
16
+ * `scripts/search_metadata.py`: A CLI tool to search the local SQLite metadata database.
17
+
18
+ ## Core Components
19
+
20
+ * **[Blog Post](blog-post.md):** The original article outlining the philosophy and the user-facing workflow for managing photos.
21
+ * **[Security Architecture](docs/SECURITY.md):** A detailed document explaining the security model for deployment, application runtime, and local development.
22
+ * **AWS CDK Application:** Located in `cdk_image_annex/`.
23
+ * **Scripts:** Located in `scripts/`.
24
+
25
+ ## Usage & Migration Workflow
26
+
27
+ The general workflow for migrating images to the Annex is as follows:
28
+
29
+ 1. **Initialize the Client:**
30
+ Run the setup script to prepare a local directory as your Annex repository.
31
+ ```bash
32
+ python scripts/setup_client.py --repo-path ~/my-photo-annex --s3-bucket-name my-annex-bucket --s3-region us-east-1
33
+ ```
34
+ 2. **Add Photos:**
35
+ Move your photos into the repository directory.
36
+ ```bash
37
+ cd ~/my-photo-annex
38
+ cp /path/to/my/photos/*.jpg .
39
+ git add .
40
+ ```
41
+ 3. **Commit (Triggers Metadata & Thumbnails):**
42
+ When you commit, the pre-commit hook automatically extracts EXIF data and generates thumbnails (stored locally in `.git-annex/thumbnails/`).
43
+ ```bash
44
+ git commit -m "Add summer vacation photos"
45
+ ```
46
+ 4. **Sync to S3:**
47
+ Upload the actual file content to AWS S3.
48
+ ```bash
49
+ git annex sync --content
50
+ ```
51
+
52
+ ## Metadata & Thumbnails
53
+
54
+ * **Metadata:** Extracted using `exiftool` and stored in a local SQLite database (`metadata.db`).
55
+ * **Thumbnails:** Generated as 256x256 JPEGs using `Pillow-SIMD`. They are stored locally and are *not* uploaded to S3, keeping the cloud storage focused on the original assets.
56
+
57
+ ## Current Status (Dev/UAT)
58
+
59
+ This project is currently in a **Development/UAT phase**. Key points:
60
+ * Automated testing (UAT) is performed via Docker (`Dockerfile.uat`).
61
+ * **Limitation:** `git-annex` currently places an `annex-uuid` file at the root of the S3 bucket during `initremote`, even if a prefix is configured. This makes multi-tenant or multi-prefix use of a single bucket slightly more complex.
62
+ * The production infrastructure is defined but should be used with caution until UAT is finalized.
63
+
64
+ ## Deployment
65
+
66
+ This project is deployed automatically via a GitHub Actions workflow. The deployment pipeline is configured to use a secure OIDC connection to AWS.
67
+
68
+ For details, see [docs/SECURITY.md](docs/SECURITY.md).
69
+
70
+ ## Local Development
71
+
72
+ To work with the CDK infrastructure locally:
73
+
74
+ 1. **Environment Setup:**
75
+ * Copy `.env.template` to `.env`.
76
+ * Set up a Python virtual environment:
77
+ ```bash
78
+ python3 -m venv .venv
79
+ source .venv/bin/activate
80
+ pip install .
81
+ ```
82
+ 2. **CDK Commands:**
83
+ ```bash
84
+ op run --env-file=.env -- cdk synth
85
+ op run --env-file=.env -- cdk deploy
86
+ ```
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "image-annex"
3
+ version = "0.1.2"
4
+ description = "High-performance, Git-Annex powered photo archive with AWS S3."
5
+ dependencies = [
6
+ "aws-cdk-lib==2.202.0",
7
+ "constructs>=10.0.0,<11.0.0",
8
+ "pillow>=11.3.0",
9
+ "platformdirs>=4.4.0",
10
+ "psutil>=6.0.0",
11
+ "pydantic>=2.0.0",
12
+ "pyexiftool>=0.5.6",
13
+ "faster-whisper>=1.1.0",
14
+ "sentence-transformers>=3.0.0",
15
+ "sqlite-vec>=0.1.0",
16
+ "textual>=1.0.0",
17
+ "toml",
18
+ "torch>=2.0.0",
19
+ ]
20
+ requires-python = ">=3.9,<4"
21
+
22
+ [project.scripts]
23
+ ia = "image_annex.cli.main:main"
24
+ image-annex = "image_annex.tui.app:main"
25
+ ia-scan = "image_annex.core.scanner:main"
26
+ ia-gallery = "image_annex.core.gallery:main"
27
+
28
+ [build-system]
29
+ requires = ["setuptools>=61.0"]
30
+ build-backend = "setuptools.build_meta"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "mypy>=1.19.1",
35
+ "pytest",
36
+ "pytest-asyncio>=1.2.0",
37
+ "ruff>=0.15.2",
38
+ "types-toml>=0.10.8.20240310",
39
+ "verkit",
40
+ ]
41
+
42
+ [tool.pytest.ini_options]
43
+ norecursedirs = [".venv", "build", "cdk.out", "appdata"]
44
+ asyncio_mode = "strict"
45
+
46
+ [tool.mypy]
47
+ exclude = ["^cdk\\.out/"]
48
+ ignore_missing_imports = true
49
+
50
+ [tool.ruff]
51
+ exclude = [
52
+ ".git",
53
+ ".mypy_cache",
54
+ ".ruff_cache",
55
+ ".venv",
56
+ "cdk.out",
57
+ "build",
58
+ "dist",
59
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,188 @@
1
+ import argparse
2
+ import sys
3
+ import subprocess
4
+ from rich.console import Console
5
+ from image_annex.tui.app import ImageAnnexApp
6
+ from image_annex.core.config import ConfigManager
7
+
8
+ console = Console()
9
+
10
+ def run_tui(args):
11
+ app = ImageAnnexApp()
12
+ app.run()
13
+
14
+ def run_gallery_start(args):
15
+ console.print("[bold yellow]Starting Gallery server...[/]")
16
+ gallery_log = open(".logs/gallery.log", "w", encoding="utf-8")
17
+ process = subprocess.Popen(
18
+ [sys.executable, "-m", "image_annex.core.gallery"],
19
+ stdout=gallery_log,
20
+ stderr=gallery_log
21
+ )
22
+ console.print(f"[bold green]Gallery started (PID: {process.pid})[/]")
23
+
24
+ def run_gallery_stop(args):
25
+ import psutil # type: ignore
26
+ killed = False
27
+ for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
28
+ if proc.info['cmdline'] and 'image_annex.core.gallery' in str(proc.info['cmdline']):
29
+ proc.terminate()
30
+ killed = True
31
+ if killed:
32
+ console.print("[bold green]Gallery stopped.[/]")
33
+ else:
34
+ console.print("[bold yellow]Gallery not running.[/]")
35
+
36
+ def run_gallery_status(args):
37
+ import psutil # type: ignore
38
+ running = False
39
+ for proc in psutil.process_iter(['name', 'cmdline']):
40
+ if proc.info['cmdline'] and 'image_annex.core.gallery' in str(proc.info['cmdline']):
41
+ running = True
42
+ break
43
+ if running:
44
+ console.print("[bold green]Gallery is running.[/]")
45
+ else:
46
+ console.print("[bold yellow]Gallery is stopped.[/]")
47
+
48
+ def run_ai_scan(args):
49
+ from image_annex.core.ai import AIScraper
50
+ cfg = ConfigManager().get_config()
51
+ thumb_dir = cfg.git_annex_repo.expanded_path / ".git-annex" / "thumbnails"
52
+ scraper = AIScraper(cfg.db_path, thumb_dir)
53
+ console.print("[bold cyan]Starting AI scraper...[/]")
54
+ scraper.start()
55
+ scraper.join()
56
+ console.print("[bold green]AI scan finished.[/]")
57
+
58
+ def run_storage_status(args):
59
+ console.print("[bold]Storage Metrics[/]")
60
+ cfg = ConfigManager().get_config()
61
+ from image_annex.core.service import ImageAnnexService
62
+ service = ImageAnnexService(cfg)
63
+ metrics = service.get_storage_metrics()
64
+ console.print(metrics["raw_output"])
65
+
66
+ def run_storage_lock(args):
67
+ cfg = ConfigManager().get_config()
68
+ from image_annex.core.service import ImageAnnexService
69
+ service = ImageAnnexService(cfg)
70
+ console.print("[bold yellow]Locking archive...[/]")
71
+ console.print("[dim]Note: This converts working files to symlinks/pointers. Ensure you have run 'ia sync' first.[/]")
72
+ service.lock_archive()
73
+ console.print("[bold green]Archive locked.[/]")
74
+
75
+ def run_storage_prune(args):
76
+ cfg = ConfigManager().get_config()
77
+ from image_annex.core.service import ImageAnnexService
78
+ service = ImageAnnexService(cfg)
79
+ console.print("[bold yellow]Pruning local content...[/]")
80
+ console.print("[bold red]DANGER:[/] This will delete local content for files confirmed to exist on S3.")
81
+ if console.input("Are you sure? [y/N]: ").lower() == 'y':
82
+ service.prune_annex()
83
+ console.print("[bold green]Prune complete.[/]")
84
+ else:
85
+ console.print("[bold]Operation cancelled.[/]")
86
+
87
+ def run_storage_regen_thumbs(args):
88
+ cfg = ConfigManager().get_config()
89
+ from image_annex.core.service import ImageAnnexService
90
+ service = ImageAnnexService(cfg)
91
+ console.print("[bold yellow]Regenerating missing thumbnails...[/]")
92
+ service.regenerate_missing_thumbnails(console.print)
93
+ console.print("[bold green]Missing thumbnail regeneration complete.[/]")
94
+
95
+ def run_hub_connect(args):
96
+ ssh_uri = args.uri
97
+ # Parse out hostname for the remote name
98
+ hostname = ssh_uri.split('@')[-1].split(':')[0]
99
+
100
+ console.print(f"[bold cyan]Setting up hub connection to {hostname}...[/]")
101
+
102
+ cfg = ConfigManager().get_config()
103
+ repo_path = cfg.git_annex_repo.expanded_path
104
+
105
+ console.print("[dim]Adding git remote...[/]")
106
+ # We ignore errors here in case the remote already exists
107
+ subprocess.run(["git", "remote", "add", hostname, ssh_uri], cwd=repo_path, capture_output=True)
108
+
109
+ console.print("[dim]Syncing annex state with hub (this may take a moment)...[/]")
110
+ subprocess.run(["git", "annex", "sync", hostname], cwd=repo_path)
111
+
112
+ console.print(f"[bold green]Successfully connected to {hostname}![/]")
113
+ console.print(f"\nTo transfer your photos to the hub, run: [bold]git annex copy --to {hostname}[/]")
114
+ console.print("Once confirmed, you can free up local space with: [bold]git annex drop .[/]")
115
+
116
+ def main():
117
+ parser = argparse.ArgumentParser(description="Image Annex CLI", formatter_class=argparse.RawDescriptionHelpFormatter)
118
+ subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
119
+
120
+ subparsers.add_parser("tui", help="Launch the Text User Interface")
121
+
122
+ gallery_parser = subparsers.add_parser("gallery", help="Manage Gallery web server")
123
+ gallery_sub = gallery_parser.add_subparsers(dest="subcommand")
124
+ gallery_sub.add_parser("start", help="Start the Gallery server")
125
+ gallery_sub.add_parser("stop", help="Stop the Gallery server")
126
+ gallery_sub.add_parser("status", help="Check Gallery server status")
127
+
128
+ subparsers.add_parser("ai-scan", help="Run AI image indexing")
129
+
130
+ storage_parser = subparsers.add_parser("storage", help="Manage storage and S3 sync")
131
+ storage_sub = storage_parser.add_subparsers(dest="subcommand")
132
+ storage_sub.add_parser("status", help="Show archive storage metrics")
133
+ storage_sub.add_parser("lock", help="Lock files locally to reclaim space (requires S3 sync)")
134
+ storage_sub.add_parser("prune", help="Drop local content for files already backed up on S3")
135
+ storage_sub.add_parser("regen-thumbs", help="Regenerate missing thumbnails")
136
+
137
+ hub_parser = subparsers.add_parser("hub", help="Manage connections to a central image-annex hub/appliance")
138
+ hub_sub = hub_parser.add_subparsers(dest="subcommand")
139
+
140
+ connect_parser = hub_sub.add_parser("connect", help="Connect to an image-annex hub")
141
+ connect_parser.add_argument("uri", help="SSH URI to the hub (e.g. user@nuc02:/path/to/repo)")
142
+
143
+ if len(sys.argv) == 1:
144
+ parser.print_help()
145
+ return
146
+
147
+ args = parser.parse_args()
148
+
149
+ if args.command == "tui":
150
+ run_tui(args)
151
+ elif args.command == "gallery":
152
+ if hasattr(args, 'subcommand'):
153
+ if args.subcommand == 'start':
154
+ run_gallery_start(args)
155
+ elif args.subcommand == 'stop':
156
+ run_gallery_stop(args)
157
+ elif args.subcommand == 'status':
158
+ run_gallery_status(args)
159
+ else:
160
+ gallery_parser.print_help()
161
+ else:
162
+ gallery_parser.print_help()
163
+ elif args.command == "ai-scan":
164
+ run_ai_scan(args)
165
+ elif args.command == "storage":
166
+ if hasattr(args, 'subcommand'):
167
+ if args.subcommand == 'status':
168
+ run_storage_status(args)
169
+ elif args.subcommand == 'lock':
170
+ run_storage_lock(args)
171
+ elif args.subcommand == 'prune':
172
+ run_storage_prune(args)
173
+ elif args.subcommand == 'regen-thumbs':
174
+ run_storage_regen_thumbs(args)
175
+ else:
176
+ storage_parser.print_help()
177
+ else:
178
+ storage_parser.print_help()
179
+ elif args.command == "hub":
180
+ if hasattr(args, 'subcommand') and args.subcommand == 'connect':
181
+ run_hub_connect(args)
182
+ else:
183
+ hub_parser.print_help()
184
+ else:
185
+ parser.print_help()
186
+
187
+ if __name__ == "__main__":
188
+ main()
File without changes
@@ -0,0 +1,257 @@
1
+ import os
2
+ import time
3
+ import logging
4
+ import sqlite3
5
+ import threading
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import List, Optional, Callable
9
+ import psutil # type: ignore
10
+ from PIL import Image
11
+
12
+ # Setup logging
13
+ ai_logger = logging.getLogger("ai")
14
+
15
+ class AIService:
16
+ """Handles model loading and vector embedding generation."""
17
+ _instance = None
18
+ _model = None
19
+ _lock = threading.Lock()
20
+
21
+ def __init__(self):
22
+ # Model name for CLIP Pro (High Accuracy)
23
+ self.model_name = "clip-ViT-L-14"
24
+ self.dimensions = 768
25
+
26
+ @classmethod
27
+ def get_instance(cls):
28
+ with cls._lock:
29
+ if cls._instance is None:
30
+ cls._instance = cls()
31
+ return cls._instance
32
+
33
+ def _load_model(self):
34
+ """Lazy load the model to save startup time and memory if not used."""
35
+ if self._model is None:
36
+ from sentence_transformers import SentenceTransformer
37
+ import torch
38
+
39
+ device = "cuda" if torch.cuda.is_available() else "cpu"
40
+ ai_logger.info(f"Loading {self.model_name} on {device}...")
41
+ self._model = SentenceTransformer(self.model_name, device=device)
42
+ ai_logger.info(f"Model {self.model_name} loaded.")
43
+ return self._model
44
+
45
+ def generate_image_embedding(self, image_path: str) -> List[float]:
46
+ """Generates a vector embedding for a single image."""
47
+ model = self._load_model()
48
+ img = Image.open(image_path)
49
+ # Sentence-transformers handles the CLIP image processing
50
+ embedding = model.encode(img)
51
+ return embedding.tolist()
52
+
53
+ def generate_text_embedding(self, text: str) -> List[float]:
54
+ """Generates a vector embedding for a text query."""
55
+ model = self._load_model()
56
+ embedding = model.encode(text)
57
+ return embedding.tolist()
58
+
59
+ class AIScraper(threading.Thread):
60
+ """Background worker that processes images when resources are available."""
61
+ def __init__(self, db_path: Path, thumb_dir: Path, status_callback: Optional[Callable[[int, int], None]] = None):
62
+ super().__init__(daemon=True)
63
+ self.db_path = db_path
64
+ self.thumb_dir = thumb_dir
65
+ self.status_callback = status_callback
66
+ self._stop_event = threading.Event()
67
+ self.scan_type = "clip_v1"
68
+ self.model_name = "clip-ViT-L-14"
69
+
70
+ def stop(self):
71
+ self._stop_event.set()
72
+
73
+ def _is_system_busy(self) -> bool:
74
+ """Checks if overall CPU usage is too high (>50%)."""
75
+ return psutil.cpu_percent(interval=0.1) > 50
76
+
77
+ def _set_low_priority(self):
78
+ """Sets the current process to background/idle priority."""
79
+ try:
80
+ p = psutil.Process(os.getpid())
81
+ if os.name == 'nt':
82
+ p.nice(psutil.IDLE_PRIORITY_CLASS)
83
+ else:
84
+ p.nice(19)
85
+ except Exception as e:
86
+ ai_logger.warning(f"Could not set low priority: {e}")
87
+
88
+ def run(self):
89
+ self._set_low_priority()
90
+ ai_service = AIService.get_instance()
91
+
92
+ while not self._stop_event.is_set():
93
+ if self._is_system_busy():
94
+ time.sleep(10)
95
+ continue
96
+
97
+ try:
98
+ from .config import ConfigManager
99
+ cfg = ConfigManager().get_config()
100
+ exclude_paths = [p.lower() for p in cfg.exclude_paths]
101
+
102
+ conn = sqlite3.connect(str(self.db_path))
103
+ conn.row_factory = sqlite3.Row
104
+ cursor = conn.cursor()
105
+
106
+ cursor.execute("""
107
+ SELECT a.key, a.thumbnail_path, i.full_path
108
+ FROM assets a
109
+ JOIN instances i ON a.key = i.key
110
+ LEFT JOIN asset_scans s ON a.key = s.key AND s.scan_type = ?
111
+ WHERE s.key IS NULL AND a.thumbnail_path IS NOT NULL AND a.thumbnail_path != ''
112
+ LIMIT 5
113
+ """, (self.scan_type,))
114
+
115
+ rows = cursor.fetchall()
116
+
117
+ valid_rows = []
118
+ for row in rows:
119
+ full_path = row["full_path"].lower()
120
+ if any(full_path.startswith(ex) for ex in exclude_paths):
121
+ cursor.execute("INSERT OR REPLACE INTO asset_scans (key, scan_type, status, scanned_at) VALUES (?, ?, ?, ?)",
122
+ (row["key"], self.scan_type, "excluded", datetime.now().isoformat()))
123
+ continue
124
+ valid_rows.append(row)
125
+
126
+ cursor.execute("SELECT count(*) FROM assets")
127
+ total_assets = cursor.fetchone()[0]
128
+ cursor.execute("SELECT count(*) FROM asset_scans WHERE scan_type = ?", (self.scan_type,))
129
+ processed_assets = cursor.fetchone()[0]
130
+
131
+ if self.status_callback:
132
+ self.status_callback(processed_assets, total_assets)
133
+
134
+ if not valid_rows:
135
+ conn.close()
136
+ time.sleep(30)
137
+ continue
138
+
139
+ for row in valid_rows:
140
+ if self._stop_event.is_set():
141
+ break
142
+
143
+ key = row["key"]
144
+ thumb_path = row["thumbnail_path"]
145
+
146
+ if not os.path.exists(thumb_path):
147
+ cursor.execute("INSERT OR REPLACE INTO asset_scans (key, scan_type, status, scanned_at) VALUES (?, ?, ?, ?)",
148
+ (key, self.scan_type, "error_missing_thumb", datetime.now().isoformat()))
149
+ continue
150
+
151
+ try:
152
+ embedding = ai_service.generate_image_embedding(thumb_path)
153
+ import struct
154
+ buf = struct.pack(f"{len(embedding)}f", *embedding)
155
+ cursor.execute("INSERT OR REPLACE INTO asset_embeddings (key, embedding) VALUES (?, ?)", (key, buf))
156
+ cursor.execute("INSERT OR REPLACE INTO asset_scans (key, scan_type, status, scanned_at, model_name) VALUES (?, ?, ?, ?, ?)",
157
+ (key, self.scan_type, "completed", datetime.now().isoformat(), self.model_name))
158
+ except Exception as e:
159
+ ai_logger.error(f"AI Scan error for {key}: {e}")
160
+ cursor.execute("INSERT OR REPLACE INTO asset_scans (key, scan_type, status, scanned_at) VALUES (?, ?, ?, ?)",
161
+ (key, self.scan_type, f"error: {str(e)}", datetime.now().isoformat()))
162
+
163
+ conn.commit()
164
+ conn.close()
165
+ time.sleep(1)
166
+ except Exception as e:
167
+ ai_logger.error(f"AIScraper loop error: {e}")
168
+ time.sleep(5)
169
+
170
+ class TranscriptionService:
171
+ """Handles audio transcription using faster-whisper."""
172
+ _instance = None
173
+ _model = None
174
+ _lock = threading.Lock()
175
+
176
+ def __init__(self):
177
+ self.model_size = 'base'
178
+ self._model = None
179
+
180
+ @classmethod
181
+ def get_instance(cls):
182
+ with cls._lock:
183
+ if cls._instance is None:
184
+ cls._instance = cls()
185
+ return cls._instance
186
+
187
+ def _load_model(self):
188
+ if self._model is None:
189
+ from faster_whisper import WhisperModel # type: ignore
190
+ ai_logger.info(f'Loading Whisper model {self.model_size}...')
191
+ self._model = WhisperModel(self.model_size, device='cpu', compute_type='int8')
192
+ return self._model
193
+
194
+ def transcribe(self, media_path: str) -> str:
195
+ model = self._load_model()
196
+ segments, _ = model.transcribe(media_path, beam_size=5)
197
+ return ' '.join([segment.text for segment in segments])
198
+
199
+ class TranscriptionScraper(threading.Thread):
200
+ """Background worker for transcribing audio/video files."""
201
+ def __init__(self, db_path: Path, status_callback: Optional[Callable[[int, int], None]] = None):
202
+ super().__init__(daemon=True)
203
+ self.db_path = db_path
204
+ self.status_callback = status_callback
205
+ self._stop_event = threading.Event()
206
+ self.scan_type = 'whisper_v1'
207
+
208
+ def stop(self):
209
+ self._stop_event.set()
210
+
211
+ def run(self):
212
+ try:
213
+ p = psutil.Process(os.getpid())
214
+ if os.name == 'nt':
215
+ p.nice(psutil.IDLE_PRIORITY_CLASS)
216
+ except Exception:
217
+ pass
218
+
219
+ transcriber = TranscriptionService.get_instance()
220
+
221
+ while not self._stop_event.is_set():
222
+ time.sleep(10)
223
+ try:
224
+ conn = sqlite3.connect(str(self.db_path))
225
+ conn.row_factory = sqlite3.Row
226
+ cursor = conn.cursor()
227
+
228
+ cursor.execute('''
229
+ SELECT a.key, i.full_path
230
+ FROM assets a
231
+ JOIN instances i ON a.key = i.key
232
+ LEFT JOIN media_transcripts t ON a.key = t.key
233
+ WHERE t.key IS NULL AND (i.full_path LIKE '%.mp4' OR i.full_path LIKE '%.mp3' OR i.full_path LIKE '%.m4a')
234
+ LIMIT 1
235
+ ''')
236
+
237
+ row = cursor.fetchone()
238
+ if not row:
239
+ conn.close()
240
+ continue
241
+
242
+ key = row['key']
243
+ path = row['full_path']
244
+
245
+ transcript = transcriber.transcribe(path)
246
+
247
+ cursor.execute('''
248
+ INSERT INTO media_transcripts (key, transcript, language, processed_at)
249
+ VALUES (?, ?, ?, ?)
250
+ ''', (key, transcript, 'en', datetime.now().isoformat()))
251
+
252
+ conn.commit()
253
+ conn.close()
254
+
255
+ except Exception as e:
256
+ ai_logger.error(f'Transcription error: {e}')
257
+ time.sleep(10)