diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | Makefile | 15 | ||||
-rwxr-xr-x | later | 281 | ||||
-rw-r--r-- | poetry.lock | 101 | ||||
-rw-r--r-- | pyproject.toml | 45 | ||||
-rw-r--r-- | ruff.toml | 18 | ||||
-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 |
14 files changed, 476 insertions, 302 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..524ef89 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/later.pyz @@ -1,7 +1,16 @@ PREFIX ?= /usr/local -install: later later.1 - install -D -m 755 -t '${DESTDIR}${PREFIX}/bin' later +install: later.pyz later.1 + install -D -m 755 later.pyz '${DESTDIR}${PREFIX}/bin/later' install -D -m 644 -t '${DESTDIR}${PREFIX}/share/man/man1' later.1 -.PHONY: install +later.pyz: + python -m zipapp -p "/usr/bin/env python3" -o later.pyz src/ + +clean: + rm -f later.pyz + +test: + pytest -q + +.PHONY: install clean test @@ -1,281 +0,0 @@ -#!/usr/bin/env python3 - -import datetime -import getopt -import hashlib -import json -import os -import re -import sys -from dataclasses import dataclass, field -from datetime import datetime as dt -from typing import Protocol - - -class YTDLPLogger: - def debug(self, msg): - pass - - def warning(self, msg): - pass - - def error(self, msg): - print("later: yt-dlp:", msg, file=sys.stderr) - - -def exit(message=""): - sys.exit(f"later: {message}" if message else 0) - - -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)) - - -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) - - -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") - - -@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 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(later_cache_dir, 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}") - - -class CommandFunction(Protocol): - def __call__(self, args: "Arguments", title_map: TitleMap) -> 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 parse_args(argv, commands, default, default_args): - 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 - - -def entries(title_map): - def get_mtime(entry): - return entry.stat().st_mtime - - with os.scandir(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 - - title_map.mark_seen(name) - yield WatchLaterEntry(name=name, path=entry.path, mtime=mtime) - - -def hexdigest(entry): - return hashlib.md5(entry.encode("utf-8")).hexdigest().upper() - - -def list_entries(args, title_map): - for entry in entries(title_map): - print(entry.format(title_map)) - - -def add_entries(args, title_map): - for entry in args.rest: - path = os.path.join(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_entries(args, title_map): - for entry in args.rest: - path = os.path.join(watch_later_dir, hexdigest(entry)) - - try: - os.remove(path) - except FileNotFoundError: - pass - except OSError as e: - exit(e) - - -commands = { - "add": Command("add", add_entries), - "del": Command("del", delete_entries), - "list": Command("list", list_entries, implies_list=False, args=False), -} - -args = parse_args(sys.argv[1:], commands, "list", "add") - -title_map = TitleMap(title_map_file, update=args.update_titles) - -args.command.fun(args=args, title_map=title_map) - -if args.command.implies_list: - list_entries(args, title_map) - -title_map.maybe_commit() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..7794395 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,101 @@ +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "tomli" +version = "2.0.2" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "269640de7b0e5d44d470b09937cffa18fda6c61c5f997e2bbf72aee448bf314a" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7f5435f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "later" +version = "0.1.0" +description = "Manage watch_later entries as saved by mpv(1)" +authors = [ + "Juhani Krekelä <juhani@krekelä.fi>", + "Wolfgang Müller <wolf@oriole.systems>" +] +license = "MIT" +homepage = "https://git.oriole.systems/later" +repository = "https://git.oriole.systems/later" +documentation = "https://git.oriole.systems/later/about" + +[tool.poetry.scripts] +later = 'later.main:main' + +[tool.poetry.dependencies] +python = "^3.8" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" + +[tool.pytest.ini_options] +pythonpath = ["src"] + +[tool.ruff.lint] +# https://docs.astral.sh/ruff/rules/ +select = [ + "F", # pyflakes + "E", # pycodestyle + "W", # pycodestyle + "I", # isort + "UP", # pyupgrade + "A", # flake8-builtins + "B", # flake8-bugbear + "SIM", # flake8-simplify + "FURB" # refurb +] + +[tool.ruff.lint.flake8-builtins] +builtins-ignorelist = ["exit"] diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index d00d37e..0000000 --- a/ruff.toml +++ /dev/null @@ -1,18 +0,0 @@ -include = ["later"] - -[lint] -# https://docs.astral.sh/ruff/rules/ -select = [ - "F", # pyflakes - "E", # pycodestyle - "W", # pycodestyle - "I", # isort - "UP", # pyupgrade - "A", # flake8-builtins - "B", # flake8-bugbear - "SIM", # flake8-simplify - "FURB" # refurb -] - -[lint.flake8-builtins] -builtins-ignorelist = ["exit"] 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) |