diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/__main__.py | 3 | ||||
-rw-r--r-- | src/later/__init__.py | 0 | ||||
-rw-r--r-- | src/later/cache.py | 82 | ||||
-rw-r--r-- | src/later/cli.py | 90 | ||||
-rw-r--r-- | src/later/entries.py | 82 | ||||
-rw-r--r-- | src/later/logger.py | 12 | ||||
-rw-r--r-- | src/later/main.py | 30 | ||||
-rw-r--r-- | src/later/utils.py | 18 |
8 files changed, 317 insertions, 0 deletions
diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..5f1043d --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,3 @@ +from later import main + +main.main() diff --git a/src/later/__init__.py b/src/later/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/later/__init__.py diff --git a/src/later/cache.py b/src/later/cache.py new file mode 100644 index 0000000..422b242 --- /dev/null +++ b/src/later/cache.py @@ -0,0 +1,82 @@ +import json +import os +import re + +from later.logger import YTDLPLogger + + +class TitleMap: + def __init__(self, path, update=False): + self.map = {} + self.path = path + self.update = update + self.commit_to_disk = update + self.ytdl = None + self.seen = set() + + try: + with open(path) as handle: + self.map = json.load(handle) + except FileNotFoundError: + pass + except json.decoder.JSONDecodeError: + # Clobber the title cache if it was corrupted + self.commit_to_disk = True + except Exception as err: + exit(f"cannot read title cache: {err}") + + def mark_seen(self, key): + self.seen.add(key) + + def get(self, key): + if key in self.map: + return self.map[key] + + if not re.fullmatch(r"https?://.*", key): + return None + + if self.update: + return self.extract(key) + + def extract(self, key): + try: + # Make painstakingly sure that we only do this when absolutely + # necessary: importing yt_dlp is noticeably slow :( + import yt_dlp + except ModuleNotFoundError: + exit("yt-dlp was requested, but yt_dlp python module not found") + + if not self.ytdl: + self.ytdl = yt_dlp.YoutubeDL({"logger": YTDLPLogger()}) + + try: + info = self.ytdl.extract_info(key, download=False) + + # The generic extractor uses the filename part of the url as the + # title. Since we already display the URL, this adds no extra + # information. + if info["extractor"] == "generic": + self.map[key] = "" + else: + self.map[key] = info["title"] + + return self.map.get(key) + + except yt_dlp.utils.DownloadError: + pass + + def maybe_commit(self): + if not self.commit_to_disk: + return + + os.makedirs(os.path.dirname(self.path), exist_ok=True) + + seen_entries = { + key: value for key, value in self.map.items() if key in self.seen + } + + try: + with open(self.path, "w") as handle: + json.dump(seen_entries, handle) + except OSError as err: + exit(f"cannot write title cache: {err}") diff --git a/src/later/cli.py b/src/later/cli.py new file mode 100644 index 0000000..809632f --- /dev/null +++ b/src/later/cli.py @@ -0,0 +1,90 @@ +import getopt +import sys +from dataclasses import dataclass, field +from typing import Protocol + +from later.entries import EntryManager + + +def usage(message=""): + lines = [] + if message: + lines.append(f"later: {message}") + lines.append("") + + lines.append("usage: later [options] [list]") + lines.append(" [options] [add] item ...") + lines.append(" [options] del item ...") + lines.append("options: [-u | --update-titles]") + + sys.exit("\n".join(lines)) + + +class CommandFunction(Protocol): + def __call__(self, args: "Arguments", entries: EntryManager) -> None: + pass + + +@dataclass +class Command: + name: str + fun: CommandFunction + implies_list: bool = True + args: bool = True + + +@dataclass +class Arguments: + command: Command + update_titles: bool = False + rest: list[str] = field(default_factory=list) + + +def add_entries(args, entries): + for entry in args.rest: + entries.add(entry) + + +def delete_entries(args, entries): + for entry in args.rest: + entries.delete(entry) + + +def list_entries(args, entries): + entries.print() + + +def parse_args(argv, default, default_args): + commands = { + "add": Command("add", add_entries), + "del": Command("del", delete_entries), + "list": Command("list", list_entries, implies_list=False, args=False), + } + + try: + options, args = getopt.gnu_getopt(argv, "u", "update-titles") + except getopt.GetoptError as e: + usage(e) + + parsed_args = Arguments(command=commands[default]) + + for option in options: + match option: + case ("-u", _) | ("--update-titles", _): + parsed_args.update_titles = True + + if args: + if args[0] in commands: + parsed_args.command = commands[args.pop(0)] + else: + parsed_args.command = commands[default_args] + + parsed_args.rest = args + + if parsed_args.rest and not parsed_args.command.args: + usage(f'unexpected argument for "{parsed_args.command.name}"') + + if not parsed_args.rest and parsed_args.command.args: + usage(f'"{parsed_args.command.name}" requires an argument') + + return parsed_args diff --git a/src/later/entries.py b/src/later/entries.py new file mode 100644 index 0000000..ed0df87 --- /dev/null +++ b/src/later/entries.py @@ -0,0 +1,82 @@ +import datetime +import os +from dataclasses import dataclass +from datetime import datetime as dt + +from later.utils import hexdigest + + +@dataclass +class WatchLaterEntry: + name: str + path: str + mtime: dt + + def format(self, title_map): + def format_time(time): + now = dt.now() + if time < now - datetime.timedelta(days=7): + return time.strftime("%b %d %Y") + return time.strftime("%b %d %H:%M") + + output = [format_time(self.mtime), self.name] + + if title := title_map.get(self.name): + output.append(f"# {title}") + + return "\t".join(output) + + +class EntryManager: + def __init__(self, directory, title_map): + self.watch_later_dir = directory + self.title_map = title_map + + def walk_entries(self): + def get_mtime(entry): + return entry.stat().st_mtime + + with os.scandir(self.watch_later_dir) as entries: + for entry in sorted(entries, key=get_mtime): + if not entry.is_file(): + continue + + mtime = dt.fromtimestamp(get_mtime(entry)) + + with open(entry.path) as handle: + first = handle.readline().rstrip() + + name = entry.name + if first.startswith("# "): + name = first.strip("# ") + + if name == "redirect entry": + continue + + self.title_map.mark_seen(name) + yield WatchLaterEntry(name=name, path=entry.path, mtime=mtime) + + def print(self): + for entry in self.walk_entries(): + print(entry.format(self.title_map)) + + def add(self, entry): + path = os.path.join(self.watch_later_dir, hexdigest(entry)) + + try: + with open(path, "x") as handle: + handle.write(f"# {entry}\n") + except FileExistsError: + pass + except OSError as e: + exit(e) + + def delete(self, entry): + path = os.path.join(self.watch_later_dir, hexdigest(entry)) + + try: + os.remove(path) + except FileNotFoundError: + pass + except OSError as e: + exit(e) diff --git a/src/later/logger.py b/src/later/logger.py new file mode 100644 index 0000000..a2c4ae7 --- /dev/null +++ b/src/later/logger.py @@ -0,0 +1,12 @@ +import sys + + +class YTDLPLogger: + def debug(self, msg): + pass + + def warning(self, msg): + pass + + def error(self, msg): + print("later: yt-dlp:", msg, file=sys.stderr) diff --git a/src/later/main.py b/src/later/main.py new file mode 100644 index 0000000..1d07cac --- /dev/null +++ b/src/later/main.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +import os +import sys + +from later.cache import TitleMap +from later.cli import list_entries, parse_args +from later.entries import EntryManager +from later.utils import get_xdg + +xdg_cache_home = get_xdg("cache", "~/.cache") +xdg_state_home = get_xdg("state", "~/.local/state") + +watch_later_dir = os.path.join(xdg_state_home, "mpv/watch_later") +later_cache_dir = os.path.join(xdg_cache_home, "later") +title_map_file = os.path.join(later_cache_dir, "titles.json") + + +def main(): + args = parse_args(sys.argv[1:], "list", "add") + + title_map = TitleMap(title_map_file, update=args.update_titles) + entries = EntryManager(watch_later_dir, title_map) + + args.command.fun(args=args, entries=entries) + + if args.command.implies_list: + list_entries(args, entries) + + title_map.maybe_commit() diff --git a/src/later/utils.py b/src/later/utils.py new file mode 100644 index 0000000..a03b540 --- /dev/null +++ b/src/later/utils.py @@ -0,0 +1,18 @@ +import hashlib +import os +import sys + + +def hexdigest(entry): + return hashlib.md5(entry.encode("utf-8")).hexdigest().upper() + + +def exit(message=""): + sys.exit(f"later: {message}" if message else 0) + + +def get_xdg(directory, fallback): + env = f"XDG_{directory.upper()}_HOME" + if env in os.environ: + return os.environ[env] + return os.path.expanduser(fallback) |