aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src
diff options
context:
space:
mode:
authorWolfgang Müller2024-11-10 13:46:44 +0100
committerWolfgang Müller2024-11-11 16:03:02 +0100
commit65894a5f7da0d58eebcdb5664ec116b31056a2ab (patch)
tree8723ee1c66ce81307f6daddce5181ce0fbd3449b /src
parentfbdc88ad574429ff236211376d07f89c818d0930 (diff)
downloadlater-65894a5f7da0d58eebcdb5664ec116b31056a2ab.tar.gz
Migrate to proper Python package layout
Up until now we've kept the entirety of later(1)'s functionality in one file. Whilst this is perfectly fine for smaller scripts, the functionality of later(1) has grown to a size where this become less feasible. Since we expect it to grow even further, switch to a "proper" source layout sooner rather than later. This will allow easier extension, testing and packaging later on. Since we want to keep installation simple for end-users still, package later(1) as a zipapp [1] in the Makefile. When installed, this file can be executed just like the single Python script before it. Using this way of installation is not mandatory however, the package layout also supports regular installation with pip and development with poetry. [1] https://docs.python.org/3.12/library/zipapp.html
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)