ytdl-sub/src/ytdl_sub/utils/chapters.py
2022-07-30 21:59:02 -07:00

209 lines
6.1 KiB
Python

import os
import re
from typing import List
from typing import Optional
from typing import Tuple
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileMetadata
class Timestamp:
# Captures the following formats:
# 0:00 title
# 00:00 title
# 1:00:00 title
# 01:00:00 title
# 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)$")
@classmethod
def _normalize_timestamp_str(cls, timestamp_str: str) -> str:
match = cls._SPLIT_TIMESTAMP_REGEX.match(timestamp_str)
if not match:
raise ValueError(f"Cannot parse youtube timestamp '{timestamp_str}'")
timestamp = match.group(1)
match len(timestamp):
case 4: # 0:00
timestamp = f"00:0{timestamp}"
case 5: # 00:00
timestamp = f"00:{timestamp}"
case 7: # 0:00:00
timestamp = f"0{timestamp}"
case _:
pass
assert len(timestamp) == 8
return timestamp
def __init__(self, timestamp_sec: int):
self._timestamp_sec = timestamp_sec
@property
def timestamp_sec(self) -> int:
"""
Returns
-------
Timestamp in seconds
"""
return self._timestamp_sec
@property
def _hours_minutes_seconds(self) -> Tuple[int, int, int]:
seconds = self.timestamp_sec
hours = int(seconds / 3600)
seconds -= hours * 3600
minutes = int(seconds / 60)
seconds -= minutes * 60
return hours, minutes, seconds
@property
def readable_str(self) -> str:
"""
Returns
-------
The timestamp in '0:SS' format (min trim).
"""
hours, minutes, seconds = self._hours_minutes_seconds
if hours:
return f"{str(hours)}:{str(minutes).zfill(2)}:{str(seconds).zfill(2)}"
if minutes:
return f"{str(minutes)}:{str(seconds).zfill(2)}"
return f"0:{str(seconds).zfill(2)}"
@property
def standardized_str(self) -> str:
"""
Returns
-------
The timestamp in 'HH:MM:SS' format
"""
hours, minutes, seconds = self._hours_minutes_seconds
return f"{str(hours).zfill(2)}:{str(minutes).zfill(2)}:{str(seconds).zfill(2)}"
@classmethod
def from_seconds(cls, timestamp_sec: int) -> "Timestamp":
"""
Parameters
----------
timestamp_sec
Timestamp in number of seconds
"""
return cls(timestamp_sec=timestamp_sec)
@classmethod
def from_str(cls, timestamp_str: str) -> "Timestamp":
"""
Parameters
----------
timestamp_str
Timestamp in the form of "HH:MM:SS"
Raises
------
ValueError
Invalid timestamp string format
"""
hour_minute_second = cls._normalize_timestamp_str(timestamp_str).split(":")
if len(hour_minute_second) != 3:
raise ValueError("Youtube timestamp must be in the form of 'HH:MM:SS'")
hour, minute, second = tuple(x for x in hour_minute_second)
try:
return cls(timestamp_sec=(int(hour) * 3600) + (int(minute) * 60) + int(second))
except ValueError as cast_exception:
raise ValueError(
"Youtube timestamp must be in the form of 'HH:MM:SS'"
) from cast_exception
class Chapters:
"""
Represents a list of (timestamps, titles)
"""
def __init__(
self,
timestamps: List[Timestamp],
titles: List[str],
):
self.timestamps = timestamps
self.titles = titles
for idx in range(len(timestamps) - 1):
if timestamps[idx].timestamp_sec >= timestamps[idx + 1].timestamp_sec:
raise ValueError("Timestamps must be in ascending order")
def contains_zero_timestamp(self) -> bool:
"""
Returns
-------
True if the first timestamp starts at 0. False otherwise.
"""
return self.timestamps[0].timestamp_sec == 0
def to_file_metadata(self, title: Optional[str] = None) -> FileMetadata:
"""
Parameters
----------
title
Optional title
Returns
-------
Chapter metadata in the format of { readable_timestamp_str: title }
"""
return FileMetadata.from_dict(
value_dict={ts.readable_str: title for ts, title in zip(self.timestamps, self.titles)},
title=title,
sort_dict=False, # timestamps + titles are already sorted
)
@classmethod
def from_file(cls, chapters_file_path: str) -> "Chapters":
"""
Parameters
----------
chapters_file_path
Path to file containing chapters
Raises
------
ValidationException
File path does not exist or contains invalid formatting
"""
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] = []
titles: List[str] = []
for idx, line in enumerate(lines):
line_split = line.strip().split(maxsplit=1)
# Allow the last line to be blank
if idx == len(lines) - 1 and not line.strip():
break
if len(line_split) != 2:
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)