#!/usr/bin/env python3 """Lookup TMDB ID for the TMDB browser tab on a Hyprland workspace and type " () [tmdb-]" via wtype. Uses the TMDB API v3 (stdlib only, no curl/jq needed). API key can be overridden without editing the file: export TMDB_API_KEY="your-key" Usage: tmdb.sh [workspace] [--print] --print / -p : print result to stdout instead of typing (for testing) """ import json import os import re import shutil import subprocess import sys import time import urllib.parse import urllib.request TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "7ba1c818fea0dec15d3f4d27bf1ffbdb") TMDB_API = "https://api.themoviedb.org/3" TMDB_LANG = "en-US" # Characters illegal on Windows (< > : " / \ | ? *) plus "/" (separator on # Linux/macOS too). Everything else - incl. numbers, unicode letters and # harmless punctuation like - _ . ' & , ! ( ) [ ] - is kept. UNSAFE_RE = re.compile(r'[/\\:*?"<>|]') CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") YEAR_PARENS_RE = re.compile(r"\s*\([^)]*\d{4}[^)]*\)") ANY_PARENS_RE = re.compile(r"\s*\([^)]*\)\s*") YEAR_RE = re.compile(r"(\d{4})") TV_RE = re.compile(r"TV\s+Series", re.IGNORECASE) UMLAUTS = str.maketrans({ "ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss", }) def die(msg: str) -> "NoReturn": print(f"tmdb: {msg}", file=sys.stderr) if shutil.which("notify-send"): subprocess.run(["notify-send", "tmdb.sh", msg], check=False) sys.exit(1) def clean_title(s: str) -> str: # Remove "(year)" / "(TV Series year...)" blocks; the wrapper re-adds # "(year)" itself so this avoids "Title (2013) (2013)". s = YEAR_PARENS_RE.sub("", s) s = s.translate(UMLAUTS) s = UNSAFE_RE.sub(" ", s) s = CONTROL_RE.sub(" ", s) s = re.sub(r"\s+", " ", s).strip() s = re.sub(r"[. ]+$", "", s).strip() return s def get_browser_title(workspace: int) -> str: try: out = subprocess.run( ["hyprctl", "clients", "-j"], capture_output=True, text=True, timeout=10, check=True, ) except FileNotFoundError: die("missing dependency: hyprctl") except subprocess.CalledProcessError as e: die(f"hyprctl failed: {e.stderr.strip() or e}") try: clients = json.loads(out.stdout) except json.JSONDecodeError as e: die(f"could not parse hyprctl output: {e}") for client in clients: try: ws = client.get("workspace", {}).get("id") except AttributeError: continue title = client.get("title", "") or "" if ws == workspace and "The Movie Database" in title: return title die(f"no TMDB tab found on workspace {workspace}") def strip_tmdb_suffix(title: str) -> str: # TMDB tab titles look like: "Dampfnudelblues (2013) — The Movie Database (TMDB)" for sep in (" — ", " -- ", " - The Movie Database"): if sep in title: title = title.split(sep, 1)[0] break return title.strip() def tmdb_search(media_type: str, query: str, want_year: str = ""): """Return (id, title, date) for the best hit, or None.""" if len(query) < 2: return None endpoint = f"{TMDB_API}/search/{'tv' if media_type == 'tv' else 'movie'}" params = { "api_key": TMDB_API_KEY, "language": TMDB_LANG, "page": "1", "include_adult": "false", "query": query, } url = endpoint + "?" + urllib.parse.urlencode(params) req = urllib.request.Request(url, headers={"Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=15) as resp: data = json.load(resp) except Exception as e: print(f"tmdb: API request failed: {e}", file=sys.stderr) return None if isinstance(data, dict) and data.get("success") is False: print(f"tmdb: API error: {data.get('status_message', 'unknown error')}", file=sys.stderr) return None results = data.get("results") or [] if want_year: for r in results: date = str(r.get("release_date") or r.get("first_air_date") or "") if date.startswith(want_year): return (r.get("id"), r.get("title") or r.get("name") or "", date) if results: r = results[0] return (r.get("id"), r.get("title") or r.get("name") or "", str(r.get("release_date") or r.get("first_air_date") or "")) return None def main() -> None: args = sys.argv[1:] do_print = any(a in ("-p", "--print", "--dry-run") for a in args) args = [a for a in args if a not in ("-p", "--print", "--dry-run")] ws = 3 if args: if not re.fullmatch(r"\d+", args[0]): die(f"invalid workspace id: {args[0]}") ws = int(args[0]) if not TMDB_API_KEY: die("TMDB_API_KEY is empty") if shutil.which("hyprctl") is None: die("missing dependency: hyprctl") if not do_print and shutil.which("wtype") is None: die("missing dependency: wtype") raw_title = get_browser_title(ws) title = strip_tmdb_suffix(raw_title).strip() search_title = ANY_PARENS_RE.sub(" ", title) search_title = re.sub(r"\s+", " ", search_title).strip() if not search_title: die(f"could not derive search title from: {title}") media_type = "tv" if TV_RE.search(title) else "movie" m = YEAR_RE.search(title) want_year = m.group(1) if m else "" result = tmdb_search(media_type, search_title, want_year) if result is None: fallback = "movie" if media_type == "tv" else "tv" result = tmdb_search(fallback, search_title, want_year) if result is None or result[0] is None: die(f"no TMDB result for: {search_title}") tmdb_id, api_title, api_date = result clean = clean_title(api_title) if api_title else clean_title(title) year = want_year if not year: m = YEAR_RE.search(api_date or "") year = m.group(1) if m else "" out = f"{clean} ({year}) [tmdb-{tmdb_id}]" if year else f"{clean} [tmdb-{tmdb_id}]" if do_print: print(out) return # Let the macro-pad key release before typing, otherwise held modifiers # can mangle/overwrite the typed text in the focused window. time.sleep(0.25) try: subprocess.run(["wtype", "--", out], check=True) except subprocess.CalledProcessError as e: die(f"wtype failed: {e}") if __name__ == "__main__": main()