schenesort 2.2.0__py3-none-any.whl → 2.4.0__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 CHANGED
@@ -412,6 +412,85 @@ def index(
412
412
  typer.echo(f"With metadata: {stats.get('with_metadata', 0)}")
413
413
 
414
414
 
415
+ @app.command()
416
+ def thumbnail(
417
+ path: Annotated[Path, typer.Argument(help="Directory to generate thumbnails for")],
418
+ recursive: Annotated[
419
+ bool, typer.Option("--recursive", "-r", help="Process directories recursively")
420
+ ] = True,
421
+ force: Annotated[bool, typer.Option("--force", "-f", help="Regenerate all thumbnails")] = False,
422
+ clear: Annotated[
423
+ bool, typer.Option("--clear", help="Clear thumbnail cache before generating")
424
+ ] = False,
425
+ ) -> None:
426
+ """Generate thumbnails for gallery view."""
427
+ from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn
428
+
429
+ from schenesort.thumbnails import (
430
+ clear_cache,
431
+ generate_thumbnail,
432
+ get_cache_stats,
433
+ thumbnail_exists,
434
+ )
435
+
436
+ path = path.resolve()
437
+
438
+ if not path.exists():
439
+ typer.echo(f"Error: Path '{path}' does not exist.", err=True)
440
+ raise typer.Exit(1)
441
+
442
+ if not path.is_dir():
443
+ typer.echo(f"Error: Path '{path}' is not a directory.", err=True)
444
+ raise typer.Exit(1)
445
+
446
+ if clear:
447
+ cleared = clear_cache()
448
+ typer.echo(f"Cleared {cleared} cached thumbnail(s).")
449
+
450
+ pattern = "**/*" if recursive else "*"
451
+ image_files = [
452
+ f for f in path.glob(pattern) if f.is_file() and f.suffix.lower() in VALID_IMAGE_EXTENSIONS
453
+ ]
454
+
455
+ if not image_files:
456
+ typer.echo("No image files found.")
457
+ raise typer.Exit(0)
458
+
459
+ generated = 0
460
+ skipped = 0
461
+ failed = 0
462
+
463
+ with Progress(
464
+ TextColumn("[progress.description]{task.description}"),
465
+ BarColumn(),
466
+ TaskProgressColumn(),
467
+ TextColumn("{task.completed}/{task.total}"),
468
+ ) as progress:
469
+ task = progress.add_task("Generating thumbnails", total=len(image_files))
470
+
471
+ for filepath in image_files:
472
+ if not force and thumbnail_exists(filepath):
473
+ skipped += 1
474
+ progress.advance(task)
475
+ continue
476
+
477
+ result = generate_thumbnail(filepath, force=force)
478
+ if result:
479
+ generated += 1
480
+ else:
481
+ failed += 1
482
+
483
+ progress.advance(task)
484
+
485
+ typer.echo(f"\nGenerated: {generated}, Skipped: {skipped}, Failed: {failed}")
486
+
487
+ # Show cache stats
488
+ stats = get_cache_stats()
489
+ typer.echo(f"\nCache: {stats['path']}")
490
+ typer.echo(f"Total thumbnails: {stats['count']}")
491
+ typer.echo(f"Cache size: {stats['size_mb']:.1f} MB")
492
+
493
+
415
494
  @app.command()
416
495
  def get(
417
496
  tag: Annotated[str | None, typer.Option("--tag", "-t", help="Filter by tag")] = None,
@@ -581,6 +660,48 @@ def browse(
581
660
  app_instance.run()
582
661
 
583
662
 
663
+ @app.command()
664
+ def gallery(
665
+ tag: Annotated[str | None, typer.Option("--tag", "-t", help="Filter by tag")] = None,
666
+ mood: Annotated[str | None, typer.Option("--mood", "-m", help="Filter by mood")] = None,
667
+ color: Annotated[str | None, typer.Option("--color", "-c", help="Filter by color")] = None,
668
+ style: Annotated[str | None, typer.Option("--style", "-s", help="Filter by style")] = None,
669
+ subject: Annotated[str | None, typer.Option("--subject", help="Filter by subject")] = None,
670
+ time: Annotated[str | None, typer.Option("--time", help="Filter by time of day")] = None,
671
+ screen: Annotated[
672
+ str | None, typer.Option("--screen", help="Filter by recommended screen (4K, 1440p, etc)")
673
+ ] = None,
674
+ min_width: Annotated[
675
+ int | None, typer.Option("--min-width", help="Minimum width in pixels")
676
+ ] = None,
677
+ min_height: Annotated[
678
+ int | None, typer.Option("--min-height", help="Minimum height in pixels")
679
+ ] = None,
680
+ search: Annotated[
681
+ str | None, typer.Option("--search", "-q", help="Search description, scene, style, subject")
682
+ ] = None,
683
+ ) -> None:
684
+ """Browse wallpapers in a thumbnail grid with filter sidebar."""
685
+ from schenesort.tui.grid_app import GridBrowser
686
+ from schenesort.tui.widgets.filter_panel import FilterValues
687
+
688
+ initial_filters = FilterValues(
689
+ search=search or "",
690
+ tag=tag or "",
691
+ mood=mood or "",
692
+ color=color or "",
693
+ style=style or "",
694
+ subject=subject or "",
695
+ time=time or "",
696
+ screen=screen or "",
697
+ min_width=min_width,
698
+ min_height=min_height,
699
+ )
700
+
701
+ app_instance = GridBrowser(initial_filters=initial_filters)
702
+ app_instance.run()
703
+
704
+
584
705
  @app.command()
585
706
  def config(
586
707
  create: Annotated[
@@ -1225,6 +1346,138 @@ def metadata_update_dimensions(
1225
1346
  typer.echo(f"\n{action} {updated_count} sidecar(s), skipped {skipped_count}.")
1226
1347
 
1227
1348
 
1349
+ @app.command()
1350
+ def collage(
1351
+ output: Annotated[Path, typer.Argument(help="Output PNG file path")],
1352
+ tag: Annotated[str | None, typer.Option("--tag", "-t", help="Filter by tag")] = None,
1353
+ mood: Annotated[str | None, typer.Option("--mood", "-m", help="Filter by mood")] = None,
1354
+ color: Annotated[str | None, typer.Option("--color", "-c", help="Filter by color")] = None,
1355
+ style: Annotated[str | None, typer.Option("--style", "-s", help="Filter by style")] = None,
1356
+ subject: Annotated[str | None, typer.Option("--subject", help="Filter by subject")] = None,
1357
+ time: Annotated[str | None, typer.Option("--time", help="Filter by time of day")] = None,
1358
+ screen: Annotated[
1359
+ str | None, typer.Option("--screen", help="Filter by recommended screen (4K, 1440p, etc)")
1360
+ ] = None,
1361
+ min_width: Annotated[
1362
+ int | None, typer.Option("--min-width", help="Minimum width in pixels")
1363
+ ] = None,
1364
+ min_height: Annotated[
1365
+ int | None, typer.Option("--min-height", help="Minimum height in pixels")
1366
+ ] = None,
1367
+ search: Annotated[
1368
+ str | None, typer.Option("--search", "-q", help="Search description, scene, style, subject")
1369
+ ] = None,
1370
+ cols: Annotated[int, typer.Option("--cols", help="Number of columns (1-4)")] = 2,
1371
+ rows: Annotated[int, typer.Option("--rows", help="Number of rows (1-4)")] = 2,
1372
+ tile_width: Annotated[
1373
+ int, typer.Option("--tile-width", "-w", help="Width of each tile in pixels")
1374
+ ] = 480,
1375
+ tile_height: Annotated[
1376
+ int, typer.Option("--tile-height", "-h", help="Height of each tile in pixels")
1377
+ ] = 270,
1378
+ random: Annotated[bool, typer.Option("--random", "-R", help="Select images randomly")] = True,
1379
+ ) -> None:
1380
+ """Create a collage of wallpapers matching the given criteria (max 4x4 grid)."""
1381
+ from PIL import Image
1382
+
1383
+ from schenesort.db import WallpaperDB
1384
+
1385
+ # Validate grid size
1386
+ if cols < 1 or cols > 4:
1387
+ typer.echo("Error: --cols must be between 1 and 4.", err=True)
1388
+ raise typer.Exit(1)
1389
+ if rows < 1 or rows > 4:
1390
+ typer.echo("Error: --rows must be between 1 and 4.", err=True)
1391
+ raise typer.Exit(1)
1392
+
1393
+ num_images = cols * rows
1394
+
1395
+ with WallpaperDB() as db:
1396
+ results = db.query(
1397
+ tag=tag,
1398
+ mood=mood,
1399
+ color=color,
1400
+ style=style,
1401
+ subject=subject,
1402
+ time_of_day=time,
1403
+ screen=screen,
1404
+ min_width=min_width,
1405
+ min_height=min_height,
1406
+ search=search,
1407
+ limit=num_images,
1408
+ random=random,
1409
+ )
1410
+
1411
+ if not results:
1412
+ typer.echo("No wallpapers found matching criteria.", err=True)
1413
+ raise typer.Exit(1)
1414
+
1415
+ if len(results) < num_images:
1416
+ typer.echo(
1417
+ f"Warning: Only found {len(results)} image(s), "
1418
+ f"need {num_images} for {cols}x{rows} grid."
1419
+ )
1420
+
1421
+ # Create collage canvas
1422
+ collage_width = cols * tile_width
1423
+ collage_height = rows * tile_height
1424
+ collage_img = Image.new("RGB", (collage_width, collage_height), color=(0, 0, 0))
1425
+
1426
+ typer.echo(f"Creating {cols}x{rows} collage ({collage_width}x{collage_height})...")
1427
+
1428
+ for idx, r in enumerate(results):
1429
+ if idx >= num_images:
1430
+ break
1431
+
1432
+ filepath = Path(r["path"])
1433
+ row = idx // cols
1434
+ col = idx % cols
1435
+
1436
+ try:
1437
+ with Image.open(filepath) as img:
1438
+ # Convert to RGB if necessary (handles RGBA, palette, etc.)
1439
+ if img.mode != "RGB":
1440
+ img = img.convert("RGB")
1441
+
1442
+ # Resize to fit tile while preserving aspect ratio, then crop to fill
1443
+ img_ratio = img.width / img.height
1444
+ tile_ratio = tile_width / tile_height
1445
+
1446
+ if img_ratio > tile_ratio:
1447
+ # Image is wider - fit by height, crop width
1448
+ new_height = tile_height
1449
+ new_width = int(tile_height * img_ratio)
1450
+ else:
1451
+ # Image is taller - fit by width, crop height
1452
+ new_width = tile_width
1453
+ new_height = int(tile_width / img_ratio)
1454
+
1455
+ img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
1456
+
1457
+ # Center crop to tile size
1458
+ left = (new_width - tile_width) // 2
1459
+ top = (new_height - tile_height) // 2
1460
+ img = img.crop((left, top, left + tile_width, top + tile_height))
1461
+
1462
+ # Paste into collage
1463
+ x = col * tile_width
1464
+ y = row * tile_height
1465
+ collage_img.paste(img, (x, y))
1466
+
1467
+ typer.echo(f" [{row},{col}] {filepath.name}")
1468
+
1469
+ except Exception as e:
1470
+ typer.echo(f" [{row},{col}] Failed to load {filepath.name}: {e}", err=True)
1471
+
1472
+ # Save collage
1473
+ output = output.resolve()
1474
+ if not output.suffix.lower() == ".png":
1475
+ output = output.with_suffix(".png")
1476
+
1477
+ collage_img.save(output, "PNG")
1478
+ typer.echo(f"\nSaved collage to: {output}")
1479
+
1480
+
1228
1481
  @metadata_app.command("embed")
1229
1482
  def metadata_embed(
1230
1483
  path: Annotated[Path, typer.Argument(help="Image file or directory")],
@@ -0,0 +1,127 @@
1
+ """Thumbnail cache management for gallery view."""
2
+
3
+ import hashlib
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from PIL import Image
8
+
9
+ APP_NAME = "schenesort"
10
+
11
+ # Thumbnail dimensions - larger for better quality when rendered in terminal
12
+ # Terminal cells are ~32x14 chars, but textual-image benefits from more source pixels
13
+ THUMBNAIL_WIDTH = 320
14
+ THUMBNAIL_HEIGHT = 200
15
+
16
+
17
+ def get_cache_dir() -> Path:
18
+ """Get the XDG cache directory for schenesort thumbnails."""
19
+ xdg_cache = os.environ.get("XDG_CACHE_HOME", "")
20
+ if xdg_cache:
21
+ cache_dir = Path(xdg_cache) / APP_NAME / "thumbnails"
22
+ else:
23
+ cache_dir = Path.home() / ".cache" / APP_NAME / "thumbnails"
24
+ return cache_dir
25
+
26
+
27
+ def get_thumbnail_path(image_path: Path) -> Path:
28
+ """Get the thumbnail path for an image.
29
+
30
+ Uses MD5 hash of the absolute path to create a unique filename.
31
+ """
32
+ # Use absolute path for consistent hashing
33
+ abs_path = str(image_path.resolve())
34
+ path_hash = hashlib.md5(abs_path.encode()).hexdigest()
35
+ return get_cache_dir() / f"{path_hash}.jpg"
36
+
37
+
38
+ def thumbnail_exists(image_path: Path) -> bool:
39
+ """Check if a thumbnail exists for the given image."""
40
+ thumb_path = get_thumbnail_path(image_path)
41
+ if not thumb_path.exists():
42
+ return False
43
+
44
+ # Check if thumbnail is newer than original
45
+ try:
46
+ orig_mtime = image_path.stat().st_mtime
47
+ thumb_mtime = thumb_path.stat().st_mtime
48
+ return thumb_mtime >= orig_mtime
49
+ except OSError:
50
+ return False
51
+
52
+
53
+ def generate_thumbnail(image_path: Path, force: bool = False) -> Path | None:
54
+ """Generate a thumbnail for the given image.
55
+
56
+ Args:
57
+ image_path: Path to the original image
58
+ force: If True, regenerate even if thumbnail exists
59
+
60
+ Returns:
61
+ Path to the thumbnail, or None if generation failed
62
+ """
63
+ if not force and thumbnail_exists(image_path):
64
+ return get_thumbnail_path(image_path)
65
+
66
+ thumb_path = get_thumbnail_path(image_path)
67
+
68
+ try:
69
+ # Ensure cache directory exists
70
+ thumb_path.parent.mkdir(parents=True, exist_ok=True)
71
+
72
+ with Image.open(image_path) as img:
73
+ # Convert to RGB if necessary (handles RGBA, palette, etc.)
74
+ if img.mode not in ("RGB", "L"):
75
+ img = img.convert("RGB")
76
+
77
+ # Calculate size preserving aspect ratio
78
+ img.thumbnail((THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT), Image.Resampling.LANCZOS)
79
+
80
+ # Save as JPEG with high quality for better terminal rendering
81
+ img.save(thumb_path, "JPEG", quality=95, optimize=True)
82
+
83
+ return thumb_path
84
+
85
+ except Exception:
86
+ # Clean up partial file if it exists
87
+ if thumb_path.exists():
88
+ thumb_path.unlink()
89
+ return None
90
+
91
+
92
+ def clear_cache() -> int:
93
+ """Clear all cached thumbnails.
94
+
95
+ Returns:
96
+ Number of thumbnails deleted
97
+ """
98
+ cache_dir = get_cache_dir()
99
+ if not cache_dir.exists():
100
+ return 0
101
+
102
+ count = 0
103
+ for thumb in cache_dir.glob("*.jpg"):
104
+ try:
105
+ thumb.unlink()
106
+ count += 1
107
+ except OSError:
108
+ pass
109
+
110
+ return count
111
+
112
+
113
+ def get_cache_stats() -> dict:
114
+ """Get statistics about the thumbnail cache."""
115
+ cache_dir = get_cache_dir()
116
+ if not cache_dir.exists():
117
+ return {"count": 0, "size_bytes": 0, "path": str(cache_dir)}
118
+
119
+ thumbnails = list(cache_dir.glob("*.jpg"))
120
+ total_size = sum(t.stat().st_size for t in thumbnails)
121
+
122
+ return {
123
+ "count": len(thumbnails),
124
+ "size_bytes": total_size,
125
+ "size_mb": total_size / (1024 * 1024),
126
+ "path": str(cache_dir),
127
+ }
@@ -1,5 +1,15 @@
1
1
  """Schenesort TUI - Terminal UI for browsing wallpapers."""
2
2
 
3
3
  from schenesort.tui.app import WallpaperBrowser
4
+ from schenesort.tui.grid_app import GridBrowser
5
+ from schenesort.tui.widgets.filter_panel import FilterPanel, FilterValues
6
+ from schenesort.tui.widgets.thumbnail_grid import ThumbnailGrid, ThumbnailText
4
7
 
5
- __all__ = ["WallpaperBrowser"]
8
+ __all__ = [
9
+ "FilterPanel",
10
+ "FilterValues",
11
+ "GridBrowser",
12
+ "ThumbnailGrid",
13
+ "ThumbnailText",
14
+ "WallpaperBrowser",
15
+ ]