channel is close

This commit is contained in:
jbannon 2022-04-09 07:42:24 +00:00
parent 764678367f
commit a1ccb0669e
10 changed files with 148 additions and 37 deletions

View file

@ -43,3 +43,31 @@ presets:
title: "{title}" title: "{title}"
album: "Music Videos" album: "Music Videos"
year: "{upload_year}" year: "{upload_year}"
yt_channel:
youtube:
ytdl_options:
format: 'best'
ignoreerrors: True
output_options:
file_name: "{output_file_name}.{ext}"
convert_thumbnail: "jpeg"
thumbnail_name: "{output_file_name}.jpeg"
metadata_options:
nfo:
nfo_name: "{output_file_name}.nfo"
nfo_root: "episodedetails"
tags:
title: "{title}"
season: "{upload_year}"
episode: "{upload_month}{upload_day_padded}"
plot: "{description}"
year: "{upload_year}"
aired: "{upload_year}-{upload_month_padded}-{upload_day_padded}"
output_directory_nfo:
nfo_name: "tvshow.nfo"
nfo_root: "tvshow"
tags:
title: "{tv_show_name}"
overrides:
output_file_name: "Season {upload_year}/s{upload_year}.e{upload_month_padded}{upload_day_padded} - {sanitized_title}"

View file

@ -48,12 +48,24 @@
# overrides: # overrides:
# artist: "Rammstein" # artist: "Rammstein"
gogo_video_test: #gogo_video_test:
preset: "music_videos" # preset: "music_videos"
# youtube:
# download_strategy: "video"
# video_id: "2RHTiXvELNg"
# output_options:
# output_directory: "/tmp/gogos"
# overrides:
# artist: "The Gogos"
recent_channel_test:
preset: "yt_channel"
ytdl_options:
daterange: now-2weeks
youtube: youtube:
download_strategy: "video" download_strategy: "channel"
video_id: "2RHTiXvELNg" channel_id: "UCRcCVDu_4oARsAKFkKYydAQ"
output_options: output_options:
output_directory: "/tmp/gogos" output_directory: "/tmp/dunkey"
overrides: overrides:
artist: "The Gogos" tv_show_name: "Youtube: Dunkey"

View file

@ -23,6 +23,11 @@ class YoutubeDownloader(Downloader):
"""Returns full video url""" """Returns full video url"""
return f"https://youtube.com/watch?v={video_id}" return f"https://youtube.com/watch?v={video_id}"
@classmethod
def channel_url(cls, channel_id: str) -> str:
"""Returns full channel url"""
return f"https://youtube.com/channel/{channel_id}"
def _download_with_metadata(self, url: str) -> None: def _download_with_metadata(self, url: str) -> None:
""" """
Do not get entries from the extract info, let it write to the info.json file and load Do not get entries from the extract info, let it write to the info.json file and load
@ -36,12 +41,13 @@ class YoutubeDownloader(Downloader):
_ = self.extract_info(ytdl_options_overrides=ytdl_metadata_override, url=url) _ = self.extract_info(ytdl_options_overrides=ytdl_metadata_override, url=url)
def download_video(self, video_id: str) -> YoutubeVideo: def download_video(self, video_id: str) -> YoutubeVideo:
"""Download a single Youtube video"""
entry = self.extract_info(url=self.video_url(video_id)) entry = self.extract_info(url=self.video_url(video_id))
return YoutubeVideo(**entry) return YoutubeVideo(**entry)
def download_playlist(self, playlist_id: str) -> List[YoutubeVideo]: def download_playlist(self, playlist_id: str) -> List[YoutubeVideo]:
""" """
Downloads all videos in a playlist Downloads all videos in a Youtube playlist
""" """
playlist_url = self.playlist_url(playlist_id=playlist_id) playlist_url = self.playlist_url(playlist_id=playlist_id)
@ -57,3 +63,23 @@ class YoutubeDownloader(Downloader):
entries.append(YoutubeVideo(**json.load(file))) entries.append(YoutubeVideo(**json.load(file)))
return entries return entries
def download_channel(self, channel_id: str) -> List[YoutubeVideo]:
"""
Downloads all videos from a channel
TODO: Add caching via ids in the metadata. Scrape output directory for any vid ids and
TODO: include it in the archive
"""
self._download_with_metadata(url=self.channel_url(channel_id))
# Load the entries from info.json
entries: List[YoutubeVideo] = []
# Load the entries from info.json
# TODO dupe code between this and playlist
for file_name in os.listdir(self.output_directory):
if file_name.endswith(".info.json") and not file_name.startswith(channel_id):
with open(Path(self.output_directory) / file_name, "r", encoding="utf-8") as file:
entries.append(YoutubeVideo(**json.load(file)))
return entries

View file

@ -62,6 +62,30 @@ class Entry:
"""Returns the entry's upload year""" """Returns the entry's upload year"""
return int(self.upload_date[:4]) return int(self.upload_date[:4])
@property
def upload_month_padded(self) -> str:
"""Returns the entry's upload month, padded"""
return self.upload_date[4:6]
@property
def upload_day_padded(self) -> str:
"""Returns the entry's upload day, padded"""
return self.upload_date[6:8]
@property
def upload_month(self) -> int:
"""Returns the entry's upload month as an int"""
return int(self.upload_month_padded.lstrip("0"))
@property
def upload_day(self) -> int:
"""Returns the entry's upload month as an int"""
return int(self.upload_day_padded.lstrip("0"))
@property
def description(self) -> str:
return self.kwargs("description")
@property @property
def thumbnail(self) -> str: def thumbnail(self) -> str:
"""Returns the entry's thumbnail url""" """Returns the entry's thumbnail url"""

View file

@ -2,7 +2,7 @@ import os
from abc import ABC from abc import ABC
from pathlib import Path from pathlib import Path
from shutil import copyfile from shutil import copyfile
from typing import Generic from typing import Generic, overload, Optional
from typing import Type from typing import Type
from typing import TypeVar from typing import TypeVar
@ -19,6 +19,7 @@ from ytdl_subscribe.validators.config.config_options.config_options_validator im
from ytdl_subscribe.validators.config.metadata_options.metadata_options_validator import ( from ytdl_subscribe.validators.config.metadata_options.metadata_options_validator import (
MetadataOptionsValidator, MetadataOptionsValidator,
) )
from ytdl_subscribe.validators.config.metadata_options.nfo_validator import NFOValidator
from ytdl_subscribe.validators.config.output_options.output_options_validator import ( from ytdl_subscribe.validators.config.output_options.output_options_validator import (
OutputOptionsValidator, OutputOptionsValidator,
) )
@ -81,20 +82,14 @@ class Subscription(Generic[S], ABC):
"""Returns the directory that the downloader saves files to""" """Returns the directory that the downloader saves files to"""
return str(Path(self.__config_options.working_directory.value) / Path(self.name)) return str(Path(self.__config_options.working_directory.value) / Path(self.name))
def _apply_formatter(self, entry: Entry, formatter: StringFormatterValidator) -> str: def _apply_formatter(self, formatter: StringFormatterValidator, entry: Optional[Entry] = None) -> str:
""" """
Parameters Returns the format_string after .format has been called on it using entry (if provided) and
---------- override values
entry
Entry with values to use in the formatter
formatter
The formatter itself
Returns
-------
The format_string after .format has been called on it using entry and override values
""" """
variable_dict = dict(entry.to_dict(), **self.overrides.dict_with_format_strings) variable_dict = self.overrides.dict_with_format_strings
if entry:
variable_dict = dict(entry.to_dict(), **variable_dict)
return formatter.apply_formatter(variable_dict) return formatter.apply_formatter(variable_dict)
def _post_process_tagging(self, entry: Entry): def _post_process_tagging(self, entry: Entry):
@ -104,21 +99,20 @@ class Subscription(Generic[S], ABC):
id3_options = self.metadata_options.id3 id3_options = self.metadata_options.id3
audio_file = music_tag.load_file(entry.file_path(relative_directory=self.working_directory)) audio_file = music_tag.load_file(entry.file_path(relative_directory=self.working_directory))
for tag, tag_formatter in id3_options.tags.dict.items(): for tag, tag_formatter in id3_options.tags.dict.items():
audio_file[tag] = self._apply_formatter(entry=entry, formatter=tag_formatter) audio_file[tag] = self._apply_formatter(formatter=tag_formatter, entry=entry)
audio_file.save() audio_file.save()
def _post_process_nfo(self, entry: Entry): def _post_process_nfo(self, nfo_options: NFOValidator, entry: Optional[Entry] = None):
""" """
Creates an entry's NFO file using values defined in the metadata options Creates an entry's NFO file using values defined in the metadata options
""" """
nfo = {} nfo = {}
nfo_options = self.metadata_options.nfo
for tag, tag_formatter in nfo_options.tags.dict.items(): for tag, tag_formatter in nfo_options.tags.dict.items():
nfo[tag] = self._apply_formatter(entry=entry, formatter=tag_formatter) nfo[tag] = self._apply_formatter(formatter=tag_formatter, entry=entry)
# Write the nfo tags to XML with the nfo_root # Write the nfo tags to XML with the nfo_root
nfo_root = self._apply_formatter(entry=entry, formatter=nfo_options.nfo_root) nfo_root = self._apply_formatter(formatter=nfo_options.nfo_root, entry=entry)
xml = dicttoxml.dicttoxml( xml = dicttoxml.dicttoxml(
obj=nfo, obj=nfo,
root=True, # We assume all NFOs have a root. Maybe we should not? root=True, # We assume all NFOs have a root. Maybe we should not?
@ -126,9 +120,9 @@ class Subscription(Generic[S], ABC):
attr_type=False, attr_type=False,
) )
nfo_file_name = self._apply_formatter(entry=entry, formatter=nfo_options.nfo_name) nfo_file_name = self._apply_formatter(formatter=nfo_options.nfo_name, entry=entry)
output_directory = self._apply_formatter( output_directory = self._apply_formatter(
entry=entry, formatter=self.output_options.output_directory formatter=self.output_options.output_directory, entry=entry
) )
# Save the nfo's XML to file # Save the nfo's XML to file
@ -150,11 +144,11 @@ class Subscription(Generic[S], ABC):
entry_source_file_path = entry.file_path(relative_directory=self.working_directory) entry_source_file_path = entry.file_path(relative_directory=self.working_directory)
output_directory = self._apply_formatter( output_directory = self._apply_formatter(
entry=entry, formatter=self.output_options.output_directory formatter=self.output_options.output_directory, entry=entry
) )
output_file_name = self._apply_formatter( output_file_name = self._apply_formatter(
entry=entry, formatter=self.output_options.file_name formatter=self.output_options.file_name, entry=entry
) )
entry_destination_file_path = Path(output_directory) / Path(output_file_name) entry_destination_file_path = Path(output_directory) / Path(output_file_name)
@ -166,7 +160,7 @@ class Subscription(Generic[S], ABC):
source_thumbnail_path = entry.thumbnail_path(relative_directory=self.working_directory) source_thumbnail_path = entry.thumbnail_path(relative_directory=self.working_directory)
output_thumbnail_name = self._apply_formatter( output_thumbnail_name = self._apply_formatter(
entry=entry, formatter=self.output_options.thumbnail_name formatter=self.output_options.thumbnail_name, entry=entry
) )
output_thumbnail_path = Path(output_directory) / Path(output_thumbnail_name) output_thumbnail_path = Path(output_directory) / Path(output_thumbnail_name)
@ -185,4 +179,7 @@ class Subscription(Generic[S], ABC):
copyfile(source_thumbnail_path, output_thumbnail_path) copyfile(source_thumbnail_path, output_thumbnail_path)
if self.metadata_options.nfo: if self.metadata_options.nfo:
self._post_process_nfo(entry) self._post_process_nfo(nfo_options=self.metadata_options.nfo, entry=entry)
if self.metadata_options.output_directory_nfo:
self._post_process_nfo(nfo_options=self.metadata_options.output_directory_nfo)

View file

@ -1,7 +1,7 @@
from ytdl_subscribe.downloaders.youtube_downloader import YoutubeDownloader from ytdl_subscribe.downloaders.youtube_downloader import YoutubeDownloader
from ytdl_subscribe.subscriptions.subscription import Subscription from ytdl_subscribe.subscriptions.subscription import Subscription
from ytdl_subscribe.validators.config.source_options.youtube_validators import ( from ytdl_subscribe.validators.config.source_options.youtube_validators import (
YoutubePlaylistSourceValidator, YoutubePlaylistSourceValidator, YoutubeChannelSourceValidator,
) )
from ytdl_subscribe.validators.config.source_options.youtube_validators import ( from ytdl_subscribe.validators.config.source_options.youtube_validators import (
YoutubeVideoSourceValidator, YoutubeVideoSourceValidator,
@ -18,6 +18,16 @@ class YoutubePlaylistSubscription(Subscription[YoutubePlaylistSourceValidator]):
self.post_process_entry(entry) self.post_process_entry(entry)
class YoutubeChannelSubscription(Subscription[YoutubeChannelSourceValidator]):
def extract_info(self):
entries = self.get_downloader(YoutubeDownloader).download_channel(
channel_id=self.source_options.channel_id.value
)
for entry in entries:
self.post_process_entry(entry)
class YoutubeVideoSubscription(Subscription[YoutubeVideoSourceValidator]): class YoutubeVideoSubscription(Subscription[YoutubeVideoSourceValidator]):
def extract_info(self): def extract_info(self):
entry = self.get_downloader(YoutubeDownloader).download_video( entry = self.get_downloader(YoutubeDownloader).download_video(

View file

@ -4,10 +4,13 @@ from ytdl_subscribe.validators.config.metadata_options.nfo_validator import NFOV
class MetadataOptionsValidator(StrictDictValidator): class MetadataOptionsValidator(StrictDictValidator):
_optional_keys = {"id3", "nfo"} _optional_keys = {"id3", "nfo", "output_directory_nfo"}
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self.id3 = self._validate_key_if_present(key="id3", validator=Id3Validator) self.id3 = self._validate_key_if_present(key="id3", validator=Id3Validator)
self.nfo = self._validate_key_if_present(key="nfo", validator=NFOValidator) self.nfo = self._validate_key_if_present(key="nfo", validator=NFOValidator)
# TODO: Ensure this does not depend on entry variables, only overrides
self.output_directory_nfo = self._validate_key_if_present(key="output_directory_nfo", validator=NFOValidator)

View file

@ -11,7 +11,7 @@ from ytdl_subscribe.validators.config.source_options.soundcloud_validators impor
) )
from ytdl_subscribe.validators.config.source_options.source_validators import SourceValidator from ytdl_subscribe.validators.config.source_options.source_validators import SourceValidator
from ytdl_subscribe.validators.config.source_options.youtube_validators import ( from ytdl_subscribe.validators.config.source_options.youtube_validators import (
YoutubePlaylistSourceValidator, YoutubePlaylistSourceValidator, YoutubeChannelSourceValidator,
) )
from ytdl_subscribe.validators.config.source_options.youtube_validators import ( from ytdl_subscribe.validators.config.source_options.youtube_validators import (
YoutubeVideoSourceValidator, YoutubeVideoSourceValidator,
@ -65,6 +65,7 @@ class SoundcloudDownloadStrategyValidator(DownloadStrategyValidator):
class YoutubeDownloadStrategyValidator(DownloadStrategyValidator): class YoutubeDownloadStrategyValidator(DownloadStrategyValidator):
_download_strategy_to_source_mapping = { _download_strategy_to_source_mapping = {
"channel": YoutubeChannelSourceValidator,
"playlist": YoutubePlaylistSourceValidator, "playlist": YoutubePlaylistSourceValidator,
"video": YoutubeVideoSourceValidator, "video": YoutubeVideoSourceValidator,
} }

View file

@ -16,3 +16,11 @@ class YoutubeVideoSourceValidator(YoutubeSourceValidator):
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self.video_id = self._validate_key("video_id", StringValidator) self.video_id = self._validate_key("video_id", StringValidator)
class YoutubeChannelSourceValidator(YoutubeSourceValidator):
_required_keys = {"channel_id"}
def __init__(self, name, value):
super().__init__(name, value)
self.channel_id = self._validate_key("channel_id", StringValidator)

View file

@ -9,7 +9,8 @@ from mergedeep import mergedeep
from ytdl_subscribe.subscriptions.soundcloud import SoundcloudAlbumsAndSinglesSubscription from ytdl_subscribe.subscriptions.soundcloud import SoundcloudAlbumsAndSinglesSubscription
from ytdl_subscribe.subscriptions.subscription import Subscription from ytdl_subscribe.subscriptions.subscription import Subscription
from ytdl_subscribe.subscriptions.youtube import YoutubePlaylistSubscription from ytdl_subscribe.subscriptions.youtube import YoutubePlaylistSubscription, \
YoutubeChannelSubscription
from ytdl_subscribe.subscriptions.youtube import YoutubeVideoSubscription from ytdl_subscribe.subscriptions.youtube import YoutubeVideoSubscription
from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator
from ytdl_subscribe.validators.base.validators import StringValidator from ytdl_subscribe.validators.base.validators import StringValidator
@ -22,7 +23,7 @@ from ytdl_subscribe.validators.config.source_options.soundcloud_validators impor
SoundcloudAlbumsAndSinglesSourceValidator, SoundcloudAlbumsAndSinglesSourceValidator,
) )
from ytdl_subscribe.validators.config.source_options.youtube_validators import ( from ytdl_subscribe.validators.config.source_options.youtube_validators import (
YoutubePlaylistSourceValidator, YoutubePlaylistSourceValidator, YoutubeChannelSourceValidator,
) )
from ytdl_subscribe.validators.config.source_options.youtube_validators import ( from ytdl_subscribe.validators.config.source_options.youtube_validators import (
YoutubeVideoSourceValidator, YoutubeVideoSourceValidator,
@ -78,7 +79,8 @@ class SubscriptionValidator(StrictDictValidator):
subscription_class = YoutubePlaylistSubscription subscription_class = YoutubePlaylistSubscription
elif isinstance(self.preset.subscription_source, YoutubeVideoSourceValidator): elif isinstance(self.preset.subscription_source, YoutubeVideoSourceValidator):
subscription_class = YoutubeVideoSubscription subscription_class = YoutubeVideoSubscription
elif isinstance(self.preset.subscription_source, YoutubeChannelSourceValidator):
subscription_class = YoutubeChannelSubscription
if subscription_class is None: if subscription_class is None:
raise ValueError("subscription source class not found") raise ValueError("subscription source class not found")