aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/__main__.py3
-rw-r--r--src/later/__init__.py0
-rw-r--r--src/later/cache.py82
-rw-r--r--src/later/cli.py90
-rw-r--r--src/later/entries.py82
-rw-r--r--src/later/logger.py12
-rw-r--r--src/later/main.py30
-rw-r--r--src/later/utils.py18
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)