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 +253 -0
- schenesort/thumbnails.py +127 -0
- schenesort/tui/__init__.py +11 -1
- schenesort/tui/grid_app.py +302 -0
- schenesort/tui/widgets/filter_panel.py +249 -0
- schenesort/tui/widgets/thumbnail_grid.py +291 -0
- {schenesort-2.2.0.dist-info → schenesort-2.4.0.dist-info}/METADATA +116 -13
- schenesort-2.4.0.dist-info/RECORD +19 -0
- schenesort-2.2.0.dist-info/RECORD +0 -15
- {schenesort-2.2.0.dist-info → schenesort-2.4.0.dist-info}/WHEEL +0 -0
- {schenesort-2.2.0.dist-info → schenesort-2.4.0.dist-info}/entry_points.txt +0 -0
- {schenesort-2.2.0.dist-info → schenesort-2.4.0.dist-info}/licenses/LICENSE +0 -0
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")],
|
schenesort/thumbnails.py
ADDED
|
@@ -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
|
+
}
|
schenesort/tui/__init__.py
CHANGED
|
@@ -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__ = [
|
|
8
|
+
__all__ = [
|
|
9
|
+
"FilterPanel",
|
|
10
|
+
"FilterValues",
|
|
11
|
+
"GridBrowser",
|
|
12
|
+
"ThumbnailGrid",
|
|
13
|
+
"ThumbnailText",
|
|
14
|
+
"WallpaperBrowser",
|
|
15
|
+
]
|