[FEATURE] Add support to scrape chapters from comments (#347)
* [FEATURE] Add support to scrape chapters from comments * tested for comments * remove timestamps file * change order of docstrings
This commit is contained in:
parent
70c78dc109
commit
ffaf07d628
8 changed files with 233 additions and 115 deletions
|
|
@ -33,6 +33,7 @@ from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||||
from ytdl_sub.entries.base_entry import BaseEntry
|
from ytdl_sub.entries.base_entry import BaseEntry
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.entries.entry_parent import EntryParent
|
from ytdl_sub.entries.entry_parent import EntryParent
|
||||||
|
from ytdl_sub.entries.variables.kwargs import COMMENTS
|
||||||
from ytdl_sub.entries.variables.kwargs import DOWNLOAD_INDEX
|
from ytdl_sub.entries.variables.kwargs import DOWNLOAD_INDEX
|
||||||
from ytdl_sub.entries.variables.kwargs import PLAYLIST_ENTRY
|
from ytdl_sub.entries.variables.kwargs import PLAYLIST_ENTRY
|
||||||
from ytdl_sub.entries.variables.kwargs import REQUESTED_SUBTITLES
|
from ytdl_sub.entries.variables.kwargs import REQUESTED_SUBTITLES
|
||||||
|
|
@ -453,6 +454,7 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT], ABC):
|
||||||
REQUESTED_SUBTITLES: download_entry.kwargs_get(REQUESTED_SUBTITLES),
|
REQUESTED_SUBTITLES: download_entry.kwargs_get(REQUESTED_SUBTITLES),
|
||||||
# Same with sponsorblock chapters
|
# Same with sponsorblock chapters
|
||||||
SPONSORBLOCK_CHAPTERS: download_entry.kwargs_get(SPONSORBLOCK_CHAPTERS),
|
SPONSORBLOCK_CHAPTERS: download_entry.kwargs_get(SPONSORBLOCK_CHAPTERS),
|
||||||
|
COMMENTS: download_entry.kwargs_get(COMMENTS),
|
||||||
# Tracks number of entries downloaded
|
# Tracks number of entries downloaded
|
||||||
DOWNLOAD_INDEX: download_idx,
|
DOWNLOAD_INDEX: download_idx,
|
||||||
# Tracks number of entries with the same upload date to make them unique
|
# Tracks number of entries with the same upload date to make them unique
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,10 @@ DOWNLOAD_INDEX = _("download_index", backend=True)
|
||||||
UPLOAD_DATE_INDEX = _("upload_date_index", backend=True)
|
UPLOAD_DATE_INDEX = _("upload_date_index", backend=True)
|
||||||
REQUESTED_SUBTITLES = _("requested_subtitles", backend=True)
|
REQUESTED_SUBTITLES = _("requested_subtitles", backend=True)
|
||||||
CHAPTERS = _("chapters", backend=True)
|
CHAPTERS = _("chapters", backend=True)
|
||||||
|
YTDL_SUB_CUSTOM_CHAPTERS = _("ytdl_sub_custom_chapters", backend=True)
|
||||||
SPONSORBLOCK_CHAPTERS = _("sponsorblock_chapters", backend=True)
|
SPONSORBLOCK_CHAPTERS = _("sponsorblock_chapters", backend=True)
|
||||||
SPLIT_BY_CHAPTERS_PARENT_ENTRY = _("split_by_chapters_parent_entry", backend=True)
|
SPLIT_BY_CHAPTERS_PARENT_ENTRY = _("split_by_chapters_parent_entry", backend=True)
|
||||||
|
COMMENTS = _("comments", backend=True)
|
||||||
UID = _("id")
|
UID = _("id")
|
||||||
EXTRACTOR = _("extractor")
|
EXTRACTOR = _("extractor")
|
||||||
EPOCH = _("epoch")
|
EPOCH = _("epoch")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from typing import Set
|
||||||
|
|
||||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
|
from ytdl_sub.entries.variables.kwargs import COMMENTS
|
||||||
|
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_CUSTOM_CHAPTERS
|
||||||
from ytdl_sub.plugins.plugin import Plugin
|
from ytdl_sub.plugins.plugin import Plugin
|
||||||
from ytdl_sub.plugins.plugin import PluginOptions
|
from ytdl_sub.plugins.plugin import PluginOptions
|
||||||
from ytdl_sub.utils.chapters import Chapters
|
from ytdl_sub.utils.chapters import Chapters
|
||||||
|
|
@ -16,7 +18,6 @@ from ytdl_sub.validators.regex_validator import RegexListValidator
|
||||||
from ytdl_sub.validators.string_select_validator import StringSelectValidator
|
from ytdl_sub.validators.string_select_validator import StringSelectValidator
|
||||||
from ytdl_sub.validators.validators import BoolValidator
|
from ytdl_sub.validators.validators import BoolValidator
|
||||||
from ytdl_sub.validators.validators import ListValidator
|
from ytdl_sub.validators.validators import ListValidator
|
||||||
from ytdl_sub.validators.validators import StringValidator
|
|
||||||
|
|
||||||
SPONSORBLOCK_HIGHLIGHT_CATEGORIES: Set[str] = {"poi_highlight"}
|
SPONSORBLOCK_HIGHLIGHT_CATEGORIES: Set[str] = {"poi_highlight"}
|
||||||
SPONSORBLOCK_CATEGORIES: Set[str] = SPONSORBLOCK_HIGHLIGHT_CATEGORIES | {
|
SPONSORBLOCK_CATEGORIES: Set[str] = SPONSORBLOCK_HIGHLIGHT_CATEGORIES | {
|
||||||
|
|
@ -62,9 +63,6 @@ class ChaptersOptions(PluginOptions):
|
||||||
Embeds chapters to video files if they are present. Additional options to add SponsorBlock
|
Embeds chapters to video files if they are present. Additional options to add SponsorBlock
|
||||||
chapters and remove specific ones. Can also remove chapters using regex.
|
chapters and remove specific ones. Can also remove chapters using regex.
|
||||||
|
|
||||||
Note that at this time, chapter removal with regex will not work with chapters added via
|
|
||||||
timestamp file.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
@ -72,7 +70,14 @@ class ChaptersOptions(PluginOptions):
|
||||||
presets:
|
presets:
|
||||||
my_example_preset:
|
my_example_preset:
|
||||||
chapters:
|
chapters:
|
||||||
|
# Embedded Chapter Fields
|
||||||
embed_chapters: True
|
embed_chapters: True
|
||||||
|
allow_chapters_from_comments: False
|
||||||
|
remove_chapters_regex:
|
||||||
|
- "Intro"
|
||||||
|
- "Outro"
|
||||||
|
|
||||||
|
# Sponsorblock Fields
|
||||||
sponsorblock_categories:
|
sponsorblock_categories:
|
||||||
- "outro"
|
- "outro"
|
||||||
- "selfpromo"
|
- "selfpromo"
|
||||||
|
|
@ -82,15 +87,13 @@ class ChaptersOptions(PluginOptions):
|
||||||
- "music_offtopic"
|
- "music_offtopic"
|
||||||
- "intro"
|
- "intro"
|
||||||
remove_sponsorblock_categories: "all"
|
remove_sponsorblock_categories: "all"
|
||||||
remove_chapters_regex:
|
|
||||||
- "Intro"
|
|
||||||
- "Outro"
|
|
||||||
force_key_frames: False
|
force_key_frames: False
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_optional_keys = {
|
_optional_keys = {
|
||||||
"embed_chapters",
|
"embed_chapters",
|
||||||
"embed_chapter_timestamps",
|
"allow_chapters_from_comments",
|
||||||
"sponsorblock_categories",
|
"sponsorblock_categories",
|
||||||
"remove_sponsorblock_categories",
|
"remove_sponsorblock_categories",
|
||||||
"remove_chapters_regex",
|
"remove_chapters_regex",
|
||||||
|
|
@ -114,18 +117,18 @@ class ChaptersOptions(PluginOptions):
|
||||||
self._force_key_frames = self._validate_key_if_present(
|
self._force_key_frames = self._validate_key_if_present(
|
||||||
key="force_key_frames", validator=BoolValidator, default=False
|
key="force_key_frames", validator=BoolValidator, default=False
|
||||||
).value
|
).value
|
||||||
self._embed_chapter_timestamps = self._validate_key_if_present(
|
self._allow_chapters_from_comments = self._validate_key_if_present(
|
||||||
"embed_chapter_timestamps", StringValidator
|
key="allow_chapters_from_comments", validator=BoolValidator, default=False
|
||||||
)
|
).value
|
||||||
|
|
||||||
if self._remove_sponsorblock_categories and not self._sponsorblock_categories:
|
if self._remove_sponsorblock_categories and not self._sponsorblock_categories:
|
||||||
raise self._validation_exception(
|
raise self._validation_exception(
|
||||||
"Must specify sponsorblock_categories if you are going to remove any of them"
|
"Must specify sponsorblock_categories if you are going to remove any of them"
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._embed_chapters and self._embed_chapter_timestamps:
|
if self._remove_sponsorblock_categories and self._allow_chapters_from_comments:
|
||||||
raise self._validation_exception(
|
raise self._validation_exception(
|
||||||
"Cannot embed chapters from the source and from a timestamp file"
|
"Cannot remove sponsorblock categories and embed chapters from comments"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -135,6 +138,24 @@ class ChaptersOptions(PluginOptions):
|
||||||
"""
|
"""
|
||||||
return self._embed_chapters
|
return self._embed_chapters
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allow_chapters_from_comments(self) -> bool:
|
||||||
|
"""
|
||||||
|
Optional. If chapters do not exist in the video/description itself, attempt to scrape
|
||||||
|
comments to find the chapters. Defaults to False.
|
||||||
|
"""
|
||||||
|
return self._allow_chapters_from_comments
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remove_chapters_regex(self) -> Optional[List[re.Pattern]]:
|
||||||
|
"""
|
||||||
|
Optional. List of regex patterns to match chapter titles against and remove them from the
|
||||||
|
entry.
|
||||||
|
"""
|
||||||
|
if self._remove_chapters_regex:
|
||||||
|
return [validator.compiled_regex for validator in self._remove_chapters_regex.list]
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sponsorblock_categories(self) -> Optional[List[str]]:
|
def sponsorblock_categories(self) -> Optional[List[str]]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -165,16 +186,6 @@ class ChaptersOptions(PluginOptions):
|
||||||
return category_list
|
return category_list
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
|
||||||
def remove_chapters_regex(self) -> Optional[List[re.Pattern]]:
|
|
||||||
"""
|
|
||||||
Optional. List of regex patterns to match chapter titles against and remove them from the
|
|
||||||
entry.
|
|
||||||
"""
|
|
||||||
if self._remove_chapters_regex:
|
|
||||||
return [validator.compiled_regex for validator in self._remove_chapters_regex.list]
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def force_key_frames(self) -> bool:
|
def force_key_frames(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -184,27 +195,6 @@ class ChaptersOptions(PluginOptions):
|
||||||
"""
|
"""
|
||||||
return self._force_key_frames
|
return self._force_key_frames
|
||||||
|
|
||||||
@property
|
|
||||||
def embed_chapter_timestamps(self) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Optional. The path to the file containing the timestamps to embed into the file as
|
|
||||||
chapters. Should be formatted as:
|
|
||||||
|
|
||||||
.. code-block:: markdown
|
|
||||||
|
|
||||||
0:00 Intro
|
|
||||||
0:24 Blackwater Park
|
|
||||||
10:23 Bleak
|
|
||||||
16:39 Jokes
|
|
||||||
1:02:23 Ending
|
|
||||||
|
|
||||||
This should only be used with single entity download strategies. Otherwise, an entire
|
|
||||||
playlist or channel would all the same embedded chapters.
|
|
||||||
"""
|
|
||||||
if self._embed_chapter_timestamps:
|
|
||||||
return self._embed_chapter_timestamps.value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class ChaptersPlugin(Plugin[ChaptersOptions]):
|
class ChaptersPlugin(Plugin[ChaptersOptions]):
|
||||||
plugin_options_type = ChaptersOptions
|
plugin_options_type = ChaptersOptions
|
||||||
|
|
@ -246,6 +236,9 @@ class ChaptersPlugin(Plugin[ChaptersOptions]):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.plugin_options.allow_chapters_from_comments:
|
||||||
|
builder.add({"getcomments": True})
|
||||||
|
|
||||||
if self._is_removing_chapters:
|
if self._is_removing_chapters:
|
||||||
remove_chapters_post_processor = {
|
remove_chapters_post_processor = {
|
||||||
"key": "ModifyChapters",
|
"key": "ModifyChapters",
|
||||||
|
|
@ -307,15 +300,23 @@ class ChaptersPlugin(Plugin[ChaptersOptions]):
|
||||||
-------
|
-------
|
||||||
entry
|
entry
|
||||||
"""
|
"""
|
||||||
if self.plugin_options.embed_chapter_timestamps and not self.is_dry_run:
|
chapters = Chapters.from_empty()
|
||||||
chapters = Chapters.from_timestamps_file(
|
|
||||||
chapters_file_path=self.plugin_options.embed_chapter_timestamps
|
if not _contains_any_chapters(entry) and self.plugin_options.allow_chapters_from_comments:
|
||||||
)
|
for comment in entry.kwargs_get(COMMENTS, []):
|
||||||
set_ffmpeg_metadata_chapters(
|
chapters = Chapters.from_string(comment.get("text", ""))
|
||||||
file_path=entry.get_download_file_path(),
|
if not chapters.is_empty():
|
||||||
chapters=chapters,
|
break
|
||||||
file_duration_sec=entry.kwargs("duration"),
|
|
||||||
)
|
if not chapters.is_empty():
|
||||||
|
entry.add_kwargs({YTDL_SUB_CUSTOM_CHAPTERS: chapters.to_file_metadata_dict()})
|
||||||
|
|
||||||
|
if not self.is_dry_run:
|
||||||
|
set_ffmpeg_metadata_chapters(
|
||||||
|
file_path=entry.get_download_file_path(),
|
||||||
|
chapters=chapters,
|
||||||
|
file_duration_sec=entry.kwargs("duration"),
|
||||||
|
)
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
@ -330,11 +331,13 @@ class ChaptersPlugin(Plugin[ChaptersOptions]):
|
||||||
-------
|
-------
|
||||||
FileMetadata outlining which chapters/SponsorBlock segments got removed
|
FileMetadata outlining which chapters/SponsorBlock segments got removed
|
||||||
"""
|
"""
|
||||||
if self.plugin_options.embed_chapter_timestamps:
|
if custom_chapters_metadata := entry.kwargs_get(YTDL_SUB_CUSTOM_CHAPTERS):
|
||||||
chapters = Chapters.from_timestamps_file(
|
title: str = "Chapters from comments"
|
||||||
chapters_file_path=self.plugin_options.embed_chapter_timestamps
|
return FileMetadata.from_dict(
|
||||||
|
value_dict=custom_chapters_metadata,
|
||||||
|
title=title,
|
||||||
|
sort_dict=False, # timestamps + titles are already sorted
|
||||||
)
|
)
|
||||||
return chapters.to_file_metadata(title="Chapters embedded from timestamp file")
|
|
||||||
|
|
||||||
if self.plugin_options.embed_chapters:
|
if self.plugin_options.embed_chapters:
|
||||||
metadata_dict = {}
|
metadata_dict = {}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.utils.exceptions import ValidationException
|
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,11 +18,11 @@ class Timestamp:
|
||||||
# 1:00:00 title
|
# 1:00:00 title
|
||||||
# 01:00:00 title
|
# 01:00:00 title
|
||||||
# where capture group 1 and 2 are the timestamp and title, respectively
|
# where capture group 1 and 2 are the timestamp and title, respectively
|
||||||
_SPLIT_TIMESTAMP_REGEX = re.compile(r"^((?:\d\d:)?(?:\d:)?(?:\d)?\d:\d\d)$")
|
TIMESTAMP_REGEX = re.compile(r"((?:\d\d:)?(?:\d:)?(?:\d)?\d:\d\d)")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp_str(cls, timestamp_str: str) -> str:
|
def _normalize_timestamp_str(cls, timestamp_str: str) -> str:
|
||||||
match = cls._SPLIT_TIMESTAMP_REGEX.match(timestamp_str)
|
match = cls.TIMESTAMP_REGEX.match(timestamp_str)
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError(f"Cannot parse youtube timestamp '{timestamp_str}'")
|
raise ValueError(f"Cannot parse youtube timestamp '{timestamp_str}'")
|
||||||
|
|
||||||
|
|
@ -158,6 +157,14 @@ class Chapters:
|
||||||
"""
|
"""
|
||||||
return self.timestamps[0].timestamp_sec == 0
|
return self.timestamps[0].timestamp_sec == 0
|
||||||
|
|
||||||
|
def to_file_metadata_dict(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Metadata dict
|
||||||
|
"""
|
||||||
|
return {ts.readable_str: title for ts, title in zip(self.timestamps, self.titles)}
|
||||||
|
|
||||||
def to_file_metadata(self, title: Optional[str] = None) -> FileMetadata:
|
def to_file_metadata(self, title: Optional[str] = None) -> FileMetadata:
|
||||||
"""
|
"""
|
||||||
Parameters
|
Parameters
|
||||||
|
|
@ -170,54 +177,48 @@ class Chapters:
|
||||||
Chapter metadata in the format of { readable_timestamp_str: title }
|
Chapter metadata in the format of { readable_timestamp_str: title }
|
||||||
"""
|
"""
|
||||||
return FileMetadata.from_dict(
|
return FileMetadata.from_dict(
|
||||||
value_dict={ts.readable_str: title for ts, title in zip(self.timestamps, self.titles)},
|
value_dict=self.to_file_metadata_dict(),
|
||||||
title=title,
|
title=title,
|
||||||
sort_dict=False, # timestamps + titles are already sorted
|
sort_dict=False, # timestamps + titles are already sorted
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_timestamps_file(cls, chapters_file_path: str) -> "Chapters":
|
def from_string(cls, input_str: str) -> "Chapters":
|
||||||
"""
|
"""
|
||||||
|
From a string (description or comment), try to extract Chapters.
|
||||||
|
The scraping logic is simple, if three or more successive lines have timestamps, grab
|
||||||
|
as many in succession as possible. Remove the timestamp portion to get the chapter title.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
chapters_file_path
|
input_str
|
||||||
Path to file containing chapters
|
String to scrape
|
||||||
|
|
||||||
Raises
|
Returns
|
||||||
------
|
-------
|
||||||
ValidationException
|
Chapters
|
||||||
File path does not exist or contains invalid formatting
|
Could be empty
|
||||||
"""
|
"""
|
||||||
if not os.path.isfile(chapters_file_path):
|
|
||||||
raise ValidationException(
|
|
||||||
f"chapter/timestamp file path '{chapters_file_path}' does not exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(chapters_file_path, "r", encoding="utf-8") as file:
|
|
||||||
lines = file.readlines()
|
|
||||||
|
|
||||||
timestamps: List[Timestamp] = []
|
timestamps: List[Timestamp] = []
|
||||||
titles: List[str] = []
|
titles: List[str] = []
|
||||||
|
|
||||||
for idx, line in enumerate(lines):
|
for line in input_str.split("\n"):
|
||||||
line_split = line.strip().split(maxsplit=1)
|
# Timestamp captured, store it
|
||||||
|
if match := Timestamp.TIMESTAMP_REGEX.search(line):
|
||||||
|
timestamp_str = match.group(1)
|
||||||
|
timestamps.append(Timestamp.from_str(timestamp_str))
|
||||||
|
|
||||||
# Allow the last line to be blank
|
# Remove timestamp and surrounding whitespace from it
|
||||||
if idx == len(lines) - 1 and not line.strip():
|
title_str = re.sub(f"\\s*{re.escape(timestamp_str)}\\s*", " ", line).strip()
|
||||||
break
|
titles.append(title_str)
|
||||||
|
elif len(timestamps) >= 3:
|
||||||
|
return Chapters(timestamps=timestamps, titles=titles)
|
||||||
|
# Timestamp was not stored, if only contained 1, reset
|
||||||
|
else:
|
||||||
|
timestamps = []
|
||||||
|
titles = []
|
||||||
|
|
||||||
if len(line_split) != 2:
|
return Chapters(timestamps=timestamps, titles=titles)
|
||||||
raise ValidationException(
|
|
||||||
f"Chapter/Timestamp file '{chapters_file_path}' could not parse '{line}': "
|
|
||||||
f"must be in the format of 'HH:MM:SS title"
|
|
||||||
)
|
|
||||||
|
|
||||||
timestamp_str, title = tuple(x for x in line_split)
|
|
||||||
|
|
||||||
timestamps.append(Timestamp.from_str(timestamp_str))
|
|
||||||
titles.append(title)
|
|
||||||
|
|
||||||
return cls(timestamps=timestamps, titles=titles)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_embedded_chapters(cls, file_path: str) -> "Chapters":
|
def from_embedded_chapters(cls, file_path: str) -> "Chapters":
|
||||||
|
|
@ -280,3 +281,26 @@ class Chapters:
|
||||||
titles.append(chapter["title"])
|
titles.append(chapter["title"])
|
||||||
|
|
||||||
return Chapters(timestamps=timestamps, titles=titles)
|
return Chapters(timestamps=timestamps, titles=titles)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_empty(cls) -> "Chapters":
|
||||||
|
"""
|
||||||
|
Initialize empty chapters
|
||||||
|
"""
|
||||||
|
return Chapters(timestamps=[], titles=[])
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Number of chapters
|
||||||
|
"""
|
||||||
|
return len(self.timestamps)
|
||||||
|
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
True if no chapters. False otherwise.
|
||||||
|
"""
|
||||||
|
return len(self) == 0
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from expected_download import assert_expected_downloads
|
from expected_download import assert_expected_downloads
|
||||||
from expected_transaction_log import assert_transaction_log_matches
|
from expected_transaction_log import assert_transaction_log_matches
|
||||||
|
|
@ -6,7 +8,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def single_video_sponsorblock_and_embedded_subs_preset_dict(output_directory):
|
def sponsorblock_and_subs_preset_dict(output_directory) -> Dict:
|
||||||
return {
|
return {
|
||||||
"preset": "music_video",
|
"preset": "music_video",
|
||||||
"download": {"url": "https://www.youtube.com/watch?v=-wJOUAuKZm8"},
|
"download": {"url": "https://www.youtube.com/watch?v=-wJOUAuKZm8"},
|
||||||
|
|
@ -42,19 +44,31 @@ def single_video_sponsorblock_and_embedded_subs_preset_dict(output_directory):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chapters_from_comments_preset_dict(sponsorblock_and_subs_preset_dict: Dict) -> Dict:
|
||||||
|
sponsorblock_and_subs_preset_dict["download"][
|
||||||
|
"url"
|
||||||
|
] = "https://www.youtube.com/watch?v=MO5AWAqe01Y"
|
||||||
|
sponsorblock_and_subs_preset_dict["chapters"] = {
|
||||||
|
"embed_chapters": True,
|
||||||
|
"allow_chapters_from_comments": True,
|
||||||
|
}
|
||||||
|
return sponsorblock_and_subs_preset_dict
|
||||||
|
|
||||||
|
|
||||||
class TestChapters:
|
class TestChapters:
|
||||||
@pytest.mark.parametrize("dry_run", [True, False])
|
@pytest.mark.parametrize("dry_run", [True, False])
|
||||||
def test_chapters_sponsorblock_and_removal_with_subs(
|
def test_chapters_sponsorblock_and_removal_with_subs(
|
||||||
self,
|
self,
|
||||||
music_video_config,
|
music_video_config,
|
||||||
single_video_sponsorblock_and_embedded_subs_preset_dict,
|
sponsorblock_and_subs_preset_dict,
|
||||||
output_directory,
|
output_directory,
|
||||||
dry_run,
|
dry_run,
|
||||||
):
|
):
|
||||||
subscription = Subscription.from_dict(
|
subscription = Subscription.from_dict(
|
||||||
config=music_video_config,
|
config=music_video_config,
|
||||||
preset_name="sponsorblock_with_embedded_subs_test",
|
preset_name="sponsorblock_with_embedded_subs_test",
|
||||||
preset_dict=single_video_sponsorblock_and_embedded_subs_preset_dict,
|
preset_dict=sponsorblock_and_subs_preset_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
transaction_log = subscription.download(dry_run=dry_run)
|
transaction_log = subscription.download(dry_run=dry_run)
|
||||||
|
|
@ -73,40 +87,28 @@ class TestChapters:
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize("dry_run", [True, False])
|
@pytest.mark.parametrize("dry_run", [True, False])
|
||||||
def test_chapters_from_timestamp_file_with_subs(
|
def test_chapters_from_comments(
|
||||||
self,
|
self,
|
||||||
music_video_config,
|
music_video_config,
|
||||||
single_video_sponsorblock_and_embedded_subs_preset_dict,
|
chapters_from_comments_preset_dict,
|
||||||
timestamps_file_path,
|
timestamps_file_path,
|
||||||
output_directory,
|
output_directory,
|
||||||
dry_run,
|
dry_run,
|
||||||
):
|
):
|
||||||
# Test chapters and video tags, throw in a video tag with special chars while we are at it
|
|
||||||
single_video_sponsorblock_and_embedded_subs_preset_dict["chapters"] = {
|
|
||||||
"embed_chapters": False,
|
|
||||||
"embed_chapter_timestamps": timestamps_file_path,
|
|
||||||
}
|
|
||||||
single_video_sponsorblock_and_embedded_subs_preset_dict["video_tags"] = {
|
|
||||||
"tags": {"description": "🎸 / ' \" \n newline?"}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription = Subscription.from_dict(
|
subscription = Subscription.from_dict(
|
||||||
config=music_video_config,
|
config=music_video_config,
|
||||||
preset_name="chapters_from_timestamps_with_subs",
|
preset_name="chapters_from_comments",
|
||||||
preset_dict=single_video_sponsorblock_and_embedded_subs_preset_dict,
|
preset_dict=chapters_from_comments_preset_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
transaction_log = subscription.download(dry_run=dry_run)
|
transaction_log = subscription.download(dry_run=dry_run)
|
||||||
assert_transaction_log_matches(
|
assert_transaction_log_matches(
|
||||||
output_directory=output_directory,
|
output_directory=output_directory,
|
||||||
transaction_log=transaction_log,
|
transaction_log=transaction_log,
|
||||||
transaction_log_summary_file_name="plugins/test_chapters_from_ts_with_subs.txt",
|
transaction_log_summary_file_name="plugins/chapters/test_chapters_from_comments.txt",
|
||||||
)
|
)
|
||||||
assert_expected_downloads(
|
assert_expected_downloads(
|
||||||
output_directory=output_directory,
|
output_directory=output_directory,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
expected_download_summary_file_name="plugins/test_chapters_from_ts_with_subs.json",
|
expected_download_summary_file_name="plugins/chapters/test_chapters_from_comments.json",
|
||||||
ignore_md5_hashes_for=[
|
|
||||||
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4"
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
".ytdl-sub-chapters_from_comments-download-archive.json": "122723ce8d257eebb05178daa26141f6",
|
||||||
|
"JMC/JMC - Move 78 - Automated Improvisation [Full Album]-thumb.jpg": "c12e6a6f242680d1096a1a99d74a62c6",
|
||||||
|
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].info.json": "fc463545b52f7f07265f07df882ca9d7",
|
||||||
|
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].mp4": "8b8a9a731a19bc37feb091559f97ebbc",
|
||||||
|
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].nfo": "7a65b184d24c68fc0ec5380432250f5b"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
Files created:
|
||||||
|
----------------------------------------
|
||||||
|
{output_directory}
|
||||||
|
.ytdl-sub-chapters_from_comments-download-archive.json
|
||||||
|
{output_directory}/JMC
|
||||||
|
JMC - Move 78 - Automated Improvisation [Full Album]-thumb.jpg
|
||||||
|
JMC - Move 78 - Automated Improvisation [Full Album].info.json
|
||||||
|
JMC - Move 78 - Automated Improvisation [Full Album].mp4
|
||||||
|
Chapters from comments:
|
||||||
|
0:00: 01. The Lonely Tears of Lee Seedol
|
||||||
|
4:30: 02. But What If We're Wrong
|
||||||
|
9:16: 03. Follow the Earworm Pt.2
|
||||||
|
12:25: 04. Keyword Salad
|
||||||
|
16:48: 05. Ultra Natural
|
||||||
|
20:47: 06. Flight Instructions
|
||||||
|
25:46: 07. Dawn of the Useless Class
|
||||||
|
29:58: 08. Schnitzel Whisperer
|
||||||
|
32:16: 09. Teilo
|
||||||
|
Embedded subtitles with lang(s) en, de
|
||||||
|
JMC - Move 78 - Automated Improvisation [Full Album].nfo
|
||||||
|
NFO tags:
|
||||||
|
musicvideo:
|
||||||
|
album: Music Videos
|
||||||
|
artist: JMC
|
||||||
|
title: Move 78 - Automated Improvisation [Full Album]
|
||||||
|
year: 2022
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from ytdl_sub.utils.chapters import Chapters
|
||||||
from ytdl_sub.utils.chapters import Timestamp
|
from ytdl_sub.utils.chapters import Timestamp
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,3 +21,54 @@ class TestTimestamp:
|
||||||
def test_timestamp_from_str(self, timestamp_str, timestamp_int):
|
def test_timestamp_from_str(self, timestamp_str, timestamp_int):
|
||||||
ts = Timestamp.from_str(timestamp_str=timestamp_str)
|
ts = Timestamp.from_str(timestamp_str=timestamp_str)
|
||||||
assert ts.timestamp_sec == timestamp_int
|
assert ts.timestamp_sec == timestamp_int
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chapter_description_1() -> str:
|
||||||
|
return """Support the artist by purchasing the record here:
|
||||||
|
https://levanika.bandcamp.com/album/p...
|
||||||
|
|
||||||
|
Album Art by: po
|
||||||
|
|
||||||
|
Tracklist:
|
||||||
|
00:00 intro
|
||||||
|
00:58 audioclip p94568
|
||||||
|
01:18 saskipao
|
||||||
|
03:32 fifqeby
|
||||||
|
05:40 dream
|
||||||
|
07:22 rulji
|
||||||
|
08:54 modymody
|
||||||
|
11:23 .ishos
|
||||||
|
16:09 pos
|
||||||
|
18:47 lamazybalaxy
|
||||||
|
|
||||||
|
Denivarlevy Socials:
|
||||||
|
|
||||||
|
https://open.spotify.com/artist/2XdIT...
|
||||||
|
https://youtube.com/channel/UCgI_vAC3...
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chapter_description_2() -> str:
|
||||||
|
return """01. 00:00 Ocean
|
||||||
|
02. 02:41 Dreams
|
||||||
|
03. 05:16 Future Tales
|
||||||
|
04. 08:50 Mind Travelling
|
||||||
|
05. 11:05 Love Supreme
|
||||||
|
06. 14:17 Reflections
|
||||||
|
07. 16:32 Moonlight Fading
|
||||||
|
08. 19:40 Between Two Worlds
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestChapters:
|
||||||
|
def test_chapters_from_str_1(self, chapter_description_1):
|
||||||
|
chapters = Chapters.from_string(chapter_description_1)
|
||||||
|
assert len(chapters) == 10
|
||||||
|
assert chapters.timestamps[-1].readable_str == "18:47"
|
||||||
|
|
||||||
|
def test_chapters_from_str_2(self, chapter_description_2):
|
||||||
|
chapters = Chapters.from_string(chapter_description_2)
|
||||||
|
assert len(chapters) == 8
|
||||||
|
assert chapters.timestamps[1].readable_str == "2:41"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue