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.
- image_annex-0.1.2/PKG-INFO +18 -0
- image_annex-0.1.2/README.md +86 -0
- image_annex-0.1.2/pyproject.toml +59 -0
- image_annex-0.1.2/setup.cfg +4 -0
- image_annex-0.1.2/src/image_annex/__init__.py +0 -0
- image_annex-0.1.2/src/image_annex/cli/main.py +188 -0
- image_annex-0.1.2/src/image_annex/core/__init__.py +0 -0
- image_annex-0.1.2/src/image_annex/core/ai.py +257 -0
- image_annex-0.1.2/src/image_annex/core/catalog.py +114 -0
- image_annex-0.1.2/src/image_annex/core/config.py +129 -0
- image_annex-0.1.2/src/image_annex/core/gallery.py +461 -0
- image_annex-0.1.2/src/image_annex/core/hook.py +340 -0
- image_annex-0.1.2/src/image_annex/core/models.py +108 -0
- image_annex-0.1.2/src/image_annex/core/scanner.py +170 -0
- image_annex-0.1.2/src/image_annex/core/service.py +591 -0
- image_annex-0.1.2/src/image_annex/tui/__init__.py +0 -0
- image_annex-0.1.2/src/image_annex/tui/app.py +1092 -0
- image_annex-0.1.2/src/image_annex/tui/image_renderer.py +47 -0
- image_annex-0.1.2/src/image_annex.egg-info/PKG-INFO +18 -0
- image_annex-0.1.2/src/image_annex.egg-info/SOURCES.txt +22 -0
- image_annex-0.1.2/src/image_annex.egg-info/dependency_links.txt +1 -0
- image_annex-0.1.2/src/image_annex.egg-info/entry_points.txt +5 -0
- image_annex-0.1.2/src/image_annex.egg-info/requires.txt +13 -0
- image_annex-0.1.2/src/image_annex.egg-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
|
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)
|