BROKEN STATE - in middle of refactoring for plugin support

This commit is contained in:
jbannon 2022-04-22 06:58:53 +00:00
parent 279de00c2b
commit 7a3144a838
17 changed files with 408 additions and 270 deletions

View file

@ -12,20 +12,19 @@ presets:
output_options: output_options:
output_directory: "/tmp/{sanitized_artist}" output_directory: "/tmp/{sanitized_artist}"
file_name: "[{album_year}] {sanitized_album}/{track_number_padded} - {sanitized_title}.{ext}" file_name: "[{album_year}] {sanitized_album}/{track_number_padded} - {sanitized_title}.{ext}"
convert_thumbnail: "jpeg" thumbnail_name: "[{album_year}] {sanitized_album}/folder.jpg"
thumbnail_name: "[{album_year}] {sanitized_album}/folder.jpeg"
maintain_download_archive: True maintain_download_archive: True
metadata_options: convert_thumbnail:
id3: to: "jpg"
id3_version: "2.4" music_tags:
tags: tags:
artist: "{artist}" artist: "{artist}"
albumartist: "{artist}" albumartist: "{artist}"
title: "{title}" title: "{title}"
album: "{album}" album: "{album}"
tracknumber: "{track_number}" tracknumber: "{track_number}"
year: "{album_year}" year: "{album_year}"
genre: "{genre}" genre: "{genre}"
music_videos: music_videos:
youtube: youtube:
@ -35,16 +34,17 @@ presets:
output_options: output_options:
file_name: "{sanitized_artist} - {sanitized_title}.{ext}" file_name: "{sanitized_artist} - {sanitized_title}.{ext}"
convert_thumbnail: "jpeg" convert_thumbnail: "jpeg"
thumbnail_name: "{sanitized_artist} - {sanitized_title}.jpeg" thumbnail_name: "{sanitized_artist} - {sanitized_title}.jpg"
metadata_options: convert_thumbnail:
nfo: to: "jpg"
nfo_name: "{sanitized_artist} - {sanitized_title}.nfo" nfo_tags:
nfo_root: "musicvideo" nfo_name: "{sanitized_artist} - {sanitized_title}.nfo"
tags: nfo_root: "musicvideo"
artist: "{artist}" tags:
title: "{title}" artist: "{artist}"
album: "Music Videos" title: "{title}"
year: "{upload_year}" album: "Music Videos"
year: "{upload_year}"
yt_channel: yt_channel:
youtube: youtube:
@ -54,24 +54,25 @@ presets:
output_options: output_options:
file_name: "{output_file_name}.{ext}" file_name: "{output_file_name}.{ext}"
convert_thumbnail: "jpeg" convert_thumbnail: "jpeg"
thumbnail_name: "{output_file_name}.jpeg" thumbnail_name: "{output_file_name}.jpg"
maintain_download_archive: True maintain_download_archive: True
maintain_stale_file_deletion: True maintain_stale_file_deletion: True
metadata_options: convert_thumbnail:
nfo: to: "jpg"
nfo_name: "{output_file_name}.nfo" nfo_tags:
nfo_root: "episodedetails" nfo_name: "{output_file_name}.nfo"
tags: nfo_root: "episodedetails"
title: "{title}" tags:
season: "{upload_year}" title: "{title}"
episode: "{upload_month}{upload_day_padded}" season: "{upload_year}"
plot: "{description}" episode: "{upload_month}{upload_day_padded}"
year: "{upload_year}" plot: "{description}"
aired: "{upload_date_standardized}" year: "{upload_year}"
output_directory_nfo: aired: "{upload_date_standardized}"
nfo_name: "tvshow.nfo" output_directory_nfo_tags:
nfo_root: "tvshow" nfo_name: "tvshow.nfo"
tags: nfo_root: "tvshow"
title: "{tv_show_name}" tags:
title: "{tv_show_name}"
overrides: overrides:
output_file_name: "Season {upload_year}/s{upload_year}.e{upload_month_padded}{upload_day_padded} - {sanitized_title}" output_file_name: "Season {upload_year}/s{upload_year}.e{upload_month_padded}{upload_day_padded} - {sanitized_title}"

View file

@ -12,14 +12,15 @@
# #
import os import os
import sys import sys
sys.path.insert(0, os.path.abspath('../'))
sys.path.insert(0, os.path.abspath("../"))
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = 'ytdl-subscribe' project = "ytdl-subscribe"
copyright = '2022, Jesse Bannon' copyright = "2022, Jesse Bannon"
author = 'Jesse Bannon' author = "Jesse Bannon"
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
@ -28,17 +29,17 @@ author = 'Jesse Bannon'
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
'sphinx.ext.autodoc', "sphinx.ext.autodoc",
'sphinx.ext.napoleon', "sphinx.ext.napoleon",
] ]
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path. # This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
@ -46,12 +47,12 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # a list of builtin themes.
# #
html_theme = 'sphinx_rtd_theme' html_theme = "sphinx_rtd_theme"
# Add any paths that contain custom static files (such as style sheets) here, # Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] html_static_path = ["_static"]
# Do not show full module path in api docs # Do not show full module path in api docs
add_module_names = False add_module_names = False

View file

@ -31,7 +31,9 @@ class SoundcloudDownloader(Downloader):
artist_albums_url = f"{self.artist_url(artist_name)}/albums" artist_albums_url = f"{self.artist_url(artist_name)}/albums"
info = self.extract_info(url=artist_albums_url) info = self.extract_info(url=artist_albums_url)
albums = [SoundcloudAlbum(**e) for e in info["entries"]] albums = [
SoundcloudAlbum(working_direcotry=self.working_directory, **e) for e in info["entries"]
]
return [album for album in albums if album.track_count > 0] return [album for album in albums if album.track_count > 0]
@ -42,4 +44,7 @@ class SoundcloudDownloader(Downloader):
artist_tracks_url = f"{self.artist_url(artist_name)}/tracks" artist_tracks_url = f"{self.artist_url(artist_name)}/tracks"
info = self.extract_info(url=artist_tracks_url) info = self.extract_info(url=artist_tracks_url)
return [SoundcloudTrack(**e) for e in info["entries"]] return [
SoundcloudTrack(working_directory=self.working_directory, kwargs=e)
for e in info["entries"]
]

View file

@ -1,5 +1,6 @@
from abc import ABC from abc import ABC
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import Optional from typing import Optional
@ -23,10 +24,18 @@ class BaseEntry(ABC):
Abstract entry object to represent anything download from ytdl (playlist metadata, media, etc). Abstract entry object to represent anything download from ytdl (playlist metadata, media, etc).
""" """
def __init__(self, **kwargs): def __init__(self, working_directory: Optional[str] = None, **kwargs):
""" """
Initialize the entry using ytdl metadata Initialize the entry using ytdl metadata
Parameters
----------
working_directory
Optional. Directory that the entry is downloaded to
kwargs
Entry metadata
""" """
self._working_directory = working_directory
self._kwargs = kwargs self._kwargs = kwargs
def kwargs_contains(self, key: str) -> bool: def kwargs_contains(self, key: str) -> bool:
@ -39,6 +48,14 @@ class BaseEntry(ABC):
raise KeyError(f"Expected '{key}' in {self.__class__.__name__} but does not exist.") raise KeyError(f"Expected '{key}' in {self.__class__.__name__} but does not exist.")
return self._kwargs[key] return self._kwargs[key]
@property
def working_directory(self) -> str:
if self._working_directory is None:
raise ValueError(
"Entry working directory is not set when trying to access its download file path"
)
return self._working_directory
@property @property
def uid(self) -> str: def uid(self) -> str:
"""Returns the entry's unique id""" """Returns the entry's unique id"""
@ -133,6 +150,11 @@ class Entry(BaseEntry):
"""Returns the entry's file name when downloaded locally""" """Returns the entry's file name when downloaded locally"""
return f"{self.uid}.{self.ext}" return f"{self.uid}.{self.ext}"
@property
def download_file_path(self) -> str:
"""Returns the entry's file path to where it was downloaded"""
return str(Path(self.working_directory) / self.download_file_name)
@property @property
def thumbnail_ext(self) -> str: def thumbnail_ext(self) -> str:
""" """
@ -145,6 +167,11 @@ class Entry(BaseEntry):
"""Returns the thumbnail's file name when downloaded locally""" """Returns the thumbnail's file name when downloaded locally"""
return f"{self.uid}.{self.thumbnail_ext}" return f"{self.uid}.{self.thumbnail_ext}"
@property
def download_thumbnail_path(self) -> str:
"""Returns the entry's thumbnail's file path to where it was downloaded"""
return str(Path(self.working_directory) / self.download_thumbnail_name)
def to_dict(self) -> Dict: def to_dict(self) -> Dict:
"""Returns the entry's values as a dictionary""" """Returns the entry's values as a dictionary"""
return dict( return dict(

View file

@ -67,11 +67,18 @@ class SoundcloudAlbumTrack(SoundcloudTrack):
Entry object to represent a Soundcloud track yt-dlp that belongs to an album. Entry object to represent a Soundcloud track yt-dlp that belongs to an album.
""" """
def __init__(self, album: str, album_year: int, playlist_metadata: PlaylistMetadata, **kwargs): def __init__(
self,
working_directory: str,
album: str,
album_year: int,
playlist_metadata: PlaylistMetadata,
**kwargs,
):
""" """
Initialize the album track using album metadata and ytdl metadata for the specific track. Initialize the album track using album metadata and ytdl metadata for the specific track.
""" """
super().__init__(**kwargs) super().__init__(working_directory=working_directory, **kwargs)
self._album = album self._album = album
self._album_year = album_year self._album_year = album_year
self._playlist_metadata = playlist_metadata self._playlist_metadata = playlist_metadata
@ -104,6 +111,7 @@ class SoundcloudAlbumTrack(SoundcloudTrack):
playlist_metadata: PlaylistMetadata, playlist_metadata: PlaylistMetadata,
) -> "SoundcloudAlbumTrack": ) -> "SoundcloudAlbumTrack":
return SoundcloudAlbumTrack( return SoundcloudAlbumTrack(
working_directory=soundcloud_track.working_directory,
album=album, album=album,
album_year=album_year, album_year=album_year,
playlist_metadata=playlist_metadata, playlist_metadata=playlist_metadata,
@ -122,7 +130,10 @@ class SoundcloudAlbum(Entry):
Returns all tracks in the album represented by singles. Use this to fetch any Returns all tracks in the album represented by singles. Use this to fetch any
data needed from the tracks before representing it as an album track. data needed from the tracks before representing it as an album track.
""" """
return [SoundcloudTrack(**entry) for entry in self.kwargs("entries")] return [
SoundcloudTrack(working_directory=self._working_directory, **entry)
for entry in self.kwargs("entries")
]
def album_tracks(self, skip_premiere_tracks: bool = True) -> List[SoundcloudAlbumTrack]: def album_tracks(self, skip_premiere_tracks: bool = True) -> List[SoundcloudAlbumTrack]:
""" """

View file

View file

@ -0,0 +1,41 @@
import os
from PIL.Image import Image
from ytdl_subscribe.entries.entry import Entry
from ytdl_subscribe.plugins.plugin import Plugin
from ytdl_subscribe.plugins.plugin import PluginValidator
from ytdl_subscribe.validators.base.string_select_validator import StringSelectValidator
class ThumbnailTypes(StringSelectValidator):
"""Valid image types that thumbnails can be converted to"""
_select_values = {"jpg"}
class ConvertThumbnailValidator(PluginValidator):
_required_keys = {"to"}
def __init__(self, name, value):
super().__init__(name, value)
self.to = self._validate_key(key="to", validator=ThumbnailTypes)
class ConvertThumbnail(Plugin[ConvertThumbnailValidator]):
def post_process_entry(self, entry: Entry):
"""
Convert the source thumbnail to the desired type. Leave the original file name and
extension intact to let output_options deal with renaming it.
"""
if not os.path.isfile(entry.download_thumbnail_path):
raise ValueError("Thumbnail not found")
image = Image.open(entry.download_thumbnail_path).convert("RGB")
# Pillow likes the formal 'jpeg' name and not 'jpg'
thumbnail_format = self.plugin_options.to.value
if thumbnail_format == "jpg":
thumbnail_format = "jpeg"
image.save(fp=entry.download_thumbnail_path, format=thumbnail_format)

View file

@ -0,0 +1,32 @@
import music_tag
from ytdl_subscribe.entries.entry import Entry
from ytdl_subscribe.plugins.plugin import Plugin
from ytdl_subscribe.plugins.plugin import PluginValidator
from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator
from ytdl_subscribe.validators.base.validators import StringValidator
class MusicTagsValidator(PluginValidator):
_required_keys = {"tags"}
_optional_keys = {"multi_value_separator"}
def __init__(self, name, value):
super().__init__(name, value)
self.tags = self._validate_key(key="tags", validator=DictFormatterValidator)
self.multi_value_separator = self._validate_key_if_present(
key="multi_value_separator", validator=StringValidator
)
class MusicTags(Plugin[MusicTagsValidator]):
def post_process_entry(self, entry: Entry):
"""
Tags the entry's audio file using values defined in the metadata options
"""
audio_file = music_tag.load_file(entry.download_file_path)
for tag, tag_formatter in self.plugin_options.tags.dict.items():
audio_file[tag] = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry)
audio_file.save()

View file

@ -0,0 +1,58 @@
from pathlib import Path
import dicttoxml
from ytdl_subscribe.entries.entry import Entry
from ytdl_subscribe.plugins.plugin import Plugin
from ytdl_subscribe.plugins.plugin import PluginValidator
from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator
from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator
class NfoTagsValidator(PluginValidator):
_required_keys = {"nfo_name", "nfo_root", "tags"}
def __init__(self, name, value):
super().__init__(name, value)
self.nfo_name = self._validate_key(key="nfo_name", validator=StringFormatterValidator)
self.nfo_root = self._validate_key(key="nfo_root", validator=StringFormatterValidator)
self.tags = self._validate_key(key="tags", validator=DictFormatterValidator)
class NfoTags(Plugin[NfoTagsValidator]):
def post_process_entry(self, entry: Entry):
"""
Creates an entry's NFO file using values defined in the metadata options
Parameters
----------
entry: Entry to create an NFO file for
"""
nfo = {}
for tag, tag_formatter in self.plugin_options.tags.dict.items():
nfo[tag] = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry)
# Write the nfo tags to XML with the nfo_root
nfo_root = self.overrides.apply_formatter(
formatter=self.plugin_options.nfo_root, entry=entry
)
xml = dicttoxml.dicttoxml(
obj=nfo,
root=True, # We assume all NFOs have a root. Maybe we should not?
custom_root=nfo_root,
attr_type=False,
)
nfo_file_name = self.overrides.apply_formatter(
formatter=self.plugin_options.nfo_name, entry=entry
)
# Save the nfo's XML to file
nfo_file_path = Path(self.output_directory) / nfo_file_name
with open(nfo_file_path, "wb") as nfo_file:
nfo_file.write(xml)
# Archive the nfo's file name
self.archive_entry_file_name(entry=entry, relative_file_path=nfo_file_name)

View file

@ -0,0 +1,59 @@
from pathlib import Path
import dicttoxml
from ytdl_subscribe.plugins.plugin import Plugin
from ytdl_subscribe.plugins.plugin import PluginValidator
from ytdl_subscribe.validators.base.string_formatter_validators import (
OverridesDictFormatterValidator,
)
from ytdl_subscribe.validators.base.string_formatter_validators import (
OverridesStringFormatterValidator,
)
class OutputDirectoryNfoTagsValidator(PluginValidator):
"""
Validates config values for the output directory nfo tags plugin.
"""
_required_keys = {"nfo_name", "nfo_root", "tags"}
def __init__(self, name, value):
super().__init__(name, value)
# Since this does not create NFOs for entries, we must use the overrides formatter classes
# to ensure we are only using values defined as string literals or in overrides
self.nfo_name = self._validate_key(
key="nfo_name", validator=OverridesStringFormatterValidator
)
self.nfo_root = self._validate_key(
key="nfo_root", validator=OverridesStringFormatterValidator
)
self.tags = self._validate_key(key="tags", validator=OverridesDictFormatterValidator)
class NfoTags(Plugin[OutputDirectoryNfoTagsValidator]):
def post_process_subscription(self):
"""
Creates an NFO file in the root of the output directory
"""
nfo = {}
for tag, tag_formatter in self.plugin_options.tags.dict.items():
nfo[tag] = self.overrides.apply_formatter(formatter=tag_formatter)
# Write the nfo tags to XML with the nfo_root
nfo_root = self.overrides.apply_formatter(formatter=self.plugin_options.nfo_root)
xml = dicttoxml.dicttoxml(
obj=nfo,
root=True, # We assume all NFOs have a root. Maybe we should not?
custom_root=nfo_root,
attr_type=False,
)
nfo_file_name = self.overrides.apply_formatter(formatter=self.plugin_options.nfo_name)
# Save the nfo's XML to file
nfo_file_path = Path(self.output_directory) / nfo_file_name
with open(nfo_file_path, "wb") as nfo_file:
nfo_file.write(xml)

View file

@ -0,0 +1,73 @@
from abc import ABC
from typing import Generic
from typing import Optional
from typing import TypeVar
from typing import final
from ytdl_subscribe.entries.entry import Entry
from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator
from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator
from ytdl_subscribe.validators.config.overrides.overrides_validator import OverridesValidator
from ytdl_subscribe.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
class PluginValidator(StrictDictValidator):
"""
Class that defines the parameters to a plugin
"""
pass
PluginValidatorT = TypeVar("PluginValidatorT", bound=PluginValidator)
class Plugin(Generic[PluginValidatorT], ABC):
"""
Class to define the new plugin functionality
"""
@final
def __init__(
self,
plugin_options: PluginValidatorT,
output_directory: str,
overrides: OverridesValidator,
enhanced_download_archive: EnhancedDownloadArchive,
):
self.plugin_options = plugin_options
self.output_directory = output_directory
self.overrides = overrides
self.__enhanced_download_archive = enhanced_download_archive
@final
def archive_entry_file_name(self, entry: Entry, relative_file_path: str) -> None:
"""
Adds an entry and a file name that belongs to it into the archive mapping.
Parameters
----------
entry:
Optional. The entry the file belongs to
relative_file_path:
The name of the file path relative to the output directory
"""
self.__enhanced_download_archive.mapping.add_entry(
entry=entry, entry_file_path=relative_file_path
)
def post_process_entry(self, entry: Entry):
"""
For each file downloaded, apply post processing to it.
Parameters
----------
entry: Entry to post process
"""
pass
def post_process_subscription(self):
"""
After all downloaded files have been post-processed, apply a subscription-wide post process
"""
pass

View file

@ -11,20 +11,11 @@ from typing import Optional
from typing import Type from typing import Type
from typing import TypeVar from typing import TypeVar
import dicttoxml
import music_tag
from PIL import Image
from ytdl_subscribe.downloaders.downloader import Downloader from ytdl_subscribe.downloaders.downloader import Downloader
from ytdl_subscribe.entries.entry import Entry from ytdl_subscribe.entries.entry import Entry
from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator
from ytdl_subscribe.validators.config.config_options.config_options_validator import ( from ytdl_subscribe.validators.config.config_options.config_options_validator import (
ConfigOptionsValidator, ConfigOptionsValidator,
) )
from ytdl_subscribe.validators.config.metadata_options.metadata_options_validator import (
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,
) )
@ -94,11 +85,6 @@ class Subscription(Generic[SourceT, EntryT], ABC):
"""Returns the output options defined for this subscription""" """Returns the output options defined for this subscription"""
return self.__preset_options.output_options return self.__preset_options.output_options
@property
def metadata_options(self) -> MetadataOptionsValidator:
"""Returns the metadata options defined for this subscription"""
return self.__preset_options.metadata_options
@property @property
def overrides(self) -> OverridesValidator: def overrides(self) -> OverridesValidator:
"""Returns the overrides defined for this subscription""" """Returns the overrides defined for this subscription"""
@ -114,19 +100,7 @@ class Subscription(Generic[SourceT, EntryT], ABC):
""" """
:return: The formatted output directory :return: The formatted output directory
""" """
return self._apply_formatter(formatter=self.output_options.output_directory) return self.overrides.apply_formatter(formatter=self.output_options.output_directory)
def _archive_entry_file_name(self, entry: Optional[Entry], relative_file_name: str) -> None:
"""
Adds an entry and a file name that belongs to it into the archive mapping.
:param entry: Optional. The entry the file belongs to
:param relative_file_name: The name of the file
"""
if entry:
self._enhanced_download_archive.mapping.add_entry(
entry=entry, entry_file_path=relative_file_name
)
def get_downloader( def get_downloader(
self, downloader_type: Type[DownloaderT], source_ytdl_options: Optional[Dict] = None self, downloader_type: Type[DownloaderT], source_ytdl_options: Optional[Dict] = None
@ -143,113 +117,30 @@ class Subscription(Generic[SourceT, EntryT], ABC):
download_archive_file_name=self._enhanced_download_archive.archive_file_name, download_archive_file_name=self._enhanced_download_archive.archive_file_name,
) )
def _apply_formatter( def _copy_file_to_output_directory(self, source_file_path: str, output_file_name: str):
self, formatter: StringFormatterValidator, entry: Optional[Entry] = None destination_file_path = Path(self.output_directory) / Path(output_file_name)
) -> str: os.makedirs(os.path.dirname(destination_file_path), exist_ok=True)
""" copyfile(source_file_path, destination_file_path)
Returns the format_string after .format has been called on it using entry (if provided) and
override values
"""
variable_dict = self.overrides.dict_with_format_strings
if entry:
variable_dict = dict(entry.to_dict(), **variable_dict)
return formatter.apply_formatter(variable_dict)
def _post_process_tagging(self, entry: Entry): def _copy_entry_files_to_output_directory(self, entry: Entry):
"""
Tags the entry's audio file using values defined in the metadata options
"""
id3_options = self.metadata_options.id3
entry_source_file_path = Path(self.working_directory) / entry.download_file_name
audio_file = music_tag.load_file(entry_source_file_path)
for tag, tag_formatter in id3_options.tags.dict.items():
audio_file[tag] = self._apply_formatter(formatter=tag_formatter, entry=entry)
audio_file.save()
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
:param nfo_options: Options for the NFO
:param entry: Optional. Will pass entry values to nfo string formatters. If None, will only
use override variables that must resolve.
"""
nfo = {}
for tag, tag_formatter in nfo_options.tags.dict.items():
nfo[tag] = self._apply_formatter(formatter=tag_formatter, entry=entry)
# Write the nfo tags to XML with the nfo_root
nfo_root = self._apply_formatter(formatter=nfo_options.nfo_root, entry=entry)
xml = dicttoxml.dicttoxml(
obj=nfo,
root=True, # We assume all NFOs have a root. Maybe we should not?
custom_root=nfo_root,
attr_type=False,
)
nfo_file_name = self._apply_formatter(formatter=nfo_options.nfo_name, entry=entry)
# Save the nfo's XML to file
nfo_file_path = Path(self.output_directory) / Path(nfo_file_name)
with open(nfo_file_path, "wb") as nfo_file:
nfo_file.write(xml)
# Archive the nfo's file name
self._archive_entry_file_name(entry=entry, relative_file_name=nfo_file_name)
def _post_process_thumbnail(self, entry: Entry):
source_thumbnail_path = Path(self.working_directory) / entry.download_thumbnail_name
# Bug that mismatches webp and jpg extensions. Try to hotfix here
if not os.path.isfile(source_thumbnail_path):
to_replace = f".{entry.thumbnail_ext}"
actual_thumbnail_ext = ".webp"
if entry.thumbnail_ext == "webp":
actual_thumbnail_ext = ".jpg"
source_thumbnail_name = entry.download_thumbnail_name.replace(
to_replace, actual_thumbnail_ext
)
source_thumbnail_path = Path(self.working_directory) / source_thumbnail_name
if not os.path.isfile(source_thumbnail_path):
raise ValueError("Hotfix for getting thumbnail file extension failed")
output_thumbnail_name = self._apply_formatter(
formatter=self.output_options.thumbnail_name, entry=entry
)
output_thumbnail_path = Path(self.output_directory) / Path(output_thumbnail_name)
os.makedirs(os.path.dirname(output_thumbnail_path), exist_ok=True)
# If the thumbnail is to be converted, then save the converted thumbnail to the
# output filepath
if self.output_options.convert_thumbnail:
image = Image.open(source_thumbnail_path).convert("RGB")
image.save(
fp=output_thumbnail_path,
format=self.output_options.convert_thumbnail.value,
)
# Otherwise, just copy the downloaded thumbnail
else:
copyfile(source_thumbnail_path, output_thumbnail_path)
# Archive the thumbnail file name
self._archive_entry_file_name(entry=entry, relative_file_name=output_thumbnail_name)
def _copy_entry_file_to_output_directory(self, entry: Entry):
# Move the file after all direct file modifications are complete # Move the file after all direct file modifications are complete
entry_source_file_path = Path(self.working_directory) / entry.download_file_name output_file_name = self.overrides.apply_formatter(
output_file_name = self._apply_formatter(
formatter=self.output_options.file_name, entry=entry formatter=self.output_options.file_name, entry=entry
) )
entry_destination_file_path = Path(self.output_directory) / Path(output_file_name) self._enhanced_download_archive.mapping.add_entry(entry, output_file_name)
self._copy_file_to_output_directory(
source_file_path=entry.download_file_path, output_file_name=output_file_name
)
os.makedirs(os.path.dirname(entry_destination_file_path), exist_ok=True) if self.output_options.thumbnail_name:
copyfile(entry_source_file_path, entry_destination_file_path) output_thumbnail_name = self.overrides.apply_formatter(
formatter=self.output_options.thumbnail_name, entry=entry
)
self._enhanced_download_archive.mapping.add_entry(entry, output_thumbnail_name)
self._copy_file_to_output_directory(
source_file_path=entry.download_thumbnail_path,
output_file_name=output_thumbnail_name,
)
self._enhanced_download_archive.mapping.add_entry(entry, output_file_name) self._enhanced_download_archive.mapping.add_entry(entry, output_file_name)
@ -305,16 +196,7 @@ class Subscription(Generic[SourceT, EntryT], ABC):
:param entry: The entry to post-process :param entry: The entry to post-process
""" """
if self.metadata_options.id3: # before move
self._post_process_tagging(entry) self._copy_entry_files_to_output_directory(entry=entry)
self._copy_entry_file_to_output_directory(entry=entry) # after move
if self.output_options.thumbnail_name:
self._post_process_thumbnail(entry=entry)
if self.metadata_options.nfo:
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,25 +0,0 @@
from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator
from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator
from ytdl_subscribe.validators.base.string_select_validator import StringSelectValidator
from ytdl_subscribe.validators.base.validators import StringValidator
class Id3VersionValidator(StringSelectValidator):
_select_values = {"2.3", "2.4"}
class Id3Validator(StrictDictValidator):
_required_keys = {"id3_version", "tags"}
_optional_keys = {"multi_value_separator"}
def __init__(self, name, value):
super().__init__(name, value)
self.id3_version = self._validate_key(
key="id3_version", validator=Id3VersionValidator
).value
self.tags = self._validate_key(key="tags", validator=DictFormatterValidator)
self.multi_value_separator = self._validate_key_if_present(
key="multi_value_separator", validator=StringValidator
)

View file

@ -1,9 +1,7 @@
from ytdl_subscribe.plugins.music_tags import MusicTagsValidator
from ytdl_subscribe.plugins.nfo_tags import NfoTagsValidator
from ytdl_subscribe.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsValidator
from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator
from ytdl_subscribe.validators.config.metadata_options.id3_validator import Id3Validator
from ytdl_subscribe.validators.config.metadata_options.nfo_validator import NFOValidator
from ytdl_subscribe.validators.config.metadata_options.nfo_validator import (
OutputDirectoryNFOValidator,
)
class MetadataOptionsValidator(StrictDictValidator): class MetadataOptionsValidator(StrictDictValidator):
@ -12,9 +10,9 @@ class MetadataOptionsValidator(StrictDictValidator):
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=MusicTagsValidator)
self.nfo = self._validate_key_if_present(key="nfo", validator=NFOValidator) self.nfo = self._validate_key_if_present(key="nfo", validator=NfoTagsValidator)
self.output_directory_nfo = self._validate_key_if_present( self.output_directory_nfo = self._validate_key_if_present(
key="output_directory_nfo", validator=OutputDirectoryNFOValidator key="output_directory_nfo", validator=OutputDirectoryNfoTagsValidator
) )

View file

@ -1,29 +0,0 @@
from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator
from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator
from ytdl_subscribe.validators.base.string_formatter_validators import (
OverridesStringFormatterValidator,
)
from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator
class NFOValidator(StrictDictValidator):
_required_keys = {"nfo_name", "nfo_root", "tags"}
def __init__(self, name, value):
super().__init__(name, value)
self.nfo_name = self._validate_key(key="nfo_name", validator=StringFormatterValidator)
self.nfo_root = self._validate_key(key="nfo_root", validator=StringFormatterValidator)
self.tags = self._validate_key(key="tags", validator=DictFormatterValidator)
class OutputDirectoryNFOValidator(NFOValidator):
def __init__(self, name, value):
super().__init__(name, value)
self.nfo_name = self._validate_key(
key="nfo_name", validator=OverridesStringFormatterValidator
)
self.nfo_root = self._validate_key(
key="nfo_root", validator=OverridesStringFormatterValidator
)
self.tags = self._validate_key(key="tags", validator=DictFormatterValidator)

View file

@ -3,22 +3,14 @@ from ytdl_subscribe.validators.base.string_formatter_validators import (
OverridesStringFormatterValidator, OverridesStringFormatterValidator,
) )
from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator
from ytdl_subscribe.validators.base.string_select_validator import StringSelectValidator
from ytdl_subscribe.validators.base.validators import BoolValidator from ytdl_subscribe.validators.base.validators import BoolValidator
class ConvertThumbnailValidator(StringSelectValidator):
"""Valid image types that thumbnails can be converted to"""
_select_values = {"jpeg"}
class OutputOptionsValidator(StrictDictValidator): class OutputOptionsValidator(StrictDictValidator):
"""Where to output the final files and thumbnails""" """Where to output the final files and thumbnails"""
_required_keys = {"output_directory", "file_name"} _required_keys = {"output_directory", "file_name"}
_optional_keys = { _optional_keys = {
"convert_thumbnail",
"thumbnail_name", "thumbnail_name",
"maintain_download_archive", "maintain_download_archive",
"maintain_stale_file_deletion", "maintain_stale_file_deletion",
@ -37,9 +29,6 @@ class OutputOptionsValidator(StrictDictValidator):
self.file_name: StringFormatterValidator = self._validate_key( self.file_name: StringFormatterValidator = self._validate_key(
key="file_name", validator=StringFormatterValidator key="file_name", validator=StringFormatterValidator
) )
self.convert_thumbnail = self._validate_key_if_present(
key="convert_thumbnail", validator=ConvertThumbnailValidator
)
self.thumbnail_name = self._validate_key_if_present( self.thumbnail_name = self._validate_key_if_present(
key="thumbnail_name", validator=StringFormatterValidator key="thumbnail_name", validator=StringFormatterValidator
) )

View file

@ -1,5 +1,8 @@
from typing import Optional
from yt_dlp.utils import sanitize_filename from yt_dlp.utils import sanitize_filename
from ytdl_subscribe.entries.entry import Entry
from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator
from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator
@ -19,3 +22,15 @@ class OverridesValidator(DictFormatterValidator):
name="__should_never_fail__", name="__should_never_fail__",
value=self._value[sanitized_key_name], value=self._value[sanitized_key_name],
) )
def apply_formatter(
self, formatter: StringFormatterValidator, entry: Optional[Entry] = None
) -> str:
"""
Returns the format_string after .format has been called on it using entry (if provided) and
override values
"""
variable_dict = self.dict_with_format_strings
if entry:
variable_dict = dict(entry.to_dict(), **variable_dict)
return formatter.apply_formatter(variable_dict)