Merge branch 'master' into jesse/bandcamp
This commit is contained in:
commit
6c06359c1e
31 changed files with 442 additions and 757 deletions
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)]
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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},
|
|
||||||
)
|
|
||||||
|
|
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
51
tests/e2e/plugins/test_music_tags.py
Normal file
51
tests/e2e/plugins/test_music_tags.py
Normal 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)
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue