196 lines
6.4 KiB
Bash
Executable File
196 lines
6.4 KiB
Bash
Executable File
#!/usr/bin/env python3
|
|
"""Lookup TMDB ID for the TMDB browser tab on a Hyprland workspace
|
|
and type "<Clean Title> (<year>) [tmdb-<id>]" 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() |