[BACKEND] Add generic.collection download strategy, use it for soundcloud, youtube channel/playlist (#230)

This commit is contained in:
Jesse Bannon 2022-09-14 15:36:14 -07:00 committed by GitHub
parent c33eed5a64
commit c08ea64b5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 606 additions and 617 deletions

View file

@ -114,7 +114,7 @@ for available source variables to use.
albums_and_singles albums_and_singles
__________________ __________________
.. autoclass:: ytdl_sub.downloaders.soundcloud.albums_and_singles.SoundcloudAlbumsAndSinglesDownloadOptions() .. autoclass:: ytdl_sub.downloaders.soundcloud.albums_and_singles.SoundcloudAlbumsAndSinglesDownloadOptions()
:members: :members: url, skip_premiere_tracks
:member-order: bysource :member-order: bysource
:inherited-members: :inherited-members:

View file

@ -85,31 +85,3 @@ presets:
overrides: overrides:
youtube_tv_shows_directory: "/path/to/youtube_tv_shows" youtube_tv_shows_directory: "/path/to/youtube_tv_shows"
episode_name: "Season {upload_year}/s{upload_year}.e{upload_month_padded}{upload_day_padded} - {title_sanitized}" episode_name: "Season {upload_year}/s{upload_year}.e{upload_month_padded}{upload_day_padded} - {title_sanitized}"
###############################################################################
# RECENT ARCHIVE
# This preset shows how you can download just the last 14 days-worth of videos
# on each download invocation.
yt_channel_as_tv__recent:
# `preset` can be set to any other preset in the config, and will inherit all its defined fields.
# This helps reduce copy-paste in the config.yaml
preset: "yt_channel_as_tv"
# We will add the `after` field onto `yt_channel_as_tv`'s YouTube channel download strategy.
# This is saying 'only download videos uploaded in the last 2 weeks'
youtube:
after: "today-2weeks"
###############################################################################
# ROLLING ARCHIVE
# This example shows how to only keep the last 14-days worth of videos, and
# delete the rest.
yt_channel_as_tv__only_recent:
# Reuse `yt_channel_as_tv__recent` to only download the last 2 weeks' of videos
preset: "yt_channel_as_tv__recent"
# This is saying "only keep files if they were uploaded in the last 2 weeks".
# All other files that this subscription had previously downloaded will be
# deleted.
output_options:
keep_files_after: "today-2weeks"

View file

@ -3,7 +3,7 @@
# in each example. # in each example.
############################################################################### ###############################################################################
# LEVEL 1 - FULL ARCHIVE # FULL ARCHIVE
# #
# Subscription names are defined by you. We will call this one # Subscription names are defined by you. We will call this one
# john_smith_archive because it will download every single video in # john_smith_archive because it will download every single video in
@ -25,27 +25,3 @@ john_smith_archive:
# '_sanitized' version that is safe for file systems. # '_sanitized' version that is safe for file systems.
overrides: overrides:
tv_show_name: "John /\ Smith" tv_show_name: "John /\ Smith"
###############################################################################
# LEVEL 2 - RECENT ARCHIVE
#
# Use the "yt_channel_as_tv__recent" preset to only download the last two
# weeks' worth of YouTube videos.
john_smith_recent_archive:
preset: "yt_channel_as_tv__recent"
youtube:
channel_url: "https://youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg"
overrides:
tv_show_name: "John /\ Smith"
###############################################################################
# LEVEL 3 - ROLLING ARCHIVE
#
# Use the "yt_channel_as_tv__recent_only" preset to only download the last two
# weeks' worth of YouTube videos, and delete any existing older videos.
john_smith_rolling_archive:
preset: "yt_channel_as_tv__only_recent"
youtube:
channel_url: "https://youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg"
overrides:
tv_show_name: "John /\ Smith"

View file

@ -13,6 +13,7 @@ disable = [
"R0903", # too-few-public-methods "R0903", # too-few-public-methods
"R0801", # similar lines "R0801", # similar lines
"R0913", # Too many arguments "R0913", # Too many arguments
"R0901", # too-many-ancestors
"W0511", # TODO "W0511", # TODO
] ]

View file

@ -186,7 +186,6 @@ class Preset(StrictDictValidator):
def __validate_and_get_plugins(self) -> PresetPlugins: def __validate_and_get_plugins(self) -> PresetPlugins:
preset_plugins = PresetPlugins() preset_plugins = PresetPlugins()
source_variables = copy.deepcopy(self._source_variables)
for key in self._keys: for key in self._keys:
if key not in PluginMapping.plugins(): if key not in PluginMapping.plugins():
@ -197,20 +196,30 @@ class Preset(StrictDictValidator):
preset_plugins.add(plugin_type=plugin, plugin_options=plugin_options) preset_plugins.add(plugin_type=plugin, plugin_options=plugin_options)
for plugin, plugin_options in sorted( return preset_plugins
preset_plugins.zipped(),
key=lambda _plugin_and_options: _plugin_and_options[0].priority.modify_entry, def __validate_added_variables(self):
source_variables = copy.deepcopy(self._source_variables)
# Validate added download option variables here since plugins could subsequently use them
self.downloader_options.validate_with_variables(
source_variables=source_variables,
override_variables=self.overrides.dict_with_format_strings,
)
source_variables.extend(self.downloader_options.added_source_variables())
for _, plugin_options in sorted(
self.plugins.zipped(), key=lambda pl: pl[0].priority.modify_entry
): ):
# Validate current plugin using source + added plugin variables # Validate current plugin using source + added plugin variables
plugin_options.validate_with_variables( plugin_options.validate_with_variables(
source_variables=source_variables, override_variables=self.overrides.keys source_variables=source_variables,
override_variables=self.overrides.dict_with_format_strings,
) )
# Extend existing source variables with ones created from this plugin # Extend existing source variables with ones created from this plugin
source_variables.extend(plugin_options.added_source_variables()) source_variables.extend(plugin_options.added_source_variables())
return preset_plugins
def __validate_override_string_formatter_validator( def __validate_override_string_formatter_validator(
self, self,
formatter_validator: Union[StringFormatterValidator, OverridesStringFormatterValidator], formatter_validator: Union[StringFormatterValidator, OverridesStringFormatterValidator],
@ -224,7 +233,11 @@ class Preset(StrictDictValidator):
# If the formatter supports source variables, set the formatter variables to include # If the formatter supports source variables, set the formatter variables to include
# both source and override variables # both source and override variables
if not isinstance(formatter_validator, OverridesStringFormatterValidator): if not isinstance(formatter_validator, OverridesStringFormatterValidator):
source_variables = {source_var: "dummy_string" for source_var in self._source_variables} source_variables = {
source_var: "dummy_string"
for source_var in self._source_variables
+ self.downloader_options.added_source_variables()
}
variable_dict = dict(source_variables, **variable_dict) variable_dict = dict(source_variables, **variable_dict)
# For all plugins, add in any extra added source variables # For all plugins, add in any extra added source variables
@ -330,6 +343,7 @@ class Preset(StrictDictValidator):
self.overrides = self._validate_key(key="overrides", validator=Overrides, default={}) self.overrides = self._validate_key(key="overrides", validator=Overrides, default={})
self.plugins: PresetPlugins = self.__validate_and_get_plugins() self.plugins: PresetPlugins = self.__validate_and_get_plugins()
self.__validate_added_variables()
# After all options are initialized, perform a recursive post-validate that requires # After all options are initialized, perform a recursive post-validate that requires
# values from multiple validators # values from multiple validators

View file

@ -1,4 +1,6 @@
from abc import ABC
from typing import Dict from typing import Dict
from typing import List
from typing import Optional from typing import Optional
from yt_dlp.utils import sanitize_filename from yt_dlp.utils import sanitize_filename
@ -13,6 +15,43 @@ from ytdl_sub.validators.validators import BoolValidator
from ytdl_sub.validators.validators import LiteralDictValidator from ytdl_sub.validators.validators import LiteralDictValidator
# pylint: disable=no-self-use
# pylint: disable=unused-argument
class AddsVariablesMixin(ABC):
"""
Mixin for parts of the Preset that adds source variables
"""
def added_source_variables(self) -> List[str]:
"""
If the plugin adds source variables, list them here.
Returns
-------
List of added source variables this plugin creates
"""
return []
def validate_with_variables(
self, source_variables: List[str], override_variables: Dict[str, str]
) -> None:
"""
Optional validation after init with the session's source and override variables.
Parameters
----------
source_variables
Available source variables when running the plugin
override_variables
Available override variables when running the plugin
"""
return None
# pylint: enable=no-self-use
# pylint: enable=unused-argument
class YTDLOptions(LiteralDictValidator): class YTDLOptions(LiteralDictValidator):
""" """
Optional. This section allows you to add any ytdl argument to ytdl-sub's downloader. Optional. This section allows you to add any ytdl argument to ytdl-sub's downloader.

View file

@ -21,6 +21,7 @@ import yt_dlp as ytdl
from yt_dlp.utils import ExistingVideoReached from yt_dlp.utils import ExistingVideoReached
from yt_dlp.utils import RejectedVideoReached from yt_dlp.utils import RejectedVideoReached
from ytdl_sub.config.preset_options import AddsVariablesMixin
from ytdl_sub.config.preset_options import Overrides from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.base_entry import BaseEntry from ytdl_sub.entries.base_entry import BaseEntry
@ -36,7 +37,7 @@ from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadAr
download_logger = Logger.get(name="downloader") download_logger = Logger.get(name="downloader")
class DownloaderValidator(StrictDictValidator, ABC): class DownloaderValidator(StrictDictValidator, AddsVariablesMixin, ABC):
""" """
Placeholder class to define downloader options Placeholder class to define downloader options
""" """
@ -203,37 +204,6 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
f"yt-dlp failed to download an entry with these arguments: {error_dict}" f"yt-dlp failed to download an entry with these arguments: {error_dict}"
) )
def _filter_entry_dicts(
self,
entry_dicts: List[Dict],
extractor: Optional[str] = None,
sort_by: Optional[str] = None,
) -> List[Dict]:
"""
Parameters
----------
entry_dicts
entry dicts to filter
extractor
Optional. Extractor that the entry dicts must have. If None, defaults to the
entry type's extractor
sort_by
Optional. Sort the entry dicts on this key
Returns
-------
filtered entry dicts
"""
if extractor is None:
extractor = self.downloader_entry_type.entry_extractor
output = [
entry_dict for entry_dict in entry_dicts if entry_dict.get("extractor") == extractor
]
if sort_by:
output = sorted(output, key=lambda entry_dict: entry_dict[sort_by])
return output
def _get_entry_dicts_from_info_json_files(self) -> List[Dict]: def _get_entry_dicts_from_info_json_files(self) -> List[Dict]:
""" """
Returns Returns
@ -265,7 +235,6 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
info_json_listener = LogEntriesDownloadedListener( info_json_listener = LogEntriesDownloadedListener(
working_directory=self.working_directory, working_directory=self.working_directory,
info_json_extractor=self.downloader_entry_type.entry_extractor,
log_prefix=log_prefix, log_prefix=log_prefix,
) )

View file

@ -0,0 +1,286 @@
import contextlib
import os.path
from typing import Dict
from typing import Generator
from typing import List
from typing import Set
from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.downloader import DownloaderOptionsT
from ytdl_sub.downloaders.downloader import DownloaderValidator
from ytdl_sub.downloaders.downloader import download_logger
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.base_entry import BaseEntry
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.entry_parent import EntryParent
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.validators import ListValidator
from ytdl_sub.validators.validators import StringValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
def _entry_key(entry: BaseEntry) -> str:
return entry.extractor + entry.uid
def _get_parent_entry_variables(parent: EntryParent) -> Dict[str, str | int]:
"""
Adds source variables to the child entry derived from the parent entry.
"""
if not parent.child_entries:
return {}
return {
"playlist_max_upload_year": max(
child_entry.to_type(Entry).upload_year for child_entry in parent.child_entries
)
}
class CollectionUrlValidator(StrictDictValidator):
_required_keys = {"url"}
_optional_keys = {"variables"}
def __init__(self, name, value):
super().__init__(name, value)
# TODO: url validate using yt-dlp IE
self._url = self._validate_key(key="url", validator=StringValidator)
variables = self._validate_key_if_present(key="variables", validator=DictFormatterValidator)
self._variables = variables.dict_with_format_strings if variables else {}
@property
def url(self) -> str:
"""
Returns
-------
URL to download from
"""
return self._url.value
@property
def variables(self) -> Dict[str, str]:
"""
Variables to add to each entry
"""
return self._variables
class CollectionUrlListValidator(ListValidator[CollectionUrlValidator]):
_inner_list_type = CollectionUrlValidator
_expected_value_type_name = "collection url list"
def __init__(self, name, value):
super().__init__(name, value)
added_variables: Dict[str, str] = self.list[0].variables
for idx, collection_url_validator in enumerate(self.list[1:]):
collection_variables = collection_url_validator.variables
# see if this collection contains new added vars (it should not)
for var in collection_variables.keys():
if var not in added_variables:
raise self._validation_exception(
f"Collection url {idx} contains the variable '{var}' that the first "
f"collection url does not. The first collection url must define all added "
f"variables."
)
# see if this collection is missing any added vars (if so, inherit from the top)
for var in added_variables.keys():
if var not in collection_variables:
collection_variables[var] = added_variables[var]
class CollectionDownloadOptions(DownloaderValidator):
"""
Downloads from multiple URLs. If an entry is returned from more than one URL, it will
resolve to the bottom-most URL settings.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
generic:
# required
download_strategy: "collection"
urls:
- url: "soundcloud.com/tracks"
variables:
season: "1"
album: "{title}"
- url: "soundcloud.com/albums"
variables:
season: "1"
album: "{playlist_title}"
"""
_required_keys = {"urls"}
def __init__(self, name, value):
super().__init__(name, value)
self._urls = self._validate_key(key="urls", validator=CollectionUrlListValidator)
@property
def collection_urls(self) -> CollectionUrlListValidator:
"""
Required. The Soundcloud user's url, i.e. ``soundcloud.com/the_username``
"""
return self._urls
def added_source_variables(self) -> List[str]:
"""
Returns
-------
List of variables added. The first collection url always contains all the variables.
"""
return list(self._urls.list[0].variables.keys())
def validate_with_variables(
self, source_variables: List[str], override_variables: Dict[str, str]
) -> None:
"""
Ensures new variables added are not existing variables
"""
for source_var_name in self.added_source_variables():
if source_var_name in source_variables:
raise self._validation_exception(
f"'{source_var_name}' cannot be used as a variable name because it "
f"is an existing source variable"
)
base_variables = dict(
override_variables, **{source_var: "dummy_string" for source_var in source_variables}
)
# Apply formatting to each new source variable, ensure it resolves
for collection_url in self.collection_urls.list:
for source_var_name, source_var_formatter_str in collection_url.variables.items():
_ = StringFormatterValidator(
name=f"{self._name}.{source_var_name}", value=source_var_formatter_str
).apply_formatter(base_variables)
class CollectionDownloader(Downloader[CollectionDownloadOptions, Entry]):
downloader_options_type = CollectionDownloadOptions
downloader_entry_type = Entry
def __init__(
self,
download_options: DownloaderOptionsT,
enhanced_download_archive: EnhancedDownloadArchive,
ytdl_options_builder: YTDLOptionsBuilder,
overrides: Overrides,
):
super().__init__(
download_options=download_options,
enhanced_download_archive=enhanced_download_archive,
ytdl_options_builder=ytdl_options_builder,
overrides=overrides,
)
self.parents: List[EntryParent] = []
self.downloaded_entries: Set[str] = set()
@contextlib.contextmanager
def _separate_download_archives(self):
"""
Separate download archive writing between collection urls. This is so break_on_existing
does not break when downloading from subset urls.
"""
archive_path = self._ytdl_options_builder.to_dict().get("download_archive", "")
backup_archive_path = f"{archive_path}.backup"
# If archive path exists, maintain download archive is enable
if archive_file_exists := archive_path and os.path.isfile(archive_path):
archive_file_exists = True
# If a backup exists, it's the one prior to any downloading, use that.
if os.path.isfile(backup_archive_path):
FileHandler.copy(src_file_path=backup_archive_path, dst_file_path=archive_path)
# If not, create the backup
else:
FileHandler.copy(src_file_path=archive_path, dst_file_path=backup_archive_path)
yield
# If an archive path did not exist at first, but now exists, delete it
if not archive_file_exists and os.path.isfile(archive_path):
FileHandler.delete(file_path=archive_path)
# If the archive file did exist, restore the backup
elif archive_file_exists:
FileHandler.copy(src_file_path=backup_archive_path, dst_file_path=archive_path)
def _download_entry(self, entry: Entry) -> Entry:
download_logger.info("Downloading entry %s", entry.title)
download_entry_dict = self.extract_info_with_retry(
is_downloaded_fn=None if self.is_dry_run else entry.is_downloaded,
url=entry.webpage_url,
ytdl_options_overrides={"writeinfojson": False, "skip_download": self.is_dry_run},
)
# Workaround for the ytdlp issue
# pylint: disable=protected-access
entry._kwargs["requested_subtitles"] = download_entry_dict.get("requested_subtitles")
# pylint: enable=protected-access
return entry
def _download_parent_entry(self, parent: EntryParent) -> Generator[Entry, None, None]:
"""Download in reverse order, that way we download older entries ones first"""
for entry_child in reversed(parent.entry_children()):
if _entry_key(entry_child) in self.downloaded_entries:
continue
yield self._download_entry(entry_child.to_type(Entry))
self.downloaded_entries.add(_entry_key(entry_child))
for parent_child in reversed(parent.parent_children()):
for entry_child in self._download_parent_entry(parent=parent_child):
yield entry_child
def download_url_metadata(self, collection_url: CollectionUrlValidator) -> List[EntryParent]:
"""
Downloads only info.json files and forms EntryParent trees
"""
with self._separate_download_archives():
entry_dicts = self.extract_info_via_info_json(
only_info_json=True,
url=collection_url.url,
log_prefix_on_info_json_dl="Downloading metadata for",
)
return EntryParent.from_entry_dicts(
entry_dicts=entry_dicts, working_directory=self.working_directory
)
def download_url(
self, collection_url: CollectionUrlValidator, parents: List[EntryParent]
) -> Generator[Entry, None, None]:
"""
Downloads the leaf entries from EntryParent trees
"""
with self._separate_download_archives():
for parent in parents:
for entry_child in self._download_parent_entry(parent=parent):
entry_child.add_variables(
dict(_get_parent_entry_variables(parent), **collection_url.variables)
)
yield entry_child
def download(self) -> Generator[Entry, None, None]:
"""
Soundcloud subscription to download albums and tracks as singles.
"""
# download the bottom-most urls first since they are top-priority
for collection_url in reversed(self.download_options.collection_urls.list):
parents = self.download_url_metadata(collection_url=collection_url)
for entry in self.download_url(collection_url=collection_url, parents=parents):
yield entry

View file

@ -1,65 +0,0 @@
from abc import ABC
from typing import Generic
from typing import TypeVar
from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.downloader import DownloaderValidator
from ytdl_sub.entries.soundcloud import SoundcloudTrack
from ytdl_sub.validators.validators import BoolValidator
class SoundcloudDownloaderOptions(DownloaderValidator, ABC):
"""
Abstract source validator for all soundcloud sources.
"""
_optional_keys = {"skip_premiere_tracks"}
def __init__(self, name: str, value: dict):
super().__init__(name=name, value=value)
self._skip_premiere_tracks = self._validate_key(
"skip_premiere_tracks", BoolValidator, default=True
)
@property
def skip_premiere_tracks(self) -> bool:
"""
Optional. True to skip tracks that require purchasing. False otherwise. Defaults to True.
"""
return self._skip_premiere_tracks.value
SoundcloudDownloaderOptionsT = TypeVar(
"SoundcloudDownloaderOptionsT", bound=SoundcloudDownloaderOptions
)
class SoundcloudDownloader(
Downloader[SoundcloudDownloaderOptionsT, SoundcloudTrack],
Generic[SoundcloudDownloaderOptionsT],
ABC,
):
"""
Class that handles downloading soundcloud entries via ytdl and converting them into
SoundcloudTrack / SoundcloudAlbumTrack objects
"""
downloader_entry_type = SoundcloudTrack
@classmethod
def artist_albums_url(cls, artist_url: str) -> str:
"""
Returns
-------
Full artist album url
"""
return f"{artist_url}/albums"
@classmethod
def artist_tracks_url(cls, artist_url: str) -> str:
"""
Returns
-------
Full artist tracks url
"""
return f"{artist_url}/tracks"

View file

@ -2,16 +2,16 @@ from typing import Dict
from typing import Generator from typing import Generator
from typing import List from typing import List
from ytdl_sub.downloaders.downloader import download_logger from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.soundcloud.abc import SoundcloudDownloader from ytdl_sub.downloaders.downloader import DownloaderValidator
from ytdl_sub.downloaders.soundcloud.abc import SoundcloudDownloaderOptions from ytdl_sub.downloaders.generic.collection import CollectionDownloader
from ytdl_sub.entries.entry_parent import EntryParent from ytdl_sub.downloaders.generic.collection import CollectionDownloadOptions
from ytdl_sub.entries.soundcloud import SoundcloudAlbumTrack from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.soundcloud import SoundcloudTrack
from ytdl_sub.validators.url_validator import SoundcloudUsernameUrlValidator from ytdl_sub.validators.url_validator import SoundcloudUsernameUrlValidator
from ytdl_sub.validators.validators import BoolValidator
class SoundcloudAlbumsAndSinglesDownloadOptions(SoundcloudDownloaderOptions): class SoundcloudAlbumsAndSinglesDownloadOptions(DownloaderValidator):
""" """
Downloads a soundcloud user's entire discography. Groups together album tracks and considers Downloads a soundcloud user's entire discography. Groups together album tracks and considers
any track not in an album as a single. Also includes any collaboration tracks. any track not in an album as a single. Also includes any collaboration tracks.
@ -32,12 +32,53 @@ class SoundcloudAlbumsAndSinglesDownloadOptions(SoundcloudDownloaderOptions):
""" """
_required_keys = {"url"} _required_keys = {"url"}
_optional_keys = {"skip_premiere_tracks"}
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self._url = self._validate_key( self._url = self._validate_key(
key="url", validator=SoundcloudUsernameUrlValidator key="url", validator=SoundcloudUsernameUrlValidator
).username_url ).username_url
self._skip_premiere_tracks = self._validate_key(
"skip_premiere_tracks", BoolValidator, default=True
)
self.collection_validator = CollectionDownloadOptions(
name=self._name,
value={
"urls": [
{
"url": f"{self._url}/tracks",
"variables": {
"track_number": "1",
"track_number_padded": "01",
"track_count": "1",
"album": "{title}",
"album_sanitized": "{title_sanitized}",
"album_year": "{upload_year}",
},
},
{
"url": f"{self._url}/albums",
"variables": {
"track_number": "{playlist_index}",
"track_number_padded": "{playlist_index_padded}",
"track_count": "{playlist_count}",
"album": "{playlist}",
"album_sanitized": "{playlist_sanitized}",
"album_year": "{playlist_max_upload_year}",
},
},
]
},
)
@property
def skip_premiere_tracks(self) -> bool:
"""
Optional. True to skip tracks that require purchasing. False otherwise. Defaults to True.
"""
return self._skip_premiere_tracks.value
@property @property
def url(self) -> str: def url(self) -> str:
@ -46,11 +87,25 @@ class SoundcloudAlbumsAndSinglesDownloadOptions(SoundcloudDownloaderOptions):
""" """
return self._url return self._url
def added_source_variables(self) -> List[str]:
"""Validate using collection_validator"""
return self.collection_validator.added_source_variables()
def validate_with_variables(
self, source_variables: List[str], override_variables: Dict[str, str]
) -> None:
"""Validate using collection_validator"""
return self.collection_validator.validate_with_variables(
source_variables, override_variables
)
class SoundcloudAlbumsAndSinglesDownloader( class SoundcloudAlbumsAndSinglesDownloader(
SoundcloudDownloader[SoundcloudAlbumsAndSinglesDownloadOptions] Downloader[SoundcloudAlbumsAndSinglesDownloadOptions, Entry]
): ):
downloader_options_type = SoundcloudAlbumsAndSinglesDownloadOptions downloader_options_type = SoundcloudAlbumsAndSinglesDownloadOptions
downloader_entry_type = Entry
supports_subtitles = False supports_subtitles = False
supports_chapters = False supports_chapters = False
@ -72,104 +127,29 @@ class SoundcloudAlbumsAndSinglesDownloader(
}, },
) )
def _get_singles( def _should_skip(self, entry: Entry) -> bool:
self, entry_dicts: List[Dict], albums: List[EntryParent] if not self.download_options.skip_premiere_tracks:
) -> List[SoundcloudTrack]: return False
tracks: List[SoundcloudTrack] = []
# Get all tracks that are not part of an album for url in [entry.kwargs_get("url", ""), entry.webpage_url]:
for entry_dict in self._filter_entry_dicts(entry_dicts): if "/preview/" in url:
if not any(entry_dict in album for album in albums): return True
tracks.append(
SoundcloudTrack(entry_dict=entry_dict, working_directory=self.working_directory)
)
return tracks return False
def _get_albums(self) -> List[EntryParent]: def download(self) -> Generator[Entry, None, None]:
# Dry-run to get the info json files
artist_albums_url = self.artist_albums_url(artist_url=self.download_options.url)
entry_dicts = self.extract_info_via_info_json(
only_info_json=True,
log_prefix_on_info_json_dl="Downloading metadata for",
url=artist_albums_url,
)
albums: List[EntryParent] = []
for entry_dict in self._filter_entry_dicts(entry_dicts, extractor="soundcloud:set"):
albums.append(
EntryParent(
entry_dict=entry_dict, working_directory=self.working_directory
).read_children_from_entry_dicts(
entry_dicts=entry_dicts, child_class=SoundcloudAlbumTrack
)
)
return albums
def _get_album_tracks(
self, albums: List[EntryParent]
) -> Generator[SoundcloudAlbumTrack, None, None]:
for album in albums:
if album.child_count > 0:
download_logger.info("Downloading album %s", album.title)
for track in album.child_entries:
if self.download_options.skip_premiere_tracks and track.is_premiere():
continue
download_logger.info(
"Downloading album track %d/%d %s",
track.track_number,
track.track_count,
track.title,
)
if not self.is_dry_run:
_ = self.extract_info_with_retry(
is_downloaded_fn=track.is_downloaded,
url=album.webpage_url,
ytdl_options_overrides={
"playlist_items": str(track.kwargs("playlist_index")),
"writeinfojson": False,
},
)
yield track
def _get_single_tracks(
self, albums: List[EntryParent]
) -> Generator[SoundcloudTrack, None, None]:
artist_tracks_url = self.artist_tracks_url(artist_url=self.download_options.url)
tracks_entry_dicts = self.extract_info_via_info_json(
only_info_json=True,
log_prefix_on_info_json_dl="Downloading metadata for",
url=artist_tracks_url,
)
# Then, get all singles
tracks = self._get_singles(entry_dicts=tracks_entry_dicts, albums=albums)
for track in tracks:
# Filter any premiere tracks if specified
if self.download_options.skip_premiere_tracks and track.is_premiere():
continue
download_logger.info("Downloading single track %s", track.title)
if not self.is_dry_run:
_ = self.extract_info_with_retry(
is_downloaded_fn=track.is_downloaded,
url=track.webpage_url,
ytdl_options_overrides={"writeinfojson": False},
)
yield track
def download(self) -> Generator[SoundcloudTrack, None, None]:
""" """
Soundcloud subscription to download albums and tracks as singles. Soundcloud subscription to download albums and tracks as singles.
""" """
albums = self._get_albums() downloader = CollectionDownloader(
for album_track in self._get_album_tracks(albums=albums): download_options=self.download_options.collection_validator,
yield album_track enhanced_download_archive=self._enhanced_download_archive,
ytdl_options_builder=self._ytdl_options_builder,
overrides=self.overrides,
)
for single_track in self._get_single_tracks(albums=albums): for entry in downloader.download():
yield single_track if self._should_skip(entry):
continue
yield entry

View file

@ -7,14 +7,14 @@ from typing import Optional
from ytdl_sub.config.preset_options import Overrides from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.downloaders.downloader import DownloaderOptionsT from ytdl_sub.downloaders.downloader import DownloaderOptionsT
from ytdl_sub.downloaders.downloader import download_logger from ytdl_sub.downloaders.downloader import download_logger
from ytdl_sub.downloaders.generic.collection import CollectionDownloader
from ytdl_sub.downloaders.generic.collection import CollectionDownloadOptions
from ytdl_sub.downloaders.youtube.abc import YoutubeDownloader from ytdl_sub.downloaders.youtube.abc import YoutubeDownloader
from ytdl_sub.downloaders.youtube.abc import YoutubeDownloaderOptions from ytdl_sub.downloaders.youtube.abc import YoutubeDownloaderOptions
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry_parent import EntryParent from ytdl_sub.entries.entry_parent import EntryParent
from ytdl_sub.entries.youtube import YoutubeVideo from ytdl_sub.entries.youtube import YoutubeVideo
from ytdl_sub.utils.datetime import to_date_range_hack
from ytdl_sub.utils.thumbnail import convert_url_thumbnail from ytdl_sub.utils.thumbnail import convert_url_thumbnail
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.url_validator import YoutubeChannelUrlValidator from ytdl_sub.validators.url_validator import YoutubeChannelUrlValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -41,8 +41,6 @@ class YoutubeChannelDownloaderOptions(YoutubeDownloaderOptions):
_required_keys = {"channel_url"} _required_keys = {"channel_url"}
_optional_keys = { _optional_keys = {
"before",
"after",
"channel_avatar_path", "channel_avatar_path",
"channel_banner_path", "channel_banner_path",
} }
@ -58,8 +56,11 @@ class YoutubeChannelDownloaderOptions(YoutubeDownloaderOptions):
self._channel_banner_path = self._validate_key_if_present( self._channel_banner_path = self._validate_key_if_present(
"channel_banner_path", OverridesStringFormatterValidator "channel_banner_path", OverridesStringFormatterValidator
) )
self._before = self._validate_key_if_present("before", StringDatetimeValidator)
self._after = self._validate_key_if_present("after", StringDatetimeValidator) self.collection_validator = CollectionDownloadOptions(
name=self._name,
value={"urls": [{"url": self._channel_url}]},
)
@property @property
def channel_url(self) -> str: def channel_url(self) -> str:
@ -84,22 +85,6 @@ class YoutubeChannelDownloaderOptions(YoutubeDownloaderOptions):
""" """
return self._channel_banner_path return self._channel_banner_path
@property
def before(self) -> Optional[StringDatetimeValidator]:
"""
DEPRECATED: use the `date_range` plugin instead. Will be removed in version 0.5.0
Optional. Only download videos before this datetime.
"""
return self._before
@property
def after(self) -> Optional[StringDatetimeValidator]:
"""
DEPRECATED: use the `date_range` plugin instead. Will be removed in version 0.5.0
Optional. Only download videos after this datetime.
"""
return self._after
class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions, YoutubeVideo]): class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions, YoutubeVideo]):
downloader_options_type = YoutubeChannelDownloaderOptions downloader_options_type = YoutubeChannelDownloaderOptions
@ -162,31 +147,19 @@ class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions
""" """
Downloads all videos from a channel Downloads all videos from a channel
""" """
ytdl_options_overrides = {} downloader = CollectionDownloader(
download_options=self.download_options.collection_validator,
# If a date range is specified when download a YT channel, add it into the ytdl options enhanced_download_archive=self._enhanced_download_archive,
source_date_range = to_date_range_hack( ytdl_options_builder=self._ytdl_options_builder,
before=self.download_options.before, after=self.download_options.after overrides=self.overrides,
) )
if source_date_range: collection_url = self.download_options.collection_validator.collection_urls.list[0]
ytdl_options_overrides["daterange"] = source_date_range
# dry-run the entire channel download first, this will get the parents = downloader.download_url_metadata(collection_url=collection_url)
# videos that will be downloaded. Afterwards, download each video one-by-one assert len(parents) == 1, "Channel should be the only entry parent"
entry_dicts = self.extract_info_via_info_json( self.channel = parents[0]
ytdl_options_overrides=ytdl_options_overrides,
only_info_json=True,
log_prefix_on_info_json_dl="Downloading metadata for",
url=self.download_options.channel_url,
)
self.channel = EntryParent.from_entry_dicts_with_children(
entry_dicts=entry_dicts,
working_directory=self.working_directory,
child_class=YoutubeVideo,
extractor="youtube:tab",
)
# TODO: Handle this better
self.overrides.add_override_variables( self.overrides.add_override_variables(
variables_to_add={ variables_to_add={
"source_uploader": self.channel.kwargs_get("uploader", "__failed_to_scrape__"), "source_uploader": self.channel.kwargs_get("uploader", "__failed_to_scrape__"),
@ -195,27 +168,11 @@ class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions
} }
) )
# Iterate in descending order to process older videos first. In case an error occurs and a for entry in downloader.download_url(collection_url=collection_url, parents=parents):
# the channel must be redownloaded, it will fetch most recent metadata first, and break
# on the older video that's been processed and is in the download archive.
for idx, video in enumerate(reversed(self.channel.child_entries), start=1):
download_logger.info("Downloading %d/%d %s", idx, self.channel.child_count, video.title)
# Re-download the contents even if it's a dry-run as a single video. At this time,
# channels do not download subtitles or subtitle metadata
as_single_video_dict = self.extract_info_with_retry(
is_downloaded_fn=None if self.is_dry_run else video.is_downloaded,
ytdl_options_overrides={"writeinfojson": False, "skip_download": self.is_dry_run},
url=video.webpage_url,
)
# Workaround for the ytdlp issue
# pylint: disable=protected-access # pylint: disable=protected-access
video._kwargs["requested_subtitles"] = as_single_video_dict.get("requested_subtitles") yield YoutubeVideo(entry_dict=entry._kwargs, working_directory=self.working_directory)
# pylint: enable=protected-access # pylint: enable=protected-access
yield video
def _download_thumbnail( def _download_thumbnail(
self, self,
thumbnail_url: str, thumbnail_url: str,

View file

@ -2,10 +2,10 @@ from typing import Dict
from typing import Generator from typing import Generator
from typing import List from typing import List
from ytdl_sub.downloaders.downloader import download_logger from ytdl_sub.downloaders.generic.collection import CollectionDownloader
from ytdl_sub.downloaders.generic.collection import CollectionDownloadOptions
from ytdl_sub.downloaders.youtube.abc import YoutubeDownloader from ytdl_sub.downloaders.youtube.abc import YoutubeDownloader
from ytdl_sub.downloaders.youtube.abc import YoutubeDownloaderOptions from ytdl_sub.downloaders.youtube.abc import YoutubeDownloaderOptions
from ytdl_sub.entries.entry_parent import EntryParent
from ytdl_sub.entries.youtube import YoutubePlaylistVideo from ytdl_sub.entries.youtube import YoutubePlaylistVideo
from ytdl_sub.validators.url_validator import YoutubePlaylistUrlValidator from ytdl_sub.validators.url_validator import YoutubePlaylistUrlValidator
@ -34,6 +34,11 @@ class YoutubePlaylistDownloaderOptions(YoutubeDownloaderOptions):
"playlist_url", YoutubePlaylistUrlValidator "playlist_url", YoutubePlaylistUrlValidator
).playlist_url ).playlist_url
self.collection_validator = CollectionDownloadOptions(
name=self._name,
value={"urls": [{"url": self._playlist_url}]},
)
@property @property
def playlist_url(self) -> str: def playlist_url(self) -> str:
""" """
@ -85,22 +90,20 @@ class YoutubePlaylistDownloader(
def download(self) -> Generator[YoutubePlaylistVideo, None, None]: def download(self) -> Generator[YoutubePlaylistVideo, None, None]:
""" """
Downloads all videos in a Youtube playlist. Downloads all videos in a Youtube playlist.
Dry-run the entire playlist download first. This will get the videos that will be
downloaded. Afterwards, download each video one-by-one
""" """
entry_dicts = self.extract_info_via_info_json( downloader = CollectionDownloader(
only_info_json=True, download_options=self.download_options.collection_validator,
log_prefix_on_info_json_dl="Downloading metadata for", enhanced_download_archive=self._enhanced_download_archive,
url=self.download_options.playlist_url, ytdl_options_builder=self._ytdl_options_builder,
overrides=self.overrides,
) )
collection_url = self.download_options.collection_validator.collection_urls.list[0]
playlist: EntryParent = EntryParent.from_entry_dicts_with_children( parents = downloader.download_url_metadata(collection_url=collection_url)
entry_dicts=entry_dicts, assert len(parents) == 1, "Playlist should be the only entry parent"
working_directory=self.working_directory, playlist = parents[0]
child_class=YoutubePlaylistVideo,
extractor="youtube:tab", # TODO: Handle this better
)
self.overrides.add_override_variables( self.overrides.add_override_variables(
variables_to_add={ variables_to_add={
"source_title": playlist.title, "source_title": playlist.title,
@ -109,23 +112,9 @@ class YoutubePlaylistDownloader(
} }
) )
# Iterate in reverse order to process older videos first. In case an error occurs and a for entry in downloader.download_url(collection_url=collection_url, parents=parents):
# the playlist must be redownloaded, it will fetch most recent metadata first, and break
# on the older video that's been processed and is in the download archive.
for idx, video in enumerate(reversed(playlist.child_entries), start=1):
download_logger.info("Downloading %d/%d %s", idx, len(entry_dicts), video.title)
# Re-download the contents even if it's a dry-run as a single video. At this time,
# playlists do not download subtitles or subtitle metadata
as_single_video_dict = self.extract_info_with_retry(
is_downloaded_fn=None if self.is_dry_run else video.is_downloaded,
ytdl_options_overrides={"writeinfojson": False, "skip_download": self.is_dry_run},
url=video.webpage_url,
)
# Workaround for the ytdlp issue
# pylint: disable=protected-access # pylint: disable=protected-access
video._kwargs["requested_subtitles"] = as_single_video_dict.get("requested_subtitles") yield YoutubePlaylistVideo(
entry_dict=entry._kwargs, working_directory=self.working_directory
)
# pylint: enable=protected-access # pylint: enable=protected-access
yield video

View file

@ -88,9 +88,6 @@ class BaseEntry(BaseEntryVariables, ABC):
Abstract entry object to represent anything download from ytdl (playlist metadata, media, etc). Abstract entry object to represent anything download from ytdl (playlist metadata, media, etc).
""" """
# The ytdl extractor type that the entry represents
entry_extractor: str
def __init__(self, entry_dict: Dict, working_directory: str): def __init__(self, entry_dict: Dict, working_directory: str):
""" """
Initialize the entry using ytdl metadata Initialize the entry using ytdl metadata
@ -126,7 +123,7 @@ class BaseEntry(BaseEntryVariables, ABC):
""" """
Dict get on kwargs Dict get on kwargs
""" """
if not self.kwargs_contains(key): if not self.kwargs_contains(key) or self.kwargs(key) is None:
return default return default
return self.kwargs(key) return self.kwargs(key)
@ -192,19 +189,3 @@ class BaseEntry(BaseEntryVariables, ABC):
source_var: getattr(self, source_var) for source_var in self.source_variables() source_var: getattr(self, source_var) for source_var in self.source_variables()
} }
return dict(source_variable_dict, **self._added_variables()) return dict(source_variable_dict, **self._added_variables())
# TODO: super typing
@classmethod
def from_entry_dicts(
cls, entry_dicts: List[Dict], working_directory: str, extractor: Optional[str]
):
"""
Load the entry from a list of dicts. There should only be one entry with the extractor
type
"""
extractor = extractor or cls.extractor
output = [
entry_dict for entry_dict in entry_dicts if entry_dict.get("extractor") == extractor
]
assert len(output) == 1, f"Expected a single entry with extractor of type '{extractor}'"
return cls(entry_dict=output[0], working_directory=working_directory)

View file

@ -6,70 +6,49 @@ from typing import TypeVar
from ytdl_sub.entries.base_entry import BaseEntry from ytdl_sub.entries.base_entry import BaseEntry
TChildEntry = TypeVar("TChildEntry", bound=BaseEntry) TBaseEntry = TypeVar("TBaseEntry", bound=BaseEntry)
class EntryParent(BaseEntry): class EntryParent(BaseEntry):
def __init__(self, entry_dict: Dict, working_directory: str): def __init__(self, entry_dict: Dict, working_directory: str):
super().__init__(entry_dict=entry_dict, working_directory=working_directory) super().__init__(entry_dict=entry_dict, working_directory=working_directory)
self._child_entries: List[TChildEntry] = [] self.child_entries: List["EntryParent"] = []
# pylint: disable=no-self-use def is_entry(self) -> bool:
def _get_children_entry_variables_to_add(
self, child_entries: List[TChildEntry]
) -> Dict[str, str | int]:
""" """
Adds source variables to the child entry derived from the parent entry.
"""
if not child_entries:
return {}
return {"playlist_max_upload_year": max(entry.upload_year for entry in child_entries)}
# pylint: enable=no-self-use
def read_children_from_entry_dicts(
self, entry_dicts: List[Dict], child_class: Type[TChildEntry]
) -> "EntryParent":
"""
Parameters
----------
entry_dicts
Entry dicts to look for children from
child_class
The class to convert the entry dict to
Returns Returns
------- -------
List of children True if the entry contains a media file. False otherwise.
""" """
child_entries: List[TChildEntry] = [] return self.kwargs_contains("ext")
def parent_children(self) -> List["EntryParent"]:
"""This parent's children that are also parents"""
return [child for child in self.child_entries if child.child_count() > 0]
def entry_children(self) -> List["EntryParent"]:
"""This parent's children that are entries"""
return [child for child in self.child_entries if child.is_entry()]
def read_children_from_entry_dicts(self, entry_dicts: List[Dict]) -> "EntryParent":
"""
Populates a tree of EntryParents that belong to this instance
"""
child_entries: List["EntryParent"] = []
for entry_dict in entry_dicts: for entry_dict in entry_dicts:
if entry_dict.get("playlist_id") == self.uid: if entry_dict.get("playlist_id") == self.uid:
child_entries.append( child_entries.append(
child_class(entry_dict=entry_dict, working_directory=self.working_directory()) self.__class__(
entry_dict=entry_dict,
working_directory=self.working_directory(),
)
) )
child_entries[-1].read_children_from_entry_dicts(entry_dicts)
child_variables_to_add = self._get_children_entry_variables_to_add(child_entries) self.child_entries = sorted(child_entries, key=lambda entry: entry.kwargs("playlist_index"))
for child_entry in child_entries:
child_entry.add_variables(variables_to_add=child_variables_to_add)
self._child_entries = sorted(
child_entries, key=lambda entry: entry.kwargs("playlist_index")
)
return self return self
@property
def child_entries(self) -> List[TChildEntry]:
"""
Returns
-------
List of child entities
"""
return self._child_entries
@property
def child_count(self) -> int: def child_count(self) -> int:
""" """
Returns Returns
@ -96,37 +75,25 @@ class EntryParent(BaseEntry):
return thumbnail["url"] return thumbnail["url"]
return None return None
def __contains__(self, item): @classmethod
def from_entry_dicts(
cls, entry_dicts: List[Dict], working_directory: str
) -> List["EntryParent"]:
"""
Reads all entry dicts and builds a tree of EntryParents
"""
return [
EntryParent(
entry_dict=entry_dict, working_directory=working_directory
).read_children_from_entry_dicts(entry_dicts)
for entry_dict in entry_dicts
if "playlist_id" not in entry_dict
]
def to_type(self, entry_type: Type[TBaseEntry]) -> TBaseEntry:
""" """
Returns Returns
------- -------
True if the the item (entry_dict) has the same id as one of the tracks. False otherwise. Converted EntryParent to Entry-like class
""" """
uid: Optional[str] = None return entry_type(entry_dict=self._kwargs, working_directory=self._working_directory)
if isinstance(item, BaseEntry):
uid = item.uid
elif isinstance(item, dict):
uid = item.get("id")
if uid is not None:
return any(uid == child_entry.uid for child_entry in self._child_entries)
return False
@classmethod
def from_entry_dicts_with_children(
cls,
entry_dicts: List[Dict],
working_directory: str,
child_class: Type[TChildEntry],
extractor: Optional[str],
) -> "EntryParent":
"""
Load the parent entry and its children
"""
entry_parent: EntryParent = cls.from_entry_dicts(
entry_dicts=entry_dicts, working_directory=working_directory, extractor=extractor
)
entry_parent.read_children_from_entry_dicts(
entry_dicts=entry_dicts, child_class=child_class
)
return entry_parent

View file

@ -1,45 +1,11 @@
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.variables.soundcloud_variables import SoundcloudVariables from ytdl_sub.entries.variables.soundcloud_variables import SoundcloudVariables
# TODO: Delete since not used
class SoundcloudTrack(SoundcloudVariables, Entry): class SoundcloudTrack(SoundcloudVariables, Entry):
""" """
Entry object to represent a Soundcloud track yt-dlp that is a single, which implies Entry object to represent a Soundcloud track yt-dlp that is a single, which implies
it does not belong to an album. it does not belong to an album.
""" """
entry_extractor = "soundcloud"
def is_premiere(self) -> bool:
"""
Returns whether the entry is a premier track. Check this by seeing if the track's url is
a preview.
"""
return "/preview/" in self.kwargs("url")
class SoundcloudAlbumTrack(SoundcloudTrack):
"""
Entry object to represent a Soundcloud track yt-dlp that belongs to an album.
"""
@property
def track_number(self) -> int:
"""Returns the entry's track number"""
return self.kwargs("playlist_index")
@property
def track_count(self) -> int:
"""Returns the entry's total tracks in album"""
return self.kwargs("playlist_count")
@property
def album(self) -> str:
"""Returns the entry's album name, fetched from its internal album"""
return self.kwargs("playlist")
@property
def album_year(self) -> int:
"""Returns the entry's album year, fetched from its internal album"""
# added from parent entry
return self._additional_variables["playlist_max_upload_year"]

View file

@ -1,3 +1,5 @@
from yt_dlp.utils import sanitize_filename
from ytdl_sub.entries.base_entry import BaseEntry from ytdl_sub.entries.base_entry import BaseEntry
from ytdl_sub.entries.base_entry import BaseEntryVariables from ytdl_sub.entries.base_entry import BaseEntryVariables
@ -14,6 +16,72 @@ _days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
class EntryVariables(BaseEntryVariables): class EntryVariables(BaseEntryVariables):
@property
def playlist(self: BaseEntry) -> str:
"""
Returns
-------
str
Name of its parent playlist/channel if it exists, otherwise returns its title.
"""
return self.kwargs_get("playlist", self.title)
@property
def playlist_sanitized(self) -> str:
"""
Returns
-------
str
The playlist name, sanitized
"""
return sanitize_filename(self.playlist)
@property
def playlist_index(self: BaseEntry) -> int:
"""
Returns
-------
int
Playlist index if it exists, otherwise returns ``1``.
Note that for channels/playlists, an index of 1 implies it's the most recent
uploaded entry. It is recommended to not use this unless you know the channel/playlist
will never add new content, i.e. a music album.
"""
return self.kwargs_get("playlist_index", 1)
@property
def playlist_index_padded(self) -> str:
"""
Returns
-------
str
playlist_index padded two digits
"""
return _pad(self.playlist_index, width=2)
@property
def playlist_count(self: BaseEntry) -> int:
"""
Returns
-------
int
Playlist count if it exists, otherwise returns ``1``.
"""
return self.kwargs_get("playlist_count", 1)
@property
def playlist_max_upload_year(self) -> int:
"""
Returns
-------
int
Max upload_year for all entries in this entry's playlist if it exists, otherwise returns
``upload_year``
"""
# override in EntryParent
return self.upload_year
@property @property
def ext(self: BaseEntry) -> str: def ext(self: BaseEntry) -> str:
""" """

View file

@ -10,8 +10,6 @@ class YoutubeVideo(YoutubeVideoVariables, Entry):
Entry object to represent a Youtube video. Reserved for shared Youtube entry logic. Entry object to represent a Youtube video. Reserved for shared Youtube entry logic.
""" """
entry_extractor = "youtube"
@property @property
def ext(self) -> str: def ext(self) -> str:
""" """

View file

@ -7,7 +7,6 @@ from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.validators import StringValidator
class MusicTagsOptions(PluginOptions): class MusicTagsOptions(PluginOptions):
@ -34,17 +33,12 @@ class MusicTagsOptions(PluginOptions):
""" """
_required_keys = {"tags"} _required_keys = {"tags"}
_optional_keys = {"multi_value_separator"}
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self._tags = self._validate_key(key="tags", validator=DictFormatterValidator) self._tags = self._validate_key(key="tags", validator=DictFormatterValidator)
self.multi_value_separator = self._validate_key_if_present(
key="multi_value_separator", validator=StringValidator
)
@property @property
def tags(self) -> DictFormatterValidator: def tags(self) -> DictFormatterValidator:
""" """

View file

@ -8,6 +8,7 @@ from typing import Type
from typing import TypeVar from typing import TypeVar
from typing import final from typing import final
from ytdl_sub.config.preset_options import AddsVariablesMixin
from ytdl_sub.config.preset_options import Overrides from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
@ -39,40 +40,11 @@ class PluginPriority:
return self.modify_entry >= PluginPriority.MODIFY_ENTRY_AFTER_SPLIT return self.modify_entry >= PluginPriority.MODIFY_ENTRY_AFTER_SPLIT
class PluginOptions(StrictDictValidator): class PluginOptions(StrictDictValidator, AddsVariablesMixin, ABC):
""" """
Class that defines the parameters to a plugin Class that defines the parameters to a plugin
""" """
# pylint: disable=no-self-use
def added_source_variables(self) -> List[str]:
"""
If the plugin adds source variables, list them here.
Returns
-------
List of added source variables this plugin creates
"""
return []
# pylint: disable=unused-argument
def validate_with_variables(
self, source_variables: List[str], override_variables: List[str]
) -> None:
"""
Optional validation after init with the session's source and override variables.
Parameters
----------
source_variables
Available source variables when running the plugin
override_variables
Available override variables when running the plugin
"""
return None
# pylint: enable=no-self-use,unused-argument
PluginOptionsT = TypeVar("PluginOptionsT", bound=PluginOptions) PluginOptionsT = TypeVar("PluginOptionsT", bound=PluginOptions)

View file

@ -165,7 +165,7 @@ class RegexOptions(PluginOptions):
return self._skip_if_match_fails return self._skip_if_match_fails
def validate_with_variables( def validate_with_variables(
self, source_variables: List[str], override_variables: List[str] self, source_variables: List[str], override_variables: Dict[str, str]
) -> None: ) -> None:
""" """
Ensures each source variable capture group is valid Ensures each source variable capture group is valid

View file

@ -1,4 +1,3 @@
from pathlib import Path
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
@ -21,14 +20,6 @@ SUBTITLE_EXTENSIONS: Set[str] = {"srt", "vtt", "ass", "lrc"}
logger = Logger.get(name="subtitles") logger = Logger.get(name="subtitles")
def _is_entry_subtitle_file(path: Path, entry: Entry) -> bool:
if path.is_file() and path.name.startswith(entry.uid):
for ext in SUBTITLE_EXTENSIONS:
if path.name.endswith(f".{ext}"):
return True
return False
class SubtitlesTypeValidator(StringSelectValidator): class SubtitlesTypeValidator(StringSelectValidator):
_expected_value_type_name = "subtitles type" _expected_value_type_name = "subtitles type"
_select_values = SUBTITLE_EXTENSIONS _select_values = SUBTITLE_EXTENSIONS

View file

@ -13,7 +13,7 @@ logger = Logger.get(name="downloader")
class LogEntriesDownloadedListener(threading.Thread): class LogEntriesDownloadedListener(threading.Thread):
def __init__(self, working_directory: str, info_json_extractor: str, log_prefix: str): def __init__(self, working_directory: str, log_prefix: str):
""" """
To be ran in a thread while download via ytdl-sub. Listens for new .info.json files in the To be ran in a thread while download via ytdl-sub. Listens for new .info.json files in the
working directory, checks the extractor value, and if it matches the input arg, log the working directory, checks the extractor value, and if it matches the input arg, log the
@ -23,20 +23,18 @@ class LogEntriesDownloadedListener(threading.Thread):
---------- ----------
working_directory working_directory
subscription download working directory subscription download working directory
info_json_extractor
print the titles of the info.json file with this extractor
log_prefix log_prefix
The message to print prefixed to the title, i.e. '{log_prefix} {title}' The message to print prefixed to the title, i.e. '{log_prefix} {title}'
""" """
threading.Thread.__init__(self) threading.Thread.__init__(self)
self.working_directory = working_directory self.working_directory = working_directory
self.info_json_extractor = info_json_extractor
self.log_prefix = log_prefix self.log_prefix = log_prefix
self.complete = False self.complete = False
self._files_read: Set[str] = set() self._files_read: Set[str] = set()
def _get_title_from_info_json(self, path: Path) -> Optional[str]: @classmethod
def _get_title_from_info_json(cls, path: Path) -> Optional[str]:
try: try:
with open(path, "r", encoding="utf-8") as file: with open(path, "r", encoding="utf-8") as file:
file_json = json.load(file) file_json = json.load(file)
@ -44,10 +42,7 @@ class LogEntriesDownloadedListener(threading.Thread):
# swallow the error since this is only printing logs # swallow the error since this is only printing logs
return None return None
if file_json.get("extractor") == self.info_json_extractor: return file_json.get("title")
return file_json.get("title")
return None
@classmethod @classmethod
def _is_info_json(cls, path: Path) -> bool: def _is_info_json(cls, path: Path) -> bool:

View file

@ -3,42 +3,9 @@ from typing import Optional
from yt_dlp import DateRange from yt_dlp import DateRange
from ytdl_sub.config.preset_options import Overrides from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.string_datetime import StringDatetimeValidator from ytdl_sub.validators.string_datetime import StringDatetimeValidator
def to_date_range_hack(
before: Optional[StringDatetimeValidator], after: Optional[StringDatetimeValidator]
) -> Optional[DateRange]:
"""
Workaround for channel before/after support.
Returns
-------
Date range if the 'before' or 'after' is defined. None otherwise.
"""
start: Optional[str] = None
end: Optional[str] = None
if after:
start = after.apply_formatter(variable_dict={})
if before:
end = before.apply_formatter(variable_dict={})
if start or end:
logger = Logger.get(name="youtube-channel")
logger.warning(
"DEPRECATED: youtube.before/after will are deprecated and will be removed in v0.0.5. "
"Use the 'date_range' plugin instead: "
"https://ytdl-sub.readthedocs.io/en/latest/config.html#date-range"
)
return DateRange(start=start, end=end)
return None
def to_date_range( def to_date_range(
before: Optional[StringDatetimeValidator], before: Optional[StringDatetimeValidator],
after: Optional[StringDatetimeValidator], after: Optional[StringDatetimeValidator],

View file

@ -244,24 +244,3 @@ class LiteralDictValidator(DictValidator):
def keys(self) -> List[str]: def keys(self) -> List[str]:
"""Returns a sorted list of the dict's keys""" """Returns a sorted list of the dict's keys"""
return super()._keys return super()._keys
class UniformDictValidator(DictValidator, Generic[ValidatorT], ABC):
"""DictValidator where all values have the same validator"""
uniform_validator_class: Type[ValidatorT]
def __init__(self, name, value):
super().__init__(name, value)
for key in self._keys:
_ = self._validate_key_if_present(key=key, validator=self.uniform_validator_class)
@property
def validator_dict(self) -> Dict[str, ValidatorT]:
"""
Returns
-------
Dict of name: validator
"""
return self._validator_dict

View file

@ -123,19 +123,6 @@ class DownloadArchive:
file.write(f"{line}\n") file.write(f"{line}\n")
return self return self
def contains(self, entry_id: str) -> bool:
"""
Parameters
----------
entry_id
Id of the entry
Returns
-------
True if the entry id is within this download archive. False otherwise.
"""
return any(entry_id in line for line in self._download_archive_lines)
def remove_entry(self, entry_id: str) -> "DownloadArchive": def remove_entry(self, entry_id: str) -> "DownloadArchive":
""" """
Parameters Parameters
@ -598,7 +585,7 @@ class DownloadArchiver:
""" """
def __init__(self, enhanced_download_archive: EnhancedDownloadArchive): def __init__(self, enhanced_download_archive: EnhancedDownloadArchive):
self.__enhanced_download_archive = enhanced_download_archive self._enhanced_download_archive = enhanced_download_archive
@property @property
def working_directory(self) -> str: def working_directory(self) -> str:
@ -607,7 +594,7 @@ class DownloadArchiver:
------- -------
Path to the working directory Path to the working directory
""" """
return self.__enhanced_download_archive.working_directory return self._enhanced_download_archive.working_directory
@property @property
def is_dry_run(self) -> bool: def is_dry_run(self) -> bool:
@ -616,7 +603,7 @@ class DownloadArchiver:
------- -------
True if this session is a dry-run. False otherwise. True if this session is a dry-run. False otherwise.
""" """
return self.__enhanced_download_archive.is_dry_run return self._enhanced_download_archive.is_dry_run
def save_file( def save_file(
self, self,
@ -640,7 +627,7 @@ class DownloadArchiver:
entry entry
Optional. Entry that the file belongs to Optional. Entry that the file belongs to
""" """
self.__enhanced_download_archive.save_file_to_output_directory( self._enhanced_download_archive.save_file_to_output_directory(
file_name=file_name, file_name=file_name,
file_metadata=file_metadata, file_metadata=file_metadata,
output_file_name=output_file_name, output_file_name=output_file_name,

View file

@ -85,6 +85,12 @@ def mock_entry_to_dict(
"thumbnail_ext": thumbnail_ext, "thumbnail_ext": thumbnail_ext,
"info_json_ext": "info.json", "info_json_ext": "info.json",
"webpage_url": webpage_url, "webpage_url": webpage_url,
"playlist": "entry title",
"playlist_sanitized": "entry title",
"playlist_index": 1,
"playlist_index_padded": "01",
"playlist_count": 1,
"playlist_max_upload_year": 2021,
} }