fix(ytdl): trash button should also remove sidecars + empty parents

DELETE_FILE_ON_TRASHCAN currently removes just the one filename the
download record points at — yt-dlp's same-stem sidecars (writethumbnail's
.jpg/.webp, writesubtitles' .vtt/.srt, writeinfojson's .info.json,
writedescription's .description) stay behind, and the (now usually
empty) per-uploader directory remains. From the user's perspective the
🗑 button claims to delete the item but actually leaves orphan files
and empty directories on disk.

This patch refactors clear()'s removal step into a helper that:

1. Walks the main file's directory once and removes every entry whose
   name starts with '<main-stem>.', covering the main file itself and
   any same-stem sidecar yt-dlp may have written. The trailing dot
   prevents collateral hits on siblings like 'Title [id] (alt).mp4'.

2. Climbs upwards from the file's directory with os.rmdir(), stopping
   the moment a directory is non-empty (rmdir raises OSError) or we
   hit the bucket root. realpath()'d paths on both sides guarantee a
   symlinked layout can't trick the walk above the bucket.

Result: the directory structure is restored to what it would have
been if the download had never happened. No new dependencies, no
new envs, no change to the existing DELETE_FILE_ON_TRASHCAN flag.
This commit is contained in:
Helmut 2026-05-29 06:41:42 +02:00
parent 5429200fba
commit e75487efae

View file

@ -1389,13 +1389,69 @@ class DownloadQueue:
dl = self.done.get(id)
try:
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
os.remove(os.path.join(dldirectory, dl.info.filename))
self.__remove_download_and_sidecars(dldirectory, dl.info.filename)
except Exception as e:
log.warning(f'deleting file for download {id} failed with error message {e!r}')
self.done.delete(id)
await self.notifier.cleared(id)
return {'status': 'ok'}
def __remove_download_and_sidecars(self, bucket_root, rel_filename):
"""When DELETE_FILE_ON_TRASHCAN is enabled, the trash button should
leave the bucket the way it found it: no orphan thumbnail / subtitle
/ info.json files, no empty `<uploader>/` directories left behind.
Removes:
1. the main file the download record references
2. every same-stem sidecar in the same directory
(writethumbnail's .jpg/.webp, writesubtitles' .vtt/.srt,
writeinfojson's .info.json, writedescription's .description,
anything else yt-dlp writes per video)
3. any directories from the file's parent up to (but not including)
the bucket root, while they're empty after the removals above
Never touches the bucket root itself (e.g. /downloads/helmut), and
os.rmdir() is non-destructive it raises if the directory still
contains anything, which is exactly the desired behaviour."""
main_path = os.path.join(bucket_root, rel_filename)
main_dir = os.path.dirname(main_path)
main_stem, _ = os.path.splitext(os.path.basename(main_path))
# Sidecar prefix matches the main file and anything yt-dlp wrote
# alongside it (e.g. "Title [id].jpg", "Title [id].en.vtt",
# "Title [id].info.json"). The trailing dot avoids matching a
# sibling whose name starts with the same stem but continues
# with other characters (e.g. "Title [id] (alt cut).mp4").
sidecar_prefix = main_stem + '.'
if os.path.isdir(main_dir):
for entry in os.listdir(main_dir):
if not entry.startswith(sidecar_prefix):
continue
entry_path = os.path.join(main_dir, entry)
if not os.path.isfile(entry_path):
continue
try:
os.remove(entry_path)
except OSError as e:
log.warning(f'failed to remove sidecar {entry_path}: {e!r}')
# Prune empty parent directories up to (but not including)
# bucket_root. Walk on realpath()ed paths so a symlinked layout
# can't trick us into climbing above the bucket.
try:
bucket_real = os.path.realpath(bucket_root)
cur = os.path.realpath(main_dir)
while cur != bucket_real and cur.startswith(bucket_real + os.sep):
try:
os.rmdir(cur)
except OSError:
# Directory is non-empty (or a permission issue) —
# in either case stop climbing, that's the signal
# to leave it alone.
break
cur = os.path.dirname(cur)
except Exception as e:
log.warning(f'failed to prune empty parents below {bucket_root}: {e!r}')
def get(self):
return (list((k, v.info) for k, v in self.queue.items()) +
list((k, v.info) for k, v in self.pending.items()),