From b71659b357fa7cb07c88e91b275fa0c511a4aa1a Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Wed, 21 Oct 2026 17:05:01 +0200 Subject: [PATCH] dev/screenshots: the tooling for the README screenshots take.sh builds arr-tui, starts a fake Radarr, Sonarr and SABnzbd with a made-up library (catalog.py, server.py, generated posters), and runs the scene scripts in Ghostty under Xvfb, writing docs/screenshots/. Run on the same day it reproduces the committed pictures byte for byte. make test also shellchecks the scripts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW --- .gitignore | 2 + Makefile | 2 +- README.md | 3 +- dev/README.md | 5 + dev/screenshots/README.md | 27 ++++ dev/screenshots/catalog.py | 45 +++++++ dev/screenshots/posters.sh | 21 +++ dev/screenshots/scenes/config.txt | 5 + dev/screenshots/scenes/episodes.txt | 10 ++ dev/screenshots/scenes/light.txt | 7 + dev/screenshots/scenes/views.txt | 24 ++++ dev/screenshots/server.py | 194 ++++++++++++++++++++++++++++ dev/screenshots/take.sh | 65 ++++++++++ dev/screenshots/xshot.sh | 36 ++++++ 14 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 dev/screenshots/README.md create mode 100644 dev/screenshots/catalog.py create mode 100755 dev/screenshots/posters.sh create mode 100644 dev/screenshots/scenes/config.txt create mode 100644 dev/screenshots/scenes/episodes.txt create mode 100644 dev/screenshots/scenes/light.txt create mode 100644 dev/screenshots/scenes/views.txt create mode 100644 dev/screenshots/server.py create mode 100755 dev/screenshots/take.sh create mode 100755 dev/screenshots/xshot.sh diff --git a/.gitignore b/.gitignore index 83b4fa9..bbcf4d2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ config.json /dev/data/ *.swp *.swo +__pycache__/ +/dev/screenshots/posters/ diff --git a/Makefile b/Makefile index 8353731..0cc3c11 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ build: test: go vet ./... go test ./... - @if command -v shellcheck >/dev/null; then shellcheck scripts/*.sh dev/*.sh; else echo "shellcheck not installed, skipping shell script checks"; fi + @if command -v shellcheck >/dev/null; then shellcheck scripts/*.sh dev/*.sh dev/screenshots/*.sh; else echo "shellcheck not installed, skipping shell script checks"; fi # Cross-compiled binaries and checksums in dist/. dist: diff --git a/README.md b/README.md index aa380ff..b60bdf1 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,8 @@ Keep the file private (`chmod 600`); it holds your API keys. `dev/` has a compose file with pinned Radarr, Sonarr and SABnzbd containers plus a fake indexer; see [dev/README.md](dev/README.md). -`make test` runs vet and the unit tests. The design and +`make test` runs vet and the unit tests. The screenshots are made with +`dev/screenshots/take.sh` (see [its README](dev/screenshots/README.md)). The design and milestone history are in [SPECS.md](SPECS.md). Releases: tag and push, then `scripts/release.sh` (or `make release`) builds the binaries and publishes diff --git a/dev/README.md b/dev/README.md index 58640c8..6609201 100644 --- a/dev/README.md +++ b/dev/README.md @@ -50,3 +50,8 @@ dev/add-indexer.sh # registers it in the dev Radarr and Sonarr Add the movie / series to the dev instances first (e.g. from the TUI), then press `i` on it. To test with a real indexer, add it in the dev Radarr/Sonarr web UI (Settings, Indexers) instead of pasting credentials anywhere else. + +## Screenshots + +`dev/screenshots/take.sh` makes the README screenshots from a fake, made-up library; see +[screenshots/README.md](screenshots/README.md). diff --git a/dev/screenshots/README.md b/dev/screenshots/README.md new file mode 100644 index 0000000..0866b90 --- /dev/null +++ b/dev/screenshots/README.md @@ -0,0 +1,27 @@ +# README screenshots + +`take.sh` makes the pictures in `docs/screenshots/`: it builds arr-tui, starts a fake Radarr, +Sonarr and SABnzbd with a made-up library, and runs arr-tui in Ghostty on a virtual X display, +following the scene scripts. Nothing real is shown: invented titles, generated posters, dummy API +keys (masked in the Config view anyway). + +```sh +dev/screenshots/take.sh +# on NixOS, with the tools from nixpkgs: +nix-shell -p xvfb-run xorg-server xdotool imagemagick oxipng --run dev/screenshots/take.sh +``` + +Needs Go, Python 3, Ghostty, `xvfb-run` (Xvfb), `xdotool`, ImageMagick 7 and fontconfig with the +DejaVu fonts; `oxipng` (optional) compresses the PNGs losslessly. It takes about a minute. + +| File | What it is | +|---|---| +| `catalog.py` | The made-up library: movies (with file details) and series (with seasons). | +| `posters.sh` | Draws a poster per title into `posters/` (gradient, title, year); run by `take.sh` when `posters/` is missing. | +| `server.py` | The fake services on 127.0.0.1: Radarr 19891, Sonarr 19892, SABnzbd 19893. Library, search, releases, queues, history, calendar, posters. | +| `xshot.sh` | Runs a command in Ghostty under Xvfb and follows a scene script. | +| `scenes/*.txt` | What to press and when to take a picture: `key`, `type`, `sleep`, `shot `. | + +To change a picture, edit its scene (the key presses depend on the order of `catalog.py`, e.g. +"The Long Orbit" is the 13th movie alphabetically) and run `take.sh` again. Some views show today's +date (Calendar, Downloads), so every run changes those pictures a little. diff --git a/dev/screenshots/catalog.py b/dev/screenshots/catalog.py new file mode 100644 index 0000000..35d94b1 --- /dev/null +++ b/dev/screenshots/catalog.py @@ -0,0 +1,45 @@ +# Fictional library for the README screenshots: invented titles, generated posters. +MOVIES = [ + # id, title, year, genres, studio, runtime, rating, file (res, codec, dr, audio, lang, size GB) or None, monitored, overview, colors + (1,"The Long Orbit",2023,["Science Fiction","Drama"],"Halcyon Pictures",142,8.1,(2160,"x265","DV HDR10","TrueHD","Atmos","English",58.2),True, + "When a deep-space freighter loses contact with Earth, its last remaining engineer must decide whether to keep the ship on course for a colony that may no longer exist, or turn back on a journey that will take the rest of her life.",("#0b132b","#5bc0be")), + (2,"Paper Lanterns",2019,["Drama","Romance"],"Northlight",118,7.4,(1080,"x264","","DTS","","English",12.4),True, + "Two strangers meet every year at the same river festival, and over a decade of single nights their lives drift together and apart.",("#3d0c02","#f4a259")), + (3,"Iron Meridian",2021,["Action","Thriller"],"Blackstone",131,6.9,(2160,"x265","HDR10","EAC3","Atmos","English",41.7),True, + "A former border guard is pulled back into service when a smuggling route through the mountains starts moving something far more dangerous than contraband.",("#1b1b1b","#c1121f")), + (4,"The Quiet Archive",2024,["Mystery"],"Fernwood",109,7.8,None,True, + "A librarian cataloguing a dead author's papers finds letters addressed to people who were never born.",("#2b2d42","#8d99ae")), + (5,"Saltwater Kings",2017,["Adventure","Comedy"],"Gull Island",101,6.6,(1080,"x265","","AAC","","English",4.9),True, + "Three washed-up fishermen bet their boats on one last race around the cape.",("#023e8a","#90e0ef")), + (6,"Glass Mountain",2022,["Science Fiction","Thriller"],"Halcyon Pictures",127,7.2,(2160,"x265","DV","TrueHD","Atmos","English",49.0),True, + "An avalanche researcher discovers that the glacier she studies is moving against the slope.",("#03045e","#caf0f8")), + (7,"Midnight at the Odeon",2015,["Comedy","Crime"],"Starling",96,7.0,(1080,"x264","","DTS","","English",9.1),True, + "A cinema projectionist and a pickpocket team up for one heist during the last screening before the theatre closes for good.",("#370617","#f48c06")), + (8,"Borrowed Summers",2020,["Drama"],"Northlight",112,7.6,(1080,"x265","HDR10","EAC3","","French",8.3),True, + "A grandmother teaches her grandson to sail on the lake where she once lost everything.",("#606c38","#fefae0")), + (9,"The Cartographer's Daughter",2025,["Fantasy","Adventure"],"Fernwood",138,0,None,True, + "The daughter of a map-maker sets out to chart the island that her father swore was real.",("#283618","#dda15e")), + (10,"Static",2018,["Horror"],"Blackstone",94,6.1,(1080,"x264","","AC3","","English",6.0),False, + "A late-night radio host starts receiving calls from listeners who say the broadcast has already ended.",("#000000","#6c757d")), + (11,"Neon Harbour",2023,["Crime","Thriller"],"Starling",121,7.3,(2160,"x265","HDR10+","DTS-HD MA","","English",44.5),True, + "A detective in a flooded port city follows a trail of stolen streetlights to a conspiracy at city hall.",("#10002b","#e0aaff")), + (12,"Wild Clover",2016,["Family","Animation"],"Gull Island",88,7.9,(1080,"x265","","AAC","","German",3.2),True, + "A rabbit who can't stop counting sets out to find the last four-leaf clover in the valley.",("#2d6a4f","#b7e4c7")), + (13,"Under the Same Sky",2024,["Drama","War"],"Northlight",146,8.3,(2160,"x265","DV HDR10","TrueHD","Atmos","English",61.8),True, + "Two soldiers on opposite sides of a frozen front share a single radio frequency through one long winter.",("#14213d","#e5e5e5")), + (14,"Hollow Point Road",2021,["Western"],"Blackstone",117,6.8,None,False, + "A retired sheriff escorts a stranger across a county that wants them both gone.",("#7f5539","#ede0d4")), + (15,"The Last Lighthouse",2026,["Drama","Mystery"],"Fernwood",0,0,None,True, + "The keeper of the coast's last manned lighthouse refuses to leave when the automation crew arrives.",("#1d3557","#f1faee")), + (16,"Copper & Sage",2019,["Romance","Comedy"],"Starling",104,6.4,(1080,"x264","","AC3","","English",7.7),True, + "Rival food-truck owners are forced to share a parking spot for a summer.",("#bc6c25","#fefae0")), +] +SERIES = [ + # id, title, year, network, status, seasons [(episodes, files)], overview, colors + (1,"Signal Fire",2021,"Aurora","continuing",[(8,8),(8,8),(10,4)],"A volunteer fire crew in a remote mountain town keeps the region safe while hiding a secret about the fire that founded it.",("#6a040f","#ffba08")), + (2,"Low Tide",2019,"Coastline","ended",[(6,6),(6,6)],"A marine biologist returns to her hometown and reopens a case everyone wanted closed.",("#03045e","#48cae4")), + (3,"The Night Shift",2023,"Aurora","continuing",[(10,10),(10,3)],"Stories from the staff of a city hospital between midnight and dawn.",("#240046","#c77dff")), + (4,"Hearth & Home",2018,"Meadow","ended",[(12,12),(12,12),(12,12)],"A family restaurant, three generations, one kitchen.",("#9c6644","#ede0d4")), + (5,"Orbital",2025,"Vector","continuing",[(8,2)],"The crew of a commercial space station tries to stay profitable, and alive.",("#000814","#ffc300")), + (6,"Ferryman",2020,"Coastline","ended",[(6,0)],"A ferry captain becomes the only link between an island and the mainland during a long quarantine.",("#2f3e46","#cad2c5")), +] diff --git a/dev/screenshots/posters.sh b/dev/screenshots/posters.sh new file mode 100755 index 0000000..0de000a --- /dev/null +++ b/dev/screenshots/posters.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Generate a poster for every title in catalog.py: a gradient with the title +# and year, 500x750 JPEG in posters/. Needs ImageMagick 7 and fontconfig. +set -euo pipefail +cd "$(dirname "$0")" + +font=$(fc-match -f '%{file}' 'DejaVu Serif:style=Bold') +small=$(fc-match -f '%{file}' 'DejaVu Sans') +mkdir -p posters +python3 -c ' +import catalog +for m in catalog.MOVIES: print("m%d|%s|%d|%s|%s" % (m[0], m[1], m[2], m[10][0], m[10][1])) +for s in catalog.SERIES: print("s%d|%s|%d|%s|%s" % (s[0], s[1], s[2], s[7][0], s[7][1])) +' | while IFS='|' read -r id title year c1 c2; do + magick -size 500x750 "gradient:$c1-$c2" \ + \( -size 420x320 -background none -fill white -font "$font" -gravity center caption:"$title" \) \ + -gravity north -geometry +0+120 -composite \ + \( -size 420x60 -background none -fill '#ffffffcc' -font "$small" -pointsize 30 -gravity center caption:"$year" \) \ + -gravity south -geometry +0+70 -composite \ + -quality 88 "posters/$id.jpg" +done diff --git a/dev/screenshots/scenes/config.txt b/dev/screenshots/scenes/config.txt new file mode 100644 index 0000000..f38594c --- /dev/null +++ b/dev/screenshots/scenes/config.txt @@ -0,0 +1,5 @@ +# The Config view; the API keys are masked. +sleep 2 +key F6 +sleep 3 +shot config diff --git a/dev/screenshots/scenes/episodes.txt b/dev/screenshots/scenes/episodes.txt new file mode 100644 index 0000000..a605600 --- /dev/null +++ b/dev/screenshots/scenes/episodes.txt @@ -0,0 +1,10 @@ +# The seasons and episodes of Signal Fire (5th series). +sleep 2 +key F3 +sleep 3 +key Tab +key Down Down Down Down +sleep 1 +key Return +sleep 3 +shot episodes diff --git a/dev/screenshots/scenes/light.txt b/dev/screenshots/scenes/light.txt new file mode 100644 index 0000000..49f0c60 --- /dev/null +++ b/dev/screenshots/scenes/light.txt @@ -0,0 +1,7 @@ +# The library in the light theme, Neon Harbour (7th movie) selected. +sleep 2 +key F3 +sleep 3 +key Down Down Down Down Down Down +sleep 2 +shot library-light diff --git a/dev/screenshots/scenes/views.txt b/dev/screenshots/scenes/views.txt new file mode 100644 index 0000000..d2c3f15 --- /dev/null +++ b/dev/screenshots/scenes/views.txt @@ -0,0 +1,24 @@ +# Search, library, releases, downloads and calendar, in the dark theme. +sleep 2 +key F1 +type orbit +sleep 3 +shot search +key F3 +sleep 3 +# The Long Orbit, 13th in the list +key Down Down Down Down Down Down Down Down Down Down Down Down +sleep 2 +shot library +key i +sleep 3 +shot releases +key Escape +key F4 +sleep 4 +shot downloads +key F5 +sleep 3 +key Down +sleep 2 +shot calendar diff --git a/dev/screenshots/server.py b/dev/screenshots/server.py new file mode 100644 index 0000000..b11553b --- /dev/null +++ b/dev/screenshots/server.py @@ -0,0 +1,194 @@ +"""Fake Radarr (19891), Sonarr (19892) and SABnzbd (19893) serving the made-up +library in catalog.py, for the README screenshots. Only the API calls arr-tui +makes are answered; posters come from posters/ (see posters.sh).""" +import datetime, json, os, sys, threading, urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import catalog + +HERE = os.path.dirname(os.path.abspath(__file__)) +TODAY = datetime.date.today() +GB = 1 << 30 +RADARR, SONARR, SAB = 19891, 19892, 19893 + +def day(n): return (TODAY + datetime.timedelta(days=n)).isoformat() + +def mediafile(res, codec, dr, audio, extra, lang, gb, name): + return {"size": int(gb * GB), "sceneName": name, "relativePath": name + ".mkv", + "quality": {"quality": {"name": ("Bluray-%dp" % res) if res == 2160 else ("WEBDL-%dp" % res), "resolution": res}}, + "mediaInfo": {"videoCodec": codec, "videoDynamicRange": "HDR" if dr else "", "videoDynamicRangeType": dr, + "audioCodec": audio, "audioAdditionalFeatures": extra}, + "languages": [{"name": lang}]} + +def poster(port, kind, id): + local = "/MediaCover/%d/poster.jpg" % id + return [{"coverType": "poster", "url": local, "remoteUrl": "http://127.0.0.1:%d/remote/%s%d.jpg" % (port, kind, id)}] + +def movies(): + out = [] + for (id, title, year, genres, studio, runtime, rating, f, mon, overview, _c) in catalog.MOVIES: + m = {"id": id, "title": title, "year": year, "genres": genres, "studio": studio, "runtime": runtime, + "overview": overview, "tmdbId": 1000 + id, "titleSlug": title.lower().replace(" ", "-"), + "monitored": mon, "hasFile": f is not None, "isAvailable": year < 2026, "status": "released" if year < 2026 else "announced", + "path": "/movies/%s (%d)" % (title, year), "images": poster(RADARR, "m", id), "qualityProfileId": 4, + "ratings": {"imdb": {"value": rating, "votes": 12000}} if rating else {}} + if f: + res, codec, dr, audio, extra, lang, gb = f + name = "%s.%d.%dp.%s%s" % (title.replace(" ", "."), year, res, (dr.replace(" ", ".") + ".") if dr else "", codec) + m["movieFile"] = mediafile(res, codec, dr, audio, extra, lang, gb, name) + m["sizeOnDisk"] = int(gb * GB) + out.append(m) + return out + +def series(): + out = [] + for (id, title, year, network, status, seasons, overview, _c) in catalog.SERIES: + eps = sum(s[0] for s in seasons); files = sum(s[1] for s in seasons) + out.append({"id": id, "title": title, "year": year, "network": network, "status": status, "overview": overview, + "runtime": 45, "genres": ["Drama"], "tvdbId": 2000 + id, "titleSlug": title.lower().replace(" ", "-"), + "images": poster(SONARR, "s", id), "monitored": True, "ratings": {"value": 8.0, "votes": 900}, + "path": "/tv/" + title, + "seasons": [{"seasonNumber": i + 1, "monitored": True, + "statistics": {"episodeFileCount": f, "episodeCount": e, "totalEpisodeCount": e, "sizeOnDisk": f * 2 * GB}} + for i, (e, f) in enumerate(seasons)], + "statistics": {"seasonCount": len(seasons), "episodeFileCount": files, "episodeCount": eps, + "totalEpisodeCount": eps, "sizeOnDisk": files * 2 * GB}}) + return out + +EPISODE_TITLES = ["Kindling", "The Ridge", "Backdraft", "Smoke Line", "Ember", "Firebreak", "Updraft", "Ash", + "Tinder", "Afterglow", "Spark", "Wildland"] + +def episodes(series_id): + s = next(x for x in catalog.SERIES if x[0] == series_id) + out, eid = [], series_id * 1000 + for si, (count, files) in enumerate(s[5]): + for e in range(count): + eid += 1 + aired = day(-(len(s[5]) - si) * 90 + e * 7) + out.append({"id": eid, "seriesId": series_id, "seasonNumber": si + 1, "episodeNumber": e + 1, + "title": EPISODE_TITLES[e % len(EPISODE_TITLES)], "airDate": aired, "airDateUtc": aired + "T20:00:00Z", + "overview": "The crew faces a long night on the ridge as the wind turns.", "hasFile": e < files, "monitored": True}) + return out + +def releases(title, year): + base = title.replace(" ", ".") + rows = [ + ("%s.%d.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FRAMES" % (base, year), "NZBgeek", 68.4, 2160, "Remux-2160p", True, [], 2, ["DV", "HDR10", "Atmos"]), + ("%s.%d.2160p.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX" % (base, year), "DrunkenSlug", 21.9, 2160, "WEBDL-2160p", True, [], 1, ["DV", "Atmos"]), + ("%s.%d.1080p.BluRay.DTS-HD.MA.5.1.x264-SPARKS" % (base, year), "NZBgeek", 14.2, 1080, "Bluray-1080p", True, [], 6, []), + ("%s.%d.1080p.WEB-DL.DDP5.1.H.264-NTb" % (base, year), "NZBFinder", 6.8, 1080, "WEBDL-1080p", True, [], 3, []), + ("%s.%d.2160p.WEB-DL.DDP5.1.HDR10.HEVC-GERMAN-iNTENSO" % (base, year), "DrunkenSlug", 18.1, 2160, "WEBDL-2160p", False, ["Language is not wanted"], 4, ["HDR10"]), + ("%s.%d.720p.WEBRip.x264-ION10" % (base, year), "NZBFinder", 1.9, 720, "WEBRip-720p", False, ["Quality is not wanted in profile"], 9, []), + ("%s.%d.1080p.HDTV.x264-CAM" % (base, year), "NZBgeek", 2.4, 1080, "HDTV-1080p", False, ["Release is a CAM", "Quality is not wanted in profile"], 1, []), + ("%s.%d.2160p.BluRay.x265.10bit.HDR.DTS-HD.MA-TERMiNAL" % (base, year), "NZBFinder", 34.0, 2160, "Bluray-2160p", True, [], 12, ["HDR10"]), + ] + out = [] + for i, (name, idx, gb, res, q, ok, rej, days, cfs) in enumerate(rows): + out.append({"guid": "g%d" % i, "title": name, "indexer": idx, "indexerId": 1, "protocol": "usenet", "size": int(gb * GB), + "ageMinutes": days * 1440.0, "qualityWeight": res, "customFormatScore": 100 * len(cfs), "releaseGroup": name.rsplit("-", 1)[-1], + "approved": ok, "rejected": not ok, "rejections": rej, + "quality": {"quality": {"name": q, "resolution": res}}, + "languages": [{"name": "German" if "GERMAN" in name else "English"}], + "customFormats": [{"name": c} for c in cfs]}) + return out + +def radarr(path, q): + if path.endswith("/system/status"): return {"appName": "Radarr", "version": "5.26.2"} + if path.endswith("/movie"): return movies() + if "/movie/" in path and path.rsplit("/", 1)[1].isdigit(): + return next(x for x in movies() if x["id"] == int(path.rsplit("/", 1)[1])) + if path.endswith("/movie/lookup"): + term = q.get("term", [""])[0].lower() + res = [m for m in movies() if term and term in m["title"].lower()] + extra = [{"id": 0, "title": "The Long Orbit: Return", "year": 2027, "overview": "The sequel.", "tmdbId": 9001, "titleSlug": "the-long-orbit-return", + "images": [{"coverType": "poster", "remoteUrl": "http://127.0.0.1:%d/remote/m1.jpg" % RADARR}], "status": "announced", "ratings": {}}] + return res + (extra if "orbit" in term else []) + if path.endswith("/qualityprofile"): return [{"id": 1, "name": "Any"}, {"id": 4, "name": "HD-1080p"}, {"id": 5, "name": "Ultra-HD"}] + if path.endswith("/rootfolder"): return [{"id": 1, "path": "/movies", "freeSpace": 3 << 40, "accessible": True}] + if path.endswith("/release"): + m = next(x for x in catalog.MOVIES if x[0] == int(q["movieId"][0])) + return releases(m[1], m[2]) + if path.endswith("/queue"): + return {"records": [ + {"id": 1, "title": "Iron.Meridian.2021.2160p.WEB-DL.DDP5.1.Atmos.HDR.H.265-FLUX", "status": "downloading", "trackedDownloadStatus": "ok", + "trackedDownloadState": "downloading", "size": 21.9 * GB, "sizeleft": 7.2 * GB, "timeleft": "00:06:12", "downloadClient": "SABnzbd", + "downloadId": "SABnzbd_nzo_a1", "protocol": "usenet", "movie": {"title": "Iron Meridian", "year": 2021}}, + {"id": 2, "title": "Static.2018.1080p.BluRay.x264-AMIABLE", "status": "completed", "trackedDownloadStatus": "warning", + "trackedDownloadState": "importBlocked", "size": 6.0 * GB, "sizeleft": 0, "timeleft": "", "downloadClient": "SABnzbd", + "downloadId": "SABnzbd_nzo_h3", "protocol": "usenet", "statusMessages": [{"title": "Static.2018.1080p", "messages": ["Not an upgrade for existing movie file"]}], + "movie": {"title": "Static", "year": 2018}}]} + if path.endswith("/calendar"): + out = [] + for off, mid, kind in [(1, 9, "digitalRelease"), (5, 15, "inCinemas"), (9, 4, "physicalRelease")]: + m = dict(next(x for x in movies() if x["id"] == mid)); m[kind] = day(off) + "T00:00:00Z"; out.append(m) + return out + return [] + +def sonarr(path, q): + if path.endswith("/system/status"): return {"appName": "Sonarr", "version": "4.0.15"} + if path.endswith("/series"): return series() + if "/series/" in path and path.rsplit("/", 1)[1].isdigit(): + return next(x for x in series() if x["id"] == int(path.rsplit("/", 1)[1])) + if path.endswith("/episode"): return episodes(int(q["seriesId"][0])) + if path.endswith("/episodefile"): return [] + if path.endswith("/qualityprofile"): return [{"id": 1, "name": "Any"}, {"id": 4, "name": "HD-1080p"}] + if path.endswith("/rootfolder"): return [{"id": 1, "path": "/tv", "freeSpace": 3 << 40, "accessible": True}] + if path.endswith("/queue"): + return {"records": [ + {"id": 7, "title": "Signal.Fire.S03E05.1080p.WEB-DL.DDP5.1.H.264-NTb", "status": "downloading", "trackedDownloadStatus": "ok", + "trackedDownloadState": "downloading", "size": 2.1 * GB, "sizeleft": 1.3 * GB, "timeleft": "00:01:40", "downloadClient": "SABnzbd", + "downloadId": "SABnzbd_nzo_a2", "protocol": "usenet", "series": {"title": "Signal Fire"}, + "episode": {"seasonNumber": 3, "episodeNumber": 5, "title": "Ember"}}]} + if path.endswith("/calendar"): + out = [] + for off, sid, s, e, t in [(0, 1, 3, 7, "Updraft"), (2, 3, 2, 4, "Code Blue"), (4, 5, 1, 3, "Dock"), (7, 1, 3, 8, "Ash"), (11, 3, 2, 5, "Triage")]: + ser = next(x for x in series() if x["id"] == sid) + out.append({"id": 90000 + off, "seriesId": sid, "seasonNumber": s, "episodeNumber": e, "title": t, + "airDate": day(off), "airDateUtc": day(off) + "T20:00:00Z", "monitored": True, "hasFile": False, + "overview": "A new episode of " + ser["title"] + ".", "series": ser}) + return out + return [] + +def sab(q): + mode = q.get("mode", [""])[0] + if mode == "version": return {"version": "4.5.1"} + if mode == "queue": + return {"queue": {"status": "Downloading", "paused": False, "kbpersec": "48213", "speedlimit_abs": "0", "size": "26.4 GB", "sizeleft": "9.1 GB", + "timeleft": "0:08:05", "noofslots_total": 3, "slots": [ + {"nzo_id": "nzo_a1", "filename": "Iron.Meridian.2021.2160p.WEB-DL.DDP5.1.Atmos.HDR.H.265-FLUX", "cat": "movies", "status": "Downloading", + "size": "21.9 GB", "sizeleft": "7.2 GB", "mb": "22425", "mbleft": "7372", "percentage": "67", "timeleft": "0:06:12"}, + {"nzo_id": "nzo_a2", "filename": "Signal.Fire.S03E05.1080p.WEB-DL.DDP5.1.H.264-NTb", "cat": "tv", "status": "Downloading", + "size": "2.1 GB", "sizeleft": "1.3 GB", "mb": "2150", "mbleft": "1331", "percentage": "38", "timeleft": "0:01:40"}, + {"nzo_id": "nzo_a3", "filename": "Hearth.and.Home.S03.1080p.BluRay.x264-ROVERS", "cat": "tv", "status": "Queued", + "size": "2.4 GB", "sizeleft": "2.4 GB", "mb": "2457", "mbleft": "2457", "percentage": "0", "timeleft": "0:00:00"}]}} + if mode == "history": + now = int(datetime.datetime.now().timestamp()) + return {"history": {"slots": [ + {"nzo_id": "nzo_h1", "name": "The.Long.Orbit.2023.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos-FRAMES", "category": "movies", "status": "Completed", "size": "58.2 GB", "fail_message": "", "completed": now - 3600}, + {"nzo_id": "nzo_h2", "name": "Signal.Fire.S03E04.1080p.WEB-DL.DDP5.1.H.264-NTb", "category": "tv", "status": "Completed", "size": "2.0 GB", "fail_message": "", "completed": now - 7200}, + {"nzo_id": "nzo_h3", "name": "Static.2018.1080p.BluRay.x264-AMIABLE", "category": "movies", "status": "Completed", "size": "6.0 GB", "fail_message": "", "completed": now - 20000}, + {"nzo_id": "nzo_h4", "name": "Low.Tide.S02E06.720p.HDTV.x264-KILLERS", "category": "tv", "status": "Failed", "size": "1.1 GB", "fail_message": "Aborted, cannot be completed - https://sabnzbd.org/not-complete", "completed": now - 90000}, + {"nzo_id": "nzo_h5", "name": "Neon.Harbour.2023.2160p.WEB-DL.DTS-HD.MA.HDR10Plus.HEVC-SMURF", "category": "movies", "status": "Completed", "size": "44.5 GB", "fail_message": "", "completed": now - 180000}]}} + return {} + +def handler(port): + class H(BaseHTTPRequestHandler): + def log_message(self, *a): pass + def do_GET(self): + u = urllib.parse.urlparse(self.path); q = urllib.parse.parse_qs(u.query); path = u.path + if path.startswith("/MediaCover/") or path.startswith("/remote/"): + if path.startswith("/MediaCover/"): + id = path.split("/")[2]; f = ("m" if port == RADARR else "s") + id + ".jpg" + else: + f = path.rsplit("/", 1)[1] + p = os.path.join(HERE, "posters", f) + if not os.path.exists(p): self.send_response(404); self.end_headers(); return + data = open(p, "rb").read() + self.send_response(200); self.send_header("Content-Type", "image/jpeg"); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data); return + out = sab(q) if port == SAB else (radarr(path, q) if port == RADARR else sonarr(path, q)) + b = json.dumps(out).encode() + self.send_response(200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b) + return H + +for port in (RADARR, SONARR): + threading.Thread(target=ThreadingHTTPServer(("127.0.0.1", port), handler(port)).serve_forever, daemon=True).start() +ThreadingHTTPServer(("127.0.0.1", SAB), handler(SAB)).serve_forever() diff --git a/dev/screenshots/take.sh b/dev/screenshots/take.sh new file mode 100755 index 0000000..00086fe --- /dev/null +++ b/dev/screenshots/take.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Take the README screenshots: build arr-tui, start the fake services with the +# made-up library, and run every scene in Ghostty on a virtual X display. The +# pictures go to docs/screenshots/ (losslessly compressed if oxipng is there). +# +# Needs: go, python3, ghostty, xvfb-run (Xvfb), xdotool, ImageMagick 7, +# fontconfig with DejaVu fonts; oxipng optional. On NixOS: +# nix-shell -p xvfb-run xorg-server xdotool imagemagick oxipng --run dev/screenshots/take.sh +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +repo=$(cd "$here/../.." && pwd) +out=$repo/docs/screenshots +# The Config view shows the config file's path; keep it neutral. +demo=/tmp/arr-tui-demo + +for tool in go python3 ghostty xvfb-run xdotool magick fc-match; do + command -v "$tool" >/dev/null || { echo "take.sh: $tool is missing" >&2; exit 1; } +done + +work=$(mktemp -d) +cleanup() { + [[ -n ${server:-} ]] && kill "$server" 2>/dev/null + rm -rf "$work" "$demo" +} +trap cleanup EXIT + +echo "building arr-tui" +go build -C "$repo" -o "$work/arr-tui" ./cmd/arr-tui + +[[ -d $here/posters ]] || { echo "generating posters"; "$here/posters.sh"; } + +echo "starting the fake Radarr, Sonarr and SABnzbd" +python3 "$here/server.py" & +server=$! +sleep 1 + +# One config per theme, each at $demo//.config/arr-tui/config.json. +for theme in dark light; do + mkdir -p "$demo/$theme/.config/arr-tui" + cat >"$demo/$theme/.config/arr-tui/config.json" </dev/null; then + oxipng -o 4 --strip safe -q "$out"/*.png +fi +echo "screenshots in $out:" +ls "$out" diff --git a/dev/screenshots/xshot.sh b/dev/screenshots/xshot.sh new file mode 100755 index 0000000..772376e --- /dev/null +++ b/dev/screenshots/xshot.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Run a command in Ghostty on a virtual X display and follow a scene script. +# +# xshot.sh "" +# +# Scene lines: key | type | sleep | shot +# (shot saves the Ghostty window as /.png). +set -euo pipefail +scene=$1 out=$2 extra=$3 +shift 3 +export GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 GSK_RENDERER=gl +unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS + +# shellcheck disable=SC2016 # expanded by the inner shell +xvfb-run -a -s "-screen 0 1800x1200x24" bash -c ' + scene=$1 out=$2 extra=$3 + shift 3 + # shellcheck disable=SC2086 # extra holds several arguments + ghostty --gtk-single-instance=false --class=arrshot --window-width=132 --window-height=38 \ + --window-decoration=false --confirm-close-surface=false --font-size=12 \ + --window-padding-x=8 --window-padding-y=6 $extra -e "$@" >/dev/null 2>&1 & + ghostty=$! + sleep 7 # Ghostty with software rendering takes a while to show its window + wid=$(xdotool search --onlyvisible --pid "$ghostty" | tail -n1) + while read -r cmd args; do + case $cmd in + key) for k in $args; do xdotool key "$k"; sleep 0.25; done ;; + type) xdotool type --delay 80 "$args"; sleep 0.5 ;; + sleep) sleep "$args" ;; + shot) import -window "$wid" "$out/$args.png" ;; + ""|"#"*) ;; + *) echo "unknown scene command: $cmd" >&2; exit 1 ;; + esac + done < "$scene" + kill "$ghostty" 2>/dev/null || true +' _ "$scene" "$out" "$extra" "$@"