[BACKEND] Always filter out live, post_live, and upcoming videos on download

This commit is contained in:
Jesse Bannon 2023-09-24 06:11:10 -07:00
parent 723c40b59b
commit 8c4973d4b2
8 changed files with 111 additions and 18 deletions

View file

@ -72,6 +72,15 @@ class Plugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC):
Class to define the new plugin functionality Class to define the new plugin functionality
""" """
@classmethod
def default_ytdl_options(cls) -> Dict:
"""
Returns
-------
ytdl options to enable if the plugin is not specified in the download
"""
return {}
def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]: def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]:
""" """
Returns Returns

View file

@ -4,6 +4,7 @@ from pathlib import Path
from typing import Dict from typing import Dict
from typing import Iterable from typing import Iterable
from typing import List from typing import List
from typing import Optional
from ytdl_sub.config.preset_options import OptionsDictValidator from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.config.preset_options import Overrides from ytdl_sub.config.preset_options import Overrides
@ -123,7 +124,7 @@ class InfoJsonDownloader(SourcePlugin[InfoJsonDownloaderOptions]):
): ):
self._enhanced_download_archive.num_entries_removed += 1 self._enhanced_download_archive.num_entries_removed += 1
def download(self, entry: Entry) -> Entry: def download(self, entry: Entry) -> Optional[Entry]:
""" """
Mock the download by copying the entry file from the output directory into Mock the download by copying the entry file from the output directory into
the working directory the working directory

View file

@ -56,7 +56,7 @@ class SourcePlugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], AB
"""Gathers metadata of all entries to download""" """Gathers metadata of all entries to download"""
@abc.abstractmethod @abc.abstractmethod
def download(self, entry: Entry) -> Entry: def download(self, entry: Entry) -> Optional[Entry]:
"""The function to perform the download of all media entries""" """The function to perform the download of all media entries"""
@final @final

View file

@ -9,6 +9,8 @@ from typing import Optional
from typing import Set from typing import Set
from typing import Tuple from typing import Tuple
from yt_dlp.utils import RejectedVideoReached
from ytdl_sub.config.plugin import PluginPriority from ytdl_sub.config.plugin import PluginPriority
from ytdl_sub.config.preset_options import Overrides from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.downloaders.source_plugin import SourcePlugin from ytdl_sub.downloaders.source_plugin import SourcePlugin
@ -459,7 +461,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
) )
yield entry yield entry
def download(self, entry: Entry) -> Entry: def download(self, entry: Entry) -> Optional[Entry]:
""" """
Parameters Parameters
---------- ----------
@ -482,7 +484,13 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
entry.title, entry.title,
) )
download_entry = self._extract_entry_info_with_retry(entry=entry) # Match-filters can be applied at the download stage. If the download is rejected,
# then return None
try:
download_entry = self._extract_entry_info_with_retry(entry=entry)
except RejectedVideoReached:
download_logger.info("Entry rejected by download match-filter, skipping ..")
return None
upload_date_idx = self._enhanced_download_archive.mapping.get_num_entries_with_upload_date( upload_date_idx = self._enhanced_download_archive.mapping.get_num_entries_with_upload_date(
upload_date_standardized=entry.upload_date_standardized upload_date_standardized=entry.upload_date_standardized

View file

@ -1,7 +1,11 @@
from typing import Any from typing import Any
from typing import Dict
from typing import List from typing import List
from typing import Optional
from typing import Tuple from typing import Tuple
from yt_dlp import match_filter_func
from ytdl_sub.config.plugin import Plugin from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_options import OptionsDictValidator from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
@ -9,6 +13,8 @@ from ytdl_sub.validators.validators import StringListValidator
logger = Logger.get("match_filters") logger = Logger.get("match_filters")
_DEFAULT_DOWNLOAD_MATCH_FILTERS: List[str] = ["!is_live & !is_upcoming & !post_live"]
class MatchFiltersOptions(OptionsDictValidator): class MatchFiltersOptions(OptionsDictValidator):
""" """
@ -40,40 +46,79 @@ class MatchFiltersOptions(OptionsDictValidator):
# - "availability=?public" # - "availability=?public"
""" """
_required_keys = {"filters"} _optional_keys = {"filters", "download_filters"}
@classmethod @classmethod
def partial_validate(cls, name: str, value: Any) -> None: def partial_validate(cls, name: str, value: Any) -> None:
"""Ensure filters looks right""" """Ensure filters looks right"""
if isinstance(value, dict): if isinstance(value, dict):
value["filters"] = value.get("filters", [""]) value["filters"] = value.get("filters", [""])
value["download_filters"] = value.get("download_filters", [""])
_ = cls(name, value) _ = cls(name, value)
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self._filters = self._validate_key(key="filters", validator=StringListValidator).list self._filters = self._validate_key_if_present(key="filters", validator=StringListValidator)
self._download_filters = self._validate_key(
key="download_filters",
validator=StringListValidator,
default=_DEFAULT_DOWNLOAD_MATCH_FILTERS,
)
@property @property
def filters(self) -> List[str]: def filters(self) -> List[str]:
""" """
The filters themselves. If used multiple times, the filter matches if at least one of the The filters themselves. If used multiple times, the filter matches if at least one of the
conditions are met. For logical AND's between match filters, use the ``&`` operator in conditions are met. For logical AND's between match filters, use the ``&`` operator in
a single match filter. a single match filter. These are applied when gathering metadata.
""" """
return [validator.value for validator in self._filters] return [validator.value for validator in self._filters.list] if self._filters else []
@property
def download_filters(self) -> List[str]:
"""
Filters to apply during the download stage. This can be useful when building presets
that contain match-filters that you do not want to conflict with metadata-based
match-filters since they act as logical OR's.
By default, if no download_filters are present, then the filter
``"!is_live & !is_upcoming & !post_live"`` is added.
"""
return [validator.value for validator in self._download_filters.list]
class MatchFiltersPlugin(Plugin[MatchFiltersOptions]): class MatchFiltersPlugin(Plugin[MatchFiltersOptions]):
plugin_options_type = MatchFiltersOptions plugin_options_type = MatchFiltersOptions
@classmethod
def default_ytdl_options(cls) -> Dict:
"""
Returns
-------
match-filter to filter out live + upcoming videos when downloading
"""
return {
"match_filter": match_filter_func(
filters=[], breaking_filters=_DEFAULT_DOWNLOAD_MATCH_FILTERS
),
}
def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]: def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]:
""" """
Returns Returns
------- -------
match_filter after calling the utility function for it match_filters to apply at the metadata stage
""" """
match_filters: List[str] = [] return self.plugin_options.filters, []
for filter_str in self.plugin_options.filters:
match_filters.append(filter_str)
return match_filters, [] def ytdl_options(self) -> Optional[Dict]:
"""
Returns
-------
match_filters to apply at the download stage
"""
return {
"match_filter": match_filter_func(
filters=[], breaking_filters=self.plugin_options.download_filters
),
}

View file

@ -295,6 +295,9 @@ class SubscriptionDownload(BaseSubscription, ABC):
continue continue
entry = downloader.download(entry) entry = downloader.download(entry)
if entry is None:
continue
entry_metadata = FileMetadata() entry_metadata = FileMetadata()
if isinstance(entry, tuple): if isinstance(entry, tuple):
entry, entry_metadata = entry entry, entry_metadata = entry

View file

@ -14,6 +14,7 @@ from ytdl_sub.plugins.audio_extract import AudioExtractPlugin
from ytdl_sub.plugins.chapters import ChaptersPlugin from ytdl_sub.plugins.chapters import ChaptersPlugin
from ytdl_sub.plugins.file_convert import FileConvertPlugin from ytdl_sub.plugins.file_convert import FileConvertPlugin
from ytdl_sub.plugins.format import FormatPlugin from ytdl_sub.plugins.format import FormatPlugin
from ytdl_sub.plugins.match_filters import MatchFiltersPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.utils.ffmpeg import FFMPEG from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -88,10 +89,10 @@ class SubscriptionYTDLOptions:
return ytdl_options return ytdl_options
def _plugin_ytdl_options(self, plugin: Type[PluginT]) -> Dict: def _plugin_ytdl_options(self, plugin: Type[PluginT]) -> Dict:
if not (audio_extract_plugin := self._get_plugin(plugin)): if not (plugin_obj := self._get_plugin(plugin)):
return {} return plugin.default_ytdl_options()
return audio_extract_plugin.ytdl_options() return plugin_obj.ytdl_options()
@property @property
def _user_ytdl_options(self) -> Dict: def _user_ytdl_options(self) -> Dict:
@ -144,6 +145,7 @@ class SubscriptionYTDLOptions:
self._plugin_ytdl_options(ChaptersPlugin), self._plugin_ytdl_options(ChaptersPlugin),
self._plugin_ytdl_options(AudioExtractPlugin), self._plugin_ytdl_options(AudioExtractPlugin),
self._plugin_ytdl_options(FormatPlugin), self._plugin_ytdl_options(FormatPlugin),
self._plugin_ytdl_options(MatchFiltersPlugin),
self._user_ytdl_options, # user ytdl options... self._user_ytdl_options, # user ytdl options...
self._download_only_options, # then download_only options self._download_only_options, # then download_only options
) )

View file

@ -11,7 +11,6 @@ def preset_dict(output_directory):
"preset": "music_video", "preset": "music_video",
"download": "https://www.youtube.com/watch?v=2zYF9JLHDmA", "download": "https://www.youtube.com/watch?v=2zYF9JLHDmA",
"output_options": {"output_directory": output_directory}, "output_options": {"output_directory": output_directory},
# download the worst format so it is fast
"ytdl_options": { "ytdl_options": {
"postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility "postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility
}, },
@ -25,7 +24,6 @@ def playlist_preset_dict(output_directory):
"preset": "music_video", "preset": "music_video",
"download": "https://www.youtube.com/playlist?list=PL5BC0FC26BECA5A35", "download": "https://www.youtube.com/playlist?list=PL5BC0FC26BECA5A35",
"output_options": {"output_directory": output_directory}, "output_options": {"output_directory": output_directory},
# download the worst format so it is fast
"ytdl_options": { "ytdl_options": {
"postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility "postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility
}, },
@ -33,7 +31,34 @@ def playlist_preset_dict(output_directory):
} }
@pytest.fixture
def livestream_preset_dict(output_directory):
return {
"preset": "music_video",
"download": "https://www.youtube.com/watch?v=DoUOrTJbIu4",
"output_options": {"output_directory": output_directory},
"ytdl_options": {
"postprocessor_args": {"ffmpeg": ["-bitexact"]},
},
}
class TestFileConvert: class TestFileConvert:
def test_livestreams_download_filtered(
self,
music_video_config,
livestream_preset_dict,
output_directory,
):
subscription = Subscription.from_dict(
config=music_video_config,
preset_name="match_filter_test",
preset_dict=livestream_preset_dict,
)
transaction_log = subscription.download(dry_run=False)
assert transaction_log.is_empty
@pytest.mark.parametrize("dry_run", [True, False]) @pytest.mark.parametrize("dry_run", [True, False])
def test_match_filters_empty( def test_match_filters_empty(
self, self,