Skip to content

Commit 0cff18c

Browse files
committed
Address review comments on sync_with_source
Raise a validation error when sync_with_source is combined with keep_files_before, keep_files_after or keep_max_files, rather than silently dropping the max_downloads cap that keep_max_files injects. Move the source id guard into remove_entries_not_in_source, since the archive class already holds the ids. break_on_existing is still forced off for the metadata fetch, because the prebuilt tv_show, singles and music_video presets set it themselves. It now logs when it does.
1 parent 0c0df93 commit 0cff18c

6 files changed

Lines changed: 97 additions & 48 deletions

File tree

docs/source/config_reference/plugins.rst

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -755,17 +755,18 @@ Defines where to output files and thumbnails after all post-processing has compl
755755

756756
:expected type: Optional[OverridesFormatter]
757757
:description:
758-
Requires ``maintain_download_archive`` set to True.
758+
Requires ``maintain_download_archive`` set to True. Cannot be used with
759+
``keep_files_before``, ``keep_files_after``, or ``keep_max_files``, since those
760+
deliberately stop metadata collection early.
759761

760762
Deletes files whose source entry is no longer present in the subscription's URL(s).
761763
After the metadata pass, any entry in the download archive whose ID is absent from
762764
the source is removed, along with all of its files.
763765

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.
766+
This forces a metadata fetch of every URL on each invocation. ytdl-sub cannot tell
767+
the difference between "this video was removed" and "metadata collection stopped
768+
early", so ``break_on_existing`` is disabled for the metadata fetch. Only enable
769+
this on sources you expect to change, and expect slower runs on large playlists.
769770

770771
If metadata collection is truncated for a reason ytdl-sub cannot override (such as
771772
``date_range`` with ``breaks`` enabled, or a user-set ``max_downloads``), or if the

src/ytdl_sub/config/preset_options.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,16 @@ def __init__(self, name, value):
212212
"keep_files/keep_max/sync_with_source requires maintain_download_archive set to True"
213213
)
214214

215+
# sync_with_source needs the entire source. keep_max_files caps metadata collection,
216+
# and the keep_files options are a competing retention policy
217+
if self._sync_with_source and (
218+
self._keep_files_before or self._keep_files_after or self._keep_max_files
219+
):
220+
raise self._validation_exception(
221+
"sync_with_source cannot be used with keep_files_before, keep_files_after, or "
222+
"keep_max_files"
223+
)
224+
215225
@property
216226
def output_directory(self) -> OverridesStringFormatterValidator:
217227
"""
@@ -370,17 +380,18 @@ def sync_with_source(self) -> Optional[OverridesBooleanFormatterValidator]:
370380
"""
371381
:expected type: Optional[OverridesFormatter]
372382
:description:
373-
Requires ``maintain_download_archive`` set to True.
383+
Requires ``maintain_download_archive`` set to True. Cannot be used with
384+
``keep_files_before``, ``keep_files_after``, or ``keep_max_files``, since those
385+
deliberately stop metadata collection early.
374386
375387
Deletes files whose source entry is no longer present in the subscription's URL(s).
376388
After the metadata pass, any entry in the download archive whose ID is absent from
377389
the source is removed, along with all of its files.
378390
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.
391+
This forces a metadata fetch of every URL on each invocation. ytdl-sub cannot tell
392+
the difference between "this video was removed" and "metadata collection stopped
393+
early", so ``break_on_existing`` is disabled for the metadata fetch. Only enable
394+
this on sources you expect to change, and expect slower runs on large playlists.
384395
385396
If metadata collection is truncated for a reason ytdl-sub cannot override (such as
386397
``date_range`` with ``breaks`` enabled, or a user-set ``max_downloads``), or if the

src/ytdl_sub/subscriptions/subscription_download.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -147,21 +147,7 @@ def _maintain_archive_file(self):
147147
if self.output_options.sync_with_source and self.overrides.apply_formatter(
148148
self.output_options.sync_with_source, expected_type=bool
149149
):
150-
source_entry_ids = self.download_archive.source_entry_ids
151-
152-
# A source that returned nothing is indistinguishable from one that was never
153-
# enumerated, so neither is treated as every entry having been removed
154-
if source_entry_ids is None:
155-
logger.warning(
156-
"sync_with_source: the source returned no entries or was not fully "
157-
"enumerated, skipping sync to avoid deleting files. This happens when "
158-
"metadata collection stops early, i.e. from `date_range.breaks` or a "
159-
"user set `max_downloads`."
160-
)
161-
else:
162-
self.download_archive.remove_entries_not_in_source(
163-
source_entry_ids=source_entry_ids
164-
)
150+
self.download_archive.remove_entries_not_in_source()
165151

166152
date_range_to_keep = to_date_range(
167153
before=self.output_options.keep_files_before,

src/ytdl_sub/subscriptions/subscription_ytdl_options.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,7 @@ def _output_options(self) -> Dict:
9797
self._enhanced_download_archive.working_ytdl_file_path
9898
)
9999

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:
100+
if self._preset.output_options.keep_max_files:
104101
keep_max_files = self._overrides.apply_formatter(
105102
self._preset.output_options.keep_max_files, expected_type=int
106103
)
@@ -115,9 +112,11 @@ def _sync_with_source_options(self) -> Dict:
115112
if not self._sync_with_source:
116113
return {}
117114

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
115+
# stopping at the first already downloaded entry would hide the rest of the source,
116+
# which sync_with_source would then interpret as deleted entries
117+
logger.info(
118+
"sync_with_source is enabled, disabling break_on_existing to fetch the entire source"
119+
)
121120
return {"break_on_existing": False}
122121

123122
def _plugin_ytdl_options(self, plugin: Type[PluginT]) -> Dict:

src/ytdl_sub/ytdl_additions/enhanced_download_archive.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -684,20 +684,24 @@ def remove_stale_files(
684684

685685
return self
686686

687-
def remove_entries_not_in_source(self, source_entry_ids: Set[str]) -> "EnhancedDownloadArchive":
687+
def remove_entries_not_in_source(self) -> "EnhancedDownloadArchive":
688688
"""
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
689+
Checks all entries within the mappings. If any entry is no longer in the source, delete
690+
it. Does nothing unless the source was fully enumerated, since a source that returned
691+
nothing looks the same as one that was never enumerated.
696692
697693
Returns
698694
-------
699695
self
700696
"""
697+
if (source_entry_ids := self.source_entry_ids) is None:
698+
logger.warning(
699+
"sync_with_source: the source returned no entries or stopped early, skipping "
700+
"sync to avoid deleting files. Can be caused by `date_range.breaks` or "
701+
"`max_downloads`."
702+
)
703+
return self
704+
701705
stale_mappings: Dict[str, DownloadMapping] = {
702706
uid: mapping
703707
for uid, mapping in self.mapping.entry_mappings.items()

tests/unit/test_sync_with_source.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ def _make_archive(tmp_path, mappings_dict, dry_run: bool = False):
2929
return archive
3030

3131

32+
def _record_source(archive, source_entry_ids):
33+
for entry_id in source_entry_ids:
34+
archive.record_source_entry_id(entry_id=entry_id)
35+
return archive
36+
37+
3238
def _mappings():
3339
return {
3440
"id1": DownloadMapping("2024-01-01", "yt", {"a.mp4"}),
@@ -40,29 +46,33 @@ def _mappings():
4046
class TestRemoveEntriesNotInSource:
4147
def test_entry_removed_from_source_is_pruned(self, tmp_path):
4248
archive = _make_archive(tmp_path, _mappings())
43-
archive.remove_entries_not_in_source(source_entry_ids={"id1", "id3"})
49+
_record_source(archive, {"id1", "id3"})
50+
archive.remove_entries_not_in_source()
4451

4552
assert sorted(archive.mapping.entry_mappings.keys()) == ["id1", "id3"]
4653
assert not (tmp_path / "output" / "b.mp4").exists()
4754

4855
def test_entry_still_in_source_is_untouched(self, tmp_path):
4956
archive = _make_archive(tmp_path, _mappings())
50-
archive.remove_entries_not_in_source(source_entry_ids={"id1", "id3"})
57+
_record_source(archive, {"id1", "id3"})
58+
archive.remove_entries_not_in_source()
5159

5260
assert (tmp_path / "output" / "a.mp4").exists()
5361
assert (tmp_path / "output" / "c.mp4").exists()
5462
assert archive.num_entries_removed == 1
5563

5664
def test_all_entries_in_source_removes_nothing(self, tmp_path):
5765
archive = _make_archive(tmp_path, _mappings())
58-
archive.remove_entries_not_in_source(source_entry_ids={"id1", "id2", "id3"})
66+
_record_source(archive, {"id1", "id2", "id3"})
67+
archive.remove_entries_not_in_source()
5968

6069
assert sorted(archive.mapping.entry_mappings.keys()) == ["id1", "id2", "id3"]
6170
assert archive.num_entries_removed == 0
6271

6372
def test_source_with_new_entries_removes_nothing(self, tmp_path):
6473
archive = _make_archive(tmp_path, _mappings())
65-
archive.remove_entries_not_in_source(source_entry_ids={"id1", "id2", "id3", "id4"})
74+
_record_source(archive, {"id1", "id2", "id3", "id4"})
75+
archive.remove_entries_not_in_source()
6676

6777
assert sorted(archive.mapping.entry_mappings.keys()) == ["id1", "id2", "id3"]
6878
assert archive.num_entries_removed == 0
@@ -77,16 +87,34 @@ def test_all_files_for_entry_are_deleted(self, tmp_path):
7787
"id2": DownloadMapping("2024-01-02", "yt", {"b.mp4"}),
7888
}
7989
archive = _make_archive(tmp_path, mappings)
80-
archive.remove_entries_not_in_source(source_entry_ids={"id2"})
90+
_record_source(archive, {"id2"})
91+
archive.remove_entries_not_in_source()
8192

8293
for file_name in ("a.mp4", "a.nfo", "a-thumb.jpg", "a.info.json"):
8394
assert not (tmp_path / "output" / file_name).exists()
8495
assert (tmp_path / "output" / "b.mp4").exists()
8596

8697
def test_dry_run_does_not_delete_files(self, tmp_path):
8798
archive = _make_archive(tmp_path, _mappings(), dry_run=True)
88-
archive.remove_entries_not_in_source(source_entry_ids={"id1", "id3"})
99+
_record_source(archive, {"id1", "id3"})
100+
archive.remove_entries_not_in_source()
101+
102+
assert (tmp_path / "output" / "b.mp4").exists()
103+
104+
def test_unenumerated_source_deletes_nothing(self, tmp_path):
105+
archive = _make_archive(tmp_path, _mappings())
106+
archive.remove_entries_not_in_source()
107+
108+
assert sorted(archive.mapping.entry_mappings.keys()) == ["id1", "id2", "id3"]
109+
assert (tmp_path / "output" / "b.mp4").exists()
110+
111+
def test_truncated_source_deletes_nothing(self, tmp_path):
112+
archive = _make_archive(tmp_path, _mappings())
113+
_record_source(archive, {"id1"})
114+
archive.mark_source_enumeration_truncated(reason="ExistingVideoReached")
115+
archive.remove_entries_not_in_source()
89116

117+
assert sorted(archive.mapping.entry_mappings.keys()) == ["id1", "id2", "id3"]
90118
assert (tmp_path / "output" / "b.mp4").exists()
91119

92120

@@ -137,3 +165,23 @@ def test_accepts_override(self):
137165
def test_requires_maintain_download_archive(self):
138166
with pytest.raises(ValidationException, match="maintain_download_archive"):
139167
OutputOptions("t", self._base | {"sync_with_source": True})
168+
169+
@pytest.mark.parametrize(
170+
"keep_option, keep_value",
171+
[
172+
("keep_files_before", "now"),
173+
("keep_files_after", "19000101"),
174+
("keep_max_files", 10),
175+
],
176+
)
177+
def test_cannot_be_used_with_keep_options(self, keep_option, keep_value):
178+
with pytest.raises(ValidationException, match="cannot be used with"):
179+
OutputOptions(
180+
"t",
181+
self._base
182+
| {
183+
"maintain_download_archive": True,
184+
"sync_with_source": True,
185+
keep_option: keep_value,
186+
},
187+
)

0 commit comments

Comments
 (0)