[FEATURE] Add split_by_chapters plugin (#184)

This commit is contained in:
Jesse Bannon 2022-08-18 23:24:03 -07:00 committed by GitHub
parent c443d2e295
commit 790c65859a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1281 additions and 62 deletions

View file

@ -219,6 +219,14 @@ regex
-------------------------------------------------------------------------------
split_by_chapters
'''''''''''''''''
.. autoclass:: ytdl_sub.plugins.split_by_chapters.SplitByChaptersOptions()
:members: when_no_chapters
:member-order: bysource
-------------------------------------------------------------------------------
subtitles
'''''''''
.. autoclass:: ytdl_sub.plugins.subtitles.SubtitleOptions()

View file

@ -9,7 +9,7 @@ presets:
output_options:
output_directory: "{music_directory}"
file_name: "{title_sanitized}.{ext}"
file_name: "{custom_track_name_sanitized}.{ext}"
audio_extract:
codec: "mp3"
@ -17,22 +17,42 @@ presets:
music_tags:
tags:
artist: "{artist}"
albumartist: "{artist}"
title: "{title}"
album: "Singles"
track: "1"
artist: "{custom_artist_name}"
albumartist: "{custom_artist_name}"
title: "{custom_track_name}"
album: "{custom_album_name}"
track: "{custom_track_number}"
year: "{upload_year}"
genre: "Unset"
overrides:
music_directory: "/path/to/music"
custom_track_name: "{title}"
custom_album_name: "Singles"
custom_artist_name: "{artist}"
custom_track_number: "1"
# TODO: make a playlist of individual songs into an album. Need playlist_title
yt_song_playlist:
preset: yt_song
youtube:
download_strategy: "playlist"
overrides:
custom_track_number: "{playlist_index}"
music_tags:
tags:
track: "{playlist_index}"
yt_album_as_chapters:
preset: "yt_song"
output_options:
file_name: "{custom_album_name_sanitized}/{chapter_index_padded} - {custom_track_name_sanitized}.{ext}"
thumbnail_name: "{custom_album_name_sanitized}/folder.{thumbnail_ext}"
chapters:
embed_chapters: True
split_by_chapters:
when_no_chapters: "pass"
overrides:
custom_track_name: "{chapter_title}"
custom_album_name: "{title}" # Have the video name be the album
custom_track_number: "{chapter_index}"

View file

@ -160,7 +160,8 @@ class Preset(StrictDictValidator):
return downloader, download_options
def __validate_and_get_plugins(self) -> PresetPlugins:
plugins = PresetPlugins()
preset_plugins = PresetPlugins()
source_variables = copy.deepcopy(self._source_variables)
for key in self._keys:
if key not in PluginMapping.plugins():
@ -168,13 +169,22 @@ class Preset(StrictDictValidator):
plugin = PluginMapping.get(plugin=key)
plugin_options = self._validate_key(key=key, validator=plugin.plugin_options_type)
preset_plugins.add(plugin_type=plugin, plugin_options=plugin_options)
for plugin, plugin_options in sorted(
preset_plugins.zipped(),
key=lambda _plugin_and_options: _plugin_and_options[0].priority.modify_entry,
):
# Validate current plugin using source + added plugin variables
plugin_options.validate_with_variables(
source_variables=self._source_variables, override_variables=self.overrides.keys
source_variables=source_variables, override_variables=self.overrides.keys
)
plugins.add(plugin_type=plugin, plugin_options=plugin_options)
# Extend existing source variables with ones created from this plugin
source_variables.extend(plugin_options.added_source_variables())
return plugins
return preset_plugins
def __validate_override_string_formatter_validator(
self,

View file

@ -16,6 +16,7 @@ from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.regex import RegexPlugin
from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.video_tags import VideoTagsPlugin
@ -117,6 +118,7 @@ class PluginMapping:
"regex": RegexPlugin,
"subtitles": SubtitlesPlugin,
"chapters": ChaptersPlugin,
"split_by_chapters": SplitByChaptersPlugin,
}
@classmethod

View file

@ -9,6 +9,7 @@ from ytdl_sub.downloaders.youtube.abc import YoutubeDownloader
from ytdl_sub.downloaders.youtube.video import YoutubeVideoDownloaderOptions
from ytdl_sub.entries.youtube import YoutubePlaylistVideo
from ytdl_sub.entries.youtube import YoutubeVideo
from ytdl_sub.plugins.split_by_chapters import _split_video_ffmpeg_cmd
from ytdl_sub.utils.chapters import Chapters
from ytdl_sub.utils.chapters import Timestamp
from ytdl_sub.utils.ffmpeg import FFMPEG
@ -31,19 +32,6 @@ def _split_video_uid(source_uid: str, idx: int) -> str:
return f"{source_uid}___{idx}"
def _split_video_ffmpeg_cmd(
input_file: str, output_file: str, timestamps: List[Timestamp], idx: int
) -> List[str]:
timestamp_begin = timestamps[idx].standardized_str
timestamp_end = timestamps[idx + 1].standardized_str if idx + 1 < len(timestamps) else ""
cmd = ["-i", input_file, "-ss", timestamp_begin]
if timestamp_end:
cmd += ["-to", timestamp_end]
cmd += ["-vcodec", "copy", "-acodec", "copy", output_file]
return cmd
class YoutubeSplitVideoDownloaderOptions(YoutubeVideoDownloaderOptions):
r"""
Downloads a single youtube video, then splits in to separate videos using a file containing
@ -158,7 +146,9 @@ class YoutubeSplitVideoDownloader(
"""Download a single Youtube video, then split it into multiple videos"""
split_videos_and_metadata: List[Tuple[YoutubePlaylistVideo, FileMetadata]] = []
chapters = Chapters.from_file(chapters_file_path=self.download_options.split_timestamps)
chapters = Chapters.from_timestamps_file(
chapters_file_path=self.download_options.split_timestamps
)
entry_dict = self.extract_info(url=self.download_options.video_url)
entry = YoutubeVideo(entry_dict=entry_dict, working_directory=self.working_directory)

View file

@ -103,7 +103,9 @@ class YoutubeVideoDownloader(YoutubeDownloader[YoutubeVideoDownloaderOptions, Yo
return [video]
# Otherwise, add the chapters and return the video + chapter metadata
chapters = Chapters.from_file(chapters_file_path=self.download_options.chapter_timestamps)
chapters = Chapters.from_timestamps_file(
chapters_file_path=self.download_options.chapter_timestamps
)
if not self.is_dry_run:
set_ffmpeg_metadata_chapters(
file_path=video.get_download_file_path(),

View file

@ -30,16 +30,20 @@ SPONSORBLOCK_CATEGORIES: Set[str] = SPONSORBLOCK_HIGHLIGHT_CATEGORIES | {
def _chapters(entry: Entry) -> List[Dict]:
if entry.kwargs_contains("chapters"):
return entry.kwargs("chapters")
return entry.kwargs("chapters") or []
return []
def _sponsorblock_chapters(entry: Entry) -> List[Dict]:
if entry.kwargs_contains("sponsorblock_chapters"):
return entry.kwargs("sponsorblock_chapters")
return entry.kwargs("sponsorblock_chapters") or []
return []
def _contains_any_chapters(entry: Entry) -> bool:
return len(_chapters(entry)) > 0 or len(_sponsorblock_chapters(entry)) > 0
class SponsorBlockCategoriesValidator(StringSelectValidator):
_expected_value_type_name = "sponsorblock category"
_select_values = {"all"} | SPONSORBLOCK_CATEGORIES
@ -274,11 +278,16 @@ class ChaptersPlugin(Plugin[ChaptersOptions]):
removed_chapters = self._get_removed_chapters(entry)
removed_sponsorblock = self._get_removed_sponsorblock_category_counts(entry)
# If no chapters are on the entry, do not report any embedded chapters
if not _contains_any_chapters(entry):
return None
if removed_chapters:
metadata_dict["Removed Chapter(s)"] = ", ".join(removed_chapters)
if removed_sponsorblock:
metadata_dict["Removed SponsorBlock Category Count(s)"] = removed_sponsorblock
# TODO: check if file actually has embedded chapters
return FileMetadata.from_dict(
value_dict=metadata_dict, title="Embedded Chapters", sort_dict=False
)

View file

@ -3,6 +3,7 @@ from typing import Dict
from typing import Generic
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type
from typing import TypeVar
from typing import final
@ -16,6 +17,28 @@ from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
class PluginPriority:
"""
Defines priority for plugins, 0 is highest priority
"""
# If modify_entry priority is >= to this value, run after split
MODIFY_ENTRY_AFTER_SPLIT = 10
def __init__(self, modify_entry: int = 0, post_process: int = 0):
self.modify_entry = modify_entry
self.post_process = post_process
@property
def modify_entry_after_split(self) -> bool:
"""
Returns
-------
True if the plugin should modify an entry after a potential split. False otherwise.
"""
return self.modify_entry >= PluginPriority.MODIFY_ENTRY_AFTER_SPLIT
class PluginOptions(StrictDictValidator):
"""
Class that defines the parameters to a plugin
@ -60,6 +83,10 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
"""
plugin_options_type: Type[PluginOptionsT] = NotImplemented
priority: PluginPriority = PluginPriority()
# If the plugin creates multile entries from a single entry
is_split_plugin: bool = False
@final
def __init__(
@ -74,6 +101,7 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
# TODO pass yaml snake case name in the class somewhere, and use it for the logger
self._logger = Logger.get(self.__class__.__name__)
# pylint: disable=no-self-use,unused-argument
def ytdl_options(self) -> Optional[Dict]:
"""
Returns
@ -81,7 +109,22 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
ytdl options to enable/disable when downloading entries for this specific plugin
"""
# pylint: disable=no-self-use
def split(self, entry: Entry) -> List[Tuple[Entry, FileMetadata]]:
"""
Very specialized function that takes an entry and creates multiple entries from it.
Should mark ``is_split_plugin`` on the plugin class.
Parameters
----------
entry
Entry to create multiple entries from
Returns
-------
List of entries and metadata created from the source entry
"""
return []
def modify_entry(self, entry: Entry) -> Optional[Entry]:
"""
For each entry downloaded, modify the entry in some way before sending it to
@ -98,8 +141,6 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
"""
return entry
# pylint: enable=no-self-use
def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]:
"""
For each entry downloaded, apply post processing to it.
@ -113,6 +154,9 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
-------
Optional file metadata for the entry media file.
"""
return None
# pylint: enable=no-self-use,unused-argument
def post_process_subscription(self):
"""

View file

@ -7,6 +7,7 @@ from yt_dlp.utils import sanitize_filename
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.plugins.plugin import PluginPriority
from ytdl_sub.utils.exceptions import RegexNoMatchException
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.regex_validator import RegexListValidator
@ -224,6 +225,9 @@ class RegexOptions(PluginOptions):
class RegexPlugin(Plugin[RegexOptions]):
plugin_options_type = RegexOptions
priority = PluginPriority(
modify_entry=PluginPriority.MODIFY_ENTRY_AFTER_SPLIT + 0,
)
def modify_entry(self, entry: Entry) -> Optional[Entry]:
"""

View file

@ -0,0 +1,217 @@
import copy
import os.path
from pathlib import Path
from typing import List
from typing import Optional
from typing import Tuple
from yt_dlp.utils import sanitize_filename
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.chapters import Chapters
from ytdl_sub.utils.chapters import Timestamp
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
from ytdl_sub.validators.string_select_validator import StringSelectValidator
def _split_video_ffmpeg_cmd(
input_file: str, output_file: str, timestamps: List[Timestamp], idx: int
) -> List[str]:
timestamp_begin = timestamps[idx].standardized_str
timestamp_end = timestamps[idx + 1].standardized_str if idx + 1 < len(timestamps) else ""
cmd = ["-i", input_file, "-ss", timestamp_begin]
if timestamp_end:
cmd += ["-to", timestamp_end]
cmd += ["-vcodec", "copy", "-acodec", "copy", output_file]
return cmd
def _split_video_uid(source_uid: str, idx: int) -> str:
return f"{source_uid}___{idx}"
class WhenNoChaptersValidator(StringSelectValidator):
_expected_value_type_name = "when no chapters option"
_select_values = {"pass", "drop", "error"}
class SplitByChaptersOptions(PluginOptions):
"""
Splits a file by chapters into multiple files. Each file becomes its own entry with the
new source variables ``chapter_title``, ``chapter_title_sanitized``, ``chapter_index``,
``chapter_index_padded``, ``chapter_count``.
If a file has no chapters, and ``when_no_chapters`` is set to "pass", then ``chapter_title`` is
set to the entry's title and ``chapter_index``, ``chapter_count`` are both set to 1.
Note that when using this plugin and performing dry-run, it assumes embedded chapters are being
used with no modifications.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
split_by_chapters:
when_no_chapters: "pass"
"""
_required_keys = {"when_no_chapters"}
def __init__(self, name, value):
super().__init__(name, value)
self._when_no_chapters = self._validate_key(
key="when_no_chapters", validator=WhenNoChaptersValidator
).value
def added_source_variables(self) -> List[str]:
return [
"chapter_title",
"chapter_title_sanitized",
"chapter_index",
"chapter_index_padded",
"chapter_count",
]
@property
def when_no_chapters(self) -> str:
"""
Behavior to perform when no chapters are present. Supports "pass" (continue processing),
"drop" (exclude it from output), and "error" (stop processing for everything).
"""
return self._when_no_chapters
class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
plugin_options_type = SplitByChaptersOptions
is_split_plugin = True
def _create_split_entry(
self, source_entry: Entry, title: str, idx: int, chapters: Chapters
) -> Tuple[Entry, FileMetadata]:
"""
Runs ffmpeg to create the split video
"""
entry = copy.deepcopy(source_entry)
entry.add_variables(
{
"chapter_title": title,
"chapter_title_sanitized": sanitize_filename(title),
"chapter_index": idx + 1,
"chapter_index_padded": f"{(idx + 1):02d}",
"chapter_count": len(chapters.timestamps),
}
)
# pylint: disable=protected-access
entry._kwargs["id"] = _split_video_uid(source_uid=entry.uid, idx=idx)
if "chapters" in entry._kwargs:
del entry._kwargs["chapters"]
if "sponsorblock_chapters" in entry._kwargs:
del entry._kwargs["sponsorblock_chapters"]
# pylint: enable=protected-access
timestamp_begin = chapters.timestamps[idx].readable_str
timestamp_end = Timestamp(entry.kwargs("duration")).readable_str
if idx + 1 < len(chapters.timestamps):
timestamp_end = chapters.timestamps[idx + 1].readable_str
metadata_value_dict = {}
if self.is_dry_run:
metadata_value_dict[
"Warning"
] = "Dry-run assumes embedded chapters with no modifications"
metadata_value_dict["Source Title"] = entry.title
metadata_value_dict["Segment"] = f"{timestamp_begin} - {timestamp_end}"
metadata = FileMetadata.from_dict(
value_dict=metadata_value_dict,
title="From Chapter Split:",
sort_dict=False,
)
return entry, metadata
def split(self, entry: Entry) -> Optional[List[Tuple[Entry, FileMetadata]]]:
"""
Tags the entry's audio file using values defined in the metadata options
"""
split_videos_and_metadata: List[Tuple[Entry, FileMetadata]] = []
if self.is_dry_run:
chapters = Chapters.from_entry_chapters(entry=entry)
else:
chapters = Chapters.from_embedded_chapters(file_path=entry.get_download_file_path())
# If no chapters, do not split anything
if not chapters.contains_any_chapters():
if self.plugin_options.when_no_chapters == "pass":
entry.add_variables(
{
"chapter_title": entry.title,
"chapter_index": 1,
"chapter_index_padded": "01",
"chapter_count": 1,
}
)
return [(entry, FileMetadata())]
if self.plugin_options.when_no_chapters == "drop":
return []
raise ValidationException(
f"Tried to split '{entry.title}' by chapters but it has no chapters"
)
# convert the entry thumbnail early so we do not have to guess the thumbnail extension
# when copying it. Do not error if it's not found, in case thumbnail_name is not set
if not self.is_dry_run:
convert_download_thumbnail(entry=entry, error_if_not_found=False)
for idx, title in enumerate(chapters.titles):
new_uid = _split_video_uid(source_uid=entry.uid, idx=idx)
if not self.is_dry_run:
# Get the input/output file paths
input_file = entry.get_download_file_path()
output_file = str(Path(self.working_directory) / f"{new_uid}.{entry.ext}")
# Run ffmpeg to create the split the video
FFMPEG.run(
_split_video_ffmpeg_cmd(
input_file=input_file,
output_file=output_file,
timestamps=chapters.timestamps,
idx=idx,
)
)
# Copy the original vid thumbnail to the working directory with the new uid. This so
# downstream logic thinks this split video has its own thumbnail
if os.path.isfile(entry.get_download_thumbnail_path()):
FileHandler.copy(
src_file_path=entry.get_download_thumbnail_path(),
dst_file_path=Path(self.working_directory)
/ f"{new_uid}.{entry.thumbnail_ext}",
)
# Format the split video as a YoutubePlaylistVideo
split_videos_and_metadata.append(
self._create_split_entry(
source_entry=entry,
title=title,
idx=idx,
chapters=chapters,
)
)
return split_videos_and_metadata

View file

@ -19,12 +19,23 @@ from ytdl_sub.downloaders.downloader import DownloaderValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
def _get_split_plugin(plugins: List[Plugin]) -> Optional[Plugin]:
split_plugins = [plugin for plugin in plugins if plugin.is_split_plugin]
if len(split_plugins) == 1:
return split_plugins[0]
if len(split_plugins) > 1:
raise ValidationException("Can not use more than one split plugins at a time")
return None
class Subscription:
"""
Subscription classes are the 'controllers' that perform...
@ -251,6 +262,74 @@ class Subscription:
return plugins
def _post_process_entry(
self, plugins: List[Plugin], dry_run: bool, entry: Entry, entry_metadata: FileMetadata
):
# Post-process the entry with all plugins
for plugin in sorted(plugins, key=lambda _plugin: _plugin.priority.post_process):
optional_plugin_entry_metadata = plugin.post_process_entry(entry)
if optional_plugin_entry_metadata:
entry_metadata.extend(optional_plugin_entry_metadata)
# Then, move it to the output directory
self._move_entry_files_to_output_directory(
dry_run=dry_run, entry=entry, entry_metadata=entry_metadata
)
# Re-save the download archive after each entry is moved to the output directory
if self.maintain_download_archive:
self._enhanced_download_archive.save_download_mappings()
def _process_entry(
self, plugins: List[Plugin], dry_run: bool, entry: Entry, entry_metadata: FileMetadata
) -> None:
# First, modify the entry with all plugins
for plugin in sorted(plugins, key=lambda _plugin: _plugin.priority.modify_entry):
# Return if it is None, it is indicated to not process any further
if (entry := plugin.modify_entry(entry)) is None:
return
self._post_process_entry(
plugins=plugins, dry_run=dry_run, entry=entry, entry_metadata=entry_metadata
)
def _process_split_entry(
self, split_plugin: Plugin, plugins: List[Plugin], dry_run: bool, entry: Entry
) -> None:
plugins_pre_split = sorted(
[plugin for plugin in plugins if not plugin.priority.modify_entry_after_split],
key=lambda _plugin: _plugin.priority.modify_entry,
)
plugins_post_split = sorted(
[plugin for plugin in plugins if plugin.priority.modify_entry_after_split],
key=lambda _plugin: _plugin.priority.modify_entry,
)
# First, modify the entry with pre_split plugins
for plugin in plugins_pre_split:
# Return if it is None, it is indicated to not process any further
if (entry := plugin.modify_entry(entry)) is None:
return
# Then, perform the split
for split_entry, split_entry_metadata in split_plugin.split(entry=entry):
for plugin in plugins_post_split:
# Return if it is None, it is indicated to not process any further.
# Break out of the plugin loop
if (split_entry := plugin.modify_entry(split_entry)) is None:
break
# If split_entry is None from modify_entry, do not post process
if split_entry:
self._process_entry(
plugins=plugins,
dry_run=dry_run,
entry=split_entry,
entry_metadata=split_entry_metadata,
)
def download(self, dry_run: bool = False) -> FileHandlerTransactionLog:
"""
Performs the subscription download
@ -284,29 +363,14 @@ class Subscription:
if isinstance(entry, tuple):
entry, entry_metadata = entry
# First, modify the entry with all plugins
for plugin in plugins:
# Break out of this plugin loop if entry is None, it is indicated to not DL it
if (entry := plugin.modify_entry(entry)) is None:
break
# If entry is None from the broken out loop, continue over the other entries
if entry is None:
continue
# Then, post-process the entry with all plugins
for plugin in plugins:
optional_plugin_entry_metadata = plugin.post_process_entry(entry)
if optional_plugin_entry_metadata:
entry_metadata.extend(optional_plugin_entry_metadata)
self._move_entry_files_to_output_directory(
dry_run=dry_run, entry=entry, entry_metadata=entry_metadata
)
# Re-save the download archive after each entry is moved to the output directory
if self.maintain_download_archive:
self._enhanced_download_archive.save_download_mappings()
if split_plugin := _get_split_plugin(plugins):
self._process_split_entry(
split_plugin=split_plugin, plugins=plugins, dry_run=dry_run, entry=entry
)
else:
self._process_entry(
plugins=plugins, dry_run=dry_run, entry=entry, entry_metadata=entry_metadata
)
downloader.post_download(overrides=self.overrides)
for plugin in plugins:

View file

@ -1,9 +1,12 @@
import json
import os
import re
import subprocess
from typing import List
from typing import Optional
from typing import Tuple
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileMetadata
@ -139,6 +142,14 @@ class Chapters:
if timestamps[idx].timestamp_sec >= timestamps[idx + 1].timestamp_sec:
raise ValueError("Timestamps must be in ascending order")
def contains_any_chapters(self) -> bool:
"""
Returns
-------
True if there are chapters. False otherwise.
"""
return len(self.timestamps) > 0
def contains_zero_timestamp(self) -> bool:
"""
Returns
@ -165,7 +176,7 @@ class Chapters:
)
@classmethod
def from_file(cls, chapters_file_path: str) -> "Chapters":
def from_timestamps_file(cls, chapters_file_path: str) -> "Chapters":
"""
Parameters
----------
@ -207,3 +218,65 @@ class Chapters:
titles.append(title)
return cls(timestamps=timestamps, titles=titles)
@classmethod
def from_embedded_chapters(cls, file_path: str) -> "Chapters":
"""
Parameters
----------
file_path
File to read ffmpeg chapter metadata from
Returns
-------
Chapters object
"""
proc = subprocess.run(
[
"ffprobe",
"-loglevel",
"quiet",
"-print_format",
"json",
"-show_chapters",
"--",
file_path,
],
check=True,
stdout=subprocess.PIPE,
encoding="utf-8",
)
embedded_chapters = json.loads(proc.stdout)
timestamps: List[Timestamp] = []
titles: List[str] = []
for chapter in embedded_chapters["chapters"]:
timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"]))))
titles.append(chapter["tags"]["title"])
return Chapters(timestamps=timestamps, titles=titles)
@classmethod
def from_entry_chapters(cls, entry: Entry) -> "Chapters":
"""
Parameters
----------
entry
Entry with yt-dlp chapter metadata
Returns
-------
Chapters object
"""
timestamps: List[Timestamp] = []
titles: List[str] = []
chapters = {}
if entry.kwargs_contains("chapters"):
chapters = entry.kwargs("chapters") or []
for chapter in chapters:
timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"]))))
titles.append(chapter["title"])
return Chapters(timestamps=timestamps, titles=titles)

View file

@ -5,7 +5,7 @@ from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.ffmpeg import FFMPEG
def convert_download_thumbnail(entry: Entry):
def convert_download_thumbnail(entry: Entry, error_if_not_found: bool = True) -> None:
"""
Converts an entry's downloaded thumbnail into jpg format
@ -13,6 +13,8 @@ def convert_download_thumbnail(entry: Entry):
----------
entry
Entry with the thumbnail
error_if_not_found
If the thumbnail is not found, error if True.
Raises
------
@ -22,7 +24,9 @@ def convert_download_thumbnail(entry: Entry):
download_thumbnail_path = entry.get_ytdlp_download_thumbnail_path()
download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path()
if not download_thumbnail_path:
raise ValueError("Thumbnail not found")
if error_if_not_found:
raise ValueError("Thumbnail not found")
return
if not download_thumbnail_path == download_thumbnail_path_as_jpg:
FFMPEG.run(["-bitexact", "-i", download_thumbnail_path, download_thumbnail_path_as_jpg])

View file

@ -1,4 +1,3 @@
import mergedeep
import pytest
from e2e.expected_download import assert_expected_downloads
from e2e.expected_transaction_log import assert_transaction_log_matches

View file

@ -0,0 +1,162 @@
import re
import mergedeep
import pytest
from e2e.expected_download import assert_expected_downloads
from e2e.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 yt_album_as_chapters_preset_dict(output_directory):
return {
"preset": "yt_album_as_chapters",
"youtube": {"video_url": "https://www.youtube.com/watch?v=zeR2_YjlXWA"},
# override the output directory with our fixture-generated dir
"output_options": {"output_directory": output_directory},
# download the worst format so it is fast
"ytdl_options": {
"format": "worst[ext=mp4]",
"postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility
},
}
@pytest.fixture
def yt_album_as_chapters_with_regex_preset_dict(yt_album_as_chapters_preset_dict):
mergedeep.merge(
yt_album_as_chapters_preset_dict,
{
"regex": {
"from": {
"chapter_title": {
"match": r"\d+\. (.+)",
"capture_group_names": "captured_chapter_title",
"capture_group_defaults": "{chapter_title}",
},
"title": {
"match": [
"(.+) - (.+) [-[\\(\\{].+",
"(.+) - (.+)",
],
"capture_group_names": [
"captured_artist",
"captured_album",
],
"capture_group_defaults": [
"{artist}",
"{title}",
],
},
}
},
"overrides": {
"custom_track_name": "{captured_chapter_title}",
"custom_album_name": "{captured_album}",
"custom_artist_name": "{captured_artist}",
},
},
)
return yt_album_as_chapters_preset_dict
class TestSplitByChapters:
@pytest.mark.parametrize("dry_run", [True, False])
def test_video_with_chapters(
self,
youtube_audio_config,
yt_album_as_chapters_preset_dict,
output_directory,
dry_run,
):
subscription = Subscription.from_dict(
config=youtube_audio_config,
preset_name="split_by_chapters_video",
preset_dict=yt_album_as_chapters_preset_dict,
)
transaction_log = subscription.download(dry_run=dry_run)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name=f"plugins/split_by_chapters_video{'-dry-run' if dry_run else ''}.txt",
)
assert_expected_downloads(
output_directory=output_directory,
dry_run=dry_run,
expected_download_summary_file_name="plugins/split_by_chapters_video.json",
)
@pytest.mark.parametrize("dry_run", [True, False])
def test_video_with_chapters_and_regex(
self,
youtube_audio_config,
yt_album_as_chapters_with_regex_preset_dict,
output_directory,
dry_run,
):
subscription = Subscription.from_dict(
config=youtube_audio_config,
preset_name="split_by_chapters_with_regex_video",
preset_dict=yt_album_as_chapters_with_regex_preset_dict,
)
transaction_log = subscription.download(dry_run=dry_run)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name=f"plugins/split_by_chapters_with_regex_video{'-dry-run' if dry_run else ''}.txt",
)
assert_expected_downloads(
output_directory=output_directory,
dry_run=dry_run,
expected_download_summary_file_name="plugins/split_by_chapters_with_regex_video.json",
)
@pytest.mark.parametrize("dry_run", [True, False])
@pytest.mark.parametrize("when_no_chapters", ["pass", "drop", "error"])
def test_video_with_no_chapters_and_regex(
self,
youtube_audio_config,
yt_album_as_chapters_with_regex_preset_dict,
output_directory,
dry_run,
when_no_chapters,
):
mergedeep.merge(
yt_album_as_chapters_with_regex_preset_dict,
{
"youtube": {"video_url": "https://youtube.com/watch?v=HKTNxEqsN3Q"},
"split_by_chapters": {"when_no_chapters": when_no_chapters},
},
)
subscription = Subscription.from_dict(
config=youtube_audio_config,
preset_name="split_by_chapters_with_regex_video",
preset_dict=yt_album_as_chapters_with_regex_preset_dict,
)
if when_no_chapters == "error":
with pytest.raises(
ValidationException,
match=re.escape(
"Tried to split 'Oblivion Mod \"Falcor\" p.1' by chapters but it has no chapters"
),
):
_ = subscription.download(dry_run=dry_run)
return
transaction_log = subscription.download(dry_run=dry_run)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name=f"plugins/split_by_chapters_with_regex_no_chapters_video_{when_no_chapters}.txt",
)
assert_expected_downloads(
output_directory=output_directory,
dry_run=dry_run,
expected_download_summary_file_name=f"plugins/split_by_chapters_with_regex_no_chapters_video_{when_no_chapters}.txt",
)

View file

@ -0,0 +1,14 @@
{
"Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "9313382b938547fa9cc02c708068ae42",
"Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "c38c7186eb52e89246ed8c79be8485e7",
"Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "3d873907e61d4af8e20539961913d486",
"Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "846898842d60b7d1d3b455dfa44821bd",
"Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "23e40cccc86e106ea576dc78dc8e6376",
"Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "cf0fe6eea473f64bbeedc3ee82d91e17",
"Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "d9eae099a8b25f603c0491c9184092da",
"Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "e07de2c95fb2561540048948f1478c25",
"Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "218386492a03e912a29dfa1a56fd983c",
"Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "0fb815e3648ad55c62328a3621aadd88",
"Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "860154a2aa2b0b0b31daca0a481ceb87",
"Alfa Mist - Nocturne [Full Album]/folder.jpg": "13be3c1a9ba600f7cab82a042cffbf72"
}

View file

@ -0,0 +1,4 @@
{
"Oblivion Mod \"Falcor\" p.1/01 - Oblivion Mod \"Falcor\" p.1.mp3": "703ffb93964ac025ee66221b98ee4d49",
"Oblivion Mod \"Falcor\" p.1/folder.jpg": "fb95b510681676e81c321171fc23143e"
}

View file

@ -0,0 +1,4 @@
{
"Oblivion Mod \"Falcor\" p.1/01 - Oblivion Mod \"Falcor\" p.1.mp3": "703ffb93964ac025ee66221b98ee4d49",
"Oblivion Mod \"Falcor\" p.1/folder.jpg": "fb95b510681676e81c321171fc23143e"
}

View file

@ -0,0 +1,14 @@
{
"Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3": "75e53ffa551ab9aebe5d25a7d7bc6757",
"Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "76f5e0a03e0984ed959809721ad2ae4a",
"Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3": "bd6495ad5b46ee7e46215e5687ac7e7a",
"Nocturne/04 - What If (Interlude).mp3": "928a7668b9e0d653cba66cb4f9e3474a",
"Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "563d0416c1c9b1eeedb3bd6fdc9f648a",
"Nocturne/06 - Closer (Feat. Lester Duval).mp3": "eb36390ef7064ee0145b4b42e6745a1e",
"Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "9cba05a39738aadf2cd36d7941484a02",
"Nocturne/08 - Dreams (Feat. Carmody).mp3": "6833e149f8ec0c98dbd0c3b3caf9362c",
"Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "2bce7f06baadb6c9cc9742e2a0288c6b",
"Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "2e359012ffa86e231ec6694508da1455",
"Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "dded786c10aade8bb0821507225b9bd2",
"Nocturne/folder.jpg": "13be3c1a9ba600f7cab82a042cffbf72"
}

View file

@ -1,3 +1,3 @@
{
"YouTube Rewind 2019 For the Record #YouTubeRewind.mp3": "3a156b122bd79c956cce5079d3530cc3"
"YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "3a156b122bd79c956cce5079d3530cc3"
}

View file

@ -0,0 +1,146 @@
Files created in '{output_directory}'
----------------------------------------
Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 0:00 - 2:06
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 01. Intro (Feat. Racheal Ofori & Barney Artist)
track: 1
year: 2017
Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 2:06 - 5:40
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 02. Answers (Feat. Rick David & Kaya Thomas - Dyke)
track: 2
year: 2017
Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 5:40 - 8:11
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 03. Blaze (Feat. Kaya Thomas - Dyke)
track: 3
year: 2017
Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 8:11 - 9:35
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 04. What If (Interlude)
track: 4
year: 2017
Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 9:35 - 13:19
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 05. No Peace (Feat. Tom Misch)
track: 5
year: 2017
Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 13:19 - 17:57
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 06. Closer (Feat. Lester Duval)
track: 6
year: 2017
Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 17:57 - 20:05
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori)
track: 7
year: 2017
Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 20:05 - 23:58
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 08. Dreams (Feat. Carmody)
track: 8
year: 2017
Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 23:58 - 24:45
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 09. Dreaming (Interlude) (Feat. Racheal Ofori)
track: 9
year: 2017
Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 24:45 - 28:53
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 10. Hopeful (Feat. Jordan Rakei)
track: 10
year: 2017
Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 28:53 - 31:43
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 11. Sunrise (Pillows) (Feat. Emmavie)
track: 11
year: 2017
Alfa Mist - Nocturne [Full Album]/folder.jpg

View file

@ -0,0 +1,135 @@
Files created in '{output_directory}'
----------------------------------------
Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 0:00 - 2:06
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 01. Intro (Feat. Racheal Ofori & Barney Artist)
track: 1
year: 2017
Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 2:06 - 5:40
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 02. Answers (Feat. Rick David & Kaya Thomas - Dyke)
track: 2
year: 2017
Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 5:40 - 8:11
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 03. Blaze (Feat. Kaya Thomas - Dyke)
track: 3
year: 2017
Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 8:11 - 9:35
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 04. What If (Interlude)
track: 4
year: 2017
Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 9:35 - 13:19
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 05. No Peace (Feat. Tom Misch)
track: 5
year: 2017
Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 13:19 - 17:57
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 06. Closer (Feat. Lester Duval)
track: 6
year: 2017
Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 17:57 - 20:05
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori)
track: 7
year: 2017
Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 20:05 - 23:58
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 08. Dreams (Feat. Carmody)
track: 8
year: 2017
Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 23:58 - 24:45
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 09. Dreaming (Interlude) (Feat. Racheal Ofori)
track: 9
year: 2017
Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 24:45 - 28:53
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 10. Hopeful (Feat. Jordan Rakei)
track: 10
year: 2017
Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 28:53 - 31:43
Music Tags:
album: Alfa Mist - Nocturne [Full Album]
albumartist: Proved Records
artist: Proved Records
genre: Unset
title: 11. Sunrise (Pillows) (Feat. Emmavie)
track: 11
year: 2017
Alfa Mist - Nocturne [Full Album]/folder.jpg

View file

@ -0,0 +1,12 @@
Files created in '{output_directory}'
----------------------------------------
Oblivion Mod "Falcor" p.1/01 - Oblivion Mod "Falcor" p.1.mp3
Music Tags:
album: Oblivion Mod "Falcor" p.1
albumartist: Project Zombie
artist: Project Zombie
genre: Unset
title: Oblivion Mod "Falcor" p.1
track: 1
year: 2010
Oblivion Mod "Falcor" p.1/folder.jpg

View file

@ -0,0 +1,146 @@
Files created in '{output_directory}'
----------------------------------------
Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 0:00 - 2:06
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Intro (Feat. Racheal Ofori & Barney Artist)
track: 1
year: 2017
Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 2:06 - 5:40
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Answers (Feat. Rick David & Kaya Thomas - Dyke)
track: 2
year: 2017
Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 5:40 - 8:11
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Blaze (Feat. Kaya Thomas - Dyke)
track: 3
year: 2017
Nocturne/04 - What If (Interlude).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 8:11 - 9:35
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: What If (Interlude)
track: 4
year: 2017
Nocturne/05 - No Peace (Feat. Tom Misch).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 9:35 - 13:19
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: No Peace (Feat. Tom Misch)
track: 5
year: 2017
Nocturne/06 - Closer (Feat. Lester Duval).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 13:19 - 17:57
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Closer (Feat. Lester Duval)
track: 6
year: 2017
Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 17:57 - 20:05
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Delusions: Rumination (Interlude) (Feat. Racheal Ofori)
track: 7
year: 2017
Nocturne/08 - Dreams (Feat. Carmody).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 20:05 - 23:58
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Dreams (Feat. Carmody)
track: 8
year: 2017
Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 23:58 - 24:45
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Dreaming (Interlude) (Feat. Racheal Ofori)
track: 9
year: 2017
Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 24:45 - 28:53
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Hopeful (Feat. Jordan Rakei)
track: 10
year: 2017
Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3
From Chapter Split:
Warning: Dry-run assumes embedded chapters with no modifications
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 28:53 - 31:43
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Sunrise (Pillows) (Feat. Emmavie)
track: 11
year: 2017
Nocturne/folder.jpg

View file

@ -0,0 +1,135 @@
Files created in '{output_directory}'
----------------------------------------
Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 0:00 - 2:06
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Intro (Feat. Racheal Ofori & Barney Artist)
track: 1
year: 2017
Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 2:06 - 5:40
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Answers (Feat. Rick David & Kaya Thomas - Dyke)
track: 2
year: 2017
Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 5:40 - 8:11
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Blaze (Feat. Kaya Thomas - Dyke)
track: 3
year: 2017
Nocturne/04 - What If (Interlude).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 8:11 - 9:35
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: What If (Interlude)
track: 4
year: 2017
Nocturne/05 - No Peace (Feat. Tom Misch).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 9:35 - 13:19
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: No Peace (Feat. Tom Misch)
track: 5
year: 2017
Nocturne/06 - Closer (Feat. Lester Duval).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 13:19 - 17:57
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Closer (Feat. Lester Duval)
track: 6
year: 2017
Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 17:57 - 20:05
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Delusions: Rumination (Interlude) (Feat. Racheal Ofori)
track: 7
year: 2017
Nocturne/08 - Dreams (Feat. Carmody).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 20:05 - 23:58
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Dreams (Feat. Carmody)
track: 8
year: 2017
Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 23:58 - 24:45
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Dreaming (Interlude) (Feat. Racheal Ofori)
track: 9
year: 2017
Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 24:45 - 28:53
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Hopeful (Feat. Jordan Rakei)
track: 10
year: 2017
Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3
From Chapter Split:
Source Title: Alfa Mist - Nocturne [Full Album]
Segment: 28:53 - 31:43
Music Tags:
album: Nocturne
albumartist: Alfa Mist
artist: Alfa Mist
genre: Unset
title: Sunrise (Pillows) (Feat. Emmavie)
track: 11
year: 2017
Nocturne/folder.jpg

View file

@ -1,6 +1,6 @@
Files created in '{output_directory}'
----------------------------------------
YouTube Rewind 2019 For the Record #YouTubeRewind.mp3
YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3
Music Tags:
album: Singles
albumartist: YouTube