BROKEN STATE - in middle of refactoring for plugin support
This commit is contained in:
parent
279de00c2b
commit
7a3144a838
17 changed files with 408 additions and 270 deletions
81
config.yaml
81
config.yaml
|
|
@ -12,20 +12,19 @@ presets:
|
|||
output_options:
|
||||
output_directory: "/tmp/{sanitized_artist}"
|
||||
file_name: "[{album_year}] {sanitized_album}/{track_number_padded} - {sanitized_title}.{ext}"
|
||||
convert_thumbnail: "jpeg"
|
||||
thumbnail_name: "[{album_year}] {sanitized_album}/folder.jpeg"
|
||||
thumbnail_name: "[{album_year}] {sanitized_album}/folder.jpg"
|
||||
maintain_download_archive: True
|
||||
metadata_options:
|
||||
id3:
|
||||
id3_version: "2.4"
|
||||
tags:
|
||||
artist: "{artist}"
|
||||
albumartist: "{artist}"
|
||||
title: "{title}"
|
||||
album: "{album}"
|
||||
tracknumber: "{track_number}"
|
||||
year: "{album_year}"
|
||||
genre: "{genre}"
|
||||
convert_thumbnail:
|
||||
to: "jpg"
|
||||
music_tags:
|
||||
tags:
|
||||
artist: "{artist}"
|
||||
albumartist: "{artist}"
|
||||
title: "{title}"
|
||||
album: "{album}"
|
||||
tracknumber: "{track_number}"
|
||||
year: "{album_year}"
|
||||
genre: "{genre}"
|
||||
|
||||
music_videos:
|
||||
youtube:
|
||||
|
|
@ -35,16 +34,17 @@ presets:
|
|||
output_options:
|
||||
file_name: "{sanitized_artist} - {sanitized_title}.{ext}"
|
||||
convert_thumbnail: "jpeg"
|
||||
thumbnail_name: "{sanitized_artist} - {sanitized_title}.jpeg"
|
||||
metadata_options:
|
||||
nfo:
|
||||
nfo_name: "{sanitized_artist} - {sanitized_title}.nfo"
|
||||
nfo_root: "musicvideo"
|
||||
tags:
|
||||
artist: "{artist}"
|
||||
title: "{title}"
|
||||
album: "Music Videos"
|
||||
year: "{upload_year}"
|
||||
thumbnail_name: "{sanitized_artist} - {sanitized_title}.jpg"
|
||||
convert_thumbnail:
|
||||
to: "jpg"
|
||||
nfo_tags:
|
||||
nfo_name: "{sanitized_artist} - {sanitized_title}.nfo"
|
||||
nfo_root: "musicvideo"
|
||||
tags:
|
||||
artist: "{artist}"
|
||||
title: "{title}"
|
||||
album: "Music Videos"
|
||||
year: "{upload_year}"
|
||||
|
||||
yt_channel:
|
||||
youtube:
|
||||
|
|
@ -54,24 +54,25 @@ presets:
|
|||
output_options:
|
||||
file_name: "{output_file_name}.{ext}"
|
||||
convert_thumbnail: "jpeg"
|
||||
thumbnail_name: "{output_file_name}.jpeg"
|
||||
thumbnail_name: "{output_file_name}.jpg"
|
||||
maintain_download_archive: True
|
||||
maintain_stale_file_deletion: True
|
||||
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_date_standardized}"
|
||||
output_directory_nfo:
|
||||
nfo_name: "tvshow.nfo"
|
||||
nfo_root: "tvshow"
|
||||
tags:
|
||||
title: "{tv_show_name}"
|
||||
convert_thumbnail:
|
||||
to: "jpg"
|
||||
nfo_tags:
|
||||
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_date_standardized}"
|
||||
output_directory_nfo_tags:
|
||||
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}"
|
||||
21
docs/conf.py
21
docs/conf.py
|
|
@ -12,14 +12,15 @@
|
|||
#
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath('../'))
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../"))
|
||||
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'ytdl-subscribe'
|
||||
copyright = '2022, Jesse Bannon'
|
||||
author = 'Jesse Bannon'
|
||||
project = "ytdl-subscribe"
|
||||
copyright = "2022, Jesse Bannon"
|
||||
author = "Jesse Bannon"
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
|
@ -28,17 +29,17 @@ author = 'Jesse Bannon'
|
|||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.napoleon',
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon",
|
||||
]
|
||||
|
||||
# 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
|
||||
# directories to ignore when looking for source files.
|
||||
# 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 -------------------------------------------------
|
||||
|
|
@ -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
|
||||
# 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,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# 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
|
||||
add_module_names = False
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ class SoundcloudDownloader(Downloader):
|
|||
artist_albums_url = f"{self.artist_url(artist_name)}/albums"
|
||||
|
||||
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]
|
||||
|
||||
|
|
@ -42,4 +44,7 @@ class SoundcloudDownloader(Downloader):
|
|||
artist_tracks_url = f"{self.artist_url(artist_name)}/tracks"
|
||||
|
||||
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"]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
|
@ -23,10 +24,18 @@ class BaseEntry(ABC):
|
|||
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
|
||||
|
||||
Parameters
|
||||
----------
|
||||
working_directory
|
||||
Optional. Directory that the entry is downloaded to
|
||||
kwargs
|
||||
Entry metadata
|
||||
"""
|
||||
self._working_directory = working_directory
|
||||
self._kwargs = kwargs
|
||||
|
||||
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.")
|
||||
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
|
||||
def uid(self) -> str:
|
||||
"""Returns the entry's unique id"""
|
||||
|
|
@ -133,6 +150,11 @@ class Entry(BaseEntry):
|
|||
"""Returns the entry's file name when downloaded locally"""
|
||||
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
|
||||
def thumbnail_ext(self) -> str:
|
||||
"""
|
||||
|
|
@ -145,6 +167,11 @@ class Entry(BaseEntry):
|
|||
"""Returns the thumbnail's file name when downloaded locally"""
|
||||
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:
|
||||
"""Returns the entry's values as a dictionary"""
|
||||
return dict(
|
||||
|
|
|
|||
|
|
@ -67,11 +67,18 @@ class SoundcloudAlbumTrack(SoundcloudTrack):
|
|||
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.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
super().__init__(working_directory=working_directory, **kwargs)
|
||||
self._album = album
|
||||
self._album_year = album_year
|
||||
self._playlist_metadata = playlist_metadata
|
||||
|
|
@ -104,6 +111,7 @@ class SoundcloudAlbumTrack(SoundcloudTrack):
|
|||
playlist_metadata: PlaylistMetadata,
|
||||
) -> "SoundcloudAlbumTrack":
|
||||
return SoundcloudAlbumTrack(
|
||||
working_directory=soundcloud_track.working_directory,
|
||||
album=album,
|
||||
album_year=album_year,
|
||||
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
|
||||
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]:
|
||||
"""
|
||||
|
|
|
|||
0
ytdl_subscribe/plugins/__init__.py
Normal file
0
ytdl_subscribe/plugins/__init__.py
Normal file
41
ytdl_subscribe/plugins/convert_thumbnail.py
Normal file
41
ytdl_subscribe/plugins/convert_thumbnail.py
Normal 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)
|
||||
32
ytdl_subscribe/plugins/music_tags.py
Normal file
32
ytdl_subscribe/plugins/music_tags.py
Normal 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()
|
||||
58
ytdl_subscribe/plugins/nfo_tags.py
Normal file
58
ytdl_subscribe/plugins/nfo_tags.py
Normal 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)
|
||||
59
ytdl_subscribe/plugins/output_directory_nfo_tags.py
Normal file
59
ytdl_subscribe/plugins/output_directory_nfo_tags.py
Normal 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)
|
||||
73
ytdl_subscribe/plugins/plugin.py
Normal file
73
ytdl_subscribe/plugins/plugin.py
Normal 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
|
||||
|
|
@ -11,20 +11,11 @@ from typing import Optional
|
|||
from typing import Type
|
||||
from typing import TypeVar
|
||||
|
||||
import dicttoxml
|
||||
import music_tag
|
||||
from PIL import Image
|
||||
|
||||
from ytdl_subscribe.downloaders.downloader import Downloader
|
||||
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 (
|
||||
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 (
|
||||
OutputOptionsValidator,
|
||||
)
|
||||
|
|
@ -94,11 +85,6 @@ class Subscription(Generic[SourceT, EntryT], ABC):
|
|||
"""Returns the output options defined for this subscription"""
|
||||
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
|
||||
def overrides(self) -> OverridesValidator:
|
||||
"""Returns the overrides defined for this subscription"""
|
||||
|
|
@ -114,19 +100,7 @@ class Subscription(Generic[SourceT, EntryT], ABC):
|
|||
"""
|
||||
:return: The formatted output directory
|
||||
"""
|
||||
return self._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
|
||||
)
|
||||
return self.overrides.apply_formatter(formatter=self.output_options.output_directory)
|
||||
|
||||
def get_downloader(
|
||||
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,
|
||||
)
|
||||
|
||||
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.overrides.dict_with_format_strings
|
||||
if entry:
|
||||
variable_dict = dict(entry.to_dict(), **variable_dict)
|
||||
return formatter.apply_formatter(variable_dict)
|
||||
def _copy_file_to_output_directory(self, source_file_path: str, output_file_name: str):
|
||||
destination_file_path = Path(self.output_directory) / Path(output_file_name)
|
||||
os.makedirs(os.path.dirname(destination_file_path), exist_ok=True)
|
||||
copyfile(source_file_path, destination_file_path)
|
||||
|
||||
def _post_process_tagging(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):
|
||||
def _copy_entry_files_to_output_directory(self, entry: Entry):
|
||||
# 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._apply_formatter(
|
||||
output_file_name = self.overrides.apply_formatter(
|
||||
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)
|
||||
copyfile(entry_source_file_path, entry_destination_file_path)
|
||||
if self.output_options.thumbnail_name:
|
||||
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)
|
||||
|
||||
|
|
@ -305,16 +196,7 @@ class Subscription(Generic[SourceT, EntryT], ABC):
|
|||
|
||||
:param entry: The entry to post-process
|
||||
"""
|
||||
if self.metadata_options.id3:
|
||||
self._post_process_tagging(entry)
|
||||
# before move
|
||||
self._copy_entry_files_to_output_directory(entry=entry)
|
||||
|
||||
self._copy_entry_file_to_output_directory(entry=entry)
|
||||
|
||||
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)
|
||||
# after move
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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.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):
|
||||
|
|
@ -12,9 +10,9 @@ class MetadataOptionsValidator(StrictDictValidator):
|
|||
def __init__(self, name, value):
|
||||
super().__init__(name, value)
|
||||
|
||||
self.id3 = self._validate_key_if_present(key="id3", validator=Id3Validator)
|
||||
self.nfo = self._validate_key_if_present(key="nfo", validator=NFOValidator)
|
||||
self.id3 = self._validate_key_if_present(key="id3", validator=MusicTagsValidator)
|
||||
self.nfo = self._validate_key_if_present(key="nfo", validator=NfoTagsValidator)
|
||||
|
||||
self.output_directory_nfo = self._validate_key_if_present(
|
||||
key="output_directory_nfo", validator=OutputDirectoryNFOValidator
|
||||
key="output_directory_nfo", validator=OutputDirectoryNfoTagsValidator
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -3,22 +3,14 @@ from ytdl_subscribe.validators.base.string_formatter_validators import (
|
|||
OverridesStringFormatterValidator,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class ConvertThumbnailValidator(StringSelectValidator):
|
||||
"""Valid image types that thumbnails can be converted to"""
|
||||
|
||||
_select_values = {"jpeg"}
|
||||
|
||||
|
||||
class OutputOptionsValidator(StrictDictValidator):
|
||||
"""Where to output the final files and thumbnails"""
|
||||
|
||||
_required_keys = {"output_directory", "file_name"}
|
||||
_optional_keys = {
|
||||
"convert_thumbnail",
|
||||
"thumbnail_name",
|
||||
"maintain_download_archive",
|
||||
"maintain_stale_file_deletion",
|
||||
|
|
@ -37,9 +29,6 @@ class OutputOptionsValidator(StrictDictValidator):
|
|||
self.file_name: StringFormatterValidator = self._validate_key(
|
||||
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(
|
||||
key="thumbnail_name", validator=StringFormatterValidator
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from typing import Optional
|
||||
|
||||
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 StringFormatterValidator
|
||||
|
||||
|
|
@ -19,3 +22,15 @@ class OverridesValidator(DictFormatterValidator):
|
|||
name="__should_never_fail__",
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue