diff --git a/apps/arrcomingsoon/arr_coming_soon.star b/apps/arrcomingsoon/arr_coming_soon.star new file mode 100644 index 000000000..588555bd0 --- /dev/null +++ b/apps/arrcomingsoon/arr_coming_soon.star @@ -0,0 +1,751 @@ +""" +Applet: Coming Soon for Sonarr/Radarr +Summary: What your *arr drops next +Description: Tonight's episodes and this week's movie releases, straight from +your own Sonarr or Radarr calendar. +Author: nsluke + +Layout (64x32): a 7px header (service glyph + TONIGHT / THIS WEEK + a "+N" +overflow counter), a 1px rule, then two 12px item rows. Sonarr rows are +series-title marquee over S05E03 + air time/weekday; Radarr rows are title +marquee over release type (DIGITAL / CINEMA / DISC) + weekday. A green pip +after the episode label means Sonarr already has the file. + +Layout (64x64): the same header, rule and item rows - the square panel just +holds twice the list. Its 56 rows of body divide into four 14px slots, so +four items show instead of two, each with a pixel of air above and below it, +and the "+N" counter starts counting from the fourth row instead of the +second. A list of fewer than four items centres in that body, exactly as a +status card's message does, so a quiet calendar sits on the panel's middle +instead of leaving three quarters of it black. + +Ordering is "what's next", not "what's on the calendar today": an episode +that already aired more than AIRED_GRACE hours ago sinks below everything +still upcoming and only ever appears as filler, so a two-row panel headed +TONIGHT cannot fill up with this morning's dailies. + +Data: the user's OWN instance on their LAN - GET /api/v3/calendar with +an X-Api-Key header. Shapes verified against both projects' published OpenAPI +specs (2026-09): Sonarr returns EpisodeResource[] (title, series.title and +airDateUtc are all nullable; includeSeries=true is required for the series +title), Radarr returns MovieResource[] (title nullable; inCinemas / +digitalRelease / physicalRelease each nullable). + +Two timezone rules, and they differ on purpose: Sonarr's airDateUtc is a real +instant, so it is converted through the configured location (an 01:00Z airdate +IS tonight in the US). Radarr's release fields are midnight-UTC stand-ins for +plain DATES - converting those through a US timezone would shift every release +a day early, so only the date part is used, unconverted. + +With no URL/key configured the app renders a built-in demo card (zero +network) so the default install always shows something worth looking at. +""" + +load("cache.star", "cache") +load("encoding/json.star", "json") +load("http.star", "http") +load("re.star", "re") +load("render.star", "canvas", "render") +load("schema.star", "schema") +load("time.star", "time") + +FRESH_TTL = 900 # got data: don't re-poll the LAN box for 15 min +RETRY_TTL = 60 # any failure: back off a minute before the next attempt +STALE_TTL = 86400 # serve the last good extract up to a day through an outage +AUTH_TTL = 3600 # a diagnosed bad key re-checks hourly at worst + +# How long an episode stays "now" after its air time. Long enough that a show +# still on air leads the panel, short enough that this morning's daily does +# not outrank tonight's premiere. +AIRED_GRACE = 3.0 + +WHITE = "#FFFFFF" +GREY = "#9C9C9C" +DIM = "#5A5A5A" +RULE = "#333333" +GREEN = "#3DDC84" +RED = "#FF6B6B" + +SONARR_BLUE = "#35C5F4" +RADARR_GOLD = "#FFC230" + +DAYS = {"1": 1, "3": 3, "7": 7} + +# Item rows the body can hold under the 7px header + 1px rule. The 2:1 panel +# leaves 24 rows, i.e. two 12px items; the square panel leaves 56, i.e. four +# 14px slots (the same 12px item with a pixel of air above and below it). +WIDE_ROWS = 2 +SQUARE_ROWS = 4 + +# Height of the body a panel leaves below the header + rule. Used twice, and +# for the same reason both times: a status card's message box is the whole +# body so CAN'T CONNECT centres instead of hugging the top edge, and a square +# list shorter than SQUARE_ROWS centres in it for the same reason. +WIDE_BODY = 24 +SQUARE_BODY = 56 + +# RFC3339 sniff: time.parse_time on a malformed string ABORTS the render +# (uncatchable), so nothing reaches it without matching one of these first. +# The regex only checks SHAPE - "T99:00:00Z" still matches - so valid_instant +# range-checks the digits too (verified on the local binary: out-of-range +# hour/month/day/second all abort parse_time; offsets and time.time do not). +TS_RE = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$" +DATE_RE = r"^\d{4}-\d{2}-\d{2}$" + +# 5x5 pixel service glyphs: Sonarr's sonar target, a play arrow for Radarr. +GLYPH_SONARR = [ + ".###.", + "#...#", + "#.#.#", + "#...#", + ".###.", +] +GLYPH_RADARR = [ + "#....", + "###..", + "#####", + "###..", + "#....", +] + +def main(config): + service = "radarr" if config.get("service", "sonarr") == "radarr" else "sonarr" + days = DAYS.get(config.get("days", "7"), 7) + tz = get_tz(config) + + # "api" is a CLI-only test hook (no schema field, per the exemplar - a + # visible field would silently override the real Server URL on phones). + base = (config.get("api", "") or config.get("url", "") or "").strip().rstrip("/") + key = (config.get("apikey", "") or "").strip() + now = time.now().in_location(tz) + + if base == "" or key == "": + items = demo_items(service, now, tz) + return render_list(service, items, now, tz, days, demo = True) + + # A schemeless/malformed base (the single most likely URL typo) is the one + # transport error that IS detectable before http.get - which would abort + # the render uncatchably once per RETRY_TTL, forever, since the URL never + # heals on its own. Catch it pre-flight and say so instead. Case-blind: + # phone keyboards capitalize, and Go's URL parser accepts "Http://" + # (verified on the local binary against the stub). + low = base.lower() + if not (low.startswith("http://") or low.startswith("https://")) or host_of(base) == "": + return card(service, "CHECK URL", host_of(base) or base, RED) + + got = get_calendar(service, base, key, days, tz, now) + if got == "auth": + return card(service, "CHECK API KEY", "unauthorized", RED) + if got == "service": + other = "sonarr" if service == "radarr" else "radarr" + return card(service, "CHECK SERVICE", "got " + other + " data", RED) + if got == None: + return card(service, "CAN'T CONNECT", host_of(base), GREY) + return render_list( + service, + got["items"], + now, + tz, + days, + demo = False, + stale_host = host_of(base) if got["stale"] else None, + ) + +def get_tz(config): + """in_location() on an unknown zone name ABORTS the render (uncatchable), + so every timezone string - the Location config's and the runtime's $tz - + goes through time.is_valid_timezone first (verified present on local + pixlet 0.34: Bogus/Zone -> False, real IANA names -> True).""" + loc = config.get("location") + if loc != None: + loc = loc.strip() + if loc.startswith("{") and loc.endswith("}"): + obj = json.decode(loc, None) # default: bad JSON must not abort + if type(obj) == "dict": + tz = obj.get("timezone", "") + if type(tz) == "string" and tz != "" and time.is_valid_timezone(tz): + return tz + tz = config.get("$tz") or "America/New_York" + return tz if time.is_valid_timezone(tz) else "America/New_York" + +def host_of(base): + i = base.find("://") + host = base[i + 3:] if i >= 0 else base + j = host.find("/") + return host[:j] if j >= 0 else host + +# ---------------------------------------------------------------- data layer + +def get_calendar(service, base, key, days, tz, now): + """Returns {"items": [...], "stale": bool} for usable data, "auth" for a + rejected key, "service" for a service/URL mismatch, or None when the + server is unreachable and nothing usable is cached. "stale" says the live + fetch failed and these items came out of the cache - the caller needs it, + because an empty STALE extract is an outage, not an empty calendar. The + cache key carries every config value the data depends on.""" + ck = "arrsoon:v3:" + service + ":" + str(days) + ":" + tz + ":" + base + ":" + key + + # A diagnosed config problem answers from cache with NO request: AUTH_TTL + # is the promise that a bad key or a service mismatch re-checks hourly at + # worst, and the gate alone cannot keep it. Recovery is still immediate + # because correcting the key, URL or service changes ck. + marker = diagnosed(cache.get(ck)) + if marker != None: + return marker + + start = time.time(year = now.year, month = now.month, day = now.day, location = tz) + + # Count calendar days like classify() does, not 24h blocks: across the + # DST fall-back, midnight + days*24h ends an HOUR SHORT of the window the + # panel displays, so the last day's late-night episodes are never even + # requested (Sonarr/Radarr filter server-side, and the client clamp can + # only remove). time.time normalizes day overflow. + end = time.time(year = now.year, month = now.month, day = now.day + days, location = tz) + + d = fetch_calendar(service, base, key, start, end, ck + ":gate") + if d == "auth": + return mark(ck, "auth") + if d == None: + raw = cache.get(ck) + if raw == None: + return None + obj = json.decode(raw, None) # our own blob, but decode defensively + if type(obj) != "dict": + return None + items = obj.get("items", []) + return {"items": items if type(items) == "list" else [], "stale": True} + + items = extract_sonarr(d) if service == "sonarr" else extract_radarr(d) + if len(items) == 0 and other_service_answered(service, d): + # Radarr selected but a Sonarr URL configured (or vice versa) would + # otherwise extract zero items from a FULL calendar and render a + # confident ALL CAUGHT UP. + return mark(ck, "service") + cache.set(ck, json.encode({"items": items}), ttl_seconds = STALE_TTL) + return {"items": items, "stale": False} + +def mark(ck, err): + """Cache a diagnosed config state AND hold the gate for as long as the + marker lives, so a permanently misconfigured install stops re-polling the + LAN box every RETRY_TTL. The exemplar paces its idle path the same way + (cache.set(ck + ":gate", "1", ttl_seconds = FRESH_IDLE)).""" + cache.set(ck, json.encode({"err": err}), ttl_seconds = AUTH_TTL) + cache.set(ck + ":gate", "1", ttl_seconds = AUTH_TTL) + return err + +def diagnosed(raw): + """A cached {"err": ...} marker -> the diagnosis; anything else -> None.""" + if raw == None: + return None + obj = json.decode(raw, None) + if type(obj) != "dict": + return None + err = obj.get("err") + return err if err == "auth" or err == "service" else None + +def other_service_answered(service, d): + """A non-empty calendar that extracted to ZERO items is either genuinely + undated or the OTHER *arr answering: sniff the first dict entry for the + other service's signature keys (presence only - values may be null). + EpisodeResource always carries seriesId/episodeNumber/airDateUtc keys; + MovieResource always carries tmdbId and the release-date keys.""" + for e in d[:5]: + if type(e) != "dict": + continue + if service == "sonarr": + return "digitalRelease" in e or "inCinemas" in e or "physicalRelease" in e or "tmdbId" in e + return "airDateUtc" in e or "episodeNumber" in e or "seriesId" in e + return False + +def fetch_calendar(service, base, key, start, end, gate_key): + """Gate-before-request: a transport error aborts the render uncatchably, + so the gate (written before the request) limits the damage to one failed + render per RETRY_TTL; every later render serves cache.""" + if cache.get(gate_key) != None: + return None + cache.set(gate_key, "1", ttl_seconds = RETRY_TTL) + + url = base + "/api/v3/calendar?start=" + fmt_utc(start) + "&end=" + fmt_utc(end) + if service == "sonarr": + url += "&includeSeries=true" + resp = http.get(url, headers = {"X-Api-Key": key}, ttl_seconds = RETRY_TTL) + if resp.status_code == 401 or resp.status_code == 403: + return "auth" + if resp.status_code != 200: + print("arrsoon: " + url[:60] + " -> " + str(resp.status_code)) + return None + body = resp.body().strip() + if not (body.startswith("[") and body.endswith("]")): + print("arrsoon: non-JSON-array body from " + url[:60]) + return None + d = json.decode(body, None) # array-SHAPED garbage must not abort either + if d == None: + print("arrsoon: undecodable body from " + url[:60]) + return None + cache.set(gate_key, "1", ttl_seconds = FRESH_TTL) + return d + +def fmt_utc(t): + return t.in_location("UTC").format("2006-01-02T15:04:05") + "Z" + +def extract_sonarr(d): + """EpisodeResource[] -> small cached items. Anything without a sane + airDateUtc can't be placed on a timeline, so it is skipped.""" + items = [] + for e in d[:40]: + if type(e) != "dict": + continue + utc = e.get("airDateUtc") + if type(utc) != "string" or not re.match(TS_RE, utc) or not valid_instant(utc): + continue + stitle = None + ser = e.get("series") + if type(ser) == "dict": + stitle = ser.get("title") + if type(stitle) != "string" or stitle.strip() == "": + stitle = "Unknown series" + items.append({ + "t": clip(stitle), + "l": ep_label(e.get("seasonNumber"), e.get("episodeNumber")), + "u": utc, + "h": True if e.get("hasFile") else False, + }) + return items + +def valid_instant(s): + """Range-check a TS_RE-shaped string so a cached value can never make + time.parse_time abort: hour 99 / month 13 / Feb 30 all pass the regex but + kill the render at parse. Character positions are fixed by the regex.""" + y = int(s[0:4]) + mo = int(s[5:7]) + dy = int(s[8:10]) + if mo < 1 or mo > 12 or dy < 1 or dy > month_days(y, mo): + return False + if int(s[11:13]) > 23 or int(s[14:16]) > 59 or int(s[17:19]) > 59: + return False + return True + +def month_days(y, mo): + if mo == 2: + leap = (y % 4 == 0 and y % 100 != 0) or y % 400 == 0 + return 29 if leap else 28 + return 30 if mo in (4, 6, 9, 11) else 31 + +def ep_label(season, ep): + """At most 7 chars, which is what the 60px meta line can spare beside a + 7-char air time. A 4+ digit episode number IS absolute numbering (anime, + dailies), so the season is noise there and dropping it is what keeps the + episode number itself readable: "S01E1100" does not fit, "E1100" does.""" + if type(ep) != "int" or ep < 0: + return "NEW" + if type(season) != "int" or season < 0 or season > 99 or ep > 999: + return ("E" + str(ep))[:7] # daily shows use year-sized season numbers + return "S" + pad2(season) + "E" + pad2(ep) + +def pad2(n): + s = str(n) + return s if len(s) >= 2 else "0" + s + +def extract_radarr(d): + """MovieResource[] -> items keeping EVERY plausible release date, because + which one is 'next' depends on the render-time date, not the fetch-time + one. Radarr dates are date-semantics: only the yyyy-mm-dd part is kept.""" + items = [] + for m in d[:40]: + if type(m) != "dict": + continue + title = m.get("title") + if type(title) != "string" or title.strip() == "": + title = "Untitled" + year = m.get("year") + if type(year) == "int" and year > 1800 and year < 2200: + title = title + " (" + str(year) + ")" + rel = [] + for pair in [("digitalRelease", "DIGITAL"), ("physicalRelease", "DISC"), ("inCinemas", "CINEMA")]: + v = m.get(pair[0]) + if type(v) == "string" and re.match(TS_RE, v): + rel.append([v[:10], pair[1]]) + if len(rel) == 0: + continue + items.append({"t": clip(title), "rel": rel}) + return items + +def clip(s, n = 48): + # Caps marquee length so one absurd title can't stretch the animation + # cycle past the device dwell. + return s if len(s) <= n else s[:n - 2] + ".." + +# ------------------------------------------------------------ classification + +def classify(items, now, tz, days): + """Cached/demo items -> renderable rows, clamped to the days window + CLIENT-side (never trust the server to honor start/end - and a stale + cache can hold items the window has since scrolled past). Sonarr instants + convert through tz; Radarr dates never do. + + Order is what's NEXT: still-upcoming rows soonest-first, then anything + that already aired, most recent first. The panel shows two rows under a + TONIGHT header, so an already-aired 6AM daily outranking tonight's 9PM + premiere would be a straight lie; as filler below it is still useful.""" + today = local_midnight(now, tz) + today_str = today.format("2006-01-02") + upcoming = [] + aired = [] + for i, it in enumerate(items): + if "u" in it: + t = time.parse_time(it["u"]).in_location(tz) # TS_RE-vetted before caching + dstr = t.format("2006-01-02") + if dstr < today_str: + continue + diff = day_diff(today, local_midnight(t, tz)) + if diff >= days: + continue + past = (now - t).hours > AIRED_GRACE + day = t.format("3:04PM") if diff == 0 else day_label(t, diff) + row = (t.format("2006-01-02T15:04:05"), i, { + "t": it["t"], + "l": it["l"], + "day": day, + "diff": diff, + "dot": it.get("h", False), + "past": past, + "eve": t.hour >= 17, + }) + if past: + aired.append(row) + else: + upcoming.append(row) + else: + best = None + for r in it.get("rel", []): + if type(r) != "list" or len(r) != 2: + continue + if type(r[0]) != "string" or not re.match(DATE_RE, r[0]): + continue + if r[0] < today_str: + continue + if best == None or r[0] < best[0]: + best = r + if best == None: + continue + t = time.time( + year = int(best[0][0:4]), + month = int(best[0][5:7]), + day = int(best[0][8:10]), + location = tz, + ) + diff = day_diff(today, t) + if diff >= days: + continue + + # Radarr dates carry no time of day, so a release dated today is + # out today - never "already aired". + upcoming.append((best[0] + "T00:00:00", i, { + "t": it["t"], + "l": best[1], + "day": "TODAY" if diff == 0 else day_label(t, diff), + "diff": diff, + "dot": False, + "past": False, + "eve": True, # unused: Radarr rows are headed OUT NOW + })) + return [k[2] for k in sorted(upcoming)] + [k[2] for k in sorted(aired, reverse = True)] + +def local_midnight(t, tz): + return time.time(year = t.year, month = t.month, day = t.day, location = tz) + +def day_diff(a, b): + return int(((b - a).hours + 1.0) // 24.0) # +1 rides out DST's 23h days + +def day_label(t, diff): + if diff > 6: + return t.format("Jan 2").upper() # a weekday 7+ days out reads as today + return t.format("Mon").upper() + +# ------------------------------------------------------------------- layouts + +def is_square(): + """True on a square panel, false on the 2:1 one. + + Branches on the canvas's SHAPE, never its size: a 2x wide device reports + 128x64 and a 2x square one 128x128, so a bare height test would call both + of those 64-tall and get one of them wrong. The slack keeps a merely + tallish panel on the wide branch.""" + w, h = canvas.size() + return h * 2 > w + 16 + +def accent_of(service): + return SONARR_BLUE if service == "sonarr" else RADARR_GOLD + +def glyph(service): + rows = GLYPH_SONARR if service == "sonarr" else GLYPH_RADARR + accent = accent_of(service) + dots = [render.Box(width = 5, height = 5)] + for y, row in enumerate(rows): + for x in range(len(row)): + if row[x] == "#": + dots.append(render.Padding( + pad = (x, y, 0, 0), + child = render.Box(width = 1, height = 1, color = accent), + )) + return render.Stack(children = dots) + +def header(service, label, corner): + layers = [ + render.Box(width = 64, height = 7), + render.Padding(pad = (1, 1, 0, 0), child = glyph(service)), + render.Padding( + pad = (9, 1, 0, 0), + child = render.Text(label, font = "tom-thumb", color = accent_of(service)), + ), + ] + if corner != "": + layers.append(render.Padding( + pad = (63 - 4 * len(corner), 1, 0, 0), + child = render.Text(corner, font = "tom-thumb", color = DIM), + )) + return render.Stack(children = layers) + +def item_row(row, service): + # tom-thumb advances 4px per character and draws 3 of them, so a label of + # L chars starting at x=2 ends at x=4L and a right-aligned day string of D + # chars starts at x=62-4D. Both are absolute, so the pip between them is + # placed from the same arithmetic instead of an independent guess. + label, day = row["l"], row["day"] + day_x = 62 - 4 * len(day) + pip_x = 2 + 4 * len(label) + day_color = WHITE if row["diff"] == 0 and not row["past"] else GREY + meta = [ + render.Box(width = 64, height = 6), + render.Padding( + pad = (2, 0, 0, 0), + child = render.Text(label, font = "tom-thumb", color = accent_of(service)), + ), + render.Padding( + pad = (day_x, 0, 0, 0), + child = render.Text(day, font = "tom-thumb", color = day_color), + ), + ] + + # The pip is decorative and its x depends on a label that came out of the + # cache, so it is drawn only where it clears the air time with a pixel to + # spare - never on top of it, which turned "12:00AM" into "2:00AM". + # ep_label caps labels at 7 chars and the widest day string is 7, so in + # practice this is a bound, not a branch the live app takes. + if row["dot"] and pip_x + 3 < day_x: + meta.append(render.Padding( + pad = (pip_x, 1, 0, 0), + child = render.Box(width = 3, height = 3, color = GREEN), + )) + return render.Column( + children = [ + render.Padding( + pad = (2, 0, 0, 0), + child = render.Marquee( + width = 62, + child = render.Text(row["t"], font = "tom-thumb", color = WHITE), + ), + ), + render.Stack(children = meta), + ], + ) + +def square_slot(item): + """One list row in a square panel's 14px slot. + + The square panel's body is 56 rows deep and the item row is the same 12px + one the wide panel draws, so four of them fit with a pixel of air above + and below each - which is what keeps four stacked title/time pairs + reading as four items, and what makes a full list run to the panel's last + row. A short list is centred in the same body by render_list.""" + return render.Column( + children = [ + render.Box(width = 64, height = 1), + item, + render.Box(width = 64, height = 1), + ], + ) + +def render_list(service, items, now, tz, days, demo, stale_host = None): + rows = classify(items, now, tz, days) + square = is_square() + shown = SQUARE_ROWS if square else WIDE_ROWS + if len(rows) == 0: + if stale_host != None: + # The live fetch failed AND the cached extract no longer holds + # anything in-window. "ALL CAUGHT UP thru " would be a + # confident claim about days that were never fetched - and + # STALE_TTL is a day, so it would hold all through an outage. + return card(service, "CAN'T CONNECT", stale_host, GREY) + + # Count CALENDAR days like classify() does, not duration hours: + # midnight + (days-1)*24h lands a day short across a DST fall-back + # (Oct 31 + 6*24h = Nov 5 23:00 EST while Nov 6 is still in-window). + # time.time normalizes day overflow (probed: Oct 31 + day 37 -> Nov 6, + # Dec 31 + day 34 -> Jan 3). + last = time.time(year = now.year, month = now.month, day = now.day + days - 1, location = tz) + sub = "thru " + last.format("Jan 2").upper() if days > 1 else "nothing tonight" + return card(service, "ALL CAUGHT UP", sub, WHITE) + + # "TONIGHT" is a claim about the top row, so only make it when the top row + # is actually tonight: not something that already aired (nothing upcoming + # is left in the window), and not a 12:00PM airing, which is what a UTC+12 + # viewer sees for a US prime-time slot. + top = rows[0] + if top["diff"] != 0: + label = "THIS WEEK" + elif service != "sonarr": + label = "OUT NOW" + elif top["past"] or not top["eve"]: + label = "TODAY" + else: + label = "TONIGHT" + corner = "" + if demo: + corner = "DEMO" + elif len(rows) > shown: + corner = "+" + str(len(rows) - shown) + + kids = [ + header(service, label, corner), + render.Box(width = 64, height = 1, color = RULE), + ] + + # Same rows, same order - the square panel just shows more of them, so + # the "+N" starts counting at the fourth item instead of the second. + slots = [] + for row in rows[:shown]: + item = item_row(row, service) + slots.append(square_slot(item) if square else item) + + if square: + # Four slots fill the 56-row body exactly, so centring is a no-op on a + # full panel; it only moves the one-, two- and three-item lists, which + # are ordinary configurations rather than edge cases ("Tonight only" + # is a first-class Days-ahead option, and a quiet Radarr week is the + # normal week). Top-aligning those left up to 44 of 64 rows black + # under a list that had stopped early. Same mechanism a status card + # already uses, so a short list and an ALL CAUGHT UP card sit on the + # same axis. The cost is real and accepted: when the calendar's length + # changes between refreshes the rows step up or down the panel. + kids.append(render.Box( + width = 64, + height = SQUARE_BODY, + child = render.Column(main_align = "center", children = slots), + )) + else: + kids.extend(slots) + return render.Root(child = render.Column(children = kids)) + +def card(service, big, small, big_color): + """Full-status card: caught-up, unreachable, bad key. The message box is + the whole body the panel leaves under the header, so the message sits in + the middle of a square panel rather than in the top third of it.""" + return render.Root( + child = render.Column( + children = [ + header(service, service.upper(), ""), + render.Box(width = 64, height = 1, color = RULE), + render.Box( + width = 64, + height = SQUARE_BODY if is_square() else WIDE_BODY, + child = render.Column( + main_align = "center", + cross_align = "center", + children = [ + render.Text(big, font = "tom-thumb", color = big_color), + render.Box(width = 1, height = 2), + render.Marquee( + width = 60, + align = "center", + child = render.Text(small, font = "tom-thumb", color = GREY), + ), + ], + ), + ), + ], + ), + ) + +# ---------------------------------------------------------------------- demo + +def demo_items(service, now, tz): + """Fictional-but-plausible calendar, built relative to render time so the + demo always opens on a TONIGHT state. Zero network. + + Four of each, which is what fills a square panel's four slots; the wide + panel shows the first two of the same list and is untouched by the fourth + (demo mode always flies the DEMO chip, never a "+N").""" + if service == "sonarr": + return [ + {"t": "Signal Lost", "l": "S03E05", "u": demo_utc(now, tz, 0, 21), "h": False}, + {"t": "Harbor Lights", "l": "S01E08", "u": demo_utc(now, tz, 0, 22), "h": True}, + {"t": "The Long Static", "l": "S02E01", "u": demo_utc(now, tz, 2, 20), "h": False}, + {"t": "Copper Canyon", "l": "S05E12", "u": demo_utc(now, tz, 4, 21), "h": False}, + ] + return [ + {"t": "Midnight Freight (2026)", "rel": [[demo_date(now, tz, 0), "DIGITAL"]]}, + {"t": "The Glass Orchard (2025)", "rel": [[demo_date(now, tz, 3), "DISC"]]}, + {"t": "Static Fields (2026)", "rel": [[demo_date(now, tz, 5), "CINEMA"]]}, + {"t": "Paper Lanterns (2026)", "rel": [[demo_date(now, tz, 6), "DIGITAL"]]}, + ] + +def demo_utc(now, tz, days_ahead, hour): + t = local_midnight(now, tz) + time.parse_duration(str(days_ahead * 24 + hour) + "h") + return fmt_utc(t) + +def demo_date(now, tz, days_ahead): + t = local_midnight(now, tz) + time.parse_duration(str(days_ahead * 24) + "h") + return t.format("2006-01-02") + +# -------------------------------------------------------------------- schema + +def get_schema(): + return schema.Schema( + version = "1", + fields = [ + schema.Dropdown( + id = "service", + name = "Service", + desc = "Which *arr calendar to watch.", + icon = "tv", + default = "sonarr", + options = [ + schema.Option(display = "Sonarr (TV)", value = "sonarr"), + schema.Option(display = "Radarr (movies)", value = "radarr"), + ], + ), + schema.Text( + id = "url", + name = "Server URL", + desc = "Base URL of your instance, e.g. http://192.168.1.50:8989.", + icon = "globe", + default = "", + ), + schema.Text( + id = "apikey", + name = "API key", + desc = "From Settings > General in Sonarr/Radarr.", + icon = "key", + default = "", + ), + schema.Dropdown( + id = "days", + name = "Days ahead", + desc = "How far into the calendar to look.", + icon = "calendarDay", + default = "7", + options = [ + schema.Option(display = "Tonight only", value = "1"), + schema.Option(display = "3 days", value = "3"), + schema.Option(display = "7 days", value = "7"), + ], + ), + schema.Location( + id = "location", + name = "Location", + desc = "Timezone for air dates.", + icon = "locationDot", + ), + ], + ) diff --git a/apps/arrcomingsoon/arrcomingsoon.webp b/apps/arrcomingsoon/arrcomingsoon.webp new file mode 100644 index 000000000..fe1001dfd Binary files /dev/null and b/apps/arrcomingsoon/arrcomingsoon.webp differ diff --git a/apps/arrcomingsoon/manifest.yaml b/apps/arrcomingsoon/manifest.yaml new file mode 100644 index 000000000..cce211995 --- /dev/null +++ b/apps/arrcomingsoon/manifest.yaml @@ -0,0 +1,18 @@ +--- +id: arr-coming-soon +name: Coming Soon For Sonarr/Radarr +summary: What your *arr drops next +desc: Tonight's episodes and this week's movie releases, straight from your own Sonarr or Radarr calendar. Point it at your instance's URL and API key (Settings > General) and it talks to your server directly, so run it on the same network. With nothing configured it shows a built-in demo card. Air times follow your location, and a green pip means Sonarr already has the file. +author: nsluke +fileName: arr_coming_soon.star +packageName: arrcomingsoon +recommendedInterval: 60 +category: utilities +tags: + - sonarr + - radarr + - media + - selfhosted + - calendar +published: '2026-09-02T23:17:52Z' +updated: '2026-09-03T05:13:38Z'