Skip to content

Commit 6114953

Browse files
committed
Add sync_with_source option to delete files removed from the source
Adds an opt-in output_options field that deletes local files when their source entry is no longer present in a subscription's URL(s). After the metadata pass, any download archive entry whose ID is absent from the source is removed along with all of its files. Reuses the existing keep_files/keep_max_files deletion path. The new predicate calls the same EnhancedDownloadArchive._remove_entry, so media, .nfo, thumbnail and .info.json files, the archive entry, and the transaction log are all handled the same, and no re-downloading occurs. Because ytdl-sub cannot distinguish "this video was removed" from "metadata collection stopped early", enabling sync_with_source forces a full enumeration of the source during the metadata pass: break_on_existing is overridden to False, and the max_downloads cap that keep_max_files injects is suppressed, only if the flag is set to True. For truncation ytdl-sub cannot override, YTDLP now reports which early-stop exception fired, and any such run skips pruning entirely with a warning. A source that returns zero entries, or a run that never enumerated the source at all (update_with_info_json), also never deletes. Defaults to False, requires maintain_download_archive, and leaves existing behavior unchanged. Refs #1038
1 parent 5b76e62 commit 6114953

9 files changed

Lines changed: 614 additions & 3 deletions

File tree

docs/source/config_reference/plugins.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,7 @@ Defines where to output files and thumbnails after all post-processing has compl
632632
keep_files_after: 19000101
633633
keep_max_files: 1000
634634
keep_files_date_eval: "{upload_date_standardized}"
635+
sync_with_source: False
635636
636637
``download_archive_name``
637638

@@ -750,6 +751,29 @@ Defines where to output files and thumbnails after all post-processing has compl
750751
When True, sets the file's mtime to match the video's upload_date from
751752
yt-dlp metadata. Defaults to False.
752753

754+
``sync_with_source``
755+
756+
:expected type: Optional[OverridesFormatter]
757+
:description:
758+
Requires ``maintain_download_archive`` set to True.
759+
760+
Deletes files whose source entry is no longer present in the subscription's URL(s).
761+
After the metadata pass, any entry in the download archive whose ID is absent from
762+
the source is removed, along with all of its files.
763+
764+
This forces a metadata fetch of every URL on each invocation. ytdl-sub cannot
765+
tell the difference between "this video was removed" and "metadata collection stopped
766+
early", so ``break_on_existing`` and ``keep_max_files``' download cap are both
767+
disabled during the metadata pass. Only enable this on sources you expect to change,
768+
and expect slower runs on large playlists.
769+
770+
If metadata collection is truncated for a reason ytdl-sub cannot override (such as
771+
``date_range`` with ``breaks`` enabled, or a user-set ``max_downloads``), or if the
772+
source returns no entries at all, syncing is skipped for that run and a warning is
773+
logged rather than deleting files.
774+
775+
Defaults to False.
776+
753777
``thumbnail_name``
754778

755779
:expected type: Optional[EntryFormatter]

src/ytdl_sub/config/preset_options.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ytdl_sub.validators.sort_by_validator import KeepMaxFilesSortByValidator
1515
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
1616
from ytdl_sub.validators.string_formatter_validators import (
17+
OverridesBooleanFormatterValidator,
1718
OverridesIntegerFormatterValidator,
1819
OverridesStringFormatterValidator,
1920
StandardizedDateValidator,
@@ -107,6 +108,7 @@ class OutputOptions(OptionsDictValidator):
107108
keep_files_after: 19000101
108109
keep_max_files: 1000
109110
keep_files_date_eval: "{upload_date_standardized}"
111+
sync_with_source: False
110112
"""
111113

112114
_required_keys = {"output_directory", "file_name"}
@@ -123,6 +125,7 @@ class OutputOptions(OptionsDictValidator):
123125
"keep_files_date_eval",
124126
"keep_max_files_sort_by",
125127
"preserve_mtime",
128+
"sync_with_source",
126129
}
127130

128131
@classmethod
@@ -195,11 +198,18 @@ def __init__(self, name, value):
195198
key="preserve_mtime", validator=BoolValidator, default=False
196199
)
197200

201+
self._sync_with_source = self._validate_key_if_present(
202+
key="sync_with_source", validator=OverridesBooleanFormatterValidator
203+
)
204+
198205
if (
199-
self._keep_files_before or self._keep_files_after or self._keep_max_files
206+
self._keep_files_before
207+
or self._keep_files_after
208+
or self._keep_max_files
209+
or self._sync_with_source
200210
) and not self.maintain_download_archive:
201211
raise self._validation_exception(
202-
"keep_files/keep_max requires maintain_download_archive set to True"
212+
"keep_files/keep_max/sync_with_source requires maintain_download_archive set to True"
203213
)
204214

205215
@property
@@ -355,6 +365,32 @@ def keep_max_files_sort_by(self) -> Optional[KeepMaxFilesSortByValidator]:
355365
"""
356366
return self._keep_max_files_sort_by
357367

368+
@property
369+
def sync_with_source(self) -> Optional[OverridesBooleanFormatterValidator]:
370+
"""
371+
:expected type: Optional[OverridesFormatter]
372+
:description:
373+
Requires ``maintain_download_archive`` set to True.
374+
375+
Deletes files whose source entry is no longer present in the subscription's URL(s).
376+
After the metadata pass, any entry in the download archive whose ID is absent from
377+
the source is removed, along with all of its files.
378+
379+
This forces a metadata fetch of every URL on each invocation. ytdl-sub cannot
380+
tell the difference between "this video was removed" and "metadata collection stopped
381+
early", so ``break_on_existing`` and ``keep_max_files``' download cap are both
382+
disabled during the metadata pass. Only enable this on sources you expect to change,
383+
and expect slower runs on large playlists.
384+
385+
If metadata collection is truncated for a reason ytdl-sub cannot override (such as
386+
``date_range`` with ``breaks`` enabled, or a user-set ``max_downloads``), or if the
387+
source returns no entries at all, syncing is skipped for that run and a warning is
388+
logged rather than deleting files.
389+
390+
Defaults to False.
391+
"""
392+
return self._sync_with_source
393+
358394
@property
359395
def preserve_mtime(self) -> bool:
360396
"""

src/ytdl_sub/downloaders/url/downloader.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,9 @@ def _iterate_child_entries(
381381
indices = reversed(indices)
382382

383383
for idx in indices:
384+
self._enhanced_download_archive.record_source_entry_id(
385+
entry_id=entries_to_iter[idx].uid
386+
)
384387
self._url_state.entries_downloaded += 1
385388

386389
if self._is_downloaded(entries_to_iter[idx]):
@@ -413,14 +416,21 @@ def _download_url_metadata(
413416
"""
414417
Downloads only info.json files and forms EntryParent trees
415418
"""
419+
truncation_reasons: List[str] = []
416420
with self._separate_download_archives():
417421
entry_dicts = YTDLP.extract_info_via_info_json(
418422
working_directory=self.working_directory,
419423
ytdl_options_overrides=ytdl_options_overrides,
420424
log_prefix_on_info_json_dl="Downloading metadata for",
425+
truncation_reasons=truncation_reasons,
421426
url=url,
422427
)
423428

429+
for reason in truncation_reasons:
430+
self._enhanced_download_archive.mark_source_enumeration_truncated(
431+
reason=f"{reason} while collecting metadata for {url}"
432+
)
433+
424434
parents = EntryParent.from_entry_dicts(
425435
url=url,
426436
entry_dicts=entry_dicts,

src/ytdl_sub/downloaders/ytdlp.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ def extract_info_via_info_json(
189189
working_directory: str,
190190
ytdl_options_overrides: Dict,
191191
log_prefix_on_info_json_dl: Optional[str] = None,
192+
truncation_reasons: Optional[List[str]] = None,
192193
**kwargs,
193194
) -> List[Dict]:
194195
"""
@@ -208,6 +209,10 @@ def extract_info_via_info_json(
208209
log_prefix_on_info_json_dl
209210
Optional. Spin a new thread to listen for new info.json files. Log
210211
f'{log_prefix_on_info_json_dl} {title}' when a new one appears
212+
truncation_reasons
213+
Optional. Appends the name of any exception that stopped metadata
214+
collection early. A non-empty list means the returned entries are
215+
an incomplete view of the source.
211216
**kwargs
212217
arguments passed directory to YoutubeDL extract_info
213218
"""
@@ -217,16 +222,22 @@ def extract_info_via_info_json(
217222
):
218223
cls.extract_info(ytdl_options_overrides=ytdl_options_overrides, **kwargs)
219224
except RejectedVideoReached:
225+
if truncation_reasons is not None:
226+
truncation_reasons.append("RejectedVideoReached")
220227
cls.logger.debug(
221228
"RejectedVideoReached, stopping additional downloads "
222229
"(Can be disable by setting `date_range.breaks` to False)."
223230
)
224231
except ExistingVideoReached:
232+
if truncation_reasons is not None:
233+
truncation_reasons.append("ExistingVideoReached")
225234
cls.logger.debug(
226235
"ExistingVideoReached, stopping additional downloads. "
227236
"(Can be disable by setting `ytdl_options.break_on_existing` to False)."
228237
)
229238
except MaxDownloadsReached:
239+
if truncation_reasons is not None:
240+
truncation_reasons.append("MaxDownloadsReached")
230241
cls.logger.info("MaxDownloadsReached, stopping additional downloads.")
231242

232243
parent_dicts: List[Dict] = []

src/ytdl_sub/subscriptions/subscription_download.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,27 @@ def _maintain_archive_file(self):
144144
# If output options maintains stale file deletion, perform the delete here prior to saving
145145
# the download archive
146146
if self.maintain_download_archive:
147+
if self.output_options.sync_with_source and self.overrides.apply_formatter(
148+
self.output_options.sync_with_source, expected_type=bool
149+
):
150+
source_entry_ids = self.download_archive.source_entry_ids
151+
152+
if source_entry_ids is None:
153+
logger.warning(
154+
"sync_with_source: the source was not fully enumerated, skipping sync "
155+
"to avoid deleting files. This happens when metadata collection stops "
156+
"early, i.e. from `date_range.breaks` or a user set `max_downloads`."
157+
)
158+
elif not source_entry_ids:
159+
logger.warning(
160+
"sync_with_source: the source returned zero entries, skipping sync. "
161+
"An empty fetch is not treated as an emptied source."
162+
)
163+
else:
164+
self.download_archive.remove_entries_not_in_source(
165+
source_entry_ids=source_entry_ids
166+
)
167+
147168
date_range_to_keep = to_date_range(
148169
before=self.output_options.keep_files_before,
149170
after=self.output_options.keep_files_after,

src/ytdl_sub/subscriptions/subscription_ytdl_options.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ def _info_json_only_options(self) -> Dict:
7979
"extract_flat": "discard", # do not store info.json in mem since its in file
8080
}
8181

82+
@property
83+
def _sync_with_source(self) -> bool:
84+
if not self._preset.output_options.sync_with_source:
85+
return False
86+
87+
return self._overrides.apply_formatter(
88+
self._preset.output_options.sync_with_source, expected_type=bool
89+
)
90+
8291
@property
8392
def _output_options(self) -> Dict:
8493
ytdl_options = {}
@@ -87,7 +96,11 @@ def _output_options(self) -> Dict:
8796
ytdl_options["download_archive"] = (
8897
self._enhanced_download_archive.working_ytdl_file_path
8998
)
90-
if self._preset.output_options.keep_max_files:
99+
100+
# sync_with_source needs a full enumeration of the source,
101+
# max_downloads would truncate the metadata pass and make
102+
# present entries look removed.
103+
if self._preset.output_options.keep_max_files and not self._sync_with_source:
91104
keep_max_files = self._overrides.apply_formatter(
92105
self._preset.output_options.keep_max_files, expected_type=int
93106
)
@@ -97,6 +110,16 @@ def _output_options(self) -> Dict:
97110

98111
return ytdl_options
99112

113+
@property
114+
def _sync_with_source_options(self) -> Dict:
115+
if not self._sync_with_source:
116+
return {}
117+
118+
# stopping at the first alreadt downloaded entry would hide
119+
# the rest of the soure, which sync_with_source would then
120+
# interpret as deleted entries
121+
return {"break_on_existing": False}
122+
100123
def _plugin_ytdl_options(self, plugin: Type[PluginT]) -> Dict:
101124
if plugin_obj := self._get_plugin(plugin):
102125
return plugin_obj.ytdl_options()
@@ -175,6 +198,7 @@ def metadata_builder(self) -> YTDLOptionsBuilder:
175198
self._plugin_ytdl_options(AudioExtractPlugin), # will override format
176199
self._user_ytdl_options, # user ytdl options...
177200
self._info_json_only_options, # then info_json_only options
201+
self._sync_with_source_options, # then sync_with_source overrides
178202
)
179203

180204
def download_builder(self) -> YTDLOptionsBuilder:

src/ytdl_sub/ytdl_additions/enhanced_download_archive.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,11 @@ def __init__(
406406
self.num_entries_added: int = 0
407407
self.num_entries_modified: int = 0
408408
self.num_entries_removed: int = 0
409+
# None means no source enum has happened, which
410+
# is not the same as a source that enumed to
411+
# zero entries
412+
self._source_entry_ids: Optional[Set[str]] = None
413+
self._source_enumeration_truncated: bool = False
409414

410415
@property
411416
def num_entries(self) -> int:
@@ -439,6 +444,8 @@ def reinitialize(self, dry_run: bool) -> "EnhancedDownloadArchive":
439444
mapping_file_path=self._output_file_path,
440445
migrated_mapping_file_path=self._migrated_file_path,
441446
)
447+
self._source_entry_ids = None
448+
self._source_enumeration_truncated = False
442449
return self
443450

444451
@property
@@ -550,6 +557,46 @@ def prepare_download_archive(self) -> "EnhancedDownloadArchive":
550557

551558
return self
552559

560+
def record_source_entry_id(self, entry_id: str) -> None:
561+
"""
562+
Records an entry ID seen in the source during the metadata pass.
563+
564+
Parameters
565+
----------
566+
entry_id
567+
Entry ID from the source.
568+
"""
569+
if self._source_entry_ids is None:
570+
self._source_entry_ids = set()
571+
572+
self._source_entry_ids.add(entry_id)
573+
574+
def mark_source_enumeration_truncated(self, reason: str) -> None:
575+
"""
576+
Marks the source enumeration as an incomplete view of the source,
577+
which disables any pruning that relies on knowing every entry.
578+
579+
Parameters
580+
----------
581+
reason
582+
Why metadata collection stopped early.
583+
"""
584+
self._source_enumeration_truncated = True
585+
logger.debug("Source enumeration truncated: %s", reason)
586+
587+
@property
588+
def source_entry_ids(self) -> Optional[Set[str]]:
589+
"""
590+
Returns
591+
-------
592+
Every entry ID seen in the source during metadata pass, or
593+
None if the source was never enumerated or stopped early.
594+
"""
595+
if self._source_enumeration_truncated:
596+
return None
597+
598+
return self._source_entry_ids
599+
553600
def _remove_entry(self, uid: str, mapping: DownloadMapping) -> None:
554601
for file_name in mapping.file_names:
555602
self._file_handler.delete_file_from_output_directory(file_name=file_name)
@@ -637,6 +684,32 @@ def remove_stale_files(
637684

638685
return self
639686

687+
def remove_entries_not_in_source(self, source_entry_ids: Set[str]) -> "EnhancedDownloadArchive":
688+
"""
689+
Checks all entries within mappings. If any entry is no longer present
690+
in the source, delete it.
691+
692+
Parameters
693+
----------
694+
source_entry_ids
695+
Every entry ID present in the source
696+
697+
Returns
698+
-------
699+
self
700+
"""
701+
stale_mappings: Dict[str, DownloadMapping] = {
702+
uid: mapping
703+
for uid, mapping in self.mapping.entry_mappings.items()
704+
if uid not in source_entry_ids
705+
}
706+
707+
for uid, mapping in stale_mappings.items():
708+
logger.info("Entry %s is no longer in the source, deleting its files", uid)
709+
self._remove_entry(uid=uid, mapping=mapping)
710+
711+
return self
712+
640713
def save_download_mappings(self) -> "EnhancedDownloadArchive":
641714
"""
642715
Saves the updated download mappings to the output directory if any files were changed.

0 commit comments

Comments
 (0)