soulsync/core/library/residual_files.py
BoulderBadgeDad 298d825757 #891: clear dead folders left with only cover images / .lrc sidecars
Reorganize leaves a cover.jpg (and other leftovers) behind when it moves an album,
and the Empty Folder Cleaner is too conservative to sweep them. Two complementary
fixes over one shared 'residual file' predicate (core/library/residual_files.py:
junk + cover/scan images + lyric/metadata sidecars), so both features agree on what
a dead folder is.

1. Reorganize (proactive): _delete_album_sidecars now sweeps ALL residual files when
   a source dir has no audio left — previously only a fixed list of cover NAMES, so
   back.jpg / disc.png / .webp survived and kept the folder un-prunable.
2. Empty Folder Cleaner (the request): new opt-in 'Remove Residual Files' setting
   (default off) treats a folder holding only images/sidecars/junk as removable —
   cleans the existing backlog + arbitrary names. Auto-renders as a UI toggle.

Safe by construction: whitelist-only (a booklet.pdf / video / .txt is real content
and kept), reorganize sweep gated on no-audio, cleaner re-checks at apply time.
20 new tests; 272 reorganize/repair/empty-folder green.
2026-06-18 20:07:41 -07:00

53 lines
1.9 KiB
Python

"""What counts as a *residual* file — a leftover with no value once the audio it
accompanied is gone: OS junk, cover/scan images, and lyric/metadata sidecars.
Single source of truth shared by:
* the **Reorganize** cleanup, which strips these from a source dir after every
track has moved out (so the empty-dir pruner can take the folder), and
* the **Empty Folder Cleaner** job, which can optionally treat a folder holding
ONLY residual files as removable (#891).
Defining "disposable" in one place keeps the two features agreeing on what a "dead
folder" is. Pure predicates — no filesystem access — so they're unit-tested in
isolation. The whitelist is deliberately conservative: anything NOT recognized here
(a booklet ``.pdf``, a video, a ``.txt`` note) is treated as real content and kept.
"""
from __future__ import annotations
import os
# OS / tooling junk.
JUNK_FILES = {'.ds_store', 'thumbs.db', 'desktop.ini', '.directory', 'album.nfo~'}
# Cover art + booklet scans.
IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'}
# Lyric / metadata / playlist sidecars that are worthless without their audio.
SIDECAR_EXTS = {'.lrc', '.nfo', '.cue', '.m3u', '.m3u8'}
def _ext(name: str) -> str:
return os.path.splitext(name or '')[1].lower()
def is_junk(name: str) -> bool:
return (name or '').lower() in JUNK_FILES
def is_image(name: str) -> bool:
return _ext(name) in IMAGE_EXTS
def is_sidecar(name: str) -> bool:
return _ext(name) in SIDECAR_EXTS
def is_disposable(name: str) -> bool:
"""True if this file is junk, a cover/scan image, or a lyric/metadata sidecar —
i.e. safe to delete from a folder that has no audio left."""
return is_junk(name) or is_image(name) or is_sidecar(name)
__all__ = [
'JUNK_FILES', 'IMAGE_EXTS', 'SIDECAR_EXTS',
'is_junk', 'is_image', 'is_sidecar', 'is_disposable',
]