Merge branch 'master' into jesse/bandcamp

This commit is contained in:
Jesse Bannon 2023-01-13 15:33:40 -08:00
commit 6c06359c1e
31 changed files with 442 additions and 757 deletions

View file

@ -69,6 +69,7 @@ jobs:
- name: Run unit tests with coverage - name: Run unit tests with coverage
run: | run: |
sudo apt-get update
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
source /opt/env/bin/activate source /opt/env/bin/activate
coverage run -m pytest tests/unit && coverage xml -o /opt/coverage/unit/coverage.xml coverage run -m pytest tests/unit && coverage xml -o /opt/coverage/unit/coverage.xml
@ -96,6 +97,7 @@ jobs:
- name: Run e2e soundcloud tests with coverage - name: Run e2e soundcloud tests with coverage
run: | run: |
sudo apt-get update
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
source /opt/env/bin/activate source /opt/env/bin/activate
coverage run -m pytest tests/e2e/soundcloud && coverage xml -o /opt/coverage/soundcloud/coverage.xml coverage run -m pytest tests/e2e/soundcloud && coverage xml -o /opt/coverage/soundcloud/coverage.xml
@ -123,6 +125,7 @@ jobs:
- name: Run e2e youtube tests with coverage - name: Run e2e youtube tests with coverage
run: | run: |
sudo apt-get update
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
source /opt/env/bin/activate source /opt/env/bin/activate
coverage run -m pytest tests/e2e/youtube && coverage xml -o /opt/coverage/youtube/coverage.xml coverage run -m pytest tests/e2e/youtube && coverage xml -o /opt/coverage/youtube/coverage.xml
@ -150,6 +153,7 @@ jobs:
- name: Run e2e plugin tests with coverage - name: Run e2e plugin tests with coverage
run: | run: |
sudo apt-get update
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
source /opt/env/bin/activate source /opt/env/bin/activate
coverage run -m pytest tests/e2e/plugins && coverage xml -o /opt/coverage/plugins/coverage.xml coverage run -m pytest tests/e2e/plugins && coverage xml -o /opt/coverage/plugins/coverage.xml

View file

@ -298,4 +298,6 @@ questions, submit a feature request, or pick up a bug.
## Support ## Support
We are pretty active in our We are pretty active in our
[Discord channel](https://discord.gg/v8j9RAHb4k) [Discord channel](https://discord.gg/v8j9RAHb4k)
if you have any questions. if you have any questions. Also see our
[FAQ](https://github.com/jmbannon/ytdl-sub/wiki/FAQ)
for commonly asked questions.

View file

@ -5,11 +5,6 @@ from typing import Type
from ytdl_sub.downloaders.downloader import Downloader from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.generic.multi_url import MultiUrlDownloader from ytdl_sub.downloaders.generic.multi_url import MultiUrlDownloader
from ytdl_sub.downloaders.generic.url import UrlDownloader from ytdl_sub.downloaders.generic.url import UrlDownloader
from ytdl_sub.downloaders.soundcloud.albums_and_singles import SoundcloudAlbumsAndSinglesDownloader
from ytdl_sub.downloaders.youtube.channel import YoutubeChannelDownloader
from ytdl_sub.downloaders.youtube.merge_playlist import YoutubeMergePlaylistDownloader
from ytdl_sub.downloaders.youtube.playlist import YoutubePlaylistDownloader
from ytdl_sub.downloaders.youtube.video import YoutubeVideoDownloader
from ytdl_sub.plugins.audio_extract import AudioExtractPlugin 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.date_range import DateRangePlugin from ytdl_sub.plugins.date_range import DateRangePlugin
@ -32,15 +27,6 @@ class DownloadStrategyMapping:
""" """
_MAPPING: Dict[str, Dict[str, Type[Downloader]]] = { _MAPPING: Dict[str, Dict[str, Type[Downloader]]] = {
"youtube": {
"video": YoutubeVideoDownloader,
"playlist": YoutubePlaylistDownloader,
"channel": YoutubeChannelDownloader,
"merge_playlist": YoutubeMergePlaylistDownloader,
},
"soundcloud": {
"albums_and_singles": SoundcloudAlbumsAndSinglesDownloader,
},
"download": { "download": {
"multi_url": MultiUrlDownloader, "multi_url": MultiUrlDownloader,
"url": UrlDownloader, "url": UrlDownloader,

View file

@ -67,9 +67,26 @@ class YTDLOptions(LiteralDictValidator):
presets: presets:
my_example_preset: my_example_preset:
ytdl_options: ytdl_options:
# Ignore any download related errors and continue
ignoreerrors: True ignoreerrors: True
# Stop downloading additional metadata/videos if it
# exists in your download archive
break_on_existing: True
# Stop downloading additional metadata/videos if it
# is out of your date range
break_on_reject: True
# Path to your YouTube cookies file to download 18+ restricted content
cookiefile: "/path/to/cookies/file.txt"
# Only download this number of videos/audio
max_downloads: 10
# Download and use English title/description/etc YouTube metadata
extractor_args:
youtube:
lang:
- "en"
where each key is a ytdl argument.
where each key is a ytdl argument. Include in the example are some popular ytdl_options.
""" """

View file

@ -45,6 +45,7 @@ from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.thumbnail import ThumbnailTypes from ytdl_sub.utils.thumbnail import ThumbnailTypes
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
from ytdl_sub.utils.thumbnail import convert_url_thumbnail from ytdl_sub.utils.thumbnail import convert_url_thumbnail
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
@ -625,6 +626,9 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT], ABC):
# If latest entry, always update the thumbnail on each entry # If latest entry, always update the thumbnail on each entry
if thumbnail_id == ThumbnailTypes.LATEST_ENTRY: if thumbnail_id == ThumbnailTypes.LATEST_ENTRY:
# Make sure the entry's thumbnail is converted to jpg
convert_download_thumbnail(entry, error_if_not_found=False)
# always save in dry-run even if it doesn't exist... # always save in dry-run even if it doesn't exist...
if self.is_dry_run or os.path.isfile(entry.get_download_thumbnail_path()): if self.is_dry_run or os.path.isfile(entry.get_download_thumbnail_path()):
self.save_file( self.save_file(

View file

@ -1,145 +0,0 @@
from typing import Any
from typing import Dict
from typing import Generator
from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.downloader import DownloaderValidator
from ytdl_sub.downloaders.generic.validators import MultiUrlValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.validators.url_validator import SoundcloudUsernameUrlValidator
from ytdl_sub.validators.validators import BoolValidator
class SoundcloudAlbumsAndSinglesDownloadOptions(DownloaderValidator):
"""
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.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
soundcloud:
# required
download_strategy: "albums_and_singles"
url: "soundcloud.com/username"
# optional
skip_premiere_tracks: True
"""
_required_keys = {"url"}
_optional_keys = {"skip_premiere_tracks"}
@classmethod
def partial_validate(cls, name: str, value: Any) -> None:
"""
Partially validate a Soundcloud source
"""
if isinstance(value, dict):
value["url"] = value.get("url", "https://soundcloud.com/jessebannon")
_ = cls(name, value)
def __init__(self, name, value):
super().__init__(name, value)
self._url = self._validate_key(
key="url", validator=SoundcloudUsernameUrlValidator
).username_url
self._skip_premiere_tracks = self._validate_key(
"skip_premiere_tracks", BoolValidator, default=True
)
@property
def collection_validator(self) -> MultiUrlValidator:
"""Downloads the album tracks first, then the tracks"""
return MultiUrlValidator(
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_title}",
"album_sanitized": "{playlist_title_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
def url(self) -> str:
"""
Required. The Soundcloud user's url, i.e. ``soundcloud.com/the_username``
"""
return self._url
class SoundcloudAlbumsAndSinglesDownloader(Downloader[SoundcloudAlbumsAndSinglesDownloadOptions]):
downloader_options_type = SoundcloudAlbumsAndSinglesDownloadOptions
downloader_entry_type = Entry
supports_subtitles = False
supports_chapters = False
@classmethod
def ytdl_option_defaults(cls) -> Dict:
"""
Default `ytdl_options`_ for ``albums_and_singles``
.. code-block:: yaml
ytdl_options:
ignoreerrors: True # ignore errors like hidden videos, age restriction, etc
format: "bestaudio[ext=mp3]" # download format the best possible mp3
"""
return dict(
super().ytdl_option_defaults(),
**{
"format": "bestaudio[ext=mp3]",
},
)
def _should_skip(self, entry: Entry) -> bool:
if not self.download_options.skip_premiere_tracks:
return False
for url in [entry.kwargs_get("url", ""), entry.webpage_url]:
if "/preview/" in url:
return True
return False
def download(self) -> Generator[Entry, None, None]:
"""
Soundcloud subscription to download albums and tracks as singles.
"""
for entry in super().download():
if self._should_skip(entry):
continue
yield entry

View file

@ -1,139 +0,0 @@
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.downloader import DownloaderValidator
from ytdl_sub.downloaders.generic.validators import MultiUrlValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.url_validator import YoutubeChannelUrlValidator
class YoutubeChannelDownloaderOptions(DownloaderValidator):
"""
Downloads all videos from a youtube channel.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
youtube:
# required
download_strategy: "channel"
channel_url: "UCsvn_Po0SmunchJYtttWpOxMg"
# optional
channel_avatar_path: "poster.jpg"
channel_banner_path: "fanart.jpg"
"""
_required_keys = {"channel_url"}
_optional_keys = {
"channel_avatar_path",
"channel_banner_path",
}
@classmethod
def partial_validate(cls, name: str, value: Any) -> None:
"""
Partially validate a YouTube channel source
"""
if isinstance(value, dict):
value["channel_url"] = value.get(
"channel_url", "https://www.youtube.com/c/ProjectZombie603"
)
_ = cls(name, value)
def __init__(self, name, value):
super().__init__(name, value)
self._channel_url = self._validate_key(
"channel_url", YoutubeChannelUrlValidator
).channel_url
self._channel_avatar_path = self._validate_key_if_present(
"channel_avatar_path", OverridesStringFormatterValidator
)
self._channel_banner_path = self._validate_key_if_present(
"channel_banner_path", OverridesStringFormatterValidator
)
@property
def collection_validator(self) -> MultiUrlValidator:
"""Download from the channel url"""
playlist_thumbnails: List[Dict] = []
if self._channel_avatar_path:
playlist_thumbnails.append(
{
"name": self._channel_avatar_path.format_string,
"uid": "avatar_uncropped",
}
)
if self._channel_banner_path:
playlist_thumbnails.append(
{
"name": self._channel_banner_path.format_string,
"uid": "banner_uncropped",
}
)
return MultiUrlValidator(
name=self._name,
value={
"urls": [
{
"url": self.channel_url,
"playlist_thumbnails": playlist_thumbnails,
"variables": {"playlist_size": "{playlist_count}"},
}
]
},
)
@property
def channel_url(self) -> str:
"""
Required. The channel's url, i.e.
``https://www.youtube.com/channel/UCsvn_Po0SmunchJYOWpOxMg``. URLs with ``/username`` or
``/c`` are valid to use.
"""
return self._channel_url
@property
def channel_avatar_path(self) -> Optional[OverridesStringFormatterValidator]:
"""
Optional. Path to store the channel's avatar thumbnail image to.
"""
return self._channel_avatar_path
@property
def channel_banner_path(self) -> Optional[OverridesStringFormatterValidator]:
"""
Optional. Path to store the channel's banner image to.
"""
return self._channel_banner_path
class YoutubeChannelDownloader(Downloader[YoutubeChannelDownloaderOptions]):
downloader_options_type = YoutubeChannelDownloaderOptions
# pylint: disable=line-too-long
@classmethod
def ytdl_option_defaults(cls) -> Dict:
"""
Default `ytdl_options`_ for ``channel``
.. code-block:: yaml
ytdl_options:
ignoreerrors: True # ignore errors like hidden videos, age restriction, etc
break_on_existing: True # stop downloads (newest to oldest) if a video is already downloaded
"""
return dict(
super().ytdl_option_defaults(),
**{
"break_on_existing": True,
},
)
# pylint: enable=line-too-long

View file

@ -1,155 +0,0 @@
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.youtube.playlist import YoutubePlaylistDownloaderOptions
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.chapters import Chapters
from ytdl_sub.utils.chapters import Timestamp
from ytdl_sub.utils.ffmpeg import set_ffmpeg_metadata_chapters
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.validators.validators import BoolValidator
class YoutubeMergePlaylistDownloaderOptions(YoutubePlaylistDownloaderOptions):
r"""
Downloads all videos in a playlist and merges them into a single video.
Usage:
.. code-block:: yaml
presets:
example_preset:
youtube:
# required
download_strategy: "merge_playlist"
playlist_url: "https://www.youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg"
# optional
add_chapters: False
CLI usage:
.. code-block:: bash
ytdl-sub dl \
--preset "example_preset" \
--youtube.playlist_url "https://www.youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg" \
--youtube.add_chapters True
"""
_required_keys = {"playlist_url"}
_optional_keys = {"add_chapters"}
def __init__(self, name, value):
super().__init__(name, value)
self._add_chapters = self._validate_key_if_present(
"add_chapters", validator=BoolValidator, default=False
).value
@property
def add_chapters(self) -> Optional[bool]:
"""
Optional. Whether to add chapters using each video's title in the merged playlist.
Defaults to False.
"""
return self._add_chapters
class YoutubeMergePlaylistDownloader(Downloader[YoutubeMergePlaylistDownloaderOptions]):
downloader_options_type = YoutubeMergePlaylistDownloaderOptions
supports_download_archive = False
supports_subtitles = False
supports_chapters = False
@classmethod
def ytdl_option_defaults(cls) -> Dict:
"""
Default `ytdl_options`_ for ``merge_playlist``
.. code-block:: yaml
ytdl_options:
ignoreerrors: True # ignore errors like hidden videos, age restriction, etc
postprocessors:
# Convert the videos to mkv format
- key: "FFmpegVideoConvertor"
when: "post_process"
preferedformat: "mkv"
# Concatenate all the playlist videos into a single file
- key: "FFmpegConcat"
when: "playlist"
"""
return dict(
super().ytdl_option_defaults(),
**{
"postprocessors": [
{
"key": "FFmpegVideoConvertor",
"when": "post_process",
"preferedformat": "mkv",
},
{
"key": "FFmpegConcat",
"when": "playlist",
},
],
},
)
def _get_chapters(self, merged_video: Entry, add_chapters: bool) -> FileMetadata:
titles: List[str] = []
timestamps: List[Timestamp] = []
current_timestamp_sec = 0
for video_entry in merged_video.kwargs("entries"):
timestamps.append(Timestamp(current_timestamp_sec))
titles.append(video_entry["title"])
current_timestamp_sec += video_entry["duration"]
chapters = Chapters(timestamps=timestamps, titles=titles)
if not self.is_dry_run and add_chapters:
set_ffmpeg_metadata_chapters(
file_path=merged_video.get_download_file_path(),
chapters=chapters,
file_duration_sec=merged_video.kwargs("duration"),
)
return chapters.to_file_metadata(title="Timestamps of playlist videos in the merged file")
def _to_merged_video(self, entry_dict: Dict) -> Entry:
"""
Adds a few entries not included in a playlist entry to make it look like a merged video
entry_dict
"""
# Set the upload date to be the latest playlist video date
entry_dict["upload_date"] = max(
playlist_entry["upload_date"] for playlist_entry in entry_dict["entries"]
)
entry_dict["duration"] = sum(
playlist_entry["duration"] for playlist_entry in entry_dict["entries"]
)
entry_dict["ext"] = (
entry_dict["requested_downloads"][0]["ext"]
if "requested_downloads" in entry_dict
else "mkv"
)
entry_dict["webpage_url"] = self.download_options.playlist_url
return Entry(entry_dict=entry_dict, working_directory=self.working_directory)
def download(self) -> List[Tuple[Entry, FileMetadata]]:
"""Download a single Youtube video, then split it into multiple videos"""
url = self.overrides.apply_formatter(self.collection.urls.list[0].url)
entry_dict = self.extract_info(url=url, ytdl_options_overrides=self.download_ytdl_options)
merged_video = self._to_merged_video(entry_dict=entry_dict)
merged_video_metadata = self._get_chapters(
merged_video=merged_video, add_chapters=self.download_options.add_chapters
)
return [(merged_video, merged_video_metadata)]

View file

@ -1,116 +0,0 @@
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.downloader import DownloaderValidator
from ytdl_sub.downloaders.generic.validators import MultiUrlValidator
from ytdl_sub.utils.thumbnail import ThumbnailTypes
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.url_validator import YoutubePlaylistUrlValidator
class YoutubePlaylistDownloaderOptions(DownloaderValidator):
"""
Downloads all videos from a youtube playlist.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
youtube:
# required
download_strategy: "playlist"
playlist_url: "https://www.youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg"
# optional
playlist_thumbnail_name: "poster.jpg"
"""
_required_keys = {"playlist_url"}
_optional_keys = {"playlist_thumbnail_name"}
@classmethod
def partial_validate(cls, name: str, value: Any) -> None:
"""
Partially validate a YouTube playlist source
"""
if isinstance(value, dict):
value["playlist_url"] = value.get(
"playlist_url", "https://www.youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg"
)
_ = cls(name, value)
def __init__(self, name, value):
super().__init__(name, value)
self._playlist_url = self._validate_key(
"playlist_url", YoutubePlaylistUrlValidator
).playlist_url
self._playlist_thumbnail_name = self._validate_key_if_present(
"playlist_thumbnail_name", OverridesStringFormatterValidator
)
@property
def collection_validator(self) -> MultiUrlValidator:
"""Downloads the playlist url"""
playlist_thumbnails: List[Dict] = []
if self.playlist_thumbnail_name:
playlist_thumbnails.append(
{
"name": self.playlist_thumbnail_name.format_string,
"uid": ThumbnailTypes.LATEST_ENTRY,
}
)
return MultiUrlValidator(
name=self._name,
value={
"urls": [
{
"url": self.playlist_url,
"playlist_thumbnails": playlist_thumbnails,
"variables": {"playlist_size": "{playlist_count}"},
}
]
},
)
@property
def playlist_url(self) -> str:
"""
Required. The playlist's url, i.e.
``https://www.youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg``.
"""
return self._playlist_url
@property
def playlist_thumbnail_name(self) -> Optional[OverridesStringFormatterValidator]:
"""
Optional. Path to store the playlist's thumbnail
"""
return self._playlist_thumbnail_name
class YoutubePlaylistDownloader(Downloader[YoutubePlaylistDownloaderOptions]):
downloader_options_type = YoutubePlaylistDownloaderOptions
# pylint: disable=line-too-long
@classmethod
def ytdl_option_defaults(cls) -> Dict:
"""
Default `ytdl_options`_ for ``playlist``
.. code-block:: yaml
ytdl_options:
ignoreerrors: True # ignore errors like hidden videos, age restriction, etc
break_on_existing: True # stop downloads (newest to oldest) if a video is already downloaded
"""
return dict(
super().ytdl_option_defaults(),
**{"break_on_existing": True},
)
# pylint: enable=line-too-long

View file

@ -1,80 +0,0 @@
from typing import Any
from typing import Dict
from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.downloader import DownloaderValidator
from ytdl_sub.downloaders.generic.url import UrlDownloadOptions
from ytdl_sub.downloaders.generic.validators import MultiUrlValidator
from ytdl_sub.validators.url_validator import YoutubeVideoUrlValidator
class YoutubeVideoDownloaderOptions(DownloaderValidator):
"""
Downloads a single youtube video. This download strategy is intended for CLI usage performing
a one-time download of a video, not a subscription.
Usage:
.. code-block:: yaml
presets:
example_preset:
youtube:
# required
download_strategy: "video"
video_url: "youtube.com/watch?v=VMAPTo7RVDo"
CLI usage:
.. code-block:: bash
ytdl-sub dl --preset "example_preset" --youtube.video_url "youtube.com/watch?v=VMAPTo7RVDo"
"""
_required_keys = {"video_url"}
@classmethod
def partial_validate(cls, name: str, value: Any) -> None:
"""
Partially validate a YouTube video source
"""
if isinstance(value, dict):
value["video_url"] = value.get("video_url", "youtube.com/watch?v=VMAPTo7RVDo")
_ = cls(name, value)
def __init__(self, name, value):
super().__init__(name, value)
self._video_url = self._validate_key("video_url", YoutubeVideoUrlValidator).video_url
@property
def collection_validator(self) -> MultiUrlValidator:
"""Downloads the video url"""
return UrlDownloadOptions(
name=self._name, value={"url": self.video_url}
).collection_validator
@property
def video_url(self) -> str:
"""
Required. The url of the video, i.e. ``youtube.com/watch?v=VMAPTo7RVDo``.
"""
return self._video_url
class YoutubeVideoDownloader(Downloader[YoutubeVideoDownloaderOptions]):
downloader_options_type = YoutubeVideoDownloaderOptions
@classmethod
def ytdl_option_defaults(cls) -> Dict:
"""
Default `ytdl_options`_ for ``video``
.. code-block:: yaml
ytdl_options:
ignoreerrors: True # ignore errors like hidden videos, age restriction, etc
"""
return dict(
super().ytdl_option_defaults(),
**{"break_on_existing": True},
)

View file

@ -300,6 +300,16 @@ class EntryVariables(BaseEntryVariables):
""" """
return self.kwargs_get(PLAYLIST_UPLOADER, self.uploader) return self.kwargs_get(PLAYLIST_UPLOADER, self.uploader)
@property
def playlist_uploader_sanitized(self: Self) -> str:
"""
Returns
-------
str
The playlist uploader, sanitized.
"""
return sanitize_filename(self.playlist_uploader)
@property @property
def playlist_uploader_url(self: Self) -> str: def playlist_uploader_url(self: Self) -> str:
""" """

View file

@ -11,6 +11,7 @@ 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.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.thumbnail import convert_download_thumbnail from ytdl_sub.utils.thumbnail import convert_download_thumbnail
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
@ -116,6 +117,13 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
""" """
Tags the entry's audio file using values defined in the metadata options Tags the entry's audio file using values defined in the metadata options
""" """
if entry.ext not in AUDIO_CODEC_EXTS:
raise self.plugin_options.validation_exception(
f"music_tags plugin received a video with the extension '{entry.ext}'. Only audio "
f"files are supported for setting music tags. Ensure you are converting the video "
f"to audio using the audio_extract plugin."
)
# Resolve the tags into this dict # Resolve the tags into this dict
tags_to_write: Dict[str, List[str]] = defaultdict(list) tags_to_write: Dict[str, List[str]] = defaultdict(list)
for tag_name, tag_formatters in self.plugin_options.tags.as_lists.items(): for tag_name, tag_formatters in self.plugin_options.tags.as_lists.items():

View file

@ -10,6 +10,7 @@ from typing import TypeVar
from ytdl_sub.config.preset_options import AddsVariablesMixin 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.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
@ -44,6 +45,23 @@ class PluginOptions(StrictDictValidator, AddsVariablesMixin, ABC):
Class that defines the parameters to a plugin Class that defines the parameters to a plugin
""" """
def validation_exception(
self,
error_message: str | Exception,
) -> ValidationException:
"""
Parameters
----------
error_message
Error message to include in the validation exception
Returns
-------
Validation exception that points to the location in the config. To be used for plugins
to throw good validation exceptions at runtime.
"""
return self._validation_exception(error_message=error_message)
PluginOptionsT = TypeVar("PluginOptionsT", bound=PluginOptions) PluginOptionsT = TypeVar("PluginOptionsT", bound=PluginOptions)

View file

@ -104,7 +104,10 @@ class FromSourceVariablesRegex(StrictDictValidator):
class RegexOptions(PluginOptions): class RegexOptions(PluginOptions):
r""" r"""
Performs regex matching on an entry's source variables. Regex can be used to filter entries Performs regex matching on an entry's source variables. Regex can be used to filter entries
from proceeding with download or capture groups to create new source variables. from proceeding with download or capture groups to create new source variables. NOTE to
use backslashes anywhere in your regex, i.e. ``\d``, you must add another backslash escape. This
means ``\d`` should be written as ``\\d``. This is because YAML requires an escape for any
backslash usage.
Usage: Usage:
@ -113,34 +116,37 @@ class RegexOptions(PluginOptions):
presets: presets:
my_example_preset: my_example_preset:
regex: regex:
# By default, if any match fails and has no defaults, the entry will be skipped. # By default, if any match fails and has no defaults, the entry will
# If set to False, ytdl-sub will error and stop all downloads from proceeding. # be skipped. If False, ytdl-sub will error and stop all downloads
# from proceeding.
skip_if_match_fails: True skip_if_match_fails: True
from: from:
# For each entry's `title` value... # For each entry's `title` value...
title: title:
# Perform this regex match on it to act as a filter. # Perform this regex match on it to act as a filter.
# This will only download videos with "Official Video" in it. # This will only download videos with "[Official Video]" in it. Note that we
# double backslash to make YAML happy
match: match:
- '\[Official Video\]' - '\\[Official Video\\]'
# For each entry's `description` value... # For each entry's `description` value...
description: description:
# Match with capture groups and defaults. # Match with capture groups and defaults.
# This tries to scrape a date from the description and produce new source variables # This tries to scrape a date from the description and produce new
# source variables
match: match:
- "([0-9]{4})-([0-9]{2})-([0-9]{2})" - "([0-9]{4})-([0-9]{2})-([0-9]{2})"
# Each capture group creates these new source variables, respectively, as well # Each capture group creates these new source variables, respectively,
# a sanitized version, i.e. `captured_upload_year_sanitized` # as well a sanitized version, i.e. `captured_upload_year_sanitized`
capture_group_names: capture_group_names:
- "captured_upload_year" - "captured_upload_year"
- "captured_upload_month" - "captured_upload_month"
- "captured_upload_day" - "captured_upload_day"
# And if the string does not match, use these as respective default values for the # And if the string does not match, use these as respective default
# new source variables. # values for the new source variables.
capture_group_defaults: capture_group_defaults:
- "{upload_year}" - "{upload_year}"
- "{upload_month}" - "{upload_month}"

View file

@ -1,4 +1,3 @@
import shutil
import subprocess import subprocess
import tempfile import tempfile
from typing import Dict from typing import Dict
@ -7,6 +6,7 @@ from typing import Optional
from ytdl_sub.utils.chapters import Chapters from ytdl_sub.utils.chapters import Chapters
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
logger = Logger.get(name="ffmpeg") logger = Logger.get(name="ffmpeg")
@ -32,6 +32,25 @@ class FFMPEG:
"Trying to use a feature which requires ffmpeg, but it cannot be found" "Trying to use a feature which requires ffmpeg, but it cannot be found"
) from subprocess_error ) from subprocess_error
@classmethod
def tmp_file_path(cls, relative_file_path: str, extension: Optional[str] = None) -> str:
"""
Parameters
----------
relative_file_path
Path of input file that is going to be modified
extension
Desired output extension. Defaults to input file's extension
Returns
-------
Temporary file path for ffmpeg output
"""
if extension is None:
extension = relative_file_path.split(".")[-1]
return f"{relative_file_path}.out.{extension}"
@classmethod @classmethod
def run(cls, ffmpeg_args: List[str]) -> None: def run(cls, ffmpeg_args: List[str]) -> None:
""" """
@ -110,8 +129,7 @@ def set_ffmpeg_metadata_chapters(
if chapters: if chapters:
lines += _create_metadata_chapters(chapters=chapters, file_duration_sec=file_duration_sec) lines += _create_metadata_chapters(chapters=chapters, file_duration_sec=file_duration_sec)
file_path_ext = file_path.split(".")[-1] tmp_file_path = FFMPEG.tmp_file_path(relative_file_path=file_path)
output_file_path = f"{file_path}.out.{file_path_ext}"
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", encoding="utf-8") as metadata_file: with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", encoding="utf-8") as metadata_file:
metadata_file.write("\n".join(lines)) metadata_file.write("\n".join(lines))
metadata_file.flush() metadata_file.flush()
@ -129,11 +147,10 @@ def set_ffmpeg_metadata_chapters(
"-bitexact", # for reproducibility "-bitexact", # for reproducibility
"-codec", "-codec",
"copy", "copy",
output_file_path, tmp_file_path,
] ]
) )
FileHandler.move(tmp_file_path, file_path)
shutil.move(src=output_file_path, dst=file_path)
def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -> None: def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -> None:
@ -145,13 +162,12 @@ def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -
key_values key_values
The key/values to add The key/values to add
""" """
file_path_ext = file_path.split(".")[-1] tmp_file_path = FFMPEG.tmp_file_path(file_path)
output_file_path = f"{file_path}.out.{file_path_ext}"
ffmpeg_args = ["-i", file_path, "-map", "0"] ffmpeg_args = ["-i", file_path, "-map", "0"]
for key, value in key_values.items(): for key, value in key_values.items():
ffmpeg_args.extend(["-metadata", f"{key}={value}"]) ffmpeg_args.extend(["-metadata", f"{key}={value}"])
ffmpeg_args.extend(["-codec", "copy", output_file_path]) ffmpeg_args.extend(["-codec", "copy", tmp_file_path])
FFMPEG.run(ffmpeg_args) FFMPEG.run(ffmpeg_args)
shutil.move(src=output_file_path, dst=file_path) FileHandler.move(tmp_file_path, file_path)

View file

@ -353,8 +353,23 @@ class FileHandler:
Source file Source file
dst_file_path dst_file_path
Destination file Destination file
Raises
------
OSError
Cross-device link workaround
""" """
shutil.move(src=src_file_path, dst=dst_file_path) try:
shutil.move(src=src_file_path, dst=dst_file_path)
except OSError as os_error_exc:
# Invalid cross-device link
# Can happen from using os.rename under the hood, which requires the two file on the
# same filesystem. Work around it by copying and deleting the file
if os_error_exc.errno == 18:
cls.copy(src_file_path, dst_file_path)
cls.delete(src_file_path)
else:
raise
@classmethod @classmethod
def delete(cls, file_path: Union[str, Path]): def delete(cls, file_path: Union[str, Path]):

View file

@ -68,6 +68,14 @@ def convert_url_thumbnail(thumbnail_url: str, output_thumbnail_path: str) -> Opt
thumbnail.write(file.read()) thumbnail.write(file.read())
os.makedirs(os.path.dirname(output_thumbnail_path), exist_ok=True) os.makedirs(os.path.dirname(output_thumbnail_path), exist_ok=True)
FFMPEG.run(["-bitexact", "-i", thumbnail.name, output_thumbnail_path])
tmp_output_path = FFMPEG.tmp_file_path(
relative_file_path=thumbnail.name, extension="jpg"
)
FFMPEG.run(["-bitexact", "-i", thumbnail.name, tmp_output_path])
# Have FileHandler handle the move to a potential cross-device
FileHandler.move(tmp_output_path, output_thumbnail_path)
FileHandler.delete(tmp_output_path)
return True return True

View file

@ -1,6 +1,10 @@
from typing import Optional
import pytest import pytest
from e2e.conftest import mock_run_from_cli from e2e.conftest import mock_run_from_cli
from ytdl_sub.utils.file_handler import FileMetadata
class TestView: class TestView:
@pytest.mark.parametrize("split_chapters", [True, False]) @pytest.mark.parametrize("split_chapters", [True, False])
@ -22,12 +26,15 @@ class TestView:
# Ensure the video and thumbnail are recognized # Ensure the video and thumbnail are recognized
assert len(transaction_log.files_created) == 2 assert len(transaction_log.files_created) == 2
file_name = f"avUT-zd9v68{'___0' if split_chapters else ''}.webm" video_metadata: Optional[FileMetadata] = None
video_file = transaction_log.files_created.get(file_name) for file_name, metadata in transaction_log.files_created.items():
assert video_file is not None if file_name.endswith("webm"):
video_metadata = metadata
break
assert "Source Variables:" in video_file.metadata assert video_metadata is not None
assert "Source Variables:" in video_metadata.metadata
if split_chapters: if split_chapters:
assert " chapter_index: 1" in video_file.metadata assert " chapter_index: 1" in video_metadata.metadata
else: else:
assert " chapter_index: 1" not in video_file.metadata assert " chapter_index: 1" not in video_metadata.metadata

View file

@ -0,0 +1,51 @@
import re
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture
def single_song_video_dict(output_directory):
return {
"download": {
"download_strategy": "url",
"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
},
"output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"},
# test multi-tags
"music_tags": {"embed_thumbnail": True, "tags": {"genres": ["multi_tag_1", "multi_tag_2"]}},
# download the worst format so it is fast
"ytdl_options": {
"format": "worst[ext=mp4]",
"postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility
},
}
class TestMusicTags:
def test_music_tags_errors_on_video(
self,
youtube_audio_config,
single_song_video_dict,
output_directory,
):
subscription = Subscription.from_dict(
config=youtube_audio_config,
preset_name="single_song_test",
preset_dict=single_song_video_dict,
)
with pytest.raises(
ValidationException,
match=re.escape(
"Validation error in single_song_test.music_tags: music_tags plugin received a "
"video with the extension 'mp4'. Only audio files are supported for setting music "
"tags. Ensure you are converting the video to audio using the audio_extract "
"plugin."
),
):
subscription.download(dry_run=True)

View file

@ -11,13 +11,11 @@ from ytdl_sub.subscriptions.subscription import Subscription
@pytest.fixture @pytest.fixture
def playlist_preset_dict(output_directory): def playlist_preset_dict(output_directory):
return { return {
"preset": "music_video", "preset": [
"download": { "jellyfin_tv_show_collection",
"url": "https://youtube.com/playlist?list=PL5BC0FC26BECA5A35", "season_by_collection__episode_by_year_month_day",
"playlist_thumbnails": {"name": "poster.jpg", "uid": "latest_entry"}, "collection_season_1",
}, ],
# override the output directory with our fixture-generated dir
"output_options": {"output_directory": output_directory},
# download the worst format so it is fast # download the worst format so it is fast
"ytdl_options": { "ytdl_options": {
"format": "worst[ext=mp4]", "format": "worst[ext=mp4]",
@ -38,10 +36,15 @@ def playlist_preset_dict(output_directory):
} }
}, },
"subtitles": { "subtitles": {
"subtitles_name": "{music_video_name}.{lang}.{subtitles_ext}", "subtitles_name": "{episode_file_path}.{lang}.{subtitles_ext}",
"allow_auto_generated_subtitles": True, "allow_auto_generated_subtitles": True,
}, },
"overrides": {"artist": "JMC"}, "overrides": {
"tv_show_name": "JMC",
"tv_show_directory": output_directory,
"collection_season_1_url": "https://youtube.com/playlist?list=PL5BC0FC26BECA5A35",
"collection_season_1_name": "JMC - Season 1",
},
} }

View file

@ -1,7 +1,7 @@
{ {
".ytdl-sub-chapters_from_comments-download-archive.json": "122723ce8d257eebb05178daa26141f6", ".ytdl-sub-chapters_from_comments-download-archive.json": "122723ce8d257eebb05178daa26141f6",
"JMC/JMC - Move 78 - Automated Improvisation [Full Album]-thumb.jpg": "c12e6a6f242680d1096a1a99d74a62c6", "JMC/JMC - Move 78 - Automated Improvisation [Full Album]-thumb.jpg": "c12e6a6f242680d1096a1a99d74a62c6",
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].info.json": "f6045c28cc588187167a3f42e6f37f0c", "JMC/JMC - Move 78 - Automated Improvisation [Full Album].info.json": "ee8437e52186219ff510214138f00e28",
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].mp4": "8b8a9a731a19bc37feb091559f97ebbc", "JMC/JMC - Move 78 - Automated Improvisation [Full Album].mp4": "8b8a9a731a19bc37feb091559f97ebbc",
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].nfo": "7a65b184d24c68fc0ec5380432250f5b" "JMC/JMC - Move 78 - Automated Improvisation [Full Album].nfo": "7a65b184d24c68fc0ec5380432250f5b"
} }

View file

@ -25,7 +25,7 @@
"Project Zombie/Season 2011/s2011.e112101 - qbMJh2df1M4.mp4": "60c3221125afbef78990bdcead78a82d", "Project Zombie/Season 2011/s2011.e112101 - qbMJh2df1M4.mp4": "60c3221125afbef78990bdcead78a82d",
"Project Zombie/Season 2011/s2011.e112101 - qbMJh2df1M4.nfo": "3c5e407a428161b75474eea13a82e009", "Project Zombie/Season 2011/s2011.e112101 - qbMJh2df1M4.nfo": "3c5e407a428161b75474eea13a82e009",
"Project Zombie/Season 2012/s2012.e012301 - y5-3ovwQQ_U-thumb.jpg": "54ebe9df801b278fdd17b21afa8373a6", "Project Zombie/Season 2012/s2012.e012301 - y5-3ovwQQ_U-thumb.jpg": "54ebe9df801b278fdd17b21afa8373a6",
"Project Zombie/Season 2012/s2012.e012301 - y5-3ovwQQ_U.mp4": "cdf5d458e148bbadba6dfed5b05e378b", "Project Zombie/Season 2012/s2012.e012301 - y5-3ovwQQ_U.mp4": "2141f0f928d55e94fef7f7d3c7a4aca0",
"Project Zombie/Season 2012/s2012.e012301 - y5-3ovwQQ_U.nfo": "a0d59e102f06b93718ab20e0238b4f95", "Project Zombie/Season 2012/s2012.e012301 - y5-3ovwQQ_U.nfo": "a0d59e102f06b93718ab20e0238b4f95",
"Project Zombie/Season 2013/s2013.e071901 - c_PZdc0Zi7M-thumb.jpg": "e29d49433175de8a761af35c5307791f", "Project Zombie/Season 2013/s2013.e071901 - c_PZdc0Zi7M-thumb.jpg": "e29d49433175de8a761af35c5307791f",
"Project Zombie/Season 2013/s2013.e071901 - c_PZdc0Zi7M.mp4": "3fb239f0a646e515bbaaeb6f6d5cdd69", "Project Zombie/Season 2013/s2013.e071901 - c_PZdc0Zi7M.mp4": "3fb239f0a646e515bbaaeb6f6d5cdd69",

View file

@ -1,17 +1,14 @@
{ {
".ytdl-sub-music_video_playlist_test-download-archive.json": "e7f7c35e33c132a96d1f12b18f388b35", "JMC/.ytdl-sub-music_video_playlist_test-download-archive.json": "8c8c22bd8a9206fa0b3c9f2eff9827fc",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg": "b232d253df621aa770b780c1301d364d", "JMC/Season 01/s01.e11020101 - 0SVukUyys10-thumb.jpg": "b232d253df621aa770b780c1301d364d",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Feb.1].info.json": "c719512a82647a676fa04d388c837e03", "JMC/Season 01/s01.e11020101 - 0SVukUyys10.mp4": "95f3abaabccdd76461be5dace92e9489",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "7bfa1475504a7c8a4aaa8c89f9306331", "JMC/Season 01/s01.e11020101 - 0SVukUyys10.nfo": "d0f2d843b091834c62ccf33e2d2e700f",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Feb.1].nfo": "de6f9a17a5ebf92aa02b14f7342b8d36", "JMC/Season 01/s01.e11022701 - qPybBrXspds-thumb.jpg": "d17c379ea8b362f5b97c6b213b0342cb",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg": "d17c379ea8b362f5b97c6b213b0342cb", "JMC/Season 01/s01.e11022701 - qPybBrXspds.mp4": "5465a5e5712351945e98667006b08747",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Feb.27].info.json": "d450357aa6856b11d079f1f11bbd92c5", "JMC/Season 01/s01.e11022701 - qPybBrXspds.nfo": "7b230eb8560735cc3ca9598b7b1b2cb7",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Feb.27].mp4": "04ab5cb3cc12325d0c96a7cd04a8b91d", "JMC/Season 01/s01.e11032101 - DBjFvs6HafU-thumb.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Feb.27].nfo": "9a4a57bb91f1ac97b588fdf66bac4c9b", "JMC/Season 01/s01.e11032101 - DBjFvs6HafU.mp4": "c79ed62c72feb49bd02595322c6e1b89",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530", "JMC/Season 01/s01.e11032101 - DBjFvs6HafU.nfo": "a0d7fd6752ca56fe4206c4c711b5765d",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Mar.21].info.json": "8fdf1c054a6b335e9624690e08a33f1c", "JMC/season01-poster.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530",
"JMC/JMC - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "025de6099a5c98e6397153c7a62d517d", "JMC/tvshow.nfo": "f7892df46b4176dde645e23b8a4b653e"
"JMC/JMC - Jesse's Minecraft Server [Trailer - Mar.21].nfo": "b98f1fa3e76b4592aeeaed4f6e220809",
"poster.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530",
"tvshow.nfo": "e4123860532466ed5e0ebf2c9e44eb18"
} }

View file

@ -2,47 +2,213 @@ Files created:
---------------------------------------- ----------------------------------------
{output_directory} {output_directory}
.ytdl-sub-music_video_playlist_test-download-archive.json .ytdl-sub-music_video_playlist_test-download-archive.json
poster.jpg season01-poster.jpg
tvshow.nfo tvshow.nfo
NFO tags: NFO tags:
test: test:
genre: ytdl-sub
namedseason:
attributes:
number: 1
tag: JMC - Season 1
playlist_description: Trailers, Updates, etc playlist_description: Trailers, Updates, etc
playlist_title: Jesse's Minecraft Server playlist_title: Jesse's Minecraft Server
playlist_uploader: Project Zombie playlist_uploader: Project Zombie
{output_directory}/JMC title: JMC
JMC - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg {output_directory}/Season 01
JMC - Jesse's Minecraft Server [Trailer - Feb.1].info.json s01.e11020101 - 0SVukUyys10-thumb.jpg
JMC - Jesse's Minecraft Server [Trailer - Feb.1].mp4 s01.e11020101 - 0SVukUyys10.mp4
JMC - Jesse's Minecraft Server [Trailer - Feb.1].nfo Video Tags:
date: 2011-02-01
episode_id: 11020101
genre: ytdl-sub
show: JMC
synopsis:
https://www.youtube.com/watch?v=0SVukUyys10
To join the server, you must apply at:
http://www.jesseminecraft.webs.com/
This is just a brief video of the server as of Feb. 1, 2011.
Texture Pack I Use:
http://www.minecraftforum.net/viewtopic.php?f=25&t=29164
title: 2011-02-01 - Jesse's Minecraft Server [Trailer - Feb.1]
year: 2011
s01.e11020101 - 0SVukUyys10.nfo
NFO tags: NFO tags:
musicvideo: episodedetails:
album: Music Videos aired: 2011-02-01
artist: JMC episode: 11020101
genre: ytdl-sub
playlist_count: 3 playlist_count: 3
playlist_index: 3 playlist_index: 3
title: Jesse's Minecraft Server [Trailer - Feb.1] plot:
https://www.youtube.com/watch?v=0SVukUyys10
To join the server, you must apply at:
http://www.jesseminecraft.webs.com/
This is just a brief video of the server as of Feb. 1, 2011.
Texture Pack I Use:
http://www.minecraftforum.net/viewtopic.php?f=25&t=29164
season: 1
title: 2011-02-01 - Jesse's Minecraft Server [Trailer - Feb.1]
year: 2011 year: 2011
JMC - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg s01.e11022701 - qPybBrXspds-thumb.jpg
JMC - Jesse's Minecraft Server [Trailer - Feb.27].info.json s01.e11022701 - qPybBrXspds.mp4
JMC - Jesse's Minecraft Server [Trailer - Feb.27].mp4 Video Tags:
JMC - Jesse's Minecraft Server [Trailer - Feb.27].nfo date: 2011-02-27
episode_id: 11022701
genre: ytdl-sub
show: JMC
synopsis:
https://www.youtube.com/watch?v=qPybBrXspds
Website Link:
http://jesseminecraft.webs.com/
All you have to do is read the rules, and fill out a quick, little application to join the Server so we know you read them. We do this to keep the griefers/newbs out, it only takes three minutes, it's not that big of a deal.
Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 750 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots.
There are over 200 empty properties that are ready for anyone to own! Join now!
This is the server state as of Feb. 27, 2011.
----------------------------------------------------------------------------------
Texture Pack:
http://www.minecraftforum.net/viewtopic.php?f=25&t=29164
Recording Software:
http://www.fraps.com/download.php
Video Editing Software:
http://explore.live.com/windows-live-movie-maker?os=other
Song:
Given to Fly - Pearl Jam
(Off of the 'Yield' album)
I claim no ownership of this song, all the credit goes to Pearl Jam and their producers.
title: 2011-02-27 - Jesse's Minecraft Server [Trailer - Feb.27]
year: 2011
s01.e11022701 - qPybBrXspds.nfo
NFO tags: NFO tags:
musicvideo: episodedetails:
album: Music Videos aired: 2011-02-27
artist: JMC episode: 11022701
genre: ytdl-sub
playlist_count: 3 playlist_count: 3
playlist_index: 2 playlist_index: 2
title: Jesse's Minecraft Server [Trailer - Feb.27] plot:
https://www.youtube.com/watch?v=qPybBrXspds
Website Link:
http://jesseminecraft.webs.com/
All you have to do is read the rules, and fill out a quick, little application to join the Server so we know you read them. We do this to keep the griefers/newbs out, it only takes three minutes, it's not that big of a deal.
Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 750 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots.
There are over 200 empty properties that are ready for anyone to own! Join now!
This is the server state as of Feb. 27, 2011.
----------------------------------------------------------------------------------
Texture Pack:
http://www.minecraftforum.net/viewtopic.php?f=25&t=29164
Recording Software:
http://www.fraps.com/download.php
Video Editing Software:
http://explore.live.com/windows-live-movie-maker?os=other
Song:
Given to Fly - Pearl Jam
(Off of the 'Yield' album)
I claim no ownership of this song, all the credit goes to Pearl Jam and their producers.
season: 1
title: 2011-02-27 - Jesse's Minecraft Server [Trailer - Feb.27]
year: 2011 year: 2011
JMC - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg s01.e11032101 - DBjFvs6HafU-thumb.jpg
JMC - Jesse's Minecraft Server [Trailer - Mar.21].info.json s01.e11032101 - DBjFvs6HafU.mp4
JMC - Jesse's Minecraft Server [Trailer - Mar.21].mp4 Video Tags:
JMC - Jesse's Minecraft Server [Trailer - Mar.21].nfo date: 2011-03-21
episode_id: 11032101
genre: ytdl-sub
show: JMC
synopsis:
https://www.youtube.com/watch?v=DBjFvs6HafU
Website Link:
http://jesseminecraft.webs.com/
To get on the whitelist, please look at the website linked above (^^^). Due to the overwhelming amount of people trying to join, I've made it so it costs $2 to become a member through paypal. All of it is explained on the website.
Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 1000 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots.
There are over 300 empty properties that are ready for anyone to own! Join now!
This is the server state as of Mar. 21, 2011.
--------------------------------------------------------------------------------­--
Texture Pack:
http://www.minecraftforum.net/viewtopic.php?f=25&t=29164
Recording Software:
http://www.fraps.com/download.php
Video Editing Software:
http://explore.live.com/windows-live-movie-maker?os=other
Song:
Indifference - Pearl Jam
(Off of the 'Vs.' album)
I claim no ownership of this song, all the credit goes to Pearl Jam and their producers.
title: 2011-03-21 - Jesse's Minecraft Server [Trailer - Mar.21]
year: 2011
s01.e11032101 - DBjFvs6HafU.nfo
NFO tags: NFO tags:
musicvideo: episodedetails:
album: Music Videos aired: 2011-03-21
artist: JMC episode: 11032101
genre: ytdl-sub
playlist_count: 3 playlist_count: 3
playlist_index: 1 playlist_index: 1
title: Jesse's Minecraft Server [Trailer - Mar.21] plot:
https://www.youtube.com/watch?v=DBjFvs6HafU
Website Link:
http://jesseminecraft.webs.com/
To get on the whitelist, please look at the website linked above (^^^). Due to the overwhelming amount of people trying to join, I've made it so it costs $2 to become a member through paypal. All of it is explained on the website.
Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 1000 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots.
There are over 300 empty properties that are ready for anyone to own! Join now!
This is the server state as of Mar. 21, 2011.
--------------------------------------------------------------------------------­--
Texture Pack:
http://www.minecraftforum.net/viewtopic.php?f=25&t=29164
Recording Software:
http://www.fraps.com/download.php
Video Editing Software:
http://explore.live.com/windows-live-movie-maker?os=other
Song:
Indifference - Pearl Jam
(Off of the 'Vs.' album)
I claim no ownership of this song, all the credit goes to Pearl Jam and their producers.
season: 1
title: 2011-03-21 - Jesse's Minecraft Server [Trailer - Mar.21]
year: 2011 year: 2011

View file

@ -48,6 +48,6 @@ def output_options() -> Dict:
@pytest.fixture @pytest.fixture
def youtube_video() -> Dict: def youtube_video() -> Dict:
return { return {
"download_strategy": "video", "download_strategy": "url",
"video_url": "youtube.com/watch?v=123abc", "url": "youtube.com/watch?v=123abc",
} }

View file

@ -66,12 +66,13 @@ class TestConfigFilePartiallyValidatesPresets:
f"Allowed fields: {', '.join(sorted(PRESET_KEYS))}", f"Allowed fields: {', '.join(sorted(PRESET_KEYS))}",
) )
def test_error__multiple_sources(self): # TODO: Update with future source
self._partial_validate( # def test_error__multiple_sources(self):
preset_dict={"youtube": {}, "download": {}}, # self._partial_validate(
expected_error_message="Validation error in partial_preset: " # preset_dict={"youtube": {}, "download": {}},
"Contains the sources download, youtube but can only have one", # expected_error_message="Validation error in partial_preset: "
) # "Contains the sources download, youtube but can only have one",
# )
def test_error__no_download_strategy(self): def test_error__no_download_strategy(self):
self._partial_validate( self._partial_validate(

View file

@ -10,18 +10,18 @@ class TestPreset:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"source, download_strategy", "source, download_strategy",
[ [
("youtube", {"download_strategy": "video", "video_url": "youtube.com/watch?v=123abc"}), ("download", {"download_strategy": "url", "url": "youtube.com/watch?v=123abc"}),
( (
"youtube", "download",
{ {
"download_strategy": "playlist", "download_strategy": "url",
"playlist_url": "youtube.com/playlist?list=123abc", "url": "youtube.com/playlist?list=123abc",
}, },
), ),
("youtube", {"download_strategy": "channel", "channel_url": "youtube.com/c/123abc"}), ("download", {"download_strategy": "url", "url": "youtube.com/c/123abc"}),
( (
"soundcloud", "download",
{"download_strategy": "albums_and_singles", "url": "soundcloud.com/123abc"}, {"download_strategy": "url", "url": "soundcloud.com/123abc"},
), ),
], ],
) )
@ -37,7 +37,7 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": youtube_video, "download": youtube_video,
"output_options": {"output_directory": "dir", "file_name": "{dne_var}"}, "output_options": {"output_directory": "dir", "file_name": "{dne_var}"},
"overrides": {"dne_var": "not dne"}, "overrides": {"dne_var": "not dne"},
}, },
@ -49,7 +49,7 @@ class TestPreset:
name="test", name="test",
value={ value={
"preset": "parent_preset_1", "preset": "parent_preset_1",
"youtube": youtube_video, "download": youtube_video,
"output_options": output_options, "output_options": output_options,
"nfo_tags": {"tags": {"key-2": "this-preset"}}, "nfo_tags": {"tags": {"key-2": "this-preset"}},
}, },
@ -74,7 +74,7 @@ class TestPreset:
name="test", name="test",
value={ value={
"preset": preset_value, "preset": preset_value,
"youtube": youtube_video, "download": youtube_video,
"output_options": output_options, "output_options": output_options,
"nfo_tags": {"tags": {"key-3": "this-preset"}}, "nfo_tags": {"tags": {"key-3": "this-preset"}},
}, },
@ -97,7 +97,7 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": youtube_video, "download": youtube_video,
"output_options": dict( "output_options": dict(
output_options, output_options,
**{"maintain_download_archive": True, "keep_files_after": "today-{ttl}"}, **{"maintain_download_archive": True, "keep_files_after": "today-{ttl}"},
@ -122,7 +122,7 @@ class TestPreset:
name="test", name="test",
value={ value={
"preset": parent_preset, "preset": parent_preset,
"youtube": youtube_video, "download": youtube_video,
"output_options": output_options, "output_options": output_options,
}, },
) )
@ -138,7 +138,7 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": youtube_video, "download": youtube_video,
"output_options": {"output_directory": "dir", "file_name": "{dne_var}"}, "output_options": {"output_directory": "dir", "file_name": "{dne_var}"},
}, },
) )
@ -154,7 +154,7 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": youtube_video, "download": youtube_video,
"output_options": {"output_directory": "{dne_var}", "file_name": "file"}, "output_options": {"output_directory": "{dne_var}", "file_name": "file"},
}, },
) )
@ -170,7 +170,7 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": youtube_video, "download": youtube_video,
"output_options": {"output_directory": "dir", "file_name": "file"}, "output_options": {"output_directory": "dir", "file_name": "file"},
"nfo_tags": { "nfo_tags": {
"nfo_name": "the nfo name", "nfo_name": "the nfo name",
@ -191,7 +191,7 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": youtube_video, "download": youtube_video,
"output_options": {"output_directory": "dir", "file_name": "file"}, "output_options": {"output_directory": "dir", "file_name": "file"},
"output_directory_nfo_tags": { "output_directory_nfo_tags": {
"nfo_name": "the nfo name", "nfo_name": "the nfo name",

View file

@ -12,7 +12,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def preset_file(youtube_video: Dict, output_options: Dict) -> Dict: def preset_file(youtube_video: Dict, output_options: Dict) -> Dict:
return { return {
"__preset__": { "__preset__": {
"youtube": youtube_video, "download": youtube_video,
"output_options": output_options, "output_options": output_options,
"nfo_tags": { "nfo_tags": {
"tags": {"key-3": "file_preset"}, "tags": {"key-3": "file_preset"},

View file

@ -116,6 +116,7 @@ def mock_entry_to_dict(
"playlist_webpage_url": "https://yourname.here", "playlist_webpage_url": "https://yourname.here",
"playlist_uid": "abc123", "playlist_uid": "abc123",
"playlist_uploader": "abc123", "playlist_uploader": "abc123",
"playlist_uploader_sanitized": "abc123",
"playlist_uploader_id": "abc123", "playlist_uploader_id": "abc123",
"playlist_uploader_url": "https://yourname.here", "playlist_uploader_url": "https://yourname.here",
"source_count": 1, "source_count": 1,