schenesort 2.1.1__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.
- schenesort/__init__.py +1 -0
- schenesort/cli.py +1323 -0
- schenesort/config.py +108 -0
- schenesort/db.py +341 -0
- schenesort/tui/__init__.py +5 -0
- schenesort/tui/app.py +180 -0
- schenesort/tui/widgets/__init__.py +6 -0
- schenesort/tui/widgets/image_preview.py +97 -0
- schenesort/tui/widgets/metadata_panel.py +161 -0
- schenesort/xmp.py +294 -0
- schenesort-2.1.1.dist-info/METADATA +318 -0
- schenesort-2.1.1.dist-info/RECORD +15 -0
- schenesort-2.1.1.dist-info/WHEEL +4 -0
- schenesort-2.1.1.dist-info/entry_points.txt +2 -0
- schenesort-2.1.1.dist-info/licenses/LICENSE +21 -0
schenesort/cli.py
ADDED
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
"""Schenesort CLI - Wallpaper collection management tool."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import filetype
|
|
9
|
+
import ollama
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from schenesort.config import load_config
|
|
13
|
+
from schenesort.xmp import get_recommended_screen, get_xmp_path, read_xmp, write_xmp
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="schenesort",
|
|
17
|
+
help="Wallpaper collection management CLI tool.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
VALID_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".tif"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sanitise_filename(name: str) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Sanitize filename to be Unix-friendly.
|
|
27
|
+
|
|
28
|
+
Rules:
|
|
29
|
+
- Convert to lowercase
|
|
30
|
+
- Replace whitespace with underscores
|
|
31
|
+
- Remove punctuation except underscore, hyphen, dot
|
|
32
|
+
- Collapse consecutive underscores/hyphens
|
|
33
|
+
- Remove leading/trailing underscores/hyphens from name (not extension)
|
|
34
|
+
- Preserve hidden file prefix (leading dot)
|
|
35
|
+
"""
|
|
36
|
+
if not name:
|
|
37
|
+
return name
|
|
38
|
+
|
|
39
|
+
# Handle hidden files (start with dot)
|
|
40
|
+
hidden_prefix = ""
|
|
41
|
+
if name.startswith("."):
|
|
42
|
+
hidden_prefix = "."
|
|
43
|
+
name = name[1:]
|
|
44
|
+
if not name:
|
|
45
|
+
return hidden_prefix
|
|
46
|
+
|
|
47
|
+
# Split into stem and extension
|
|
48
|
+
if "." in name:
|
|
49
|
+
# Find the last dot for extension
|
|
50
|
+
last_dot = name.rfind(".")
|
|
51
|
+
stem = name[:last_dot]
|
|
52
|
+
ext = name[last_dot:]
|
|
53
|
+
# If ext is just a dot (no actual extension), treat as part of stem
|
|
54
|
+
if ext == ".":
|
|
55
|
+
stem = name
|
|
56
|
+
ext = ""
|
|
57
|
+
else:
|
|
58
|
+
stem = name
|
|
59
|
+
ext = ""
|
|
60
|
+
|
|
61
|
+
# Lowercase
|
|
62
|
+
stem = stem.lower()
|
|
63
|
+
ext = ext.lower()
|
|
64
|
+
|
|
65
|
+
# Replace whitespace with underscore
|
|
66
|
+
stem = re.sub(r"\s+", "_", stem)
|
|
67
|
+
|
|
68
|
+
# Remove unwanted punctuation (keep alphanumeric, underscore, hyphen)
|
|
69
|
+
stem = re.sub(r"[^\w\-]", "", stem)
|
|
70
|
+
|
|
71
|
+
# Collapse multiple underscores or hyphens
|
|
72
|
+
stem = re.sub(r"_+", "_", stem)
|
|
73
|
+
stem = re.sub(r"-+", "-", stem)
|
|
74
|
+
stem = re.sub(r"[-_]{2,}", "_", stem) # mixed sequences become single underscore
|
|
75
|
+
|
|
76
|
+
# Remove leading/trailing underscores and hyphens
|
|
77
|
+
stem = stem.strip("_-")
|
|
78
|
+
|
|
79
|
+
# Handle edge case where stem becomes empty
|
|
80
|
+
if not stem:
|
|
81
|
+
stem = "unnamed"
|
|
82
|
+
|
|
83
|
+
return hidden_prefix + stem + ext
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_actual_image_type(filepath: Path) -> str | None:
|
|
87
|
+
"""Detect actual image type by reading file header."""
|
|
88
|
+
kind = filetype.guess(filepath)
|
|
89
|
+
if kind is None:
|
|
90
|
+
return None
|
|
91
|
+
if kind.mime.startswith("image/"):
|
|
92
|
+
ext = kind.extension
|
|
93
|
+
if ext == "jpeg":
|
|
94
|
+
return ".jpg"
|
|
95
|
+
return f".{ext}"
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_extension(filepath: Path) -> tuple[bool, str | None]:
|
|
100
|
+
"""
|
|
101
|
+
Validate that a file's extension matches its actual content type.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Tuple of (is_valid, actual_extension or None if not an image)
|
|
105
|
+
"""
|
|
106
|
+
actual_type = get_actual_image_type(filepath)
|
|
107
|
+
if actual_type is None:
|
|
108
|
+
return False, None
|
|
109
|
+
|
|
110
|
+
current_ext = filepath.suffix.lower()
|
|
111
|
+
if current_ext in (".jpg", ".jpeg") and actual_type in (".jpg", ".jpeg"):
|
|
112
|
+
return True, actual_type
|
|
113
|
+
|
|
114
|
+
return current_ext == actual_type, actual_type
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_image_dimensions(filepath: Path) -> tuple[int, int]:
|
|
118
|
+
"""Get image width and height using PIL."""
|
|
119
|
+
try:
|
|
120
|
+
from PIL import Image
|
|
121
|
+
|
|
122
|
+
with Image.open(filepath) as img:
|
|
123
|
+
return img.size # (width, height)
|
|
124
|
+
except Exception:
|
|
125
|
+
return 0, 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def sanitise(
|
|
130
|
+
path: Annotated[Path, typer.Argument(help="Directory or file to sanitise")],
|
|
131
|
+
dry_run: Annotated[
|
|
132
|
+
bool, typer.Option("--dry-run", "-n", help="Show what would be renamed")
|
|
133
|
+
] = False,
|
|
134
|
+
recursive: Annotated[
|
|
135
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
136
|
+
] = False,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Convert filenames to lowercase and replace spaces with underscores."""
|
|
139
|
+
path = path.resolve()
|
|
140
|
+
|
|
141
|
+
if not path.exists():
|
|
142
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
|
|
145
|
+
if path.is_file():
|
|
146
|
+
files = [path]
|
|
147
|
+
else:
|
|
148
|
+
pattern = "**/*" if recursive else "*"
|
|
149
|
+
files = [f for f in path.glob(pattern) if f.is_file()]
|
|
150
|
+
|
|
151
|
+
renamed_count = 0
|
|
152
|
+
for filepath in files:
|
|
153
|
+
# Skip .xmp sidecar files - they follow their parent image
|
|
154
|
+
if filepath.suffix.lower() == ".xmp":
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
old_name = filepath.name
|
|
158
|
+
new_name = sanitise_filename(old_name)
|
|
159
|
+
|
|
160
|
+
if old_name != new_name:
|
|
161
|
+
new_path = filepath.parent / new_name
|
|
162
|
+
if dry_run:
|
|
163
|
+
typer.echo(f"Would rename: {filepath} -> {new_path}")
|
|
164
|
+
else:
|
|
165
|
+
if new_path.exists():
|
|
166
|
+
typer.echo(
|
|
167
|
+
f"Skipping: {filepath} (target '{new_path}' already exists)",
|
|
168
|
+
err=True,
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
filepath.rename(new_path)
|
|
172
|
+
typer.echo(f"Renamed: {old_name} -> {new_name}")
|
|
173
|
+
|
|
174
|
+
# Handle associated XMP sidecar file
|
|
175
|
+
old_xmp = get_xmp_path(filepath)
|
|
176
|
+
if old_xmp.exists():
|
|
177
|
+
new_xmp = get_xmp_path(new_path)
|
|
178
|
+
if dry_run:
|
|
179
|
+
typer.echo(f"Would rename: {old_xmp} -> {new_xmp}")
|
|
180
|
+
else:
|
|
181
|
+
if new_xmp.exists():
|
|
182
|
+
typer.echo(
|
|
183
|
+
f"Skipping: {old_xmp} (target '{new_xmp}' already exists)",
|
|
184
|
+
err=True,
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
old_xmp.rename(new_xmp)
|
|
188
|
+
typer.echo(f"Renamed: {old_xmp.name} -> {new_xmp.name}")
|
|
189
|
+
renamed_count += 1
|
|
190
|
+
|
|
191
|
+
renamed_count += 1
|
|
192
|
+
|
|
193
|
+
action = "Would rename" if dry_run else "Renamed"
|
|
194
|
+
typer.echo(f"\n{action} {renamed_count} file(s).")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def validate(
|
|
199
|
+
path: Annotated[Path, typer.Argument(help="Directory or file to validate")],
|
|
200
|
+
fix: Annotated[bool, typer.Option("--fix", "-f", help="Fix incorrect extensions")] = False,
|
|
201
|
+
recursive: Annotated[
|
|
202
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
203
|
+
] = False,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Validate that image file extensions match their actual content type."""
|
|
206
|
+
path = path.resolve()
|
|
207
|
+
|
|
208
|
+
if not path.exists():
|
|
209
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
if path.is_file():
|
|
213
|
+
files = [path]
|
|
214
|
+
else:
|
|
215
|
+
pattern = "**/*" if recursive else "*"
|
|
216
|
+
files = [f for f in path.glob(pattern) if f.is_file()]
|
|
217
|
+
|
|
218
|
+
valid_count = 0
|
|
219
|
+
invalid_count = 0
|
|
220
|
+
non_image_count = 0
|
|
221
|
+
fixed_count = 0
|
|
222
|
+
|
|
223
|
+
for filepath in files:
|
|
224
|
+
if filepath.suffix.lower() not in VALID_IMAGE_EXTENSIONS:
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
is_valid, actual_ext = validate_extension(filepath)
|
|
228
|
+
|
|
229
|
+
if actual_ext is None:
|
|
230
|
+
typer.echo(f"[NOT IMAGE] {filepath}")
|
|
231
|
+
non_image_count += 1
|
|
232
|
+
elif is_valid:
|
|
233
|
+
valid_count += 1
|
|
234
|
+
else:
|
|
235
|
+
typer.echo(f"[INVALID] {filepath} (actual: {actual_ext})")
|
|
236
|
+
invalid_count += 1
|
|
237
|
+
|
|
238
|
+
if fix:
|
|
239
|
+
new_path = filepath.with_suffix(actual_ext)
|
|
240
|
+
if new_path.exists():
|
|
241
|
+
typer.echo(f" Cannot fix: target '{new_path}' already exists", err=True)
|
|
242
|
+
else:
|
|
243
|
+
filepath.rename(new_path)
|
|
244
|
+
typer.echo(f" Fixed: {filepath.name} -> {new_path.name}")
|
|
245
|
+
fixed_count += 1
|
|
246
|
+
|
|
247
|
+
typer.echo("\nValidation complete:")
|
|
248
|
+
typer.echo(f" Valid: {valid_count}")
|
|
249
|
+
typer.echo(f" Invalid: {invalid_count}")
|
|
250
|
+
typer.echo(f" Not images: {non_image_count}")
|
|
251
|
+
if fix:
|
|
252
|
+
typer.echo(f" Fixed: {fixed_count}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command()
|
|
256
|
+
def info(
|
|
257
|
+
path: Annotated[Path, typer.Argument(help="Directory to analyze")],
|
|
258
|
+
recursive: Annotated[
|
|
259
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
260
|
+
] = False,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Show information about wallpaper collection."""
|
|
263
|
+
path = path.resolve()
|
|
264
|
+
|
|
265
|
+
if not path.exists():
|
|
266
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
267
|
+
raise typer.Exit(1)
|
|
268
|
+
|
|
269
|
+
if not path.is_dir():
|
|
270
|
+
typer.echo(f"Error: Path '{path}' is not a directory.", err=True)
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
|
|
273
|
+
pattern = "**/*" if recursive else "*"
|
|
274
|
+
files = [f for f in path.glob(pattern) if f.is_file()]
|
|
275
|
+
|
|
276
|
+
ext_counts: dict[str, int] = {}
|
|
277
|
+
total_size = 0
|
|
278
|
+
files_with_spaces = 0
|
|
279
|
+
|
|
280
|
+
for filepath in files:
|
|
281
|
+
ext = filepath.suffix.lower() or "(no extension)"
|
|
282
|
+
ext_counts[ext] = ext_counts.get(ext, 0) + 1
|
|
283
|
+
total_size += filepath.stat().st_size
|
|
284
|
+
if " " in filepath.name:
|
|
285
|
+
files_with_spaces += 1
|
|
286
|
+
|
|
287
|
+
typer.echo(f"Collection: {path}")
|
|
288
|
+
typer.echo(f"Total files: {len(files)}")
|
|
289
|
+
typer.echo(f"Total size: {total_size / (1024 * 1024):.2f} MB")
|
|
290
|
+
typer.echo(f"Files with spaces in name: {files_with_spaces}")
|
|
291
|
+
typer.echo("\nExtensions:")
|
|
292
|
+
for ext, count in sorted(ext_counts.items(), key=lambda x: -x[1]):
|
|
293
|
+
typer.echo(f" {ext}: {count}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.command()
|
|
297
|
+
def cleanup(
|
|
298
|
+
path: Annotated[Path, typer.Argument(help="Directory to clean up")],
|
|
299
|
+
dry_run: Annotated[
|
|
300
|
+
bool, typer.Option("--dry-run", "-n", help="Show what would be deleted")
|
|
301
|
+
] = False,
|
|
302
|
+
recursive: Annotated[
|
|
303
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
304
|
+
] = False,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Delete orphaned XMP sidecar files that have no corresponding image."""
|
|
307
|
+
path = path.resolve()
|
|
308
|
+
|
|
309
|
+
if not path.exists():
|
|
310
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
311
|
+
raise typer.Exit(1)
|
|
312
|
+
|
|
313
|
+
if not path.is_dir():
|
|
314
|
+
typer.echo(f"Error: Path '{path}' is not a directory.", err=True)
|
|
315
|
+
raise typer.Exit(1)
|
|
316
|
+
|
|
317
|
+
pattern = "**/*.xmp" if recursive else "*.xmp"
|
|
318
|
+
xmp_files = list(path.glob(pattern))
|
|
319
|
+
|
|
320
|
+
if not xmp_files:
|
|
321
|
+
typer.echo("No XMP sidecar files found.")
|
|
322
|
+
raise typer.Exit(0)
|
|
323
|
+
|
|
324
|
+
orphaned_count = 0
|
|
325
|
+
total_size = 0
|
|
326
|
+
|
|
327
|
+
for xmp_path in xmp_files:
|
|
328
|
+
# The image path is the XMP path without the .xmp suffix
|
|
329
|
+
# e.g., "image.jpg.xmp" -> "image.jpg"
|
|
330
|
+
image_path = xmp_path.parent / xmp_path.stem
|
|
331
|
+
|
|
332
|
+
if not image_path.exists():
|
|
333
|
+
size = xmp_path.stat().st_size
|
|
334
|
+
total_size += size
|
|
335
|
+
|
|
336
|
+
if dry_run:
|
|
337
|
+
typer.echo(f"Would delete: {xmp_path}")
|
|
338
|
+
else:
|
|
339
|
+
xmp_path.unlink()
|
|
340
|
+
typer.echo(f"Deleted: {xmp_path}")
|
|
341
|
+
|
|
342
|
+
orphaned_count += 1
|
|
343
|
+
|
|
344
|
+
if orphaned_count == 0:
|
|
345
|
+
typer.echo("No orphaned XMP sidecars found.")
|
|
346
|
+
else:
|
|
347
|
+
action = "Would delete" if dry_run else "Deleted"
|
|
348
|
+
size_kb = total_size / 1024
|
|
349
|
+
typer.echo(f"\n{action} {orphaned_count} orphaned sidecar(s) ({size_kb:.1f} KB).")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.command()
|
|
353
|
+
def index(
|
|
354
|
+
path: Annotated[Path, typer.Argument(help="Directory to index")],
|
|
355
|
+
recursive: Annotated[
|
|
356
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
357
|
+
] = True,
|
|
358
|
+
prune: Annotated[
|
|
359
|
+
bool, typer.Option("--prune", "-p", help="Remove entries for deleted files")
|
|
360
|
+
] = False,
|
|
361
|
+
rebuild: Annotated[bool, typer.Option("--rebuild", help="Rebuild index from scratch")] = False,
|
|
362
|
+
) -> None:
|
|
363
|
+
"""Build or update the SQLite index of wallpaper metadata."""
|
|
364
|
+
from schenesort.db import WallpaperDB
|
|
365
|
+
|
|
366
|
+
path = path.resolve()
|
|
367
|
+
|
|
368
|
+
if not path.exists():
|
|
369
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
370
|
+
raise typer.Exit(1)
|
|
371
|
+
|
|
372
|
+
if not path.is_dir():
|
|
373
|
+
typer.echo(f"Error: Path '{path}' is not a directory.", err=True)
|
|
374
|
+
raise typer.Exit(1)
|
|
375
|
+
|
|
376
|
+
with WallpaperDB() as db:
|
|
377
|
+
if rebuild:
|
|
378
|
+
typer.echo("Rebuilding index from scratch...")
|
|
379
|
+
db.clear()
|
|
380
|
+
|
|
381
|
+
pattern = "**/*" if recursive else "*"
|
|
382
|
+
image_files = [
|
|
383
|
+
f
|
|
384
|
+
for f in path.glob(pattern)
|
|
385
|
+
if f.is_file() and f.suffix.lower() in VALID_IMAGE_EXTENSIONS
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
typer.echo(f"Indexing {len(image_files)} image(s)...")
|
|
389
|
+
|
|
390
|
+
indexed = 0
|
|
391
|
+
for filepath in image_files:
|
|
392
|
+
xmp_path = get_xmp_path(filepath)
|
|
393
|
+
if xmp_path.exists():
|
|
394
|
+
metadata = read_xmp(filepath)
|
|
395
|
+
db.index_image(filepath, metadata)
|
|
396
|
+
indexed += 1
|
|
397
|
+
|
|
398
|
+
db.commit()
|
|
399
|
+
|
|
400
|
+
if prune:
|
|
401
|
+
valid_paths = {str(f) for f in image_files}
|
|
402
|
+
pruned = db.prune(valid_paths)
|
|
403
|
+
if pruned:
|
|
404
|
+
typer.echo(f"Pruned {pruned} removed file(s) from index.")
|
|
405
|
+
|
|
406
|
+
typer.echo(f"Indexed {indexed} wallpaper(s) with metadata.")
|
|
407
|
+
|
|
408
|
+
# Show stats
|
|
409
|
+
stats = db.stats()
|
|
410
|
+
typer.echo(f"\nDatabase: {db.db_path}")
|
|
411
|
+
typer.echo(f"Total indexed: {stats.get('total_wallpapers', 0)}")
|
|
412
|
+
typer.echo(f"With metadata: {stats.get('with_metadata', 0)}")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@app.command()
|
|
416
|
+
def get(
|
|
417
|
+
tag: Annotated[str | None, typer.Option("--tag", "-t", help="Filter by tag")] = None,
|
|
418
|
+
mood: Annotated[str | None, typer.Option("--mood", "-m", help="Filter by mood")] = None,
|
|
419
|
+
color: Annotated[str | None, typer.Option("--color", "-c", help="Filter by color")] = None,
|
|
420
|
+
style: Annotated[str | None, typer.Option("--style", "-s", help="Filter by style")] = None,
|
|
421
|
+
subject: Annotated[str | None, typer.Option("--subject", help="Filter by subject")] = None,
|
|
422
|
+
time: Annotated[str | None, typer.Option("--time", help="Filter by time of day")] = None,
|
|
423
|
+
screen: Annotated[
|
|
424
|
+
str | None, typer.Option("--screen", help="Filter by recommended screen (4K, 1440p, etc)")
|
|
425
|
+
] = None,
|
|
426
|
+
min_width: Annotated[
|
|
427
|
+
int | None, typer.Option("--min-width", help="Minimum width in pixels")
|
|
428
|
+
] = None,
|
|
429
|
+
min_height: Annotated[
|
|
430
|
+
int | None, typer.Option("--min-height", help="Minimum height in pixels")
|
|
431
|
+
] = None,
|
|
432
|
+
search: Annotated[
|
|
433
|
+
str | None, typer.Option("--search", "-q", help="Search description, scene, style, subject")
|
|
434
|
+
] = None,
|
|
435
|
+
limit: Annotated[
|
|
436
|
+
int | None, typer.Option("--limit", "-n", help="Maximum number of results")
|
|
437
|
+
] = None,
|
|
438
|
+
random: Annotated[
|
|
439
|
+
bool, typer.Option("--random", "-R", help="Return results in random order")
|
|
440
|
+
] = False,
|
|
441
|
+
one: Annotated[
|
|
442
|
+
bool, typer.Option("--one", "-1", help="Return single random result (shortcut for -R -n1)")
|
|
443
|
+
] = False,
|
|
444
|
+
paths_only: Annotated[
|
|
445
|
+
bool, typer.Option("--paths-only", "-p", help="Output only file paths (for scripting)")
|
|
446
|
+
] = False,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Query wallpapers by metadata attributes."""
|
|
449
|
+
from schenesort.db import WallpaperDB
|
|
450
|
+
|
|
451
|
+
if one:
|
|
452
|
+
random = True
|
|
453
|
+
limit = 1
|
|
454
|
+
|
|
455
|
+
with WallpaperDB() as db:
|
|
456
|
+
results = db.query(
|
|
457
|
+
tag=tag,
|
|
458
|
+
mood=mood,
|
|
459
|
+
color=color,
|
|
460
|
+
style=style,
|
|
461
|
+
subject=subject,
|
|
462
|
+
time_of_day=time,
|
|
463
|
+
screen=screen,
|
|
464
|
+
min_width=min_width,
|
|
465
|
+
min_height=min_height,
|
|
466
|
+
search=search,
|
|
467
|
+
limit=limit,
|
|
468
|
+
random=random,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
if not results:
|
|
472
|
+
typer.echo("No wallpapers found matching criteria.", err=True)
|
|
473
|
+
raise typer.Exit(1)
|
|
474
|
+
|
|
475
|
+
if paths_only:
|
|
476
|
+
for r in results:
|
|
477
|
+
typer.echo(r["path"])
|
|
478
|
+
else:
|
|
479
|
+
typer.echo(f"Found {len(results)} wallpaper(s):\n")
|
|
480
|
+
for r in results:
|
|
481
|
+
typer.echo(f"{r['path']}")
|
|
482
|
+
if r.get("description"):
|
|
483
|
+
typer.echo(f" {r['description']}")
|
|
484
|
+
details = []
|
|
485
|
+
if r.get("style"):
|
|
486
|
+
details.append(r["style"])
|
|
487
|
+
if r.get("subject"):
|
|
488
|
+
details.append(r["subject"])
|
|
489
|
+
if r.get("recommended_screen"):
|
|
490
|
+
details.append(r["recommended_screen"])
|
|
491
|
+
if details:
|
|
492
|
+
typer.echo(f" [{', '.join(details)}]")
|
|
493
|
+
typer.echo()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@app.command()
|
|
497
|
+
def stats() -> None:
|
|
498
|
+
"""Show statistics about the indexed wallpaper collection."""
|
|
499
|
+
from schenesort.db import WallpaperDB
|
|
500
|
+
|
|
501
|
+
with WallpaperDB() as db:
|
|
502
|
+
s = db.stats()
|
|
503
|
+
|
|
504
|
+
if not s.get("total_wallpapers"):
|
|
505
|
+
typer.echo("No wallpapers indexed. Run 'schenesort index <path>' first.")
|
|
506
|
+
raise typer.Exit(1)
|
|
507
|
+
|
|
508
|
+
typer.echo(f"Database: {db.db_path}\n")
|
|
509
|
+
typer.echo(f"Total wallpapers: {s['total_wallpapers']}")
|
|
510
|
+
typer.echo(f"With metadata: {s['with_metadata']}")
|
|
511
|
+
|
|
512
|
+
if s.get("by_screen"):
|
|
513
|
+
typer.echo("\nBy screen size:")
|
|
514
|
+
for screen, count in s["by_screen"].items():
|
|
515
|
+
typer.echo(f" {screen}: {count}")
|
|
516
|
+
|
|
517
|
+
if s.get("by_style"):
|
|
518
|
+
typer.echo("\nBy style:")
|
|
519
|
+
for style, count in s["by_style"].items():
|
|
520
|
+
typer.echo(f" {style}: {count}")
|
|
521
|
+
|
|
522
|
+
if s.get("by_subject"):
|
|
523
|
+
typer.echo("\nBy subject:")
|
|
524
|
+
for subject, count in s["by_subject"].items():
|
|
525
|
+
typer.echo(f" {subject}: {count}")
|
|
526
|
+
|
|
527
|
+
if s.get("top_tags"):
|
|
528
|
+
typer.echo("\nTop tags:")
|
|
529
|
+
for tag, count in list(s["top_tags"].items())[:10]:
|
|
530
|
+
typer.echo(f" {tag}: {count}")
|
|
531
|
+
|
|
532
|
+
if s.get("top_moods"):
|
|
533
|
+
typer.echo("\nTop moods:")
|
|
534
|
+
for mood, count in s["top_moods"].items():
|
|
535
|
+
typer.echo(f" {mood}: {count}")
|
|
536
|
+
|
|
537
|
+
if s.get("top_colors"):
|
|
538
|
+
typer.echo("\nTop colors:")
|
|
539
|
+
for color, count in s["top_colors"].items():
|
|
540
|
+
typer.echo(f" {color}: {count}")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@app.command()
|
|
544
|
+
def browse(
|
|
545
|
+
path: Annotated[Path, typer.Argument(help="Directory or file to browse")],
|
|
546
|
+
recursive: Annotated[
|
|
547
|
+
bool, typer.Option("--recursive", "-r", help="Browse directories recursively")
|
|
548
|
+
] = False,
|
|
549
|
+
) -> None:
|
|
550
|
+
"""Browse wallpapers in a terminal UI with image preview and metadata display."""
|
|
551
|
+
from schenesort.tui import WallpaperBrowser
|
|
552
|
+
|
|
553
|
+
path = path.resolve()
|
|
554
|
+
|
|
555
|
+
if not path.exists():
|
|
556
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
557
|
+
raise typer.Exit(1)
|
|
558
|
+
|
|
559
|
+
app_instance = WallpaperBrowser(path, recursive=recursive)
|
|
560
|
+
app_instance.run()
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@app.command()
|
|
564
|
+
def config(
|
|
565
|
+
create: Annotated[
|
|
566
|
+
bool, typer.Option("--create", "-c", help="Create default config file if missing")
|
|
567
|
+
] = False,
|
|
568
|
+
) -> None:
|
|
569
|
+
"""Show or create the configuration file."""
|
|
570
|
+
from schenesort.config import create_default_config, get_config_path
|
|
571
|
+
|
|
572
|
+
config_path = get_config_path()
|
|
573
|
+
|
|
574
|
+
if create:
|
|
575
|
+
path = create_default_config()
|
|
576
|
+
if path.exists():
|
|
577
|
+
typer.echo(f"Config file: {path}")
|
|
578
|
+
if path == config_path:
|
|
579
|
+
typer.echo("(already existed)" if not create else "(created)")
|
|
580
|
+
else:
|
|
581
|
+
typer.echo(f"Config file: {config_path}")
|
|
582
|
+
if config_path.exists():
|
|
583
|
+
typer.echo("\nCurrent settings:")
|
|
584
|
+
cfg = load_config()
|
|
585
|
+
typer.echo(f" ollama.host: {cfg.ollama_host or '(default: localhost:11434)'}")
|
|
586
|
+
typer.echo(f" ollama.model: {cfg.ollama_model}")
|
|
587
|
+
if cfg.wallpaper_path:
|
|
588
|
+
typer.echo(f" paths.wallpaper: {cfg.wallpaper_path}")
|
|
589
|
+
else:
|
|
590
|
+
typer.echo("(file does not exist, use --create to create)")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
DEFAULT_MODEL = "llava"
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def get_ollama_settings(
|
|
597
|
+
host: str | None = None, model: str | None = None
|
|
598
|
+
) -> tuple[str | None, str]:
|
|
599
|
+
"""Get Ollama host and model, using config defaults if not specified."""
|
|
600
|
+
config = load_config()
|
|
601
|
+
effective_host = host if host is not None else (config.ollama_host or None)
|
|
602
|
+
effective_model = model if model is not None else config.ollama_model
|
|
603
|
+
return effective_host, effective_model
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@app.command()
|
|
607
|
+
def models(
|
|
608
|
+
host: Annotated[
|
|
609
|
+
str | None,
|
|
610
|
+
typer.Option("--host", "-H", help="Ollama server URL (e.g., http://server:11434)"),
|
|
611
|
+
] = None,
|
|
612
|
+
) -> None:
|
|
613
|
+
"""List available models on the Ollama server."""
|
|
614
|
+
effective_host, _ = get_ollama_settings(host=host)
|
|
615
|
+
try:
|
|
616
|
+
client = ollama.Client(host=effective_host) if effective_host else ollama
|
|
617
|
+
response = client.list()
|
|
618
|
+
|
|
619
|
+
if not response.models:
|
|
620
|
+
typer.echo("No models found.")
|
|
621
|
+
raise typer.Exit(0)
|
|
622
|
+
|
|
623
|
+
server_info = effective_host or "localhost:11434"
|
|
624
|
+
typer.echo(f"Models on {server_info}:\n")
|
|
625
|
+
|
|
626
|
+
for model in response.models:
|
|
627
|
+
name = model.model
|
|
628
|
+
size_gb = (model.size or 0) / (1024**3)
|
|
629
|
+
param_size = model.details.parameter_size if model.details else ""
|
|
630
|
+
typer.echo(f" {name} ({size_gb:.1f} GB, {param_size})")
|
|
631
|
+
|
|
632
|
+
except ollama.ResponseError as e:
|
|
633
|
+
typer.echo(f"Ollama error: {e}", err=True)
|
|
634
|
+
raise typer.Exit(1) from None
|
|
635
|
+
except Exception as e:
|
|
636
|
+
typer.echo(f"Error connecting to Ollama: {e}", err=True)
|
|
637
|
+
raise typer.Exit(1) from None
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
DESCRIBE_PROMPT = """Describe this image in 3-5 words suitable for a filename.
|
|
641
|
+
Focus on the main subject and style. Be concise and specific.
|
|
642
|
+
Output ONLY the description, no punctuation, no explanation.
|
|
643
|
+
Example outputs: mountain sunset landscape, cyberpunk city night, abstract blue waves"""
|
|
644
|
+
|
|
645
|
+
ANALYZE_PROMPT = """Analyze this image and provide metadata in the following exact format.
|
|
646
|
+
Each field on its own line, use commas to separate multiple values.
|
|
647
|
+
Be concise and specific. Use lowercase.
|
|
648
|
+
|
|
649
|
+
Description: [3-5 word description for filename]
|
|
650
|
+
Scene: [1-3 sentence description of what the image depicts, including composition and atmosphere]
|
|
651
|
+
Tags: [comma-separated keywords, 3-6 tags]
|
|
652
|
+
Mood: [comma-separated moods like peaceful, dramatic, mysterious, vibrant, melancholic]
|
|
653
|
+
Style: [one of: photography, digital art, illustration, 3d render, anime, painting, pixel art]
|
|
654
|
+
Colors: [comma-separated dominant colors, 2-4 colors]
|
|
655
|
+
Time: [one of: day, night, sunset, sunrise, golden hour, overcast, or unknown]
|
|
656
|
+
Subject: [landscape, portrait, architecture, wildlife, abstract, space, urban, nature, fantasy]
|
|
657
|
+
|
|
658
|
+
Example output:
|
|
659
|
+
Description: neon cyberpunk city night
|
|
660
|
+
Tags: cyberpunk, city, neon, futuristic, rain
|
|
661
|
+
Mood: mysterious, vibrant
|
|
662
|
+
Style: digital art
|
|
663
|
+
Colors: purple, cyan, pink
|
|
664
|
+
Time: night
|
|
665
|
+
Subject: urban"""
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def describe_image(
|
|
669
|
+
filepath: Path,
|
|
670
|
+
model: str = DEFAULT_MODEL,
|
|
671
|
+
use_cpu: bool = False,
|
|
672
|
+
host: str | None = None,
|
|
673
|
+
) -> str | None:
|
|
674
|
+
"""Use Ollama vision model to describe an image (simple description only)."""
|
|
675
|
+
try:
|
|
676
|
+
with open(filepath, "rb") as f:
|
|
677
|
+
image_data = base64.b64encode(f.read()).decode("utf-8")
|
|
678
|
+
|
|
679
|
+
options = {"num_gpu": 0} if use_cpu else {}
|
|
680
|
+
client = ollama.Client(host=host) if host else ollama
|
|
681
|
+
|
|
682
|
+
response = client.chat(
|
|
683
|
+
model=model,
|
|
684
|
+
messages=[
|
|
685
|
+
{
|
|
686
|
+
"role": "user",
|
|
687
|
+
"content": DESCRIBE_PROMPT,
|
|
688
|
+
"images": [image_data],
|
|
689
|
+
}
|
|
690
|
+
],
|
|
691
|
+
options=options,
|
|
692
|
+
)
|
|
693
|
+
return response["message"]["content"].strip()
|
|
694
|
+
except ollama.ResponseError as e:
|
|
695
|
+
typer.echo(f"Ollama error: {e}", err=True)
|
|
696
|
+
return None
|
|
697
|
+
except Exception as e:
|
|
698
|
+
typer.echo(f"Error describing image: {e}", err=True)
|
|
699
|
+
return None
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def parse_metadata_response(response: str) -> dict[str, str | list[str]]:
|
|
703
|
+
"""Parse structured metadata response from vision model."""
|
|
704
|
+
result: dict[str, str | list[str]] = {}
|
|
705
|
+
|
|
706
|
+
for line in response.strip().split("\n"):
|
|
707
|
+
if ":" not in line:
|
|
708
|
+
continue
|
|
709
|
+
|
|
710
|
+
key, _, value = line.partition(":")
|
|
711
|
+
key = key.strip().lower()
|
|
712
|
+
value = value.strip()
|
|
713
|
+
|
|
714
|
+
if not value:
|
|
715
|
+
continue
|
|
716
|
+
|
|
717
|
+
# Fields that should be lists
|
|
718
|
+
if key in ("tags", "mood", "colors"):
|
|
719
|
+
result[key] = [v.strip().lower() for v in value.split(",") if v.strip()]
|
|
720
|
+
# Scene keeps original case (it's a sentence)
|
|
721
|
+
elif key == "scene":
|
|
722
|
+
result[key] = value
|
|
723
|
+
else:
|
|
724
|
+
result[key] = value.lower()
|
|
725
|
+
|
|
726
|
+
return result
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def analyze_image(
|
|
730
|
+
filepath: Path,
|
|
731
|
+
model: str = DEFAULT_MODEL,
|
|
732
|
+
use_cpu: bool = False,
|
|
733
|
+
host: str | None = None,
|
|
734
|
+
) -> dict[str, str | list[str]] | None:
|
|
735
|
+
"""Use Ollama vision model to analyze an image and extract full metadata."""
|
|
736
|
+
try:
|
|
737
|
+
with open(filepath, "rb") as f:
|
|
738
|
+
image_data = base64.b64encode(f.read()).decode("utf-8")
|
|
739
|
+
|
|
740
|
+
options = {"num_gpu": 0} if use_cpu else {}
|
|
741
|
+
client = ollama.Client(host=host) if host else ollama
|
|
742
|
+
|
|
743
|
+
response = client.chat(
|
|
744
|
+
model=model,
|
|
745
|
+
messages=[
|
|
746
|
+
{
|
|
747
|
+
"role": "user",
|
|
748
|
+
"content": ANALYZE_PROMPT,
|
|
749
|
+
"images": [image_data],
|
|
750
|
+
}
|
|
751
|
+
],
|
|
752
|
+
options=options,
|
|
753
|
+
)
|
|
754
|
+
return parse_metadata_response(response["message"]["content"])
|
|
755
|
+
except ollama.ResponseError as e:
|
|
756
|
+
typer.echo(f"Ollama error: {e}", err=True)
|
|
757
|
+
return None
|
|
758
|
+
except Exception as e:
|
|
759
|
+
typer.echo(f"Error analyzing image: {e}", err=True)
|
|
760
|
+
return None
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@app.command()
|
|
764
|
+
@app.command("rename", hidden=True)
|
|
765
|
+
def describe(
|
|
766
|
+
path: Annotated[Path, typer.Argument(help="Directory or file to describe and rename")],
|
|
767
|
+
dry_run: Annotated[
|
|
768
|
+
bool, typer.Option("--dry-run", "-n", help="Show what would be renamed")
|
|
769
|
+
] = False,
|
|
770
|
+
recursive: Annotated[
|
|
771
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
772
|
+
] = False,
|
|
773
|
+
model: Annotated[
|
|
774
|
+
str | None, typer.Option("--model", "-m", help="Ollama vision model to use")
|
|
775
|
+
] = None,
|
|
776
|
+
cpu: Annotated[bool, typer.Option("--cpu", help="Use CPU only (no GPU acceleration)")] = False,
|
|
777
|
+
host: Annotated[
|
|
778
|
+
str | None,
|
|
779
|
+
typer.Option("--host", "-H", help="Ollama server URL (e.g., http://server:11434)"),
|
|
780
|
+
] = None,
|
|
781
|
+
) -> None:
|
|
782
|
+
"""Rename images based on AI-generated descriptions using Ollama."""
|
|
783
|
+
effective_host, effective_model = get_ollama_settings(host=host, model=model)
|
|
784
|
+
|
|
785
|
+
path = path.resolve()
|
|
786
|
+
|
|
787
|
+
if not path.exists():
|
|
788
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
789
|
+
raise typer.Exit(1)
|
|
790
|
+
|
|
791
|
+
if path.is_file():
|
|
792
|
+
files = [path]
|
|
793
|
+
else:
|
|
794
|
+
pattern = "**/*" if recursive else "*"
|
|
795
|
+
files = [f for f in path.glob(pattern) if f.is_file()]
|
|
796
|
+
|
|
797
|
+
# Filter to only image files
|
|
798
|
+
image_files = [f for f in files if f.suffix.lower() in VALID_IMAGE_EXTENSIONS]
|
|
799
|
+
|
|
800
|
+
if not image_files:
|
|
801
|
+
typer.echo("No image files found.")
|
|
802
|
+
raise typer.Exit(0)
|
|
803
|
+
|
|
804
|
+
typer.echo(f"Processing {len(image_files)} image(s) with model '{effective_model}'...\n")
|
|
805
|
+
|
|
806
|
+
renamed_count = 0
|
|
807
|
+
skipped_count = 0
|
|
808
|
+
|
|
809
|
+
for filepath in image_files:
|
|
810
|
+
typer.echo(f"Analyzing: {filepath.name}...", nl=False)
|
|
811
|
+
|
|
812
|
+
description = describe_image(filepath, effective_model, use_cpu=cpu, host=effective_host)
|
|
813
|
+
if not description:
|
|
814
|
+
typer.echo(" [FAILED]")
|
|
815
|
+
skipped_count += 1
|
|
816
|
+
continue
|
|
817
|
+
|
|
818
|
+
# Create new filename from description
|
|
819
|
+
new_stem = sanitise_filename(description)
|
|
820
|
+
new_name = new_stem + filepath.suffix.lower()
|
|
821
|
+
|
|
822
|
+
typer.echo(f" -> {description}")
|
|
823
|
+
|
|
824
|
+
if filepath.name == new_name:
|
|
825
|
+
typer.echo(" (no change needed)")
|
|
826
|
+
continue
|
|
827
|
+
|
|
828
|
+
new_path = filepath.parent / new_name
|
|
829
|
+
|
|
830
|
+
# Handle filename collisions by adding a number
|
|
831
|
+
counter = 1
|
|
832
|
+
while new_path.exists() and new_path != filepath:
|
|
833
|
+
new_name = f"{new_stem}_{counter}{filepath.suffix.lower()}"
|
|
834
|
+
new_path = filepath.parent / new_name
|
|
835
|
+
counter += 1
|
|
836
|
+
|
|
837
|
+
if dry_run:
|
|
838
|
+
typer.echo(f" Would rename: {filepath.name} -> {new_name}")
|
|
839
|
+
else:
|
|
840
|
+
# Rename any existing XMP sidecar first
|
|
841
|
+
old_xmp = get_xmp_path(filepath)
|
|
842
|
+
if old_xmp.exists():
|
|
843
|
+
new_xmp = get_xmp_path(new_path)
|
|
844
|
+
old_xmp.rename(new_xmp)
|
|
845
|
+
|
|
846
|
+
filepath.rename(new_path)
|
|
847
|
+
typer.echo(f" Renamed: {filepath.name} -> {new_name}")
|
|
848
|
+
|
|
849
|
+
# Save description to XMP sidecar
|
|
850
|
+
metadata = read_xmp(new_path)
|
|
851
|
+
metadata.description = description
|
|
852
|
+
metadata.ai_model = effective_model
|
|
853
|
+
|
|
854
|
+
# Add image dimensions
|
|
855
|
+
width, height = get_image_dimensions(new_path)
|
|
856
|
+
if width and height:
|
|
857
|
+
metadata.width = width
|
|
858
|
+
metadata.height = height
|
|
859
|
+
metadata.recommended_screen = get_recommended_screen(width, height)
|
|
860
|
+
|
|
861
|
+
write_xmp(new_path, metadata)
|
|
862
|
+
typer.echo(f" Saved metadata to {get_xmp_path(new_path).name}")
|
|
863
|
+
renamed_count += 1
|
|
864
|
+
|
|
865
|
+
action = "Would rename" if dry_run else "Renamed"
|
|
866
|
+
typer.echo(f"\n{action} {renamed_count} file(s), skipped {skipped_count}.")
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# Metadata subcommand group
|
|
870
|
+
metadata_app = typer.Typer(help="Manage image metadata in XMP sidecar files.")
|
|
871
|
+
app.add_typer(metadata_app, name="metadata")
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
@metadata_app.command("show")
|
|
875
|
+
def metadata_show(
|
|
876
|
+
path: Annotated[Path, typer.Argument(help="Image file or directory")],
|
|
877
|
+
recursive: Annotated[
|
|
878
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
879
|
+
] = False,
|
|
880
|
+
) -> None:
|
|
881
|
+
"""Display metadata for image(s)."""
|
|
882
|
+
path = path.resolve()
|
|
883
|
+
|
|
884
|
+
if not path.exists():
|
|
885
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
886
|
+
raise typer.Exit(1)
|
|
887
|
+
|
|
888
|
+
if path.is_file():
|
|
889
|
+
files = [path]
|
|
890
|
+
else:
|
|
891
|
+
pattern = "**/*" if recursive else "*"
|
|
892
|
+
files = [f for f in path.glob(pattern) if f.is_file()]
|
|
893
|
+
|
|
894
|
+
image_files = [f for f in files if f.suffix.lower() in VALID_IMAGE_EXTENSIONS]
|
|
895
|
+
|
|
896
|
+
if not image_files:
|
|
897
|
+
typer.echo("No image files found.")
|
|
898
|
+
raise typer.Exit(0)
|
|
899
|
+
|
|
900
|
+
for filepath in image_files:
|
|
901
|
+
metadata = read_xmp(filepath)
|
|
902
|
+
xmp_path = get_xmp_path(filepath)
|
|
903
|
+
|
|
904
|
+
typer.echo(f"\n{filepath.name}")
|
|
905
|
+
typer.echo(f" XMP: {xmp_path.name if xmp_path.exists() else '(not found)'}")
|
|
906
|
+
|
|
907
|
+
if metadata.is_empty():
|
|
908
|
+
typer.echo(" (no metadata)")
|
|
909
|
+
else:
|
|
910
|
+
if metadata.description:
|
|
911
|
+
typer.echo(f" Description: {metadata.description}")
|
|
912
|
+
if metadata.scene:
|
|
913
|
+
typer.echo(f" Scene: {metadata.scene}")
|
|
914
|
+
if metadata.tags:
|
|
915
|
+
typer.echo(f" Tags: {', '.join(metadata.tags)}")
|
|
916
|
+
if metadata.mood:
|
|
917
|
+
typer.echo(f" Mood: {', '.join(metadata.mood)}")
|
|
918
|
+
if metadata.style:
|
|
919
|
+
typer.echo(f" Style: {metadata.style}")
|
|
920
|
+
if metadata.colors:
|
|
921
|
+
typer.echo(f" Colors: {', '.join(metadata.colors)}")
|
|
922
|
+
if metadata.time_of_day:
|
|
923
|
+
typer.echo(f" Time: {metadata.time_of_day}")
|
|
924
|
+
if metadata.subject:
|
|
925
|
+
typer.echo(f" Subject: {metadata.subject}")
|
|
926
|
+
if metadata.width and metadata.height:
|
|
927
|
+
typer.echo(f" Dimensions: {metadata.width} x {metadata.height}")
|
|
928
|
+
if metadata.recommended_screen:
|
|
929
|
+
typer.echo(f" Best for: {metadata.recommended_screen}")
|
|
930
|
+
if metadata.source:
|
|
931
|
+
typer.echo(f" Source: {metadata.source}")
|
|
932
|
+
if metadata.ai_model:
|
|
933
|
+
typer.echo(f" AI Model: {metadata.ai_model}")
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
@metadata_app.command("set")
|
|
937
|
+
def metadata_set(
|
|
938
|
+
path: Annotated[Path, typer.Argument(help="Image file")],
|
|
939
|
+
description: Annotated[
|
|
940
|
+
str | None, typer.Option("--description", "-d", help="Set description")
|
|
941
|
+
] = None,
|
|
942
|
+
tags: Annotated[
|
|
943
|
+
str | None, typer.Option("--tags", "-t", help="Set tags (comma-separated)")
|
|
944
|
+
] = None,
|
|
945
|
+
add_tags: Annotated[
|
|
946
|
+
str | None, typer.Option("--add-tags", "-a", help="Add tags (comma-separated)")
|
|
947
|
+
] = None,
|
|
948
|
+
source: Annotated[
|
|
949
|
+
str | None, typer.Option("--source", "-s", help="Set source URL/info")
|
|
950
|
+
] = None,
|
|
951
|
+
) -> None:
|
|
952
|
+
"""Set metadata fields for an image."""
|
|
953
|
+
path = path.resolve()
|
|
954
|
+
|
|
955
|
+
if not path.exists():
|
|
956
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
957
|
+
raise typer.Exit(1)
|
|
958
|
+
|
|
959
|
+
if not path.is_file():
|
|
960
|
+
typer.echo("Error: Path must be a file.", err=True)
|
|
961
|
+
raise typer.Exit(1)
|
|
962
|
+
|
|
963
|
+
if path.suffix.lower() not in VALID_IMAGE_EXTENSIONS:
|
|
964
|
+
typer.echo(f"Error: '{path.name}' is not a supported image format.", err=True)
|
|
965
|
+
raise typer.Exit(1)
|
|
966
|
+
|
|
967
|
+
# Read existing metadata
|
|
968
|
+
metadata = read_xmp(path)
|
|
969
|
+
|
|
970
|
+
# Update fields
|
|
971
|
+
if description is not None:
|
|
972
|
+
metadata.description = description
|
|
973
|
+
|
|
974
|
+
if tags is not None:
|
|
975
|
+
metadata.tags = [t.strip() for t in tags.split(",") if t.strip()]
|
|
976
|
+
|
|
977
|
+
if add_tags is not None:
|
|
978
|
+
new_tags = [t.strip() for t in add_tags.split(",") if t.strip()]
|
|
979
|
+
for tag in new_tags:
|
|
980
|
+
if tag not in metadata.tags:
|
|
981
|
+
metadata.tags.append(tag)
|
|
982
|
+
|
|
983
|
+
if source is not None:
|
|
984
|
+
metadata.source = source
|
|
985
|
+
|
|
986
|
+
# Write metadata
|
|
987
|
+
write_xmp(path, metadata)
|
|
988
|
+
typer.echo(f"Updated metadata for {path.name}")
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
@metadata_app.command("generate")
|
|
992
|
+
def metadata_generate(
|
|
993
|
+
path: Annotated[Path, typer.Argument(help="Image file or directory")],
|
|
994
|
+
dry_run: Annotated[
|
|
995
|
+
bool, typer.Option("--dry-run", "-n", help="Show what would be generated")
|
|
996
|
+
] = False,
|
|
997
|
+
recursive: Annotated[
|
|
998
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
999
|
+
] = False,
|
|
1000
|
+
model: Annotated[
|
|
1001
|
+
str | None, typer.Option("--model", "-m", help="Ollama vision model to use")
|
|
1002
|
+
] = None,
|
|
1003
|
+
overwrite: Annotated[
|
|
1004
|
+
bool, typer.Option("--overwrite", help="Overwrite existing descriptions")
|
|
1005
|
+
] = False,
|
|
1006
|
+
rename: Annotated[
|
|
1007
|
+
bool, typer.Option("--rename/--no-rename", help="Rename files based on description")
|
|
1008
|
+
] = True,
|
|
1009
|
+
cpu: Annotated[bool, typer.Option("--cpu", help="Use CPU only (no GPU acceleration)")] = False,
|
|
1010
|
+
host: Annotated[
|
|
1011
|
+
str | None,
|
|
1012
|
+
typer.Option("--host", "-H", help="Ollama server URL (e.g., http://server:11434)"),
|
|
1013
|
+
] = None,
|
|
1014
|
+
) -> None:
|
|
1015
|
+
"""Generate metadata using AI vision model and optionally rename files."""
|
|
1016
|
+
effective_host, effective_model = get_ollama_settings(host=host, model=model)
|
|
1017
|
+
|
|
1018
|
+
path = path.resolve()
|
|
1019
|
+
|
|
1020
|
+
if not path.exists():
|
|
1021
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
1022
|
+
raise typer.Exit(1)
|
|
1023
|
+
|
|
1024
|
+
if path.is_file():
|
|
1025
|
+
files = [path]
|
|
1026
|
+
else:
|
|
1027
|
+
pattern = "**/*" if recursive else "*"
|
|
1028
|
+
files = [f for f in path.glob(pattern) if f.is_file()]
|
|
1029
|
+
|
|
1030
|
+
image_files = [f for f in files if f.suffix.lower() in VALID_IMAGE_EXTENSIONS]
|
|
1031
|
+
|
|
1032
|
+
if not image_files:
|
|
1033
|
+
typer.echo("No image files found.")
|
|
1034
|
+
raise typer.Exit(0)
|
|
1035
|
+
|
|
1036
|
+
typer.echo(f"Generating metadata for {len(image_files)} image(s) with '{effective_model}'...\n")
|
|
1037
|
+
|
|
1038
|
+
generated_count = 0
|
|
1039
|
+
skipped_count = 0
|
|
1040
|
+
|
|
1041
|
+
for filepath in image_files:
|
|
1042
|
+
# Check if metadata already exists
|
|
1043
|
+
existing = read_xmp(filepath)
|
|
1044
|
+
if existing.description and not overwrite:
|
|
1045
|
+
typer.echo(f"Skipping: {filepath.name} (already has description)")
|
|
1046
|
+
skipped_count += 1
|
|
1047
|
+
continue
|
|
1048
|
+
|
|
1049
|
+
typer.echo(f"Analyzing: {filepath.name}...", nl=False)
|
|
1050
|
+
|
|
1051
|
+
result = analyze_image(filepath, effective_model, use_cpu=cpu, host=effective_host)
|
|
1052
|
+
if not result:
|
|
1053
|
+
typer.echo(" [FAILED]")
|
|
1054
|
+
skipped_count += 1
|
|
1055
|
+
continue
|
|
1056
|
+
|
|
1057
|
+
description = result.get("description", "")
|
|
1058
|
+
typer.echo(f" -> {description}")
|
|
1059
|
+
|
|
1060
|
+
if dry_run:
|
|
1061
|
+
if rename:
|
|
1062
|
+
new_stem = sanitise_filename(str(description))
|
|
1063
|
+
new_name = new_stem + filepath.suffix.lower()
|
|
1064
|
+
typer.echo(f" Would rename: {filepath.name} -> {new_name}")
|
|
1065
|
+
if result.get("scene"):
|
|
1066
|
+
typer.echo(f" Scene: {result['scene']}")
|
|
1067
|
+
if result.get("tags"):
|
|
1068
|
+
typer.echo(f" Tags: {', '.join(result['tags'])}")
|
|
1069
|
+
if result.get("mood"):
|
|
1070
|
+
typer.echo(f" Mood: {', '.join(result['mood'])}")
|
|
1071
|
+
if result.get("style"):
|
|
1072
|
+
typer.echo(f" Style: {result['style']}")
|
|
1073
|
+
if result.get("colors"):
|
|
1074
|
+
typer.echo(f" Colors: {', '.join(result['colors'])}")
|
|
1075
|
+
if result.get("time"):
|
|
1076
|
+
typer.echo(f" Time: {result['time']}")
|
|
1077
|
+
if result.get("subject"):
|
|
1078
|
+
typer.echo(f" Subject: {result['subject']}")
|
|
1079
|
+
typer.echo(" (dry run, not saving)")
|
|
1080
|
+
else:
|
|
1081
|
+
# Rename file if requested
|
|
1082
|
+
target_path = filepath
|
|
1083
|
+
if rename:
|
|
1084
|
+
new_stem = sanitise_filename(str(description))
|
|
1085
|
+
new_name = new_stem + filepath.suffix.lower()
|
|
1086
|
+
|
|
1087
|
+
if filepath.name != new_name:
|
|
1088
|
+
new_path = filepath.parent / new_name
|
|
1089
|
+
|
|
1090
|
+
# Handle filename collisions
|
|
1091
|
+
counter = 1
|
|
1092
|
+
while new_path.exists() and new_path != filepath:
|
|
1093
|
+
new_name = f"{new_stem}_{counter}{filepath.suffix.lower()}"
|
|
1094
|
+
new_path = filepath.parent / new_name
|
|
1095
|
+
counter += 1
|
|
1096
|
+
|
|
1097
|
+
# Rename any existing XMP sidecar first
|
|
1098
|
+
old_xmp = get_xmp_path(filepath)
|
|
1099
|
+
if old_xmp.exists():
|
|
1100
|
+
new_xmp = get_xmp_path(new_path)
|
|
1101
|
+
old_xmp.rename(new_xmp)
|
|
1102
|
+
|
|
1103
|
+
filepath.rename(new_path)
|
|
1104
|
+
target_path = new_path
|
|
1105
|
+
typer.echo(f" Renamed: {filepath.name} -> {new_name}")
|
|
1106
|
+
|
|
1107
|
+
# Build and save metadata
|
|
1108
|
+
metadata = existing
|
|
1109
|
+
metadata.description = str(description)
|
|
1110
|
+
metadata.ai_model = effective_model
|
|
1111
|
+
if isinstance(result.get("scene"), str):
|
|
1112
|
+
metadata.scene = result["scene"]
|
|
1113
|
+
if isinstance(result.get("tags"), list):
|
|
1114
|
+
metadata.tags = result["tags"]
|
|
1115
|
+
if isinstance(result.get("mood"), list):
|
|
1116
|
+
metadata.mood = result["mood"]
|
|
1117
|
+
if isinstance(result.get("style"), str):
|
|
1118
|
+
metadata.style = result["style"]
|
|
1119
|
+
if isinstance(result.get("colors"), list):
|
|
1120
|
+
metadata.colors = result["colors"]
|
|
1121
|
+
if isinstance(result.get("time"), str):
|
|
1122
|
+
metadata.time_of_day = result["time"]
|
|
1123
|
+
if isinstance(result.get("subject"), str):
|
|
1124
|
+
metadata.subject = result["subject"]
|
|
1125
|
+
|
|
1126
|
+
# Add image dimensions
|
|
1127
|
+
width, height = get_image_dimensions(target_path)
|
|
1128
|
+
if width and height:
|
|
1129
|
+
metadata.width = width
|
|
1130
|
+
metadata.height = height
|
|
1131
|
+
metadata.recommended_screen = get_recommended_screen(width, height)
|
|
1132
|
+
|
|
1133
|
+
write_xmp(target_path, metadata)
|
|
1134
|
+
typer.echo(f" Saved to {get_xmp_path(target_path).name}")
|
|
1135
|
+
|
|
1136
|
+
generated_count += 1
|
|
1137
|
+
|
|
1138
|
+
action = "Would generate" if dry_run else "Generated"
|
|
1139
|
+
typer.echo(f"\n{action} metadata for {generated_count} file(s), skipped {skipped_count}.")
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
@metadata_app.command("update-dimensions")
|
|
1143
|
+
def metadata_update_dimensions(
|
|
1144
|
+
path: Annotated[Path, typer.Argument(help="Image file or directory")],
|
|
1145
|
+
dry_run: Annotated[
|
|
1146
|
+
bool, typer.Option("--dry-run", "-n", help="Show what would be updated")
|
|
1147
|
+
] = False,
|
|
1148
|
+
recursive: Annotated[
|
|
1149
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
1150
|
+
] = False,
|
|
1151
|
+
) -> None:
|
|
1152
|
+
"""Update existing XMP sidecars with image dimensions (no AI inference)."""
|
|
1153
|
+
path = path.resolve()
|
|
1154
|
+
|
|
1155
|
+
if not path.exists():
|
|
1156
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
1157
|
+
raise typer.Exit(1)
|
|
1158
|
+
|
|
1159
|
+
if path.is_file():
|
|
1160
|
+
files = [path]
|
|
1161
|
+
else:
|
|
1162
|
+
pattern = "**/*" if recursive else "*"
|
|
1163
|
+
files = [f for f in path.glob(pattern) if f.is_file()]
|
|
1164
|
+
|
|
1165
|
+
# Filter to image files that have existing sidecars
|
|
1166
|
+
image_files = [
|
|
1167
|
+
f for f in files if f.suffix.lower() in VALID_IMAGE_EXTENSIONS and get_xmp_path(f).exists()
|
|
1168
|
+
]
|
|
1169
|
+
|
|
1170
|
+
if not image_files:
|
|
1171
|
+
typer.echo("No image files with existing XMP sidecars found.")
|
|
1172
|
+
raise typer.Exit(0)
|
|
1173
|
+
|
|
1174
|
+
typer.echo(f"Updating dimensions for {len(image_files)} image(s)...\n")
|
|
1175
|
+
|
|
1176
|
+
updated_count = 0
|
|
1177
|
+
skipped_count = 0
|
|
1178
|
+
|
|
1179
|
+
for filepath in image_files:
|
|
1180
|
+
width, height = get_image_dimensions(filepath)
|
|
1181
|
+
|
|
1182
|
+
if not width or not height:
|
|
1183
|
+
typer.echo(f"Skipping: {filepath.name} (could not read dimensions)")
|
|
1184
|
+
skipped_count += 1
|
|
1185
|
+
continue
|
|
1186
|
+
|
|
1187
|
+
recommended = get_recommended_screen(width, height)
|
|
1188
|
+
|
|
1189
|
+
if dry_run:
|
|
1190
|
+
typer.echo(f"Would update: {filepath.name} -> {width}x{height} ({recommended})")
|
|
1191
|
+
else:
|
|
1192
|
+
metadata = read_xmp(filepath)
|
|
1193
|
+
metadata.width = width
|
|
1194
|
+
metadata.height = height
|
|
1195
|
+
metadata.recommended_screen = recommended
|
|
1196
|
+
write_xmp(filepath, metadata)
|
|
1197
|
+
typer.echo(f"Updated: {filepath.name} -> {width}x{height} ({recommended})")
|
|
1198
|
+
|
|
1199
|
+
updated_count += 1
|
|
1200
|
+
|
|
1201
|
+
action = "Would update" if dry_run else "Updated"
|
|
1202
|
+
typer.echo(f"\n{action} {updated_count} sidecar(s), skipped {skipped_count}.")
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
@metadata_app.command("embed")
|
|
1206
|
+
def metadata_embed(
|
|
1207
|
+
path: Annotated[Path, typer.Argument(help="Image file or directory")],
|
|
1208
|
+
dry_run: Annotated[
|
|
1209
|
+
bool, typer.Option("--dry-run", "-n", help="Show what would be embedded")
|
|
1210
|
+
] = False,
|
|
1211
|
+
recursive: Annotated[
|
|
1212
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
1213
|
+
] = False,
|
|
1214
|
+
) -> None:
|
|
1215
|
+
"""Embed XMP sidecar metadata into image files using exiftool."""
|
|
1216
|
+
import shutil
|
|
1217
|
+
import subprocess
|
|
1218
|
+
|
|
1219
|
+
# Check for exiftool
|
|
1220
|
+
if not shutil.which("exiftool"):
|
|
1221
|
+
typer.echo("Error: exiftool not found. Install it first:", err=True)
|
|
1222
|
+
typer.echo(" Arch: pacman -S perl-image-exiftool", err=True)
|
|
1223
|
+
typer.echo(" Debian: apt install libimage-exiftool-perl", err=True)
|
|
1224
|
+
raise typer.Exit(1)
|
|
1225
|
+
|
|
1226
|
+
path = path.resolve()
|
|
1227
|
+
|
|
1228
|
+
if not path.exists():
|
|
1229
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
1230
|
+
raise typer.Exit(1)
|
|
1231
|
+
|
|
1232
|
+
if path.is_file():
|
|
1233
|
+
files = [path]
|
|
1234
|
+
else:
|
|
1235
|
+
pattern = "**/*" if recursive else "*"
|
|
1236
|
+
files = [f for f in path.glob(pattern) if f.is_file()]
|
|
1237
|
+
|
|
1238
|
+
# Filter to image files that have sidecars
|
|
1239
|
+
image_files = [
|
|
1240
|
+
f for f in files if f.suffix.lower() in VALID_IMAGE_EXTENSIONS and get_xmp_path(f).exists()
|
|
1241
|
+
]
|
|
1242
|
+
|
|
1243
|
+
if not image_files:
|
|
1244
|
+
typer.echo("No image files with XMP sidecars found.")
|
|
1245
|
+
raise typer.Exit(0)
|
|
1246
|
+
|
|
1247
|
+
typer.echo(f"Embedding metadata into {len(image_files)} image(s)...\n")
|
|
1248
|
+
|
|
1249
|
+
embedded_count = 0
|
|
1250
|
+
skipped_count = 0
|
|
1251
|
+
|
|
1252
|
+
for filepath in image_files:
|
|
1253
|
+
metadata = read_xmp(filepath)
|
|
1254
|
+
|
|
1255
|
+
if metadata.is_empty():
|
|
1256
|
+
typer.echo(f"Skipping: {filepath.name} (empty sidecar)")
|
|
1257
|
+
skipped_count += 1
|
|
1258
|
+
continue
|
|
1259
|
+
|
|
1260
|
+
# Build exiftool arguments
|
|
1261
|
+
args = ["exiftool", "-overwrite_original"]
|
|
1262
|
+
|
|
1263
|
+
# Description: combine short description and scene for full context
|
|
1264
|
+
full_description = metadata.description
|
|
1265
|
+
if metadata.scene:
|
|
1266
|
+
full_description = f"{metadata.description}. {metadata.scene}"
|
|
1267
|
+
|
|
1268
|
+
if full_description:
|
|
1269
|
+
args.extend([f"-IPTC:Caption-Abstract={full_description}"])
|
|
1270
|
+
args.extend([f"-XMP:Description={full_description}"])
|
|
1271
|
+
|
|
1272
|
+
# Tags/Keywords
|
|
1273
|
+
if metadata.tags:
|
|
1274
|
+
for tag in metadata.tags:
|
|
1275
|
+
args.extend([f"-IPTC:Keywords={tag}"])
|
|
1276
|
+
args.extend([f"-XMP:Subject={tag}"])
|
|
1277
|
+
|
|
1278
|
+
# Only description and tags have standard fields
|
|
1279
|
+
# Log what custom fields exist but can't be embedded in standard fields
|
|
1280
|
+
custom_fields = []
|
|
1281
|
+
if metadata.mood:
|
|
1282
|
+
custom_fields.append(f"mood: {', '.join(metadata.mood)}")
|
|
1283
|
+
if metadata.style:
|
|
1284
|
+
custom_fields.append(f"style: {metadata.style}")
|
|
1285
|
+
if metadata.colors:
|
|
1286
|
+
custom_fields.append(f"colors: {', '.join(metadata.colors)}")
|
|
1287
|
+
if metadata.time_of_day:
|
|
1288
|
+
custom_fields.append(f"time: {metadata.time_of_day}")
|
|
1289
|
+
if metadata.subject:
|
|
1290
|
+
custom_fields.append(f"subject: {metadata.subject}")
|
|
1291
|
+
|
|
1292
|
+
args.append(str(filepath))
|
|
1293
|
+
|
|
1294
|
+
if dry_run:
|
|
1295
|
+
typer.echo(f"Would embed: {filepath.name}")
|
|
1296
|
+
if full_description:
|
|
1297
|
+
ellipsis = "..." if len(full_description) > 80 else ""
|
|
1298
|
+
typer.echo(f" Description: {full_description[:80]}{ellipsis}")
|
|
1299
|
+
if metadata.tags:
|
|
1300
|
+
typer.echo(f" Keywords: {', '.join(metadata.tags)}")
|
|
1301
|
+
if custom_fields:
|
|
1302
|
+
typer.echo(f" (sidecar-only: {'; '.join(custom_fields)})")
|
|
1303
|
+
else:
|
|
1304
|
+
result = subprocess.run(args, capture_output=True, text=True)
|
|
1305
|
+
if result.returncode == 0:
|
|
1306
|
+
typer.echo(f"Embedded: {filepath.name}")
|
|
1307
|
+
embedded_count += 1
|
|
1308
|
+
else:
|
|
1309
|
+
typer.echo(f"Failed: {filepath.name} - {result.stderr.strip()}", err=True)
|
|
1310
|
+
skipped_count += 1
|
|
1311
|
+
continue
|
|
1312
|
+
|
|
1313
|
+
if not dry_run:
|
|
1314
|
+
pass # already counted above
|
|
1315
|
+
else:
|
|
1316
|
+
embedded_count += 1
|
|
1317
|
+
|
|
1318
|
+
action = "Would embed" if dry_run else "Embedded"
|
|
1319
|
+
typer.echo(f"\n{action} metadata in {embedded_count} file(s), skipped {skipped_count}.")
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
if __name__ == "__main__":
|
|
1323
|
+
app()
|