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/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()