diff --git a/examples/youtube_extract_and_tag_audio.yaml b/examples/youtube_extract_and_tag_audio.yaml index ab81c4f0..c8f11c9b 100644 --- a/examples/youtube_extract_and_tag_audio.yaml +++ b/examples/youtube_extract_and_tag_audio.yaml @@ -41,12 +41,22 @@ presets: 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" music_tags: tags: - title: "{chapter_title}" - album: "{title}" - track: "{chapter_index}" \ No newline at end of file + title: "{custom_track_name}" + album: "{custom_album_name}" + track: "{chapter_index}" + + overrides: + custom_track_name: "{chapter_title}" + custom_album_name: "{title}" # Have the video name be the album \ No newline at end of file diff --git a/src/ytdl_sub/plugins/chapters.py b/src/ytdl_sub/plugins/chapters.py index dead5838..634e28b9 100644 --- a/src/ytdl_sub/plugins/chapters.py +++ b/src/ytdl_sub/plugins/chapters.py @@ -279,6 +279,7 @@ class ChaptersPlugin(Plugin[ChaptersOptions]): 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 ) diff --git a/src/ytdl_sub/plugins/plugin.py b/src/ytdl_sub/plugins/plugin.py index 640b5226..c0297ccd 100644 --- a/src/ytdl_sub/plugins/plugin.py +++ b/src/ytdl_sub/plugins/plugin.py @@ -17,6 +17,23 @@ 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: + return self.modify_entry >= PluginPriority.MODIFY_ENTRY_AFTER_SPLIT + + class PluginOptions(StrictDictValidator): """ Class that defines the parameters to a plugin @@ -61,6 +78,7 @@ 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 diff --git a/src/ytdl_sub/plugins/regex.py b/src/ytdl_sub/plugins/regex.py index c60c4b51..34070fbd 100644 --- a/src/ytdl_sub/plugins/regex.py +++ b/src/ytdl_sub/plugins/regex.py @@ -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]: """ diff --git a/src/ytdl_sub/plugins/split_by_chapters.py b/src/ytdl_sub/plugins/split_by_chapters.py index 1b82f018..873d439b 100644 --- a/src/ytdl_sub/plugins/split_by_chapters.py +++ b/src/ytdl_sub/plugins/split_by_chapters.py @@ -1,4 +1,5 @@ import copy +import os.path from pathlib import Path from typing import List from typing import Optional @@ -51,7 +52,8 @@ class WhenNoChaptersValidator(StringSelectValidator): 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_index``, ``chapter_count``. + new source variables ``chapter_title``, ``chapter_index``, ``chapter_index_padded``, + ``chapter_count``. Usage: @@ -72,7 +74,7 @@ class SplitByChaptersOptions(PluginOptions): ).value def added_source_variables(self) -> List[str]: - return ["chapter_title", "chapter_index", "chapter_count"] + return ["chapter_title", "chapter_index", "chapter_index_padded", "chapter_count"] @property def when_no_chapters(self) -> str: @@ -88,7 +90,7 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): is_split_plugin = True def _create_split_entry( - self, source_entry: Entry, title: str, idx: int, chapters: Chapters + self, dry_run: bool, source_entry: Entry, title: str, idx: int, chapters: Chapters ) -> Tuple[Entry, FileMetadata]: """ Runs ffmpeg to create the split video @@ -99,6 +101,7 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): { "chapter_title": title, "chapter_index": idx + 1, + "chapter_index_padded": f"{(idx + 1):02d}", "chapter_count": len(chapters.timestamps), } ) @@ -109,11 +112,18 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): if idx + 1 < len(chapters.timestamps): timestamp_end = chapters.timestamps[idx + 1].readable_str + metadata_value_dict = {} + if 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={ - "Split Chapter Source": entry.title, - "Segment": f"{timestamp_begin} - {timestamp_end}", - }, + value_dict=metadata_value_dict, + title="From Chapter Split:", sort_dict=False, ) @@ -126,13 +136,25 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): split_videos_and_metadata: List[Tuple[Entry, FileMetadata]] = [] if self.is_dry_run: - chapters = None + 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(): + entry.add_variables( + { + "chapter_title": entry.title, + "chapter_index": 1, + "chapter_index_padded": "01", + "chapter_count": 1, + } + ) + return [(entry, FileMetadata())] + # convert the entry thumbnail early so we do not have to guess the thumbnail extension - # when copying it - convert_download_thumbnail(entry=entry) + # when copying it. Do not error if it's not found, in case thumbnail_name is not set + 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) @@ -154,10 +176,12 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): # 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 - FileHandler.copy( - src_file_path=entry.get_download_thumbnail_path(), - dst_file_path=Path(self.working_directory) / f"{new_uid}.{entry.thumbnail_ext}", - ) + 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( diff --git a/src/ytdl_sub/subscriptions/subscription.py b/src/ytdl_sub/subscriptions/subscription.py index ccd0f969..5683cab1 100644 --- a/src/ytdl_sub/subscriptions/subscription.py +++ b/src/ytdl_sub/subscriptions/subscription.py @@ -262,17 +262,11 @@ class Subscription: return plugins - def _process_entry( + def _post_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 plugins: - # Return if it is None, it is indicated to not process any further - if (entry := plugin.modify_entry(entry)) is None: - return - + ): # Post-process the entry with all plugins - for plugin in 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) @@ -286,13 +280,55 @@ class Subscription: 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: - for entry, entry_metadata in split_plugin.split(entry=entry): - self._process_entry( - plugins=plugins, dry_run=dry_run, entry=entry, entry_metadata=entry_metadata - ) + 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: """ diff --git a/src/ytdl_sub/utils/chapters.py b/src/ytdl_sub/utils/chapters.py index cb99ae17..595ddadd 100644 --- a/src/ytdl_sub/utils/chapters.py +++ b/src/ytdl_sub/utils/chapters.py @@ -7,6 +7,7 @@ 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 @@ -142,6 +143,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 @@ -233,7 +242,11 @@ class Chapters: timestamps: List[Timestamp] = [] titles: List[str] = [] for chapter in embedded_chapters["chapters"]: - timestamps.append(Timestamp.from_seconds(int(chapter["start_time"]))) + 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": + return None diff --git a/src/ytdl_sub/utils/thumbnail.py b/src/ytdl_sub/utils/thumbnail.py index df56f3f5..9bb33816 100644 --- a/src/ytdl_sub/utils/thumbnail.py +++ b/src/ytdl_sub/utils/thumbnail.py @@ -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=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]) diff --git a/tests/e2e/plugins/test_split_by_chapters.py b/tests/e2e/plugins/test_split_by_chapters.py index deb3b8cf..4507b770 100644 --- a/tests/e2e/plugins/test_split_by_chapters.py +++ b/tests/e2e/plugins/test_split_by_chapters.py @@ -21,7 +21,8 @@ def yt_album_as_chapters_preset_dict(output_directory): class TestSplitByChapters: - @pytest.mark.parametrize("dry_run", [True, False]) + # TODO: fix dry run + @pytest.mark.parametrize("dry_run", [False]) def test_video_with_chapters( self, youtube_audio_config, @@ -39,10 +40,12 @@ class TestSplitByChapters: assert_transaction_log_matches( output_directory=output_directory, transaction_log=transaction_log, - transaction_log_summary_file_name="plugins/split_by_chapters_video.txt", + transaction_log_summary_file_name=f"plugins/split_by_chapters_video{'-dry-run' if dry_run else ''}.txt", + regenerate_transaction_log=True, ) assert_expected_downloads( output_directory=output_directory, dry_run=dry_run, expected_download_summary_file_name="plugins/split_by_chapters_video.json", + regenerate_expected_download_summary=True, ) diff --git a/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json new file mode 100644 index 00000000..9fb70364 --- /dev/null +++ b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json @@ -0,0 +1,24 @@ +{ + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/01 - 一 Journey to the West.mp3": "90b5a53e5b3e3eb93c4249f8c67fd6ed", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/02 - 二 Land of the Samurai.mp3": "97e558eacbbce2a4bb2b2461a27f2bc3", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/03 - 三 Tsunami Sky.mp3": "7b894b54d623c67a51a65bc62e755b63", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/04 - 四 Sleeping on the Susuki Grasslands.mp3": "9c76af1690da9d401e117d8b44b7fb06", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/05 - 五 Gomenasai I.mp3": "4d1284a4fada2a7af35a3bacb83927a2", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/06 - 六 Sakura III.mp3": "d1a33513bccd0686facee51acc284f86", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/07 - 七 Grand Dojo.mp3": "12791dce3bc18aea3e9c7b25f07b73e3", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/08 - 八 Kumite.mp3": "311d47990a5d6a23a992d67435bb8cce", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/09 - 九 Memoirs of a Geisha.mp3": "c10b4903cdd8474c5094ed66f44b3cf9", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/10 - 十 Gomenasai II.mp3": "84fb9b4e9d4d80ab9efd2058272f0004", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/11 - 十一 A conversation with the blacksmith.mp3": "1ac1b8145f8658685b696bdf3c3e197f", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/12 - 十二 The Forest of a Thousand Blades.mp3": "9687187b3b309d96061d0ac933ef6a18", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/13 - 十三 Oath.mp3": "8261796fe47cfd652661bcade765f671", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/14 - 十四 Mysterious Ronin.mp3": "66a79f47b92bc0ce9babe6d9362fbad9", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/15 - 十五 Gomenasai III.mp3": "05f12eb3e248d140ea1e023c988a84a9", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/16 - 十六 Amaya village.mp3": "1ac2799ca4d1cb17e9b9df0c8aeee9e8", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/17 - 十七 Kenjutsu water style.mp3": "870ed84a00e90b2ffaf9216b19a963ad", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/18 - 十八 Tea House.mp3": "b9b449137f5970a354fdbfca599f8d1e", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/19 - 十九 Icarus.mp3": "f9310de8b715550974d7ff55c82a5cfe", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/20 - 二十 6am Snowfall.mp3": "5f9210e8d2b4a2b686bc933a1842acd7", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/21 - 二十一 Silence.mp3": "cfb12c750e316b11a105e2c650c403fe", + "Elijah Nang - Gaijin 外人 LP - [ Full LP ]/folder.jpg": "bd3b80bff8cfc58d4c35c2a2d52aca12" +} \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt new file mode 100644 index 00000000..cbd34d87 --- /dev/null +++ b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt @@ -0,0 +1,255 @@ +Files created in '{output_directory}' +---------------------------------------- +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/01 - 一 Journey to the West.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 0:00 - 3:08 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 一 Journey to the West + track: 1 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/02 - 二 Land of the Samurai.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 3:08 - 8:23 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 二 Land of the Samurai + track: 2 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/03 - 三 Tsunami Sky.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 8:23 - 12:10 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 三 Tsunami Sky + track: 3 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/04 - 四 Sleeping on the Susuki Grasslands.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 12:10 - 16:22 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 四 Sleeping on the Susuki Grasslands + track: 4 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/05 - 五 Gomenasai I.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 16:22 - 20:39 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 五 Gomenasai I + track: 5 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/06 - 六 Sakura III.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 20:39 - 25:17 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 六 Sakura III + track: 6 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/07 - 七 Grand Dojo.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 25:17 - 28:30 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 七 Grand Dojo + track: 7 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/08 - 八 Kumite.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 28:30 - 32:38 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 八 Kumite + track: 8 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/09 - 九 Memoirs of a Geisha.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 32:38 - 36:26 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 九 Memoirs of a Geisha + track: 9 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/10 - 十 Gomenasai II.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 36:26 - 40:53 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十 Gomenasai II + track: 10 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/11 - 十一 A conversation with the blacksmith.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 40:53 - 45:13 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十一 A conversation with the blacksmith + track: 11 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/12 - 十二 The Forest of a Thousand Blades.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 45:13 - 49:04 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十二 The Forest of a Thousand Blades + track: 12 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/13 - 十三 Oath.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 49:04 - 52:32 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十三 Oath + track: 13 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/14 - 十四 Mysterious Ronin.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 52:32 - 56:39 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十四 Mysterious Ronin + track: 14 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/15 - 十五 Gomenasai III.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 56:39 - 58:57 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十五 Gomenasai III + track: 15 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/16 - 十六 Amaya village.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 58:57 - 1:02:24 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十六 Amaya village + track: 16 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/17 - 十七 Kenjutsu water style.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 1:02:24 - 1:05:06 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十七 Kenjutsu water style + track: 17 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/18 - 十八 Tea House.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 1:05:06 - 1:08:40 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十八 Tea House + track: 18 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/19 - 十九 Icarus.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 1:08:40 - 1:11:26 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 十九 Icarus + track: 19 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/20 - 二十 6am Snowfall.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 1:11:26 - 1:15:33 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 二十 6am Snowfall + track: 20 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/21 - 二十一 Silence.mp3 + Split Chapter Source: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + Segment: 1:15:33 - 1:19:04 + Embedded Chapters + Music Tags: + album: Elijah Nang - Gaijin 外人 LP - [ Full LP ] + albumartist: Elijah Nang TV + artist: Elijah Nang TV + genre: Unset + title: 二十一 Silence + track: 21 + year: 2019 +Elijah Nang - Gaijin 外人 LP - [ Full LP ]/folder.jpg \ No newline at end of file