soulsync/core/repair_jobs/empty_folder_cleaner.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

221 lines
10 KiB
Python

"""Empty Folder Cleaner maintenance job (corruption's request).
After imports, relocations, and deletions, the music library accumulates empty
artist/album folders (and folders left holding only OS junk like .DS_Store). This
scans the library root and flags folders that are safe to remove, so the library
stays tidy.
Safety is the whole point — deleting directories is destructive:
- only TRULY empty folders (no real files) are ever flagged; a folder with a
cover.jpg or any audio is never touched,
- optionally folders holding *only* OS-junk files (.DS_Store, Thumbs.db, …),
- the library root itself is never removed, nor symlinked directories,
- it walks bottom-up so a parent left empty by its (removable) children cascades,
- and the apply handler RE-CHECKS emptiness at delete time, so anything that
gained a file between scan and apply is left alone.
``dir_is_removable`` is the pure decision seam — unit-tested independent of the FS.
"""
from __future__ import annotations
import os
from typing import Iterable, List
from core.library.residual_files import JUNK_FILES, is_disposable, is_junk # noqa: F401 — JUNK_FILES/is_junk re-exported
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger
logger = get_logger("repair_jobs.empty_folder_cleaner")
def dir_is_removable(files: Iterable[str], surviving_subdirs: Iterable[str],
*, ignore_junk: bool = True, ignore_disposable: bool = False) -> bool:
"""Pure: is a directory safe to remove?
Removable iff it has **no surviving subdirectories** and **no real files** —
where "no real files" means literally empty, or (when ``ignore_junk``) only
OS-junk files, or (when ``ignore_disposable`` — #891) only *residual* files:
junk + cover/scan images + lyric/metadata sidecars. ``ignore_disposable`` is the
broader opt-in that clears the cover.jpg-only folders a reorganize leaves behind.
``surviving_subdirs`` is the list of child dirs that are NOT themselves being
removed (i.e. still hold content).
"""
if list(surviving_subdirs):
return False
files = list(files)
if not files:
return True
if ignore_disposable:
return all(is_disposable(f) for f in files)
if ignore_junk:
return all(is_junk(f) for f in files)
return False
@register_job
class EmptyFolderCleanerJob(RepairJob):
job_id = 'empty_folder_cleaner'
display_name = 'Empty Folder Cleaner'
description = 'Finds empty (or junk-only) folders in the library and removes them'
help_text = (
'Scans your music library for empty folders left behind after imports, '
'relocations, and deletions — empty artist/album folders, or folders that '
'hold only OS junk like .DS_Store / Thumbs.db.\n\n'
'A finding is created for each. Applying one deletes the folder (after '
're-checking it is still empty). Folders that contain any real file — an '
'audio track, a booklet, anything not recognized as a leftover — are never '
'touched, the library root is never removed, and it cascades: a folder left '
'empty once its empty children are removed is cleaned too.\n\n'
'Enable "Also remove image/sidecar-only folders" to clear the cover.jpg / '
'.lrc leftovers a Library Reorganize leaves behind — folders whose only '
'remaining files are cover/scan images or lyric/metadata sidecars.'
)
icon = 'repair-icon-folder'
default_enabled = False
default_interval_hours = 168 # weekly — empties accrue slowly
default_settings = {'remove_junk_files': True, 'remove_residual_files': False}
auto_fix = False
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
root = context.transfer_folder
if not root or not os.path.isdir(root):
logger.info("[Empty Folder Cleaner] library root not available — skipping")
return result
root = os.path.realpath(root)
ignore_junk = True
ignore_disposable = False
try:
if context.config_manager:
ignore_junk = bool(context.config_manager.get(
'repair.jobs.empty_folder_cleaner.remove_junk_files', True))
# #891: also clear folders left holding only images / .lrc / sidecars
# (what a reorganize leaves behind). Opt-in — default off.
ignore_disposable = bool(context.config_manager.get(
'repair.jobs.empty_folder_cleaner.remove_residual_files', False))
except Exception: # noqa: S110 — setting read is best-effort; defaults to True
pass
flagged = set() # dir paths we'd remove → a parent sees them as "gone"
# topdown=False ⇒ deepest first, so children are decided before parents.
for dirpath, dirnames, filenames in os.walk(root, topdown=False):
if context.check_stop():
return result
real = os.path.realpath(dirpath)
if real == root:
continue # never the library root itself
if os.path.islink(dirpath):
continue # don't delete symlinked dirs
result.scanned += 1
surviving = [d for d in dirnames
if os.path.join(dirpath, d) not in flagged]
if not dir_is_removable(filenames, surviving,
ignore_junk=ignore_junk, ignore_disposable=ignore_disposable):
result.skipped += 1
continue
flagged.add(dirpath)
junk = [f for f in filenames if is_junk(f)]
# Files that will be swept along with the folder (junk always; images/
# sidecars only when the residual option is on).
purgeable = [f for f in filenames
if is_junk(f) or (ignore_disposable and is_disposable(f))]
residual = [f for f in purgeable if not is_junk(f)]
rel = os.path.relpath(dirpath, root)
if context.report_progress:
context.report_progress(log_line=f'Empty folder: {rel}', log_type='info')
if context.create_finding:
try:
if residual:
extra = f' (only {len(residual)} leftover image/sidecar file(s))'
elif junk:
extra = f' (only {len(junk)} junk file(s))'
else:
extra = ''
inserted = context.create_finding(
job_id=self.job_id,
finding_type='empty_folder',
severity='info',
entity_type='folder',
entity_id=dirpath,
file_path=dirpath,
title=f'Empty folder: {os.path.basename(dirpath) or rel}',
description=(f'"{rel}" holds no music' + extra + ' — safe to remove.'),
details={
'folder_path': dirpath,
'junk_files': junk,
'purgeable_files': purgeable,
'remove_junk': ignore_junk,
'remove_disposable': ignore_disposable,
})
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("[Empty Folder Cleaner] create finding failed for %s: %s", dirpath, e)
result.errors += 1
logger.info("[Empty Folder Cleaner] %d folders scanned, %d empty flagged",
result.scanned, result.findings_created)
return result
def estimate_scope(self, context: JobContext) -> int:
root = context.transfer_folder
if not root or not os.path.isdir(root):
return 0
total = 0
for _dp, dirnames, _f in os.walk(root):
total += len(dirnames)
return total
def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk: bool,
root: str, listdir, isdir, islink, remove_file, rmdir,
remove_disposable: bool = False) -> dict:
"""Pure-ish orchestration for the apply handler — RE-CHECKS the folder is still
removable, then deletes any purgeable leftovers + the folder. Effects injected
for testing.
With ``remove_disposable`` (#891) the re-check also treats cover images and
lyric/metadata sidecars as removable, and sweeps them before rmdir. Returns
``{'removed': bool, 'error': str|None}``. Refuses to touch the root, a symlink, a
non-dir, or a folder that gained REAL content (audio, a booklet, anything not
recognized as residual) since the scan.
"""
if not folder_path or not isdir(folder_path):
return {'removed': False, 'error': 'Folder no longer exists'}
if islink(folder_path):
return {'removed': False, 'error': 'Refusing to remove a symlinked folder'}
if root and os.path.realpath(folder_path) == os.path.realpath(root):
return {'removed': False, 'error': 'Refusing to remove the library root'}
def _purgeable(e: str) -> bool:
return (remove_junk and is_junk(e)) or (remove_disposable and is_disposable(e))
# Re-check at apply time: only purgeable leftovers now? (Anything else = leave it.)
entries = list(listdir(folder_path))
real_entries = [e for e in entries if not _purgeable(e)]
if real_entries:
return {'removed': False, 'error': 'Folder is no longer empty — left untouched'}
for e in entries:
if _purgeable(e):
try:
remove_file(os.path.join(folder_path, e))
except Exception: # noqa: S110 — leftover best-effort; rmdir below fails loudly if blocked
pass
try:
rmdir(folder_path)
except Exception as e:
return {'removed': False, 'error': f'Could not remove folder: {e}'}
return {'removed': True, 'error': None}
__all__ = ['dir_is_removable', 'is_junk', 'remove_empty_folder', 'EmptyFolderCleanerJob']