From eaca4d18e5006784fe79f79fae1aaaf874a38dfc Mon Sep 17 00:00:00 2001 From: thehack904 <35552907+thehack904@users.noreply.github.com> Date: Sat, 2 May 2026 20:08:00 -0500 Subject: [PATCH 1/3] Initial update for v4.9.5 -beta --- CHANGELOG.md | 90 ++++--------------- ROADMAP.md | 59 +++---------- app.py | 15 ++-- static/css/base.css | 21 ++++- static/css/change_tuner.css | 40 +++++++++ static/css/mobile-scroll-fix.css | 56 ++++++------ static/css/mobile.css | 15 ++++ static/js/auto-scroll.js | 28 ++++-- static/js/display-size.js | 56 +++++------- static/js/grid-adapt.js | 30 ++++--- static/js/guide-refresh.js | 37 +++++++- static/js/mobile-scroll-fix.js | 39 ++------- static/js/tv-remote-nav.js | 16 +++- static/js/video-resize.js | 14 ++- templates/guide.html | 144 +++++++++++++++++++++++++------ templates/nasa.html | 4 +- templates/news.html | 4 +- templates/on_this_day.html | 4 +- templates/status.html | 4 +- templates/traffic.html | 4 +- templates/weather.html | 4 +- tests/test_audio.py | 4 + tests/test_logo_upload.py | 3 +- tests/test_traffic_demo.py | 3 + tests/test_tuner_validation.py | 14 +-- tests/test_weather.py | 3 + 26 files changed, 420 insertions(+), 291 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3408fb3..87f00c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## v4.9.5 - 2026-04-29 + +### Fixed +- Fixed virtual channel standalone pages not fitting the viewport on mobile devices. + - Updated `weather.html`, `news.html`, `status.html`, `traffic.html`, `nasa.html`, and `on_this_day.html` to use `height: 100vh` (instead of `min-height: 100vh`) so the page fills the screen without overflow. + - Added `calc(100vh * 16 / 9)` as an additional constraint to each channel frame's `width: min(...)` so the 16:9 frame stays fully visible and correctly proportioned on landscape mobile screens. This brings these pages in line with `sports.html` and `updates.html`. +- Improved mobile layout of the Virtual Channels admin page (`/virtual_channels`): + - Channel list items now wrap gracefully on narrow screens instead of overflowing. + - Channel names allow wrapping on very small viewports. + - Per-channel preferences fields stack vertically at ≤ 700 px. + - Traffic demo city table gains horizontal scrolling so all columns remain accessible on narrow screens. + - Audio file list items stack vertically on mobile so filenames and audio controls are readable. + - Drag handles in the Channel Order list are larger for easier touch interaction. + - Page title font size is reduced slightly on narrow screens to prevent overflow. + +--- + ## v4.9.4 - 2026-04-25 ### Added @@ -15,7 +32,6 @@ This project follows [Semantic Versioning](https://semver.org/). - Deprecation notice added to the Windows Installation section of `INSTALL.md`. - Deprecation note added to `ROADMAP.md` under Installer Enhancements. - Deprecation notice shown on the **About** page when RetroIPTVGuide is running on Windows. - - The About page now shows a Windows deprecation notice when running on Windows. - Added structured wiki documentation under `docs/wiki/`: - `Home.md` — wiki landing page with navigation table - `Installation.md` — full installation guide covering Docker, Linux, Raspberry Pi, and Windows @@ -24,78 +40,6 @@ This project follows [Semantic Versioning](https://semver.org/). - `FAQ.md` — frequently asked questions - `Troubleshooting.md` — common problems and step-by-step fixes - Updated `README.md` to link to the new wiki pages. -- Added Space Channel naming to replace NASA-facing labels. -- Added optional external sports data configuration: - - External sports data is disabled by default. - - Users must explicitly enable external sports data. - - Users must provide their own compatible scores API base URL. - - Added notice that RetroIPTVGuide does not provide sports data. -- Added weather channel segment rotation controls. - - Current Conditions - - 5-Day Forecast - - Regional Radar - - Severe Weather Alerts -- Added configurable weather segment duration. -- Added NOAA-style regional radar URL generation based on configured latitude/longitude. -- Added XMLTV program icon support for guide display. -- Added M3U `group-title` parsing for channel grouping. -- Added new guide theme styling: - - Classic Cable-style theme rules - - Icon/ErsatzTV-style guide theme rules - -### Changed -- Bumped application version from `v4.9.3` to `v4.9.4`. -- Updated release date from `2026-04-08` to `2026-04-25`. -- Renamed the virtual NASA channel display name to **Space Channel**. -- Updated NASA-facing preview text to Space Channel-facing text. -- Removed hardcoded ESPN scoreboard API behavior. -- Replaced ESPN-specific sports score fetching with a user-configured external JSON scores endpoint. -- Changed sports league defaults so leagues are no longer enabled by default. -- Changed external sports data behavior so no outside sports requests are made unless explicitly enabled. -- Changed virtual channel defaults from enabled by default to disabled by default. -- Changed default overlay refresh from 300 seconds to 30 seconds. -- Updated Updates / Announcements wording from “Beta” to “Pre-release”. -- Updated README documentation links to point to the new wiki documentation. -- Updated install documentation to better position Docker as the recommended long-term deployment method. -- Updated guide theme handling so server-side/admin-assigned default themes override local browser storage. -- Updated guide rendering to carry channel group metadata into the DOM. -- Updated program blocks to support optional XMLTV icon artwork. -- Updated Channel Mix fullscreen behavior to better sync the active sub-channel iframe while fullscreen is open. -- Updated Virtual Channels admin UI: - - Shows disabled virtual channels differently due to default-disabled behavior. - - Adds weather rotation details. - - Adds sports external data opt-in controls. - - Adds sports scores base URL input. - - Starts icon pack details collapsed by default. -- Updated diagnostics/config reporting to include newer virtual-channel and sports/weather configuration fields. -- Updated Linux and Raspberry Pi installer scripts. -- Updated Windows installer scripts with deprecation notices. -- Updated CodeQL workflow configuration. -- Updated Unraid Docker documentation wording. - -### Fixed -- Fixed virtual channel enablement logic so missing settings no longer implicitly enable all virtual channels. -- Fixed sports channel behavior to avoid relying on a specific third-party API by default. -- Fixed weather virtual channel layout to support multi-screen rotation without overflowing. -- Fixed guide theme persistence so admin/user-selected defaults apply reliably. -- Fixed tuner validation behavior to allow single `.m3u8` stream tuners without requiring an XML URL. -- Fixed tuner validation behavior around private/local M3U URLs. -- Fixed drag/reorder handling cleanup in the Virtual Channels admin page. -- Fixed additional test coverage around: - - virtual channels - - tuner validation - - traffic demo behavior - - admin diagnostics - - app configuration diagnostics - -### Removed -- Removed default Plex and Tubi sample tuner entries. -- Removed ESPN-specific naming and API dependency from sports score handling. -- Removed public-facing NASA channel naming in favor of Space Channel naming. - -### Deprecated -- Deprecated the Windows installer path for future removal in v5.0. -- Docker is now the recommended deployment method going forward. --- diff --git a/ROADMAP.md b/ROADMAP.md index 6621402..7095218 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,10 +11,10 @@ These are **not yet implemented**, partially implemented, or completed in previo ## 🔮 Feature Upgrades ### 1. Tuner Management -- [x] Add ability to **add/remove tuners** from the UI *(v2.3.0)* -- [x] Add ability to rename tuners via the UI *(v2.3.0)* -- [x] Support for **.m3u8 single-channel playlists** as tuner sources *(v4.6.0)* -- [x] Validate tuner URLs *(v2.0.0 → improved v4.9.1–v4.9.4)* +- [x] Add ability to **add/remove tuners** from the UI (v2.3.0). +- [x] Add ability to rename tuners via the UI (v2.3.0). +- [x] Support for **.m3u8 single-channel playlists** as tuner sources. *(v4.6.0)* +- [x] Validate tuner URLs *(v2.0.0 → improved v4.9.1–v4.9.2)* - [x] Duplicate tuner name prevention *(v4.6.0)* - [x] Optional auto-refresh of tuner lineup *(v4.3.0)* - [x] Per-user tuner assignment and default preferences *(v4.7.0)* @@ -32,7 +32,6 @@ These are **not yet implemented**, partially implemented, or completed in previo - [x] Post-install verification *(v3.1.0)* - [x] Unified guide refresh scheduler *(v4.2.0)* - [x] Sanitized diagnostics and error responses *(v4.9.1–v4.9.2)* -- [x] Expanded diagnostics/config reporting *(v4.9.4)* --- @@ -40,10 +39,7 @@ These are **not yet implemented**, partially implemented, or completed in previo - [x] Auto-Scroll *(v4.1.0 → v4.6.0 enhancements)* - [x] Responsive layout *(v4.2.0)* - [x] Fullscreen improvements for virtual channels *(v4.9.3)* -- [x] Channel Mix dynamic fullscreen switching *(v4.9.3 → improved v4.9.4)* -- [x] XMLTV program icon support *(v4.9.4)* -- [x] M3U `group-title` parsing *(v4.9.4)* -- [x] Theme priority handling (server/admin defaults override stale browser storage) *(v4.9.4)* +- [x] Channel Mix dynamic fullscreen switching *(v4.9.3)* - [ ] Search/filter box - [ ] Favorites (lightweight) - [ ] Channel Info Banner @@ -78,10 +74,7 @@ These are **not yet implemented**, partially implemented, or completed in previo - [x] Mobile responsiveness *(v4.2.0)* - [x] Theme auto-detect *(v4.5.0)* - [x] Display size settings *(v4.6.0)* -- [x] Virtual channel fullscreen enhancements *(v4.8.0–v4.9.4)* -- [x] New guide themes: - - Classic Cable Style *(v4.9.4)* - - Icon / ErsatzTV Style *(v4.9.4)* +- [x] Virtual channel fullscreen enhancements *(v4.8.0–v4.9.3)* --- @@ -89,7 +82,6 @@ These are **not yet implemented**, partially implemented, or completed in previo - [x] Linux / Windows / Raspberry Pi installers *(v4.0.0)* - [x] Windows parity *(v4.1.0)* - [ ] TrueNAS SCALE App Catalog certification -- [ ] Windows installer retirement in v5.0 (Docker-first direction) --- @@ -101,26 +93,16 @@ These are **not yet implemented**, partially implemented, or completed in previo ### 8. Virtual Channels - [x] Virtual Channels framework *(v4.8.0)* -- [x] Weather *(v4.8.0 → expanded v4.9.4)* +- [x] Weather *(v4.8.0)* - [x] News *(v4.8.0)* - [x] Traffic *(v4.8.0)* - [x] System Status *(v4.8.0)* - [x] Updates *(v4.9.3)* -- [x] Sports *(v4.9.3 → reworked v4.9.4)* -- [x] Space Channel *(renamed from NASA in v4.9.4)* +- [x] Sports *(v4.9.3)* +- [x] NASA *(v4.9.3)* - [x] On This Day *(v4.9.3)* - [x] Channel Mix *(v4.9.3)* -- [x] Expanded Virtual Channels admin UI *(v4.9.3 → improved v4.9.4)* -- [x] Virtual channels default disabled for safer deployments *(v4.9.4)* -- [x] Weather segment rotation: - - Current Conditions - - 5-Day Forecast - - Regional Radar - - Severe Weather Alerts *(v4.9.4)* -- [x] Configurable weather segment duration *(v4.9.4)* -- [x] Regional radar generation using configured coordinates *(v4.9.4)* -- [x] External sports data opt-in only *(v4.9.4)* -- [x] User-configurable sports scores API base URL *(v4.9.4)* +- [x] Expanded Virtual Channels admin UI *(v4.9.3)* - [ ] Advanced Virtual Channel Composition Engine - Scheduled rotation blocks - Weighted/random playback @@ -133,34 +115,19 @@ These are **not yet implemented**, partially implemented, or completed in previo - [x] DB schema migration guards *(v4.7.1)* - [x] Security hardening *(v4.9.1–v4.9.2)* - [x] Database-backed activity logging *(v4.9.3)* -- [x] CodeQL workflow improvements *(v4.9.4)* --- ## 🍓 Installer Enhancements - [x] Unified installer architecture - [x] Windows parity -- [x] Windows deprecation notices added *(v4.9.4)* -- [ ] **Deprecate Windows installer in v5.0** — Docker is the recommended deployment method going forward - ---- - -## 📚 Documentation -- [x] Added GitHub Wiki-ready documentation structure *(v4.9.4)* - - Home - - Installation - - Configuration - - Virtual Channels - - FAQ - - Troubleshooting -- [x] Updated README links to point users toward the new wiki documentation *(v4.9.4)* -- [x] Updated install documentation around Docker-first deployment direction *(v4.9.4)* +- [ ] **Deprecate Windows installer in v5.0** — Docker is the recommended deployment method going forward --- ## User Submitted Enhancements - [x] Resize Pop Out Video *(v4.6.0)* - [x] Resize video *(v4.6.0)* -- [x] Auto load Channel from Guide +- [ ] Auto load Channel from Guide - [x] Adjustable scrolling speed *(v4.6.0)* -- [x] Unraid Template \ No newline at end of file +- [x] Unraid Template diff --git a/app.py b/app.py index fc22681..0cce54d 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ # app.py — merged version (features from both sources) -APP_VERSION = "v4.9.4" -APP_RELEASE_DATE = "2026-04-25" +APP_VERSION = "v4.9.5-beta" +APP_RELEASE_DATE = "2026-05-02" from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort, make_response from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user @@ -485,10 +485,11 @@ def add_tuner(name, xml_url, m3u_url): if name in tuners: raise ValueError(f"Tuner '{name}' already exists") - # Validate XML URL (optional – single .m3u8 stream tuners may omit it) - if xml_url and xml_url.strip(): - if not xml_url.startswith(('http://', 'https://')): - raise ValueError("XML URL must start with http:// or https://") + # Validate XML URL + if not xml_url or not xml_url.strip(): + raise ValueError("XML URL cannot be empty") + if not xml_url.startswith(('http://', 'https://')): + raise ValueError("XML URL must start with http:// or https://") # Validate M3U URL if not m3u_url or not m3u_url.strip(): @@ -4084,8 +4085,8 @@ def change_tuner(): log_event(current_user.username, f"Failed to add combined tuner {name}: {str(e)}") else: if tuner_mode == "single_stream": - xml_url = "" m3u_url = request.form.get("m3u8_stream_url", "").strip() + xml_url = m3u_url else: xml_url = request.form.get("xml_url", "").strip() m3u_url = request.form.get("m3u_url", "").strip() diff --git a/static/css/base.css b/static/css/base.css index ced8ede..c256726 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -322,8 +322,8 @@ body { font-family: Arial, sans-serif; margin:0; } display: flex; align-items: stretch; overflow: hidden; - background: transparent; - box-shadow: 0 2px 6px rgba(0,0,0,0.12); + background: var(--timebar-bg, rgba(255,255,255,0.95)); + box-shadow: 0 2px 8px rgba(0,0,0,0.18); border-bottom: 1px solid var(--timebar-border, rgba(0,0,0,0.12)); height: 34px; top: 0; @@ -339,6 +339,23 @@ body { font-family: Arial, sans-serif; margin:0; } .time-header-fixed .now-line { position:absolute; top:0; bottom:0; width:2px; z-index:1300; pointer-events:none; } .time-header-fixed .left-spacer { flex: 0 0 auto; height:100%; background: var(--chan-col-bg, #1a1a1a); border-right: 1px solid var(--timebar-border, rgba(0,0,0,0.12)); pointer-events: none; } .hide-in-grid .time-header-wrap { visibility: hidden; height: 0; margin:0; padding:0; } +/* Collapse the entire gridTimeRow so the empty chan-header strip is not visible + between the fixed timebar and the first real channel row. Elements stay in the + DOM so JS can still clone time cells and compute the spacer width. + visibility:hidden is required in addition to height:0/overflow:hidden because + .chan-header uses position:sticky and sticky elements anchor to the nearest + scroll container (.guide-outer), allowing them to escape an intermediate + overflow:hidden parent — visibility:hidden makes all descendants invisible + regardless of their positioning model. JS cloneNode() is unaffected since + visibility is a CSS cascade property on the ancestor, not carried on clones. */ +.guide-row.hide-in-grid { + height: 0 !important; + overflow: hidden !important; + visibility: hidden !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; +} /* Theme variables & theme-specific rules preserved below (copied/preserved from original base.css) */ diff --git a/static/css/change_tuner.css b/static/css/change_tuner.css index f317bca..117729d 100644 --- a/static/css/change_tuner.css +++ b/static/css/change_tuner.css @@ -849,6 +849,46 @@ button:focus, select:focus, input:focus, textarea:focus { .mode-card { min-width: unset; } .tuners-table { font-size: 0.8rem; } .tuners-table th, .tuners-table td { padding: 6px 6px; } + + /* Virtual channel list items: allow wrapping on very narrow screens */ + .vc-enabled-item, + .vc-available-item { + flex-wrap: wrap; + gap: 8px; + padding: 8px 10px; + } + + /* Allow channel name to wrap so it doesn't overflow */ + .vc-item-name { + white-space: normal; + word-break: break-word; + } + + /* Keep sub-text readable */ + .vc-item-sub { font-size: 0.75rem; } + + /* Prefs panel: stack fields vertically, allow them full width */ + .vc-prefs-fields { flex-direction: column; gap: 10px; } + .vc-prefs-field, + .vc-prefs-field-wide { min-width: unset; width: 100%; flex-basis: 100%; } + + /* Traffic city table: allow horizontal scroll on narrow screens */ + .traffic-demo-city-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; } + + /* Audio file items: stack on mobile */ + .audio-file-item { flex-direction: column; align-items: flex-start; } + .audio-preview { width: 100%; max-width: 100%; min-width: unset; } + .audio-file-name { white-space: normal; word-break: break-all; } + + /* Channel order list: ensure drag handles are touch-friendly */ + .channel-order-item { padding: 10px 8px; } + .drag-handle { font-size: 1.3rem; padding: 4px 6px; } + + /* Channel order save row: stack on small screens */ + .channel-order-save-row { flex-wrap: wrap; gap: 8px; } + + /* Page header: reduce title size */ + .page-header h1 { font-size: 1.3rem; } } /* Smooth transitions */ diff --git a/static/css/mobile-scroll-fix.css b/static/css/mobile-scroll-fix.css index 64b1447..3ecf6f8 100644 --- a/static/css/mobile-scroll-fix.css +++ b/static/css/mobile-scroll-fix.css @@ -1,34 +1,42 @@ -/* Mobile scroll fixes and timebar recompute helpers - Load this after mobile.css (or append these rules to mobile.css). - Re-enables body scrolling on narrow viewports while keeping mobile-nav - independently scrollable and locking background scroll only when nav open. +/* Mobile fixed-top layout + Load this after mobile.css. + On mobile the header, player row, and timebar are all pinned at the top of + the viewport. Only the guide channel rows scroll — inside guide-outer which + is an internal scroll container (overflow-y: auto), the same model used on + desktop. display-size.js uses position:fixed for #appZoomRoot on mobile too. */ @media (max-width: 900px) { - /* Re-enable page scrolling on mobile so users can reach the guide and channels. - #appZoomRoot is position:fixed + overflow:hidden on desktop so the internal - .guide-outer can scroll. On mobile we want the *page* to scroll instead, so - we switch to normal document flow and let html/body scroll. - display-size.js applies transform:scale() and compensating width/height for - Display Size — those are left to JS so Medium/Small still work on mobile. */ + /* Use the same viewport-filling fixed layout as desktop so that the header, + player row, and timebar stay pinned at the top and only guide-outer scrolls + internally. base.css already sets position:fixed + overflow:hidden on + #appZoomRoot; these rules make sure nothing below overrides those properties. + Do NOT set width/height here — display-size.js sets them as inline styles + (compensated px values) so that transform:scale() fills the full viewport + for Medium (0.8×) and Small (0.67×) display sizes. */ #appZoomRoot { - position: relative !important; - overflow: visible !important; + position: fixed !important; + top: 0 !important; + left: 0 !important; + overflow: hidden !important; } html, body { - height: auto !important; - min-height: 100% !important; - overflow: auto !important; - -webkit-overflow-scrolling: touch !important; + height: 100% !important; + overflow: hidden !important; } - /* guideOuter should grow with content rather than being a fixed viewport-sub layout */ + /* guide-outer fills all remaining flex space below the pinned items and + provides the only scroll surface on mobile. */ .guide-outer { + flex: 1 !important; + min-height: 0 !important; height: auto !important; max-height: none !important; - overflow: visible !important; - padding-bottom: 120px !important; /* ensure page can scroll past the fixed timebar */ + overflow-y: auto !important; + overflow-x: hidden !important; + -webkit-overflow-scrolling: touch !important; + padding-bottom: 40px !important; box-sizing: border-box !important; } @@ -38,19 +46,19 @@ max-height: 100vh !important; } - /* When mobile nav is open, prevent the background page from scrolling */ + /* When mobile nav is open, prevent background scroll */ body.mobile-nav-open { overflow: hidden !important; touch-action: none !important; } - /* Ensure the fixed time header remains accessible and not covered by content. - This keeps it visually anchored at top on mobile; we also recompute on scroll in JS. */ + /* Fixed timebar: position is set at runtime by createOrUpdateFixedTimeBar(). + It is always just below the player row which is itself at the top of the + fixed #appZoomRoot flex column, so the top value never changes on scroll. */ .time-header-fixed { - top: 0 !important; + position: fixed !important; left: 0 !important; width: 100% !important; - position: fixed !important; z-index: 1200 !important; } } diff --git a/static/css/mobile.css b/static/css/mobile.css index 390314e..122a281 100644 --- a/static/css/mobile.css +++ b/static/css/mobile.css @@ -137,6 +137,21 @@ html, body { padding: 10px !important; } + /* Video player wrap must be block (not inline-block) so it stretches to the + full column width; otherwise it shrinks to the natural video size. + .video-wrapper is the div dynamically injected by video-resize.js around + #video — it also needs block + 100% so the video inside resolves width + against the full player column rather than the inline-block natural size. */ + .video-player-wrap, + #videoPlayerWrap, + .video-wrapper { + display: block !important; + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box !important; + flex-shrink: 0 !important; + } + /* Video fills the width and is constrained in height so it does not become huge */ #video { order: 2; diff --git a/static/js/auto-scroll.js b/static/js/auto-scroll.js index 1699a14..932780d 100644 --- a/static/js/auto-scroll.js +++ b/static/js/auto-scroll.js @@ -1,8 +1,9 @@ -// auto-scroll v36.3 — deterministic wrap + RAF primary with interval fallback watchdog. +// auto-scroll v36.4 — deterministic wrap + RAF primary with interval fallback watchdog. // - Clone full row elements so program cells are carried with clones. // - Deterministic immediate wrap to prep offset to avoid stop/restart races. // - Primary animation via requestAnimationFrame; fallback watcher uses setInterval to nudge scrollTop // when RAF hasn't advanced (handles throttling/race across browsers). +// - guide-outer (overflow:auto, flex:1) is the scroll container on both desktop and mobile. // - Exposes status and cloneNow APIs. (function () { @@ -10,6 +11,12 @@ function prefEnabled() { return localStorage.getItem(PREF_KEY) !== 'false'; } function setPref(v) { localStorage.setItem(PREF_KEY, v ? 'true' : 'false'); } + // Previously returned true on ≤900px viewports when guide-outer had overflow:visible + // and the page itself scrolled via window.scrollY. Mobile now uses the same + // fixed-viewport model as desktop (guide-outer has overflow:auto and scrolls + // internally), so window-scroll handling is no longer needed anywhere. + function isMobilePage() { return false; } + const SELECTOR_PRIORITY = ['#guideOuter', '.guide-outer', '.grid-col']; let scrollSpeed = 1.2; // px per frame (visual) const idleDelay = 15000; // ms initial inactivity/start delay (15s) @@ -280,7 +287,7 @@ try { if (!isScrolling || !scroller) return; const now = performance.now(); - // If RAF hasn't run in last 250ms, nudge scrollTop a tiny amount + // If RAF hasn't run in last 250ms, nudge scroll a tiny amount if (now - lastFrameTime > 250) { try { scroller.scrollTop = (scroller.scrollTop || 0) + scrollSpeed; } catch (e) {} // update lastFrameTime so we don't double-nudge @@ -297,6 +304,7 @@ if (document.hidden) { rafId = requestAnimationFrame(frameLoop); return; } try { lastFrameTime = performance.now(); + if (scroller && scroller.scrollHeight > scroller.clientHeight) { scroller.scrollTop += scrollSpeed; const maxScroll = scroller.scrollHeight - scroller.clientHeight; @@ -361,6 +369,7 @@ ensureStyles(scroller); } + // Clone rows for seamless loop, then begin animation if (loopMode && scroller && scroller.dataset.__autoScrollCloned !== '1') { waitForContent(scroller, waitForContentMs, contentSampleCount).then(() => cloneOnce(scroller)).catch(() => cloneOnce(scroller)).then(() => { // start animation after a tiny tick to let layout settle @@ -369,7 +378,7 @@ return; } - // clones already present or no cloning needed + // clones already present, no cloning needed, or mobile mode startAnimation(); } @@ -445,13 +454,20 @@ } }; window.__autoScroll.getSpeed = function(){ return scrollSpeed; }; - window.__autoScroll.status = function(){ return { isScrolling, pref: prefEnabled(), loopMode, scrollerInfo: scroller ? { id: scroller.id, scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, cloned: !!scroller.dataset.__autoScrollCloned, prependedHeight: scroller.dataset.__autoScrollPrependedHeight } : null, rafId: !!rafId, watchdog: !!watchdogInterval }; }; - window.__autoScroll.debug = function(){ return { lastActivity, idleDelay, scrollSpeed, isScrolling, pref: prefEnabled(), loopMode, endReached, endReachedAt, autoRestart, autoRestartDelayMs, scrollerInfo: scroller ? { id: scroller.id, scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, cloned: !!scroller.dataset.__autoScrollCloned, prependedHeight: scroller.dataset.__autoScrollPrependedHeight } : null, rafId, lastFrameTime, watchdogInterval }; }; + window.__autoScroll.status = function(){ + var mob = isMobilePage(); + return { isScrolling, pref: prefEnabled(), loopMode, mobile: mob, scrollerInfo: scroller ? { id: scroller.id, scrollTop: mob ? window.scrollY : scroller.scrollTop, scrollHeight: mob ? document.body.scrollHeight : scroller.scrollHeight, clientHeight: mob ? window.innerHeight : scroller.clientHeight, cloned: !!scroller.dataset.__autoScrollCloned, prependedHeight: scroller.dataset.__autoScrollPrependedHeight } : null, rafId: !!rafId, watchdog: !!watchdogInterval }; + }; + window.__autoScroll.debug = function(){ + var mob = isMobilePage(); + return { lastActivity, idleDelay, scrollSpeed, isScrolling, pref: prefEnabled(), loopMode, endReached, endReachedAt, autoRestart, autoRestartDelayMs, mobile: mob, scrollerInfo: scroller ? { id: scroller.id, scrollTop: mob ? window.scrollY : scroller.scrollTop, scrollHeight: mob ? document.body.scrollHeight : scroller.scrollHeight, clientHeight: mob ? window.innerHeight : scroller.clientHeight, cloned: !!scroller.dataset.__autoScrollCloned, prependedHeight: scroller.dataset.__autoScrollPrependedHeight } : null, rafId, lastFrameTime, watchdogInterval }; + }; - log('auto-scroll (conservative v36.3) initialized'); + log('auto-scroll (conservative v36.4) initialized'); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })(); + diff --git a/static/js/display-size.js b/static/js/display-size.js index b17c099..1e29411 100644 --- a/static/js/display-size.js +++ b/static/js/display-size.js @@ -41,46 +41,30 @@ function applyUiZoom(scale) { var root = document.getElementById('appZoomRoot'); if (root) { - var isMobile = window.innerWidth <= 900; var vp = getViewportSize(); root.style.transformOrigin = 'top left'; - if (isMobile) { - // On mobile, normal document flow (position:relative, overflow:visible) is set - // by mobile-scroll-fix.css so the page can scroll. We only set the transform - // and compensating width here so Display Size (Medium/Small) still works. - root.style.position = 'relative'; - root.style.top = ''; - root.style.left = ''; - root.style.overflow = 'visible'; - if (scale < 1) { - root.style.transform = 'scale(' + scale + ')'; - root.style.width = Math.ceil(vp.w / scale) + 'px'; - root.style.height = 'auto'; // let content height flow naturally for page scroll - } else { - root.style.transform = ''; - root.style.width = '100%'; - root.style.height = 'auto'; - } + // Use the same fixed-viewport approach on both mobile and desktop. + // Mobile now uses the same layout model as desktop: #appZoomRoot is + // position:fixed so the header, player row, and timebar are pinned at the + // top and guide-outer scrolls internally. mobile-scroll-fix.css enforces + // this at the CSS level; here we ensure JS-applied inline styles match. + root.style.position = 'fixed'; + root.style.top = '0'; + root.style.left = '0'; + // Guide page uses its own internal scroll container (.guide-outer); all other pages + // (Logs, About, Tuner Management, etc.) need the zoom root itself to be scrollable. + root.style.overflow = document.body.classList.contains('guide-page') ? 'hidden' : 'auto'; + if (scale < 1) { + // Explicit px avoids 100%/vh resolution quirks when overflow:hidden is involved + root.style.transform = 'scale(' + scale + ')'; + root.style.width = Math.ceil(vp.w / scale) + 'px'; + root.style.height = Math.ceil(vp.h / scale) + 'px'; } else { - // Desktop: fixed wrapper so body/percent sizing cannot clip the scaled UI. - root.style.position = 'fixed'; - root.style.top = '0'; - root.style.left = '0'; - // Guide page uses its own internal scroll container (.guide-outer); all other pages - // (Logs, About, Tuner Management, etc.) need the zoom root itself to be scrollable. - root.style.overflow = document.body.classList.contains('guide-page') ? 'hidden' : 'auto'; - if (scale < 1) { - // Explicit px avoids 100%/vh resolution quirks when overflow:hidden is involved - root.style.transform = 'scale(' + scale + ')'; - root.style.width = Math.ceil(vp.w / scale) + 'px'; - root.style.height = Math.ceil(vp.h / scale) + 'px'; - } else { - root.style.transform = ''; - // Fixed + 100% resolves to the viewport - root.style.width = '100%'; - root.style.height = '100%'; - } + root.style.transform = ''; + // Fixed + 100% resolves to the viewport + root.style.width = '100%'; + root.style.height = '100%'; } } // Keep --display-zoom in sync so CSS/JS consumers always read the right value diff --git a/static/js/grid-adapt.js b/static/js/grid-adapt.js index e36a369..6fdf3c6 100644 --- a/static/js/grid-adapt.js +++ b/static/js/grid-adapt.js @@ -83,18 +83,26 @@ // Adjust guideOuter height to accommodate scaled content so that elements below remain reachable const guideOuter = document.getElementById('guideOuter'); if (guideOuter) { - if (scale < 1) { - // estimate original content height using bounding rect and scale factor - const rect = gridContent.getBoundingClientRect(); - // boundingRect is already scaled; estimate original by dividing by scale - const estimatedUnscaledH = rect.height / (scale || 1); - const scaledHeight = Math.round(estimatedUnscaledH * scale); - // keep a sensible minimum and add room for the player row above - const minHeight = 220; - guideOuter.style.height = Math.max(minHeight, scaledHeight + 180) + 'px'; + // On mobile guide-outer scrolls internally (overflow:auto, flex:1) inside the + // fixed #appZoomRoot — the same model as desktop. An explicit pixel height would + // fight the flex layout, so always use resetGuideHeight() on mobile. + if (window.innerWidth > MOBILE_MAX) { + if (scale < 1) { + // estimate original content height using bounding rect and scale factor + const rect = gridContent.getBoundingClientRect(); + // boundingRect is already scaled; estimate original by dividing by scale + const estimatedUnscaledH = rect.height / (scale || 1); + const scaledHeight = Math.round(estimatedUnscaledH * scale); + // keep a sensible minimum and add room for the player row above + const minHeight = 220; + guideOuter.style.height = Math.max(minHeight, scaledHeight + 180) + 'px'; + } else { + // Use the zoom-aware helper rather than clearing to '' so the correct + // height is always set when a display-size zoom is active. + resetGuideHeight(); + } } else { - // Use the zoom-aware helper rather than clearing to '' so the correct - // height is always set when a display-size zoom is active. + // Mobile: clear any explicit height so flex:1 drives the layout resetGuideHeight(); } } diff --git a/static/js/guide-refresh.js b/static/js/guide-refresh.js index afb9d0b..f921a95 100644 --- a/static/js/guide-refresh.js +++ b/static/js/guide-refresh.js @@ -3,7 +3,27 @@ const ENDPOINT = '/api/guide_snapshot?hours=6'; const REFRESH_INTERVAL_MIN = 30; // same cadence as Cairo + // Track whether a refresh was skipped because video was in fullscreen + let _pendingRefresh = false; + + function isFullscreenActive() { + return !!( + document.fullscreenElement || + document.webkitFullscreenElement || + document.mozFullScreenElement || + document.msFullscreenElement + ); + } + async function refreshGuide() { + // Do not reload while video is playing in fullscreen — it kills the stream. + // Set a flag so we reload as soon as fullscreen is exited. + if (isFullscreenActive()) { + _pendingRefresh = true; + console.log('[guide-refresh] Fullscreen active — deferring guide refresh.'); + return; + } + try { const res = await fetch(ENDPOINT, { cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -11,14 +31,27 @@ // You can either rebuild just the EPG grid, or simply reload the page // depending on how your current guide is generated. // For now, safest approach is a full reload: + _pendingRefresh = false; window.location.reload(); - - console.log(`[guide-refresh] Guide refreshed at ${new Date().toLocaleTimeString()}`); } catch (err) { console.error('[guide-refresh] Failed to refresh guide:', err); } } + // When the user exits fullscreen, perform the deferred reload if one is waiting. + function onFullscreenChange() { + if (!isFullscreenActive() && _pendingRefresh) { + _pendingRefresh = false; + console.log('[guide-refresh] Fullscreen exited — running deferred guide refresh.'); + window.location.reload(); + } + } + + document.addEventListener('fullscreenchange', onFullscreenChange); + document.addEventListener('webkitfullscreenchange', onFullscreenChange); + document.addEventListener('mozfullscreenchange', onFullscreenChange); + document.addEventListener('MSFullscreenChange', onFullscreenChange); + // Run once every X minutes so the grid rolls forward with real time setInterval(refreshGuide, REFRESH_INTERVAL_MIN * 60 * 1000); })(); diff --git a/static/js/mobile-scroll-fix.js b/static/js/mobile-scroll-fix.js index 9a16115..6743a8f 100644 --- a/static/js/mobile-scroll-fix.js +++ b/static/js/mobile-scroll-fix.js @@ -1,32 +1,17 @@ -// Keep fixed timebar correctly positioned on mobile when the page scrolls +// Keep fixed timebar correctly positioned on mobile. +// With the fixed-viewport layout, the timebar sits permanently just below the +// player row which is itself at the top of the fixed #appZoomRoot. The bar +// position never changes on scroll (guide-outer scrolls internally), so we only +// need to recompute on resize / orientation change. // Place at: static/js/mobile-scroll-fix.js -// Include after mobile-nav.js and grid-adapt.js: (function(){ - // Don't run unnecessarily on desktop const MOBILE_MAX = 900; - let last = 0; - let rafPending = false; - - function throttle(fn) { - return function() { - const now = Date.now(); - if (rafPending) return; - if (now - last < 80) { // ~12 FPS throttle - rafPending = true; - setTimeout(() => { rafPending = false; last = Date.now(); fn(); }, 80); - return; - } - last = now; - fn(); - }; - } function recomputeFixedBar() { try { if (window.innerWidth > MOBILE_MAX) return; if (typeof window.createOrUpdateFixedTimeBar === 'function') { - // use rAF to ensure layout has settled requestAnimationFrame(() => { window.createOrUpdateFixedTimeBar(); if (typeof window.updateNowLine === 'function') window.updateNowLine(); @@ -35,18 +20,10 @@ } catch (e) { console.debug('recomputeFixedBar err', e); } } - const recomputeThrottled = throttle(recomputeFixedBar); - - // Recompute on scroll & touchmove for mobile - window.addEventListener('scroll', recomputeThrottled, { passive: true }); - window.addEventListener('touchmove', recomputeThrottled, { passive: true }); - - // Also recompute when nav opens/closes via class change (mobile-nav-open) - const bodyObserver = new MutationObserver(() => { - recomputeThrottled(); - }); + // Recompute when nav opens/closes (class mutation changes player row height) + const bodyObserver = new MutationObserver(() => { recomputeFixedBar(); }); bodyObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); // Recompute on resize / orientation change - window.addEventListener('resize', recomputeThrottled); + window.addEventListener('resize', recomputeFixedBar); })(); diff --git a/static/js/tv-remote-nav.js b/static/js/tv-remote-nav.js index afb3e26..4777273 100644 --- a/static/js/tv-remote-nav.js +++ b/static/js/tv-remote-nav.js @@ -46,8 +46,20 @@ 'body.tv-mode .chan-name img { width: 20px; height: 20px; object-fit: contain; flex-shrink: 0; }', 'body.tv-mode .chan-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', 'body.tv-mode .chan-header { height: 17px; }', - /* Program grid cells */ - 'body.tv-mode .program { height: 24px !important; font-size: 9px; padding: 2px 3px; top: 3px; }', + /* Program grid cells – fill the full row height so current and future programs + are always the same height as the channel-column cell on every theme. + Targeting both .program and .program.now (specificity 0,3,0) ensures the + tv-mode rule wins over any theme-specific .program.now override that would + otherwise cause the current program to appear taller than future ones. */ + 'body.tv-mode .program,', + 'body.tv-mode .program.now { height: 100% !important; top: 0 !important; font-size: 9px; padding: 2px 3px; }', + /* Icon Guide (ersatztv) – icon cards need taller rows; give the channel name + enough height to show the channel logo and let programs fill the row with + comfortable inset padding to match the desktop design proportions. */ + 'body.tv-mode.ersatztv .chan-name { height: 64px; }', + 'body.tv-mode.ersatztv .program,', + 'body.tv-mode.ersatztv .program.now { top: 4px !important; height: calc(100% - 8px) !important; font-size: 10px; padding: 4px 5px; }', + 'body.tv-mode.ersatztv .program .prog-icon-img { width: 42px; height: 42px; }', 'body.tv-mode .time-cell { font-size: 9px; }', /* Fixed time bar */ 'body.tv-mode .time-header-fixed { height: 17px; }', diff --git a/static/js/video-resize.js b/static/js/video-resize.js index e710829..8dbf5a1 100644 --- a/static/js/video-resize.js +++ b/static/js/video-resize.js @@ -195,15 +195,21 @@ /* ── Restore previously saved sizes ───────────────────────────── */ function restoreSizes() { var video = document.getElementById('video'); + var isMobile = window.innerWidth <= 900; try { var w = localStorage.getItem(LS_VIDEO_W); var h = localStorage.getItem(LS_VIDEO_H); var c = localStorage.getItem(LS_CHAN_W); - if (video) { - if (w) video.style.width = w; - if (h) { video.style.height = h; video.style.removeProperty('max-height'); } + // On mobile, CSS (mobile.css + guide.html inline style) handles video sizing + // and channel column width. Restoring saved desktop pixel values would + // override those rules and produce a small/misaligned player on mobile. + if (!isMobile) { + if (video) { + if (w) video.style.width = w; + if (h) { video.style.height = h; video.style.removeProperty('max-height'); } + } + if (c) document.documentElement.style.setProperty('--chan-col-width', c); } - if (c) document.documentElement.style.setProperty('--chan-col-width', c); } catch (e) {} updateGuideHeight(); reflow(); diff --git a/templates/guide.html b/templates/guide.html index 31936be..6644953 100644 --- a/templates/guide.html +++ b/templates/guide.html @@ -87,6 +87,13 @@ /* optional: expose channel column width if you compute it server-side or want to override */ --chan-col-width: 200px; } + /* On mobile, narrow the channel column so the guide grid gets more room */ + @media (max-width: 900px) { + :root { --chan-col-width: 80px; } + .chan-name { padding: 4px 3px !important; gap: 1px !important; } + .chan-name img { width: 22px !important; height: 22px !important; } + .chan-name span { font-size: 9px !important; } + } .video-player-wrap { position: relative; display: inline-block; @@ -916,36 +923,74 @@

Program Info

fixedBar.style.left = '0px'; fixedBar.style.width = Math.ceil(window.innerWidth / getDisplayZoom()) + 'px'; - // On mobile, #appZoomRoot is position:relative + transform:scale(s), so - // position:fixed children behave like position:absolute within appZoomRoot. - // The CSS top must compensate for window.scrollY so the bar stays in view: - // top_css = (scrollY + desired_visual_y) / scale - // On desktop, appZoomRoot is position:fixed so position:fixed children are - // truly viewport-anchored and no scroll compensation is needed. - const isMobileZoom = window.innerWidth <= 900 && getDisplayZoom() < 1; - const scrollOff = isMobileZoom ? (window.scrollY || 0) : 0; + // #appZoomRoot is position:fixed on both mobile and desktop. + // When scale=1 (Large): no transform, position:fixed children are viewport-anchored. + // top = playerRow.getBoundingClientRect().bottom (a viewport Y coordinate). + // When scale<1 (Medium/Small): transform:scale is applied to appZoomRoot, so + // browsers treat position:fixed children as positioned within that element. + // top_css = desired_visual_y / scale (scrollY is always 0 since no page scroll). + // We clamp visualY to headerH on mobile so the bar can't slide behind the nav header. + const isMobile = window.innerWidth <= 900; + const headerEl = document.querySelector('.header'); + const headerH = headerEl ? Math.round(headerEl.getBoundingClientRect().height) : 40; if (playerRow) { const rect = playerRow.getBoundingClientRect(); - // Clamp to 0: when player scrolls off the top, anchor timebar to viewport top. - const visualY = isMobileZoom ? Math.max(0, rect.bottom) : rect.bottom; - fixedBar.style.top = Math.ceil((scrollOff + visualY) / getDisplayZoom()) + 'px'; + const visualY = isMobile ? Math.max(headerH, rect.bottom) : rect.bottom; + // Math.floor ensures the timebar is placed at-or-above playerRow.bottom. + // Math.ceil could position it 1-2px below (when visualY/zoom is non-integer + // due to float imprecision at medium/small display sizes), leaving a gap + // that shows raw body background above the timebar on dark/light themes. + fixedBar.style.top = Math.floor(visualY / getDisplayZoom()) + 'px'; } else { - const header = document.querySelector('.header'); - const headerRect = header ? header.getBoundingClientRect() : { bottom: 40 }; - const visualY = isMobileZoom ? Math.max(0, headerRect.bottom) : headerRect.bottom; - fixedBar.style.top = Math.ceil((scrollOff + visualY) / getDisplayZoom()) + 'px'; + const headerRect = headerEl ? headerEl.getBoundingClientRect() : { bottom: 40 }; + const visualY = isMobile ? Math.max(headerH, headerRect.bottom) : headerRect.bottom; + fixedBar.style.top = Math.floor(visualY / getDisplayZoom()) + 'px'; } - requestAnimationFrame(() => { - const zoom = getDisplayZoom(); - // getBCR returns visual/viewport px; convert to CSS px by dividing by zoom - const fbHeightCss = Math.ceil((fixedBar.getBoundingClientRect().height || 34) / zoom); - const currentPaddingTop = parseFloat(window.getComputedStyle(guideOuter).paddingTop) || 0; - if (currentPaddingTop < fbHeightCss) { - guideOuter.style.paddingTop = fbHeightCss + 'px'; - } - }); + // Compute and apply guide-outer padding so the first guide row starts at or + // below the timebar's visual bottom edge. + // + // We intentionally avoid reading fixedBar.getBoundingClientRect() here. + // On Amazon Fire TV Silk browser (and some older WebKit builds), BCR on + // position:fixed elements inside a CSS-transformed container can return + // stale viewport coordinates even after a forced layout flush, causing the + // padding to be computed as 0 and leaving guide rows visible above the bar. + // (RetroIPTV appeared to work only because its box-shadow: 0 3px 8px creates + // a visual buffer that hides the mis-measurement.) + // + // Analytical approach: + // fixedBarTopCss – the CSS-px top value we just wrote to fixedBar.style.top + // fixedBar.offsetHeight – timebar height in CSS px (content + border), never + // affected by transforms or BCR quirks + // guideRect.top / zoom – guide-outer's CSS-px top edge (BCR of a normal + // flex element, consistently accurate on all browsers) + // + // neededPaddingCss = fixedBarTopCss + timebar_height - guide_outer_css_top + // ≈ timebar_height (since fixedBarTopCss ≈ guide_outer_css_top) + // + // guideOuter.getBoundingClientRect() also serves as the layout flush that + // ensures fixedBar is correctly positioned in the compositor before paint. + { + const zoom = getDisplayZoom(); + const guideRect = guideOuter.getBoundingClientRect(); + // CSS-px top value we just set; parseFloat never mismatches because we + // own every write to fixedBar.style.top in this function. + const fixedBarTopCss = parseFloat(fixedBar.style.top) || 0; + // offsetHeight: content-box height + border. Reliable on all browsers; + // unaffected by CSS transforms or the BCR quirks mentioned above. + const fixedBarHCss = fixedBar.offsetHeight; + // Distance (CSS px) from guide-outer's outer top to the timebar's bottom. + const neededPaddingCss = Math.ceil( + Math.max(0, fixedBarTopCss + fixedBarHCss - guideRect.top / zoom) + ); + guideOuter.style.paddingTop = neededPaddingCss + 'px'; + // Reserve the same space for programmatic scrollIntoView / DPAD navigation. + guideOuter.style.scrollPaddingTop = neededPaddingCss + 'px'; + // Fill the remaining viewport height below guide-outer's top. + const availableHeightCss = Math.floor((window.innerHeight - guideRect.top) / zoom); + if (availableHeightCss > 0) guideOuter.style.height = availableHeightCss + 'px'; + } const origNow = document.getElementById('nowLineOriginal'); if (origNow) origNow.style.display = 'none'; @@ -1016,8 +1061,33 @@

Program Info

setInterval(updateClock, 1000); setInterval(updateNowLine, 60000); + + // On mobile, abbreviate channel names so they fit the narrower column + if (window.innerWidth <= 900) { + applyMobileChanAbbreviations(); + } }); +/** + * Abbreviate channel name spans on mobile so they fit in the 80px column. + * Re-uses abbreviateChannelName() which is also used by the classic-cable theme. + * Called on DOMContentLoaded and after a theme:applied event restores full names. + */ +function applyMobileChanAbbreviations() { + if (window.innerWidth > 900) return; + document.querySelectorAll('.chan-name').forEach(function(el) { + // Skip names inside auto-scroll clones (they'll be updated when clones are rebuilt) + if (el.closest('.__auto_scroll_clone')) return; + // Skip cols already decorated by a theme (tvguide1990 / classic-cable use different markup) + if (el.querySelector('.channel-number')) return; + var span = el.querySelector('span'); + if (!span) return; + var fullName = el.dataset.name || span.textContent || ''; + var abbr = abbreviateChannelName(fullName); + if (abbr && abbr !== span.textContent) span.textContent = abbr; + }); +} + /* Ensure layout recomputes on viewport resize */ window.addEventListener('resize', () => { // recompute fixed header placement and now-line @@ -1067,6 +1137,19 @@

Program Info

if (theme === "tvguide1990") requestAnimationFrame(applyTvGuide1990Capsules); if (theme === "classic-cable") requestAnimationFrame(applyClassicCableCapsules); if (theme === "ersatztv") requestAnimationFrame(applyIconGuide); + // After theme CSS has settled (new body class + any capsule RAF), recompute + // the fixed timebar position and guide-outer padding so every theme aligns + // correctly on all browsers including TV/Fire TV. + // Two nested RAFs are needed: the first lets any capsule-applying RAF (e.g. + // applyTvGuide1990Capsules) commit its DOM changes and trigger layout, and + // the second ensures the browser has fully resolved the new theme CSS before + // we measure timebar and guide-outer positions. + requestAnimationFrame(function () { + requestAnimationFrame(function () { + if (typeof createOrUpdateFixedTimeBar === 'function') createOrUpdateFixedTimeBar(); + if (typeof updateNowLine === 'function') updateNowLine(); + }); + }); setTimeout(()=>b.classList.remove("fade-switch"), 100); },150); } @@ -1231,7 +1314,18 @@

Program Info

c.classList.remove('tvguide1990-applied', 'classic-cable-applied'); }); restoreIconGuidePrograms(); + // Re-apply mobile abbreviations after full names are restored + if (window.innerWidth <= 900) requestAnimationFrame(applyMobileChanAbbreviations); } + // Recompute fixed timebar position and guide-outer padding for every theme change. + // Two nested RAFs let any DOM-mutating RAF above (e.g. capsule application) settle + // first, and guarantee the browser has resolved the new theme CSS before measuring. + requestAnimationFrame(function() { + requestAnimationFrame(function() { + if (typeof createOrUpdateFixedTimeBar === 'function') createOrUpdateFixedTimeBar(); + if (typeof updateNowLine === 'function') updateNowLine(); + }); + }); }); // Also apply on page load in case tvguide1990, classic-cable or ersatztv was the saved @@ -1548,6 +1642,7 @@

Program Info

themesMenu.innerHTML = ` `; linksContainer.insertBefore(themesMenu, logoutLink); } diff --git a/templates/nasa.html b/templates/nasa.html index 1106603..f076532 100644 --- a/templates/nasa.html +++ b/templates/nasa.html @@ -11,7 +11,7 @@ font-family: Arial, Helvetica, sans-serif; background: #03061a; color: #fff; - min-height: 100vh; + height: 100vh; display: flex; align-items: center; justify-content: center; @@ -20,7 +20,7 @@ /* ── Outer 16:9 frame ─────────────────────────────────────── */ .nasa-frame { - width: min(1280px, 100vw); + width: min(1280px, 100vw, calc(100vh * 16 / 9)); aspect-ratio: 16 / 9; background: #000; display: flex; diff --git a/templates/news.html b/templates/news.html index a4d1cbf..3cf4301 100644 --- a/templates/news.html +++ b/templates/news.html @@ -12,7 +12,7 @@ font-family: Arial, Helvetica, sans-serif; background: #07124a; color: #fff; - min-height: 100vh; + height: 100vh; display: flex; align-items: center; justify-content: center; @@ -21,7 +21,7 @@ /* ── Outer frame ────────────────────────────────────────────── */ .nw-frame { - width: min(1280px, 100vw); + width: min(1280px, 100vw, calc(100vh * 16 / 9)); aspect-ratio: 16 / 9; background: linear-gradient(160deg, #1535cc 0%, #0c22a0 35%, #081870 100%); display: flex; diff --git a/templates/on_this_day.html b/templates/on_this_day.html index 407883d..77cefe2 100644 --- a/templates/on_this_day.html +++ b/templates/on_this_day.html @@ -11,7 +11,7 @@ font-family: Arial, Helvetica, sans-serif; background: #050d2a; color: #fff; - min-height: 100vh; + height: 100vh; display: flex; align-items: center; justify-content: center; @@ -20,7 +20,7 @@ /* ── Outer 16:9 frame ─────────────────────────────────────── */ .otd-frame { - width: min(1280px, 100vw); + width: min(1280px, 100vw, calc(100vh * 16 / 9)); aspect-ratio: 16 / 9; background: linear-gradient(160deg, #1030c8 0%, #0a1a80 40%, #081060 100%); display: flex; diff --git a/templates/status.html b/templates/status.html index f634987..6e14605 100644 --- a/templates/status.html +++ b/templates/status.html @@ -12,7 +12,7 @@ font-family: Arial, Helvetica, sans-serif; background: #07124a; color: #fff; - min-height: 100vh; + height: 100vh; display: flex; align-items: center; justify-content: center; @@ -21,7 +21,7 @@ /* ── Outer frame ────────────────────────────────────────────── */ .st-frame { - width: min(1280px, 100vw); + width: min(1280px, 100vw, calc(100vh * 16 / 9)); aspect-ratio: 16 / 9; background: linear-gradient(160deg, #1535cc 0%, #0c22a0 35%, #081870 100%); display: flex; diff --git a/templates/traffic.html b/templates/traffic.html index 98338fb..8606798 100644 --- a/templates/traffic.html +++ b/templates/traffic.html @@ -14,7 +14,7 @@ font-family: Arial, Helvetica, sans-serif; background: #0a1a6e; color: #fff; - min-height: 100vh; + height: 100vh; display: flex; align-items: center; justify-content: center; @@ -22,7 +22,7 @@ } .tf-frame { - width: min(1280px, 100vw); + width: min(1280px, 100vw, calc(100vh * 16 / 9)); aspect-ratio: 16 / 9; background: linear-gradient(160deg, #0f2ab0 0%, #0a1a80 40%, #081060 100%); display: flex; diff --git a/templates/weather.html b/templates/weather.html index 7f86cfc..48cd09a 100644 --- a/templates/weather.html +++ b/templates/weather.html @@ -12,7 +12,7 @@ font-family: Arial, Helvetica, sans-serif; background: #0a1a6e; color: #fff; - min-height: 100vh; + height: 100vh; display: flex; align-items: center; justify-content: center; @@ -21,7 +21,7 @@ /* ── Outer frame ─────────────────────────────────────────────── */ .wx-frame { - width: min(1280px, 100vw); + width: min(1280px, 100vw, calc(100vh * 16 / 9)); aspect-ratio: 16 / 9; background: linear-gradient(160deg, #1030c8 0%, #0a1a80 40%, #081060 100%); display: flex; diff --git a/tests/test_audio.py b/tests/test_audio.py index 5dd4cdc..cf799c9 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -12,6 +12,7 @@ app, init_db, init_tuners_db, add_user, get_channel_music_file, save_channel_music_file, list_audio_files, AUDIO_UPLOAD_DIR, _ALLOWED_AUDIO_EXTENSIONS, + VIRTUAL_CHANNELS, save_virtual_channel_settings, ) @@ -311,6 +312,7 @@ def test_save_empty_music_file_clears(self, client, tmp_path, monkeypatch): class TestGuideMusicFile: def test_guide_has_data_music_file_attribute(self, client): """Guide page HTML includes the data-music-file attribute on virtual channels.""" + save_virtual_channel_settings({ch['tvg_id']: True for ch in VIRTUAL_CHANNELS}) login(client) resp = client.get('/guide') assert resp.status_code == 200 @@ -318,6 +320,7 @@ def test_guide_has_data_music_file_attribute(self, client): def test_guide_includes_music_src_when_set(self, client, tmp_path, monkeypatch): """When a music file is saved for a channel, the guide page embeds its path.""" + save_virtual_channel_settings({ch['tvg_id']: True for ch in VIRTUAL_CHANNELS}) d = tmp_path / "audio" d.mkdir() monkeypatch.setattr(app_module, "AUDIO_UPLOAD_DIR", str(d)) @@ -329,6 +332,7 @@ def test_guide_includes_music_src_when_set(self, client, tmp_path, monkeypatch): def test_guide_music_file_empty_when_not_set(self, client): """With no music file configured, the data-music-file attribute is empty.""" + save_virtual_channel_settings({ch['tvg_id']: True for ch in VIRTUAL_CHANNELS}) login(client) resp = client.get('/guide') assert b'data-music-file=""' in resp.data diff --git a/tests/test_logo_upload.py b/tests/test_logo_upload.py index d9997e5..f7c14cb 100644 --- a/tests/test_logo_upload.py +++ b/tests/test_logo_upload.py @@ -12,7 +12,7 @@ app, init_db, init_tuners_db, add_user, get_channel_custom_logo, save_channel_custom_logo, LOGO_UPLOAD_DIR, _ALLOWED_LOGO_EXTENSIONS, _DEFAULT_CHANNEL_LOGOS, - VIRTUAL_CHANNELS, + VIRTUAL_CHANNELS, save_virtual_channel_settings, ) @@ -271,6 +271,7 @@ def test_all_channels_can_be_reset(self, client): class TestVirtualChannelPageLogoUI: def test_logo_upload_section_present(self, client): + save_virtual_channel_settings({ch['tvg_id']: True for ch in VIRTUAL_CHANNELS}) login(client) resp = client.get('/virtual_channels') assert resp.status_code == 200 diff --git a/tests/test_traffic_demo.py b/tests/test_traffic_demo.py index 5b078b5..d498259 100644 --- a/tests/test_traffic_demo.py +++ b/tests/test_traffic_demo.py @@ -28,6 +28,7 @@ _BASEMAP_W, _BASEMAP_H, _prewarm_roads_cache, _roads_cache_path, + save_virtual_channel_settings, ) @@ -437,6 +438,7 @@ def test_pick_random_uses_default_pack_size(self, client): class TestChangeTunerTrafficDemoUI: def test_traffic_demo_fields_in_change_tuner_page(self, client): + save_virtual_channel_settings({'virtual.traffic': True}) login(client, 'admin', 'adminpass') resp = client.get('/virtual_channels') assert resp.status_code == 200 @@ -452,6 +454,7 @@ def test_no_api_key_field_in_change_tuner_page(self, client): assert b'TomTom' not in resp.data def test_city_table_present(self, client): + save_virtual_channel_settings({'virtual.traffic': True}) login(client, 'admin', 'adminpass') resp = client.get('/virtual_channels') assert b'traffic-demo-city-table' in resp.data diff --git a/tests/test_tuner_validation.py b/tests/test_tuner_validation.py index 817cf6e..dc01b30 100644 --- a/tests/test_tuner_validation.py +++ b/tests/test_tuner_validation.py @@ -71,19 +71,13 @@ def test_xml_url_must_be_http_or_https_if_provided(self): with pytest.raises(ValueError, match="XML URL must start with http:// or https://"): app_module.add_tuner("TestTuner", "ftp://example.com/epg.xml", "http://example.com/playlist.m3u") - def test_xml_url_optional(self): - """Test that XML URL is optional – empty or whitespace is allowed for single .m3u8 streams.""" - with patch('app.socket.gethostbyname', return_value='93.184.216.34'): + def test_xml_url_required(self): + """Test that XML URL is required – empty or whitespace is rejected.""" + with pytest.raises(ValueError, match="cannot be empty"): app_module.add_tuner("NoXmlTuner1", "", "http://example.com/playlist.m3u") - tuners = app_module.get_tuners() - assert "NoXmlTuner1" in tuners - assert tuners["NoXmlTuner1"]["xml"] == "" - with patch('app.socket.gethostbyname', return_value='93.184.216.34'): + with pytest.raises(ValueError, match="cannot be empty"): app_module.add_tuner("NoXmlTuner2", " ", "http://example.com/playlist2.m3u") - tuners = app_module.get_tuners() - assert "NoXmlTuner2" in tuners - assert tuners["NoXmlTuner2"]["xml"] == " " def test_url_reachability_check(self): """Test that valid external URLs are accepted without making HTTP requests.""" diff --git a/tests/test_weather.py b/tests/test_weather.py index 091e8db..6b8c4ea 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -13,6 +13,7 @@ get_weather_config, save_weather_config, _wmo_label, _wmo_icon, _to_night_icon, _wind_dir, _build_weather_payload, + save_virtual_channel_settings, ) @@ -305,6 +306,7 @@ class TestChangeTunerWeatherSettings: """Weather config fields are surfaced in the Virtual Channel overlay panel.""" def test_weather_fields_in_change_tuner_page(self, client): + save_virtual_channel_settings({'virtual.weather': True}) login(client, 'admin', 'adminpass') resp = client.get('/virtual_channels') assert resp.status_code == 200 @@ -330,6 +332,7 @@ def test_preview_weather_link_present(self, client): def test_saved_location_prefills_in_page(self, client): save_weather_config({'lat': '25.77', 'lon': '-80.19', 'location_name': 'Miami, FL', 'units': 'F'}) + save_virtual_channel_settings({'virtual.weather': True}) login(client, 'admin', 'adminpass') resp = client.get('/virtual_channels') assert b'Miami, FL' in resp.data From e9fbf92b00f6b3cf0eb9fc601e2bacf16bd106bb Mon Sep 17 00:00:00 2001 From: thehack904 <35552907+thehack904@users.noreply.github.com> Date: Thu, 7 May 2026 21:48:02 -0500 Subject: [PATCH 2/3] Second update for v4.9.5-beta2 --- CHANGELOG.md | 14 + README.md | 11 +- app.py | 493 +++++++++++++++++++++++++++- docs/wiki/FAQ.md | 6 +- docs/wiki/Home.md | 1 + docs/wiki/IPTV-Backends.md | 173 ++++++++++ docs/wiki/Troubleshooting.md | 4 +- docs/wiki/Virtual-Channels.md | 19 +- static/css/base.css | 12 + static/js/user-prefs.js | 95 +----- static/overlays/news.js | 119 +++++++ static/overlays/weather.js | 500 ++++++++++++++++++++++++++++- templates/_header.html | 2 + templates/guide.html | 13 +- templates/manage_users.html | 34 ++ templates/virtual_channels.html | 342 +++++++++++++++++++- templates/weather.html | 274 ++++++++++++++++ tests/test_current_program_logo.py | 129 ++++++++ tests/test_traffic_demo.py | 369 +++++++++++++++++++++ tests/test_user_prefs.py | 155 +++++++-- tests/test_weather.py | 350 +++++++++++++++++++- 21 files changed, 2952 insertions(+), 163 deletions(-) create mode 100644 docs/wiki/IPTV-Backends.md create mode 100644 tests/test_current_program_logo.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 87f00c3..1cf2aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ This project follows [Semantic Versioning](https://semver.org/). ## v4.9.5 - 2026-04-29 +### Added +- Added ability to hide channels per user (#190). + - Users can right-click any channel name in the guide grid to access a context menu with **Hide Channel** / **Unhide Channel** options; hidden channels are persisted server-side per user account. + - Added **Show/Hide Hidden Channels** toggle to the **Guide Preferences** submenu in the Settings menu (desktop and mobile) so users can reveal or re-hide all their hidden channels with one click. + - Admins can now view a summary of hidden channels per user and **set hidden channels directly** from the **Manage Users** preferences panel using a multi-select channel picker — selecting channels marks them as hidden, deselecting all clears the list. +- Added **IPTV Backends** wiki page (`docs/wiki/IPTV-Backends.md`) listing compatible IPTV server software that provides HLS segmenter streams (#189). + - Documents Tunarr, dizqueTV, Channels DVR, TVHeadend, Jellyfin, Plex, and Emby as ErsatzTV alternatives. + - Includes an FFmpeg direct-segmenter example for advanced users. + - Includes guidance on using the built-in stream detector to verify backend compatibility. + - Updated `README.md` and `docs/wiki/FAQ.md` to reference the new page and broaden ErsatzTV-specific language. + +### Removed +- Removed **Sizzle Reels** feature (hover-to-preview channel stream). The `sizzle_reels_enabled` preference key has been retired; any stored values are silently ignored going forward. + ### Fixed - Fixed virtual channel standalone pages not fitting the viewport on mobile devices. - Updated `weather.html`, `news.html`, `status.html`, `traffic.html`, `nasa.html`, and `on_this_day.html` to use `height: 100vh` (instead of `min-height: 100vh`) so the page fills the screen without overflow. diff --git a/README.md b/README.md index c52541d..bcf47dd 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,11 @@ It aggregates **IPTV playlists and XMLTV program data** and presents them in a **retro‑style channel guide** designed for home lab and local network deployments, including use on smart TV browsers. -The project works particularly well alongside playout systems such as -**ErsatzTV**, but supports any IPTV backend that provides: +The project works well alongside any IPTV playout system that provides HLS segmented +streams, such as **Tunarr**, **dizqueTV**, **Channels DVR**, **ErsatzTV**, and others. +See [IPTV Backends](docs/wiki/IPTV-Backends.md) for a full compatibility list. + +Any backend that provides: - `.m3u` / `.m3u8` playlists - XMLTV `.xml` EPG data @@ -115,6 +118,7 @@ A structured wiki is available in [`docs/wiki/`](docs/wiki/Home.md): - [Home](docs/wiki/Home.md) -- Wiki home and navigation - [Installation](docs/wiki/Installation.md) -- Installation and update instructions - [Configuration](docs/wiki/Configuration.md) -- Tuner setup and settings +- [IPTV Backends](docs/wiki/IPTV-Backends.md) -- Compatible IPTV servers with HLS output - [Virtual Channels](docs/wiki/Virtual-Channels.md) -- Built-in virtual channels - [FAQ](docs/wiki/FAQ.md) -- Frequently asked questions - [Troubleshooting](docs/wiki/Troubleshooting.md) -- Common problems and fixes @@ -139,7 +143,8 @@ Many IPTV servers output **MPEG‑TS streams**, which are not directly playable in browsers. For best compatibility, use an IPTV backend that provides **HLS -output**, such as **ErsatzTV**. +output**, such as **Tunarr** or **Channels DVR**. See +[IPTV Backends](docs/wiki/IPTV-Backends.md) for a full list of compatible servers. ------------------------------------------------------------------------ diff --git a/app.py b/app.py index 0cce54d..40b3567 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ # app.py — merged version (features from both sources) APP_VERSION = "v4.9.5-beta" -APP_RELEASE_DATE = "2026-05-02" +APP_RELEASE_DATE = "2026-05-07" from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort, make_response from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user @@ -633,7 +633,6 @@ def inject_tuner_context(): _DEFAULT_PREFS = { "auto_load_channel": None, "hidden_channels": [], - "sizzle_reels_enabled": False, "default_theme": None, } @@ -1778,15 +1777,22 @@ def get_current_feed_state(feed_count): -_WEATHER_CONFIG_KEYS = ('lat', 'lon', 'location_name', 'units', 'seconds_per_segment') +_WEATHER_CONFIG_KEYS = ('lat', 'lon', 'location_name', 'units', 'seconds_per_segment', + 'bg_condition_override') _WEATHER_SECONDS_PER_SEGMENT_DEFAULT = 300 # 5 minutes _WEATHER_SEGMENT_LABELS = ('current', 'forecast', 'radar', 'alerts') +# Valid condition values the admin may force for animated-background testing +_WEATHER_BG_VALID_CONDITIONS = ( + '', 'sunny', 'partly_cloudy', 'cloudy', 'rain', 'drizzle', + 'showers', 'snow', 'thunderstorm', 'foggy', 'windy', +) def get_weather_config(): """Return weather configuration: lat, lon (strings), location_name, units ('F'/'C'), - seconds_per_segment (int, default 300).""" + seconds_per_segment (int, default 300), bg_condition_override (string, default '').""" result = {'lat': '', 'lon': '', 'location_name': '', 'units': 'F', - 'seconds_per_segment': str(_WEATHER_SECONDS_PER_SEGMENT_DEFAULT)} + 'seconds_per_segment': str(_WEATHER_SECONDS_PER_SEGMENT_DEFAULT), + 'bg_condition_override': ''} try: with sqlite3.connect(TUNER_DB, timeout=10) as conn: c = conn.cursor() @@ -1812,6 +1818,8 @@ def save_weather_config(config_dict): raise ValueError(f"Invalid value for {key}: {val!r}. Must be a number.") if key == 'units' and val not in ('F', 'C', ''): raise ValueError(f"Invalid units: {val!r}. Must be 'F' or 'C'.") + if key == 'bg_condition_override' and val not in _WEATHER_BG_VALID_CONDITIONS: + raise ValueError(f"Invalid bg_condition_override: {val!r}.") if key == 'seconds_per_segment' and val: try: sps = int(val) @@ -2066,6 +2074,265 @@ def pick_random_traffic_demo_pack(pack_size=10): return chosen +def lookup_zip_city(postal_code: str, country_code: str = 'us') -> dict: + """Look up city name, state abbreviation, latitude, and longitude for a postal code. + + Uses the free Nominatim OpenStreetMap geocoding API (no API key required). + Returns a dict with keys: name, state, lat, lon. + Raises ValueError if the postal code is not found or the response is invalid. + """ + postal_code = (postal_code or '').strip() + if not postal_code: + raise ValueError("Postal code must not be empty.") + if len(postal_code) > 20: + raise ValueError("Postal code is too long.") + + country_code = (country_code or 'us').strip().lower()[:2] + + # US state name → 2-letter abbreviation mapping used when the API returns a full name + _US_STATE_ABBR = { + 'Alabama': 'AL', 'Alaska': 'AK', 'Arizona': 'AZ', 'Arkansas': 'AR', + 'California': 'CA', 'Colorado': 'CO', 'Connecticut': 'CT', 'Delaware': 'DE', + 'Florida': 'FL', 'Georgia': 'GA', 'Hawaii': 'HI', 'Idaho': 'ID', + 'Illinois': 'IL', 'Indiana': 'IN', 'Iowa': 'IA', 'Kansas': 'KS', + 'Kentucky': 'KY', 'Louisiana': 'LA', 'Maine': 'ME', 'Maryland': 'MD', + 'Massachusetts': 'MA', 'Michigan': 'MI', 'Minnesota': 'MN', 'Mississippi': 'MS', + 'Missouri': 'MO', 'Montana': 'MT', 'Nebraska': 'NE', 'Nevada': 'NV', + 'New Hampshire': 'NH', 'New Jersey': 'NJ', 'New Mexico': 'NM', 'New York': 'NY', + 'North Carolina': 'NC', 'North Dakota': 'ND', 'Ohio': 'OH', 'Oklahoma': 'OK', + 'Oregon': 'OR', 'Pennsylvania': 'PA', 'Rhode Island': 'RI', 'South Carolina': 'SC', + 'South Dakota': 'SD', 'Tennessee': 'TN', 'Texas': 'TX', 'Utah': 'UT', + 'Vermont': 'VT', 'Virginia': 'VA', 'Washington': 'WA', 'West Virginia': 'WV', + 'Wisconsin': 'WI', 'Wyoming': 'WY', 'District of Columbia': 'DC', + } + + url = "https://nominatim.openstreetmap.org/search" + params = { + 'postalcode': postal_code, + 'countrycodes': country_code, + 'format': 'json', + 'addressdetails': '1', + 'limit': '1', + } + headers = { + 'User-Agent': 'RetroIPTVGuide/1.0 (traffic demo city lookup; ' + 'https://github.com/thehack904/RetroIPTVGuide)', + 'Accept-Language': 'en', + } + try: + resp = requests.get(url, params=params, headers=headers, timeout=10) + resp.raise_for_status() + results = resp.json() + except Exception as exc: + logging.warning("lookup_zip_city: Nominatim request failed: %s", exc) + raise ValueError("Postal code lookup service unavailable. Enter city details manually.") from exc + + if not results: + raise ValueError(f"No results found for postal code \"{postal_code}\".") + + item = results[0] + lat = float(item.get('lat', 0)) + lon = float(item.get('lon', 0)) + address = item.get('address', {}) + + # City name: try several address keys in priority order + name = (address.get('city') + or address.get('town') + or address.get('village') + or address.get('hamlet') + or address.get('county') + or address.get('state') + or postal_code) + + # State: for US prefer the abbreviation; for other countries use full name + state_full = address.get('state', '') + state = _US_STATE_ABBR.get(state_full, state_full) if country_code == 'us' else state_full + if not state: + state = address.get('country_code', country_code).upper() + + return {'name': name, 'state': state, 'lat': lat, 'lon': lon} + + +def add_traffic_demo_city(name, state, lat, lon, population): + """Insert a new city into traffic_demo_cities. + + Validates all inputs, inserts the row, and starts a background thread that + downloads road data (Overpass GeoJSON) and the basemap PNG for the new city + so that it works fully offline after the next page load. + + Returns the new city's integer id. + Raises ValueError on invalid input. + """ + name = (name or '').strip() + state = (state or '').strip() + if not name: + raise ValueError("City name must not be empty.") + if len(name) > 100: + raise ValueError("City name must be 100 characters or fewer.") + if not state: + raise ValueError("State/region must not be empty.") + if len(state) > 50: + raise ValueError("State/region must be 50 characters or fewer.") + try: + lat = float(lat) + if not (-90.0 <= lat <= 90.0): + raise ValueError("Latitude must be between -90 and 90.") + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid latitude: {exc}") from exc + try: + lon = float(lon) + if not (-180.0 <= lon <= 180.0): + raise ValueError("Longitude must be between -180 and 180.") + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid longitude: {exc}") from exc + try: + # Strip all common thousands-separator styles before parsing: + # 37,390 (US/UK comma) + # 37.390 (European period) + # 37 390 (French/SI non-breaking or plain space) + # 37'390 (Swiss apostrophe) + # 37_390 (Python / some locales) + pop_raw = re.sub(r"[,.\s'_\u00a0\u202f]", '', str(population).strip()) + population = int(pop_raw) if pop_raw else 0 + if population < 0: + raise ValueError("Population must be a non-negative integer.") + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid population: {exc}") from exc + + now_iso = datetime.now(timezone.utc).isoformat() + try: + with sqlite3.connect(TUNER_DB, timeout=10) as conn: + c = conn.cursor() + c.execute( + "INSERT INTO traffic_demo_cities " + "(name, state, lat, lon, population, enabled, weight, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, 1, 1, ?, ?)", + (name, state, lat, lon, population, now_iso, now_iso), + ) + conn.commit() + new_id = c.lastrowid + except Exception: + logging.exception("add_traffic_demo_city failed for name=%r", name) + raise + + # Kick off a background thread to download road data and basemap so the city + # works offline immediately without blocking the HTTP response. + threading.Thread( + target=_download_city_offline_data, + args=(new_id, name, lat, lon), + daemon=True, + name=f"city-data-dl-{new_id}", + ).start() + + return new_id + + +def delete_traffic_demo_city(city_id): + """Remove a city from traffic_demo_cities and clean up its cached data. + + Deletes the DB row and removes: + - the disk road-data cache (ROADS_CACHE_DIR/.json) + - the basemap PNG (static/maps/traffic_demo/.png) + + Raises if the city_id does not exist. + """ + # Look up the city name before deleting so we can remove the slug-based files. + cities = get_traffic_demo_cities() + city = next((c for c in cities if c['id'] == int(city_id)), None) + if city is None: + raise ValueError(f"City id={city_id} not found.") + + # Remove DB row + try: + with sqlite3.connect(TUNER_DB, timeout=10) as conn: + c = conn.cursor() + c.execute("DELETE FROM traffic_demo_cities WHERE id=?", (int(city_id),)) + conn.commit() + except Exception: + logging.exception("delete_traffic_demo_city: DB delete failed for id=%s", city_id) + raise + + # Evict in-memory road cache + _ROADS_CACHE.pop(int(city_id), None) + _ROADS_CACHE_TIME.pop(int(city_id), None) + + # Remove disk road-data cache file + cache_path = _roads_cache_path(int(city_id)) + try: + if os.path.isfile(cache_path): + os.remove(cache_path) + logging.info("delete_traffic_demo_city: removed road cache %s", cache_path) + except Exception: + logging.warning("delete_traffic_demo_city: could not remove road cache %s", cache_path, + exc_info=True) + + # Remove basemap PNG + slug = _city_slug(city['name']) + basemap_path = os.path.join(_BASEMAP_DIR, f"{slug}.png") + try: + if os.path.isfile(basemap_path): + os.remove(basemap_path) + logging.info("delete_traffic_demo_city: removed basemap %s", basemap_path) + except Exception: + logging.warning("delete_traffic_demo_city: could not remove basemap %s", basemap_path, + exc_info=True) + + +def _download_city_offline_data(city_id: int, city_name: str, lat: float, lon: float) -> None: + """Background thread: download road GeoJSON and basemap PNG for a newly added city. + + This is called from add_traffic_demo_city() so the city has usable offline + data as soon as possible after it is created. Both downloads are best-effort + — failures are logged but do not raise so the thread always terminates cleanly. + """ + logging.info("_download_city_offline_data: starting for %r (id=%s)", city_name, city_id) + + # 1. Road data (Overpass GeoJSON) ───────────────────────────────────────── + try: + raw = _fetch_overpass_roads(lat, lon) + geojson = _overpass_to_geojson(raw) + if geojson["features"]: + _save_roads_to_disk(city_id, geojson) + # Warm the in-memory cache too + _ROADS_CACHE[city_id] = geojson + _ROADS_CACHE_TIME[city_id] = time.time() + logging.info("_download_city_offline_data: road data saved for %r (%d features)", + city_name, len(geojson["features"])) + else: + logging.warning("_download_city_offline_data: no road features returned for %r", + city_name) + except Exception: + logging.exception("_download_city_offline_data: road download failed for %r", city_name) + + # 2. Basemap PNG (OSM tile stitch) ───────────────────────────────────────── + if not _PILLOW_AVAILABLE: + logging.info("_download_city_offline_data: Pillow unavailable, skipping basemap for %r", + city_name) + return + + slug = _city_slug(city_name) + out_path = os.path.join(_BASEMAP_DIR, f"{slug}.png") + if os.path.isfile(out_path): + logging.debug("_download_city_offline_data: basemap already exists for %r, skipping", city_name) + return + + try: + ok = _generate_basemap_png(lat, lon, out_path) + if ok: + logging.info("_download_city_offline_data: basemap saved for %r", city_name) + else: + # Tile download failed — use placeholder so the overlay always has a background + logging.info("_download_city_offline_data: tile download failed for %r, generating placeholder", + city_name) + _generate_placeholder_basemap_png(city_name, out_path) + except Exception: + logging.exception("_download_city_offline_data: basemap generation failed for %r", city_name) + try: + _generate_placeholder_basemap_png(city_name, out_path) + except Exception: + logging.exception("_download_city_offline_data: placeholder also failed for %r", city_name) + + def _get_congestion_distribution(hour, is_weekend=False): """Return (green_pct, yellow_pct, red_pct) based on local hour and day type. Percentages always sum to 100.""" @@ -3003,6 +3270,43 @@ def _build_radar_url(lat, lon): return _RADAR_URL_CONUS +_NWS_ALERT_SEVERITIES = ('Extreme', 'Severe', 'Moderate', 'Minor') + +def _fetch_nws_alerts(lat, lon): + """Fetch active NWS weather alerts for a lat/lon point (US only). + + Calls https://api.weather.gov/alerts/active?point={lat},{lon}. + Returns a list of alert dicts (may be empty) or [] on failure / non-US location. + Each dict has keys: event, headline, description, severity, urgency, + certainty, onset, expires. + """ + try: + url = f"https://api.weather.gov/alerts/active?point={lat},{lon}" + resp = requests.get(url, timeout=10, headers={ + 'User-Agent': 'RetroIPTVGuide/1.0', + 'Accept': 'application/geo+json', + }) + resp.raise_for_status() + features = resp.json().get('features', []) + alerts = [] + for feat in features: + props = feat.get('properties', {}) + alerts.append({ + 'event': props.get('event', ''), + 'headline': props.get('headline', ''), + 'description': props.get('description', ''), + 'severity': props.get('severity', 'Unknown'), + 'urgency': props.get('urgency', 'Unknown'), + 'certainty': props.get('certainty', 'Unknown'), + 'onset': props.get('onset', ''), + 'expires': props.get('expires', ''), + }) + return alerts + except Exception: + logging.exception("_fetch_nws_alerts failed for lat=%s lon=%s", lat, lon) + return [] + + def _fetch_open_meteo(lat, lon, units): """Fetch current + hourly + daily weather from open-meteo. Returns dict or None on failure.""" temp_unit = 'fahrenheit' if units != 'C' else 'celsius' @@ -3025,6 +3329,12 @@ def _fetch_open_meteo(lat, lon, units): logging.exception("_fetch_open_meteo failed for lat=%s lon=%s", lat, lon) return None +# Wind speed threshold (in mph or kmh depending on the configured units) above +# which a "clear/partly-cloudy/cloudy" sky is reported as a "windy" animated +# background. 25 mph is roughly "breezy / fresh breeze" on the Beaufort scale. +_WINDY_BG_THRESHOLD_MPH = 25 +_WINDY_BG_THRESHOLD_KMH = 40 + def _build_weather_payload(cfg): """Build the full weather API payload from open-meteo data or a stub when unconfigured.""" now_utc = datetime.now(timezone.utc) @@ -3034,10 +3344,13 @@ def _build_weather_payload(cfg): location_name = cfg.get('location_name') or 'Local Weather' units = cfg.get('units') or 'F' deg = '°F' if units != 'C' else '°C' + bg_override = cfg.get('bg_condition_override', '').strip() raw = None + nws_alerts = [] if lat and lon: raw = _fetch_open_meteo(lat, lon, units) + nws_alerts = _fetch_nws_alerts(lat, lon) if raw: cur = raw.get('current', {}) @@ -3126,15 +3439,30 @@ def _period_avg(start_h, end_h): 'condition': _wmo_label(wc), 'icon': _wmo_icon(wc)}) ticker = [] - if wcode in (95, 96, 99): - ticker.append('Severe Thunderstorms Possible') - if wcode in (71, 73, 75, 77, 85, 86): - ticker.append('Winter Weather Advisory in Effect') + # Prefer real NWS alert headlines; fall back to WMO-code hints when no alerts + if nws_alerts: + ticker = [a['headline'] or a['event'] for a in nws_alerts if a['headline'] or a['event']] + else: + if wcode in (95, 96, 99): + ticker.append('Severe Thunderstorms Possible') + if wcode in (71, 73, 75, 77, 85, 86): + ticker.append('Winter Weather Advisory in Effect') # backward-compat forecast list compat_forecast = [{'label': d['dow'], 'hi': d['hi'], 'lo': d['lo'], 'condition': d['condition']} for d in extended] + # Derive the animated-background condition from the actual icon, then let + # the admin override override it when set. + auto_bg = now_info['icon'] + # Treat high wind speeds as a "windy" hint when no stronger condition. + # open-meteo returns mph when units='F' and km/h when units='C'. + windy_threshold = (_WINDY_BG_THRESHOLD_KMH if units == 'C' + else _WINDY_BG_THRESHOLD_MPH) + if wind_spd is not None and wind_spd >= windy_threshold and auto_bg in ('sunny', 'partly_cloudy', 'cloudy'): + auto_bg = 'windy' + bg_condition = bg_override if bg_override in _WEATHER_BG_VALID_CONDITIONS and bg_override else auto_bg + return { 'updated': updated_str, 'location': location_name, @@ -3143,11 +3471,15 @@ def _period_avg(start_h, end_h): 'extended': extended, 'five_day': five_day, 'ticker': ticker, + 'alerts': nws_alerts, 'forecast': compat_forecast, 'radar_url': _build_radar_url(lat, lon), + 'bg_condition': bg_condition, + 'bg_condition_override': bg_override, } # Stub / demo data when no coordinates configured + bg_condition = bg_override if bg_override in _WEATHER_BG_VALID_CONDITIONS and bg_override else 'cloudy' return { 'updated': updated_str, 'location': location_name, @@ -3161,8 +3493,11 @@ def _period_avg(start_h, end_h): 'extended': [], 'five_day': [], 'ticker': [], + 'alerts': [], 'forecast': [], 'radar_url': _build_radar_url('', ''), + 'bg_condition': bg_condition, + 'bg_condition_override': bg_override, } _RSS_NS = {'atom': 'http://www.w3.org/2005/Atom', 'media': 'http://search.yahoo.com/mrss/'} @@ -3692,10 +4027,14 @@ def manage_users(): ch_name = request.form.get('auto_load_channel_name', '').strip() or ch_id auto_load = {"id": ch_id, "name": ch_name} if ch_id else None raw_theme = request.form.get('default_theme', '').strip() or None - save_user_prefs(username, { + patch = { "auto_load_channel": auto_load, "default_theme": raw_theme, - }) + } + if request.form.get('update_hidden_channels'): + patch["hidden_channels"] = request.form.getlist('hidden_channel_ids') + log_event(current_user.username, f"Set hidden channels for {username}: {patch['hidden_channels']}") + save_user_prefs(username, patch) log_event(current_user.username, f"Updated prefs for {username}") flash(f"✅ Preferences saved for '{username}'.") @@ -4746,6 +5085,43 @@ def api_weather(): payload['ms_until_next'] = ms_until_next return jsonify(payload) + +@app.route('/api/weather/bg_override', methods=['GET', 'POST', 'DELETE']) +@login_required +def api_weather_bg_override(): + """Admin endpoint: get/set/clear the animated-background condition override. + + GET → returns {"condition": ""}. + POST → body JSON {"condition": ""} sets override; "" or "auto" clears it. + DELETE → clears the override (sets to ""). + """ + if request.method == 'GET': + cfg = get_weather_config() + return jsonify({'condition': cfg.get('bg_condition_override', '')}) + + if request.method == 'DELETE': + try: + save_weather_config({**get_weather_config(), 'bg_condition_override': ''}) + return jsonify({'ok': True, 'condition': ''}) + except Exception as exc: + logging.exception("api_weather_bg_override DELETE failed: %s", exc) + return jsonify({'ok': False, 'error': 'Internal server error'}), 500 + + # POST + data = request.get_json(silent=True) or {} + condition = str(data.get('condition', '')).strip() + if condition == 'auto': + condition = '' + if condition not in _WEATHER_BG_VALID_CONDITIONS: + return jsonify({'ok': False, 'error': f'Invalid condition: {condition!r}'}), 400 + try: + save_weather_config({**get_weather_config(), 'bg_condition_override': condition}) + return jsonify({'ok': True, 'condition': condition}) + except Exception as exc: + logging.exception("api_weather_bg_override POST failed: %s", exc) + return jsonify({'ok': False, 'error': 'Internal server error'}), 500 + + @app.route('/api/traffic', methods=['GET']) @login_required def api_traffic(): @@ -4828,6 +5204,93 @@ def api_traffic_demo_roads(city_id): geojson = get_traffic_demo_roads(city_id) return jsonify(geojson) + +@app.route('/api/traffic/demo/cities/add', methods=['POST']) +@login_required +def api_traffic_demo_city_add(): + """Add a new city to the traffic demo rotation. + + Expected JSON body: + { "name": "Portland", "state": "OR", "lat": 45.5051, "lon": -122.6750, "population": 652503 } + + On success returns { "ok": true, "city": { } }. + The server immediately starts a background thread to download road data + (Overpass GeoJSON) and the basemap PNG so the new city is offline-ready. + """ + if current_user.username != 'admin': + return jsonify({'ok': False, 'error': 'Admin only'}), 403 + data = request.get_json(silent=True) or {} + try: + new_id = add_traffic_demo_city( + name=data.get('name', ''), + state=data.get('state', ''), + lat=data.get('lat', 0), + lon=data.get('lon', 0), + population=data.get('population', 0), + ) + except ValueError as exc: + logging.debug("api_traffic_demo_city_add: validation failed: %s", exc) + return jsonify({'ok': False, 'error': 'Invalid city data. Check name, state, lat/lon and population.'}), 400 + except Exception as exc: + logging.exception("api_traffic_demo_city_add failed: %s", exc) + return jsonify({'ok': False, 'error': 'Internal server error'}), 500 + + cities = get_traffic_demo_cities() + new_city = next((c for c in cities if c['id'] == new_id), None) + return jsonify({'ok': True, 'city': new_city}) + + +@app.route('/api/traffic/demo/cities/', methods=['DELETE']) +@login_required +def api_traffic_demo_city_delete(city_id): + """Delete a city from the traffic demo rotation. + + Removes the DB row and cleans up the cached road GeoJSON and basemap PNG. + Returns { "ok": true } on success. + """ + if current_user.username != 'admin': + return jsonify({'ok': False, 'error': 'Admin only'}), 403 + try: + delete_traffic_demo_city(city_id) + return jsonify({'ok': True}) + except ValueError as exc: + logging.debug("api_traffic_demo_city_delete: city not found: %s", exc) + return jsonify({'ok': False, 'error': 'City not found.'}), 404 + except Exception as exc: + logging.exception("api_traffic_demo_city_delete failed for city_id=%s: %s", city_id, exc) + return jsonify({'ok': False, 'error': 'Internal server error'}), 500 + + +@app.route('/api/traffic/demo/zip_lookup', methods=['GET']) +@login_required +def api_traffic_demo_zip_lookup(): + """Look up city info for a postal code using the free Nominatim OSM geocoding API. + + Query parameters: + zip — postal code (required) + country — 2-letter ISO country code, defaults to 'us' + + Returns { "ok": true, "name": "...", "state": "...", "lat": ..., "lon": ... } + on success, or { "ok": false, "error": "..." } on failure. + No API key is required. + """ + if current_user.username != 'admin': + return jsonify({'ok': False, 'error': 'Admin only'}), 403 + postal_code = request.args.get('zip', '').strip() + country_code = request.args.get('country', 'us').strip() + if not postal_code: + return jsonify({'ok': False, 'error': 'zip parameter is required'}), 400 + try: + info = lookup_zip_city(postal_code, country_code) + return jsonify({'ok': True, **info}) + except ValueError as exc: + logging.debug("api_traffic_demo_zip_lookup: lookup failed: %s", exc) + return jsonify({'ok': False, 'error': 'Postal code not found or lookup service unavailable.'}), 404 + except Exception as exc: + logging.exception("api_traffic_demo_zip_lookup failed: %s", exc) + return jsonify({'ok': False, 'error': 'Internal server error'}), 500 + + @app.route('/api/virtual/status', methods=['GET']) @login_required def api_virtual_status(): @@ -5272,7 +5735,7 @@ def api_current_program(): Query params: - tvg_id (preferred) OR id (fallback) — the channel identifier used in cached_channels Response: - { ok: True, channel: "", tvg_id: "", program: { title, desc, start_iso, stop_iso } } + { ok: True, channel: "", tvg_id: "", logo: "", program: { title, desc, start_iso, stop_iso } } or { ok: False, error: "..." } """ tvg_id = request.args.get('tvg_id') or request.args.get('id') or '' @@ -5281,11 +5744,13 @@ def api_current_program(): try: now = datetime.now(timezone.utc) - # find channel name + # find channel name and logo channel_name = None + channel_logo = '' for ch in cached_channels: if ch.get('tvg_id') == tvg_id or str(ch.get('number')) == str(tvg_id): channel_name = ch.get('name') + channel_logo = ch.get('logo') or '' break # get epg entries for tvg_id @@ -5316,6 +5781,7 @@ def api_current_program(): "ok": True, "channel": channel_name, "tvg_id": tvg_id, + "logo": channel_logo, "program": { "title": current_prog.get('title') or '', "desc": current_prog.get('desc') or '', @@ -5328,6 +5794,7 @@ def api_current_program(): "ok": True, "channel": channel_name, "tvg_id": tvg_id, + "logo": channel_logo, "program": { "title": "No Guide Data Available", "desc": "", diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md index 9355a74..989fd23 100644 --- a/docs/wiki/FAQ.md +++ b/docs/wiki/FAQ.md @@ -28,7 +28,11 @@ Any backend that provides: - XMLTV `.xml` or `.xml.gz` EPG URLs - **HLS segmented streams** for in-browser playback -ErsatzTV is particularly well-suited because it natively outputs HLS streams. +Several open-source and commercial IPTV server applications provide HLS segmenter +output. See [IPTV Backends](IPTV-Backends.md) for a full list with setup notes. +ErsatzTV was a popular choice; following the archiving of that project, **Tunarr** +is the most actively maintained open-source alternative with equivalent +HLS segmenter functionality. ### Does RetroIPTVGuide work with MPEG-TS streams? diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index a3197e5..1420cae 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -36,6 +36,7 @@ immediately. |------|-------------| | [Installation](Installation.md) | Docker, Linux, Raspberry Pi, and Windows install guides | | [Configuration](Configuration.md) | Tuner setup, settings, data directory, environment variables | +| [IPTV Backends](IPTV-Backends.md) | Compatible IPTV servers that provide HLS segmenter streams | | [Virtual Channels](Virtual-Channels.md) | Overview of all 9 built-in virtual channels | | [FAQ](FAQ.md) | Frequently asked questions | | [Troubleshooting](Troubleshooting.md) | Common problems and how to fix them | diff --git a/docs/wiki/IPTV-Backends.md b/docs/wiki/IPTV-Backends.md new file mode 100644 index 0000000..c3caa63 --- /dev/null +++ b/docs/wiki/IPTV-Backends.md @@ -0,0 +1,173 @@ +# IPTV Backends + +This page lists IPTV server software that is compatible with RetroIPTVGuide, with +particular focus on backends that provide **HLS segmenter streams** — the delivery +mode required for reliable in-browser playback. + +--- + +## What Is an HLS Segmenter? + +An HLS segmenter takes a live video stream (from a TV tuner, IPTV source, or media +library), re-encodes it using FFmpeg or a similar tool, and outputs it as a sequence +of short `.ts` segment files advertised through a `.m3u8` media playlist. + +This is the preferred delivery mode for RetroIPTVGuide because: + +- Browsers can play `.m3u8` media playlists natively or via `hls.js`. +- The server handles the transcoding; no plugin is needed in the browser. +- Segment-based delivery is resilient to network hiccups. + +--- + +## Compatible Backends + +### Tunarr + +**Type:** HLS Segmenter (FFmpeg-based)  |  **Licence:** Open-source (MIT) + +[https://github.com/chrisbenincasa/tunarr](https://github.com/chrisbenincasa/tunarr) + +Tunarr is the community-maintained successor project most closely inspired by +ErsatzTV. It creates virtual channels from your media library and IPTV sources, +uses FFmpeg to transcode content, and outputs HLS media playlists (`.m3u8`) with +`.ts` segments — the same delivery model as ErsatzTV's `?mode=segmenter` URL. + +**Why it works with RetroIPTVGuide:** Tunarr's HLS output matches the stream type +that RetroIPTVGuide's stream detector classifies as `HLS Segmenter`. Tunarr also +exposes `.m3u` playlist and XMLTV EPG endpoints that can be added directly as a +RetroIPTVGuide tuner. + +--- + +### dizqueTV + +**Type:** HLS Segmenter (FFmpeg-based)  |  **Licence:** Open-source (GPL-3.0) + +[https://github.com/vexorain/dizqueTV](https://github.com/vexorain/dizqueTV) + +dizqueTV is the project that inspired ErsatzTV. It provides virtual channel +scheduling and uses FFmpeg to produce HLS output. While development has slowed, +it remains functional and produces HLS segmenter streams compatible with +RetroIPTVGuide. + +--- + +### Channels DVR + +**Type:** HLS Segmenter  |  **Licence:** Commercial (subscription) + +[https://getchannels.com/dvr/](https://getchannels.com/dvr/) + +Channels DVR supports OTA tuners, Cable Card, and IPTV sources. It transcodes +streams and serves them as HLS, making it fully compatible with RetroIPTVGuide. +Its IPTV source support accepts M3U playlists, and it exports a standard M3U + +XMLTV feed that can be added as a RetroIPTVGuide tuner. + +--- + +### TVHeadend + +**Type:** HLS Segmenter (via FFmpeg streaming profile)  |  **Licence:** Open-source (GPL-3.0) + +[https://tvheadend.org/](https://tvheadend.org/) + +TVHeadend is a mature TV streaming server supporting OTA, cable, and IPTV inputs. +Its native output is MPEG-TS; however, configuring an **HLS streaming profile** +(using the built-in FFmpeg transcoding pipeline) enables HLS segment output that +is compatible with RetroIPTVGuide. + +**Note:** Streams accessed without an HLS profile will be MPEG-TS and will not +play in a browser. Confirm that your TVHeadend stream URL includes an HLS profile +or that the stream type is verified with the RetroIPTVGuide stream detector. + +--- + +### Jellyfin (Live TV) + +**Type:** HLS Segmenter (when transcoding is active)  |  **Licence:** Open-source (GPL-2.0) + +[https://jellyfin.org/](https://jellyfin.org/) + +Jellyfin's Live TV feature can ingest IPTV M3U sources and serve them as HLS +streams when transcoding is enabled. The resulting HLS output is compatible with +RetroIPTVGuide. Jellyfin also exposes an XMLTV-compatible EPG that can be used +as the guide data source. + +**Note:** Jellyfin requires a TV tuner backend (e.g. TVHeadend) or an IPTV +M3U/XMLTV source configured under **Dashboard → Live TV** to provide channel +data. Direct playback of a source stream (without transcoding) may produce +MPEG-TS output that is not compatible with browser playback. + +--- + +### Plex (Live TV & DVR) + +**Type:** HLS Segmenter (when transcoding is active)  |  **Licence:** Commercial (freemium) + +[https://www.plex.tv/](https://www.plex.tv/) + +Plex supports Live TV and DVR with OTA tuners and compatible network tuners. When +transcoding is enabled, Plex serves streams as HLS and is compatible with +RetroIPTVGuide via its M3U and XMLTV export capabilities. + +--- + +### Emby (Live TV) + +**Type:** HLS Segmenter (when transcoding is active)  |  **Licence:** Commercial (freemium) + +[https://emby.media/](https://emby.media/) + +Emby's Live TV implementation is architecturally similar to Jellyfin (they share +a common origin). It supports IPTV sources and can serve streams as HLS when +transcoding is active, making it compatible with RetroIPTVGuide. + +--- + +## Using FFmpeg Directly + +For advanced users, FFmpeg can be run as a standalone HLS segmenter without a +dedicated IPTV server application. This is useful for testing or for integrating +a custom stream source. + +Example — transcode an RTSP or MPEG-TS source to HLS: + +```bash +ffmpeg -i "rtsp://camera/stream" \ + -c:v libx264 -c:a aac \ + -f hls \ + -hls_time 4 \ + -hls_list_size 6 \ + -hls_flags delete_segments \ + /path/to/web/stream.m3u8 +``` + +Serve the `.m3u8` file and `.ts` segments over HTTP (e.g. via `nginx` or Python's +`http.server`) and use the URL as the stream source in a RetroIPTVGuide tuner. + +--- + +## Checking Stream Compatibility + +RetroIPTVGuide includes a built-in stream type detector. Before adding a new +backend: + +1. Navigate to **Tuner Management → Stream Detect** (or use the URL diagnostics + tool in the admin area). +2. Paste a channel stream URL from your IPTV backend. +3. The detector will classify the stream as one of: + - **HLS Segmenter** — fully compatible ✅ + - **HLS Direct** — usually compatible ✅ (playback depends on source codec) + - **MPEG-TS** — not playable in a browser ❌ + +If your backend returns MPEG-TS, configure a transcoding/HLS profile in the +backend before adding the stream URL to RetroIPTVGuide. + +--- + +## See Also + +- [Configuration](Configuration.md) — how to add a tuner to RetroIPTVGuide +- [Troubleshooting](Troubleshooting.md) — fixing stream playback issues +- [FAQ](FAQ.md) — general questions about backend compatibility diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 36ef80a..9c441a3 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -147,7 +147,9 @@ sudo chattr -i /home/iptv/iptv-server/config/users.db | Channel | External API | |---------|-------------| - | Weather | `api.open-meteo.com` | + | Weather (conditions/forecast) | `api.open-meteo.com` | + | Weather (alerts, US only) | `api.weather.gov` | + | Weather (radar) | `opengeo.ncep.noaa.gov` | | News | Configured RSS feed URLs | | Traffic | `overpass-api.de` | | Sports | User-configured external data source (RSS feeds or JSON scores endpoint) | diff --git a/docs/wiki/Virtual-Channels.md b/docs/wiki/Virtual-Channels.md index 00fe670..b5cd046 100644 --- a/docs/wiki/Virtual-Channels.md +++ b/docs/wiki/Virtual-Channels.md @@ -12,7 +12,7 @@ same fullscreen playback experience. | Channel | ID | Data Source | |---------|----|-------------| -| [Weather](#weather) | `virtual.weather` | Open-Meteo API | +| [Weather](#weather) | `virtual.weather` | Open-Meteo API (conditions/forecast), NOAA/NWS API (alerts), NOAA WMS (radar) | | [News](#news) | `virtual.news` | RSS feeds | | [Traffic](#traffic) | `virtual.traffic` | Overpass API | | [System Status](#system-status) | `virtual.status` | Internal system data | @@ -30,18 +30,23 @@ Displays current conditions, a 5-day forecast, radar, and active weather alerts. The channel cycles through four segments on a configurable timer: -| Segment | Content | -|---------|---------| -| 0 — Current | Current conditions at the configured location | -| 1 — Forecast | 5-day forecast | -| 2 — Radar | Radar map | -| 3 — Alerts | Active weather alerts | +| Segment | Content | Data Source | +|---------|---------|-------------| +| 0 — Current | Current conditions at the configured location | Open-Meteo API | +| 1 — Forecast | 5-day forecast | Open-Meteo API | +| 2 — Radar | NOAA regional radar map | NOAA WMS | +| 3 — Alerts | Active severe weather alerts | NOAA/NWS Alerts API (US only) | **Configuration:** - Location is set in the Weather admin settings. - Rotation timer: 30–600 seconds per segment (default 300 s). Configurable via the `weather.seconds_per_segment` setting. +- Alerts (segment 3) are fetched from the NWS Alerts API + (`https://api.weather.gov/alerts/active`). This service is US-only; non-US + locations show "No Active Alerts" for that segment. +- Alert cards are colour-coded by NWS severity: **Extreme/Severe** (red), + **Moderate** (orange), **Minor** (yellow). **API endpoint:** `GET /api/weather` diff --git a/static/css/base.css b/static/css/base.css index c256726..771cf25 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -162,6 +162,18 @@ body { font-family: Arial, sans-serif; margin:0; } .summary { flex:1; } #video { width:620px; height:350px; } +/* Channel logo + program title header inside the summary panel */ +.summary-header { display:flex; align-items:center; gap:10px; margin-bottom:6px; } +.summary-header h3 { margin:0; flex:1; } +.summary-channel-logo { + width:48px; + height:48px; + object-fit:contain; + flex-shrink:0; + border-radius:4px; + background:rgba(0,0,0,0.15); +} + /* === Resize handles === */ /* Wrapper around #video to host the corner resize handle. inline-block (not inline-flex) keeps the video as a normal replaced element diff --git a/static/js/user-prefs.js b/static/js/user-prefs.js index c31c658..9afe59c 100644 --- a/static/js/user-prefs.js +++ b/static/js/user-prefs.js @@ -2,7 +2,6 @@ // Features: // • Auto-load channel: automatically plays a saved channel when the guide opens // • Hidden channels: hides selected channels from the guide grid (right-click to hide) -// • Sizzle reels: muted hover preview of a channel stream after a short delay // // Prefs are loaded from window.__initialUserPrefs (injected by guide.html) and // saved back to the server via POST /api/user_prefs. @@ -12,7 +11,6 @@ // .save(patch) — PATCH prefs on server and apply locally // .setAutoLoad() — set currently-playing channel as auto-load // .clearAutoLoad() — clear auto-load channel -// .toggleSizzleReels() — toggle sizzle-reel mode and save // .toggleShowHidden() — toggle visibility of hidden rows in the guide // .hideChannel(id) — add channel to hidden list // .unhideChannel(id) — remove channel from hidden list @@ -22,13 +20,10 @@ // ─── State ──────────────────────────────────────────────────────────────── let prefs = Object.assign( - { auto_load_channel: null, hidden_channels: [], sizzle_reels_enabled: false }, + { auto_load_channel: null, hidden_channels: [] }, (typeof window.__initialUserPrefs === 'object' && window.__initialUserPrefs) || {} ); let showingHidden = false; // current toggle state for "show hidden channels" - let sizzleTimer = null; - let sizzleHls = null; - let sizzleVideo = null; // ─── Helpers ────────────────────────────────────────────────────────────── function log() { @@ -189,86 +184,6 @@ } } - // ─── Sizzle reels ───────────────────────────────────────────────────────── - function syncSizzleButton() { - const label = prefs.sizzle_reels_enabled ? '🎬 Sizzle Reels: On' : '🎬 Sizzle Reels: Off'; - ['toggleSizzleReels', 'mobileToggleSizzleReels'].forEach(function (id) { - const el = document.getElementById(id); - if (el) el.textContent = label; - }); - } - - async function toggleSizzleReels() { - await savePatch({ sizzle_reels_enabled: !prefs.sizzle_reels_enabled }); - syncSizzleButton(); - attachSizzleListeners(); - if (!prefs.sizzle_reels_enabled) hideSizzle(); - log('sizzle reels', prefs.sizzle_reels_enabled ? 'enabled' : 'disabled'); - } - - function ensureSizzleVideo() { - if (sizzleVideo) return; - sizzleVideo = document.createElement('video'); - sizzleVideo.id = 'sizzle-preview'; - sizzleVideo.muted = true; - sizzleVideo.autoplay = true; - sizzleVideo.playsinline = true; - sizzleVideo.setAttribute('aria-hidden', 'true'); - sizzleVideo.style.cssText = [ - 'position:fixed', 'right:12px', 'bottom:64px', - 'width:220px', 'height:124px', - 'background:#000', - 'border:2px solid var(--primary-color,#1ed3ce)', - 'border-radius:6px', - 'z-index:8800', - 'display:none', - 'pointer-events:none' - ].join(';'); - document.body.appendChild(sizzleVideo); - } - - function showSizzle(url) { - ensureSizzleVideo(); - sizzleVideo.style.display = 'block'; - if (sizzleHls) { sizzleHls.destroy(); sizzleHls = null; } - if (window.Hls && Hls.isSupported()) { - sizzleHls = new Hls({ maxBufferLength: 8, maxMaxBufferLength: 16 }); - sizzleHls.loadSource(url); - sizzleHls.attachMedia(sizzleVideo); - } else { - sizzleVideo.src = url; - } - sizzleVideo.play().catch(function () {}); - } - - function hideSizzle() { - clearTimeout(sizzleTimer); - sizzleTimer = null; - if (sizzleVideo) sizzleVideo.style.display = 'none'; - if (sizzleHls) { sizzleHls.destroy(); sizzleHls = null; } - if (sizzleVideo) { sizzleVideo.src = ''; } - } - - function attachSizzleListeners() { - document.querySelectorAll('.chan-name').forEach(el => { - // Remove stale listeners by replacing dataset flag - if (prefs.sizzle_reels_enabled) { - if (!el.dataset.sizzleAttached) { - el.dataset.sizzleAttached = '1'; - el.addEventListener('mouseenter', function () { - if (!prefs.sizzle_reels_enabled) return; - const url = el.dataset.url; - if (!url) return; - sizzleTimer = setTimeout(function () { showSizzle(url); }, 1500); - }); - el.addEventListener('mouseleave', hideSizzle); - } - } else { - delete el.dataset.sizzleAttached; - } - }); - } - // ─── Right-click context menu ────────────────────────────────────────────── let ctxMenu = null; let ctxTarget = null; // the .chan-name element that was right-clicked @@ -363,14 +278,9 @@ applyHiddenChannels(); applyAutoLoadMarker(); syncAutoLoadButton(); - syncSizzleButton(); syncShowHiddenButton(); scheduleAutoLoad(); - if (prefs.sizzle_reels_enabled) { - attachSizzleListeners(); - } - // Wire Settings-menu buttons (desktop + mobile, present in _header.html) function wire(id, fn) { const el = document.getElementById(id); @@ -378,11 +288,9 @@ } wire('setAutoLoadChannel', setAutoLoad); wire('clearAutoLoadChannel', clearAutoLoad); - wire('toggleSizzleReels', toggleSizzleReels); wire('toggleShowHidden', toggleShowHidden); wire('mobileSetAutoLoadChannel', setAutoLoad); wire('mobileClearAutoLoadChannel', clearAutoLoad); - wire('mobileToggleSizzleReels', toggleSizzleReels); wire('mobileToggleShowHidden', toggleShowHidden); // Right-click context menu on channel names @@ -409,7 +317,6 @@ save: savePatch, setAutoLoad: setAutoLoad, clearAutoLoad: clearAutoLoad, - toggleSizzleReels: toggleSizzleReels, toggleShowHidden: toggleShowHidden, hideChannel: hideChannel, unhideChannel: unhideChannel diff --git a/static/overlays/news.js b/static/overlays/news.js index 102e23f..d165856 100644 --- a/static/overlays/news.js +++ b/static/overlays/news.js @@ -38,6 +38,53 @@ pointer-events: none; z-index: 10; } + + /* ── News animated background ──────────────────────────────────── */ + .vc-news-bg-anim { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; + background: linear-gradient(130deg, + #0c1a90 0%, #1535cc 25%, #0a1060 50%, #1a28a8 75%, #0c1a90 100%); + background-size: 300% 300%; + animation: vc-news-bg-drift 20s ease infinite; + } + @keyframes vc-news-bg-drift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } + } + .vc-news-bg-anim::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(ellipse 60% 40% at 80% 20%, rgba(60, 80, 200, 0.12) 0%, transparent 65%), + radial-gradient(ellipse 50% 35% at 15% 75%, rgba(40, 60, 180, 0.10) 0%, transparent 60%); + animation: vc-news-glow-shift 15s ease-in-out infinite alternate; + } + @keyframes vc-news-glow-shift { + from { opacity: 0.5; transform: scale(1); } + to { opacity: 1; transform: scale(1.06); } + } + .vc-news-particle-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; + } + @media (prefers-reduced-motion: reduce) { + .vc-news-bg-anim, + .vc-news-bg-anim::before { + animation: none !important; + background-position: 50% 50% !important; + } + .vc-news-particle-canvas { display: none; } + } .vc-news-header { background: linear-gradient(90deg, #0d2aaa 0%, #1840d8 50%, #0d2aaa 100%); border-bottom: 0.18em solid #4a70ff; @@ -281,9 +328,77 @@ }, 0); } + // ── News animated background ────────────────────────────────────────────── + let _newsParticleRafId = null; + + function _newsPrefersReducedMotion() { + try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } + catch (e) { return false; } + } + + function _cancelNewsParticles() { + if (_newsParticleRafId !== null) { + cancelAnimationFrame(_newsParticleRafId); + _newsParticleRafId = null; + } + } + + function _runNewsParticles(canvas) { + if (_newsPrefersReducedMotion()) return; + var COUNT = 28; + var pts = []; + for (var i = 0; i < COUNT; i++) { + pts.push({ + x: Math.random(), + y: Math.random(), + vx: (Math.random() - 0.5) * 0.00012, + vy: -(Math.random() * 0.00045 + 0.00015), + r: Math.random() * 1.2 + 0.4, + a: Math.random() * 0.25 + 0.08 + }); + } + function draw() { + var el = canvas.parentElement; + if (!el) return; + var w = el.offsetWidth, h = el.offsetHeight; + if (!w || !h) { _newsParticleRafId = requestAnimationFrame(draw); return; } + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; canvas.height = h; + } + var ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, w, h); + for (var i = 0; i < pts.length; i++) { + var p = pts[i]; + ctx.globalAlpha = p.a; + ctx.fillStyle = '#c8d4ff'; + ctx.beginPath(); + ctx.arc(p.x * w, p.y * h, p.r, 0, Math.PI * 2); + ctx.fill(); + p.x += p.vx; p.y += p.vy; + if (p.y < -0.02) { p.y = 1.02; p.x = Math.random(); } + if (p.x < 0) p.x = 1; if (p.x > 1) p.x = 0; + } + ctx.globalAlpha = 1; + _newsParticleRafId = requestAnimationFrame(draw); + } + _cancelNewsParticles(); + _newsParticleRafId = requestAnimationFrame(draw); + } + + function applyNewsBackground(frame) { + var bgDiv = document.createElement('div'); + bgDiv.className = 'vc-news-bg-anim'; + var canvas = document.createElement('canvas'); + canvas.className = 'vc-news-particle-canvas'; + bgDiv.appendChild(canvas); + frame.insertBefore(bgDiv, frame.firstChild); + requestAnimationFrame(function () { _runNewsParticles(canvas); }); + } + function render(data, root) { ensureStyles(); root.querySelectorAll('.vc-overlay').forEach(function (e) { e.remove(); }); + _cancelNewsParticles(); root.classList.remove('hidden'); const overlay = document.createElement('div'); @@ -370,6 +485,9 @@ '' + ''; + // Add animated background layer behind content + applyNewsBackground(frame); + overlay.appendChild(frame); root.appendChild(overlay); @@ -422,4 +540,5 @@ } window.OverlayEngine.register(TYPE, { fetch: fetchDataWithCycling, render: render }); + window.OverlayEngine.onStop(_cancelNewsParticles); })(); diff --git a/static/overlays/weather.js b/static/overlays/weather.js index 35da989..d95216e 100644 --- a/static/overlays/weather.js +++ b/static/overlays/weather.js @@ -368,21 +368,90 @@ background: rgba(140,20,20,0.7); border: 1px solid #cc4444; border-radius: 3px; - padding: 0.5em 0.7em; + padding: 0.4em 0.7em; display: flex; - align-items: center; - gap: 0.5em; + flex-direction: column; + gap: 0.15em; box-shadow: 0 2px 6px rgba(0,0,0,0.4); + overflow: hidden; + } + .vc-wx-alert-item.moderate { + background: rgba(140,80,10,0.75); + border-color: #cc8833; + } + .vc-wx-alert-item.minor { + background: rgba(100,100,10,0.75); + border-color: #bbaa22; + } + .vc-wx-alert-header { + display: flex; + align-items: center; + gap: 0.4em; } - .vc-wx-alert-icon { - font-size: 1.6em; + .vc-wx-alert-badge { + font-size: 0.68em; + font-weight: 900; + letter-spacing: 0.07em; + text-transform: uppercase; + padding: 0.12em 0.45em; + border-radius: 2px; flex-shrink: 0; } - .vc-wx-alert-text { - font-size: 0.88em; + .vc-wx-alert-badge.extreme, .vc-wx-alert-badge.severe { + background: #cc2222; + color: #fff; + } + .vc-wx-alert-badge.moderate { + background: #cc7711; + color: #fff; + } + .vc-wx-alert-badge.minor { + background: #aaaa00; + color: #111; + } + .vc-wx-alert-badge.unknown { + background: #445577; + color: #ddd; + } + .vc-wx-alert-event { + font-size: 0.84em; font-weight: 900; letter-spacing: 0.03em; color: #ffdddd; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .vc-wx-alert-item.moderate .vc-wx-alert-event { color: #ffe8c0; } + .vc-wx-alert-item.minor .vc-wx-alert-event { color: #fffac0; } + .vc-wx-alert-headline { + font-size: 0.7em; + font-weight: 700; + color: #ffcccc; + line-height: 1.3; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .vc-wx-alert-item.moderate .vc-wx-alert-headline { color: #ffddb0; } + .vc-wx-alert-item.minor .vc-wx-alert-headline { color: #fefaaa; } + .vc-wx-alert-expires { + font-size: 0.63em; + color: #cc9999; + margin-top: 0.05em; + } + .vc-wx-alert-item.moderate .vc-wx-alert-expires { color: #cc9966; } + .vc-wx-alert-item.minor .vc-wx-alert-expires { color: #aaaa55; } + .vc-wx-alerts-source { + font-size: 0.62em; + font-weight: 700; + color: #607090; + letter-spacing: 0.04em; + text-transform: uppercase; + text-align: right; + padding: 0.1em 0.1em 0; + flex-shrink: 0; } .vc-wx-no-alerts { flex: 1; @@ -404,6 +473,188 @@ font-size: 0.72em; color: #70a870; } + + /* ── Animated Background Layer ──────────────────────────────────── */ + .vc-wx-bg-anim { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; + } + /* Keep all weather content above the animated background layer */ + .vc-wx-frame > :not(.vc-wx-bg-anim) { + position: relative; + z-index: 1; + } + + /* Sunny ──────────────────────────────────────────────────────────── */ + .vc-wx-bg-anim.wx-bg-sunny { + background: + radial-gradient(ellipse 110% 55% at 50% -8%, + rgba(255, 210, 0, 0.25) 0%, transparent 55%), + linear-gradient(180deg, #1838cc 0%, #0a1a80 50%, #081060 100%); + } + .vc-wx-bg-anim.wx-bg-sunny::before { + content: ''; + position: absolute; + top: -18%; left: 32%; + width: 36%; height: 55%; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 210, 0, 0.28) 0%, transparent 65%); + animation: vc-wx-sun-pulse 5s ease-in-out infinite; + } + @keyframes vc-wx-sun-pulse { + 0%, 100% { opacity: 0.5; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.12); } + } + + /* Partly Cloudy ──────────────────────────────────────────────────── */ + .vc-wx-bg-anim.wx-bg-partly-cloudy { + background: + radial-gradient(ellipse 85% 45% at 62% -10%, + rgba(255, 200, 0, 0.18) 0%, transparent 55%), + linear-gradient(180deg, #1535cc 0%, #0a1a80 55%, #081060 100%); + } + .vc-wx-bg-anim.wx-bg-partly-cloudy::before { + content: ''; + position: absolute; + top: 3%; left: -2%; + width: 65%; height: 28%; + background: + radial-gradient(ellipse 50% 60% at 38% 50%, + rgba(155, 175, 225, 0.14) 0%, transparent 70%); + animation: vc-wx-cloud-slow1 13s ease-in-out infinite alternate; + } + + /* Cloudy ─────────────────────────────────────────────────────────── */ + .vc-wx-bg-anim.wx-bg-cloudy { + background: linear-gradient(180deg, #1a2258 0%, #0d1440 55%, #090e30 100%); + } + .vc-wx-bg-anim.wx-bg-cloudy::before { + content: ''; + position: absolute; + top: 0%; left: -5%; + width: 80%; height: 42%; + background: + radial-gradient(ellipse 55% 55% at 35% 40%, + rgba(140, 155, 210, 0.18) 0%, transparent 65%), + radial-gradient(ellipse 40% 40% at 78% 28%, + rgba(120, 140, 200, 0.12) 0%, transparent 55%); + animation: vc-wx-cloud-slow1 14s ease-in-out infinite alternate; + } + .vc-wx-bg-anim.wx-bg-cloudy::after { + content: ''; + position: absolute; + top: 6%; right: -12%; + width: 62%; height: 36%; + background: + radial-gradient(ellipse 50% 50% at 60% 55%, + rgba(130, 150, 210, 0.11) 0%, transparent 60%); + animation: vc-wx-cloud-slow2 19s ease-in-out infinite alternate; + } + @keyframes vc-wx-cloud-slow1 { + from { transform: translateX(0); } + to { transform: translateX(5%); } + } + @keyframes vc-wx-cloud-slow2 { + from { transform: translateX(0); } + to { transform: translateX(-6%); } + } + + /* Thunderstorm ───────────────────────────────────────────────────── */ + .vc-wx-bg-anim.wx-bg-thunderstorm { + background: linear-gradient(180deg, #0e1530 0%, #070d20 65%, #050910 100%); + } + .vc-wx-bg-anim.wx-bg-thunderstorm::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(ellipse 80% 50% at 50% 18%, + rgba(80, 90, 130, 0.22) 0%, transparent 65%); + } + .vc-wx-lightning-flash { + position: absolute; + inset: 0; + background: rgba(200, 210, 255, 0.06); + animation: vc-wx-lightning-flash 7s steps(1) infinite; + pointer-events: none; + } + @keyframes vc-wx-lightning-flash { + 0%, 88%, 90.6%, 92.6%, 94%, 100% { opacity: 0; } + 89%, 90% { opacity: 1; } + 91%, 92% { opacity: 0.7; } + 93%, 93.6% { opacity: 0.4; } + } + + /* Foggy ──────────────────────────────────────────────────────────── */ + .vc-wx-bg-anim.wx-bg-foggy { + background: linear-gradient(180deg, #18202e 0%, #0e1620 65%, #080e18 100%); + } + .vc-wx-bg-anim.wx-bg-foggy::before { + content: ''; + position: absolute; + top: 8%; left: -12%; + width: 125%; height: 32%; + background: linear-gradient(90deg, + transparent 0%, + rgba(155, 170, 200, 0.12) 25%, + rgba(160, 175, 205, 0.16) 55%, + rgba(155, 170, 200, 0.12) 78%, + transparent 100%); + border-radius: 50%; + animation: vc-wx-fog-drift1 22s linear infinite alternate; + } + .vc-wx-bg-anim.wx-bg-foggy::after { + content: ''; + position: absolute; + top: 42%; left: -18%; + width: 135%; height: 28%; + background: linear-gradient(90deg, + transparent 0%, + rgba(140, 155, 185, 0.09) 22%, + rgba(148, 163, 192, 0.13) 55%, + rgba(140, 155, 185, 0.09) 82%, + transparent 100%); + border-radius: 50%; + animation: vc-wx-fog-drift2 30s linear infinite alternate; + } + @keyframes vc-wx-fog-drift1 { + from { transform: translateX(0); } + to { transform: translateX(12%); } + } + @keyframes vc-wx-fog-drift2 { + from { transform: translateX(0); } + to { transform: translateX(-10%); } + } + + /* Canvas-based backgrounds (rain, snow, windy) */ + .vc-wx-bg-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; + } + .vc-wx-bg-anim.wx-bg-rain, + .vc-wx-bg-anim.wx-bg-snow, + .vc-wx-bg-anim.wx-bg-windy { + background: linear-gradient(180deg, #0e1a48 0%, #081030 55%, #050820 100%); + } + + /* Reduced motion: disable all background animations */ + @media (prefers-reduced-motion: reduce) { + .vc-wx-bg-anim, + .vc-wx-bg-anim::before, + .vc-wx-bg-anim::after, + .vc-wx-lightning-flash { + animation: none !important; + transition: none !important; + } + .vc-wx-bg-canvas { display: none; } + } `; // ── SVG icon defs (same symbols as weather.html) ───────────────────────── @@ -523,6 +774,161 @@ foggy: 'vc-icon-foggy', }; + // ── Animated Background ─────────────────────────────────────────────────── + + // Map bg_condition value → CSS class on .vc-wx-bg-anim + const BG_CSS_CLASS = { + sunny: 'wx-bg-sunny', + partly_cloudy: 'wx-bg-partly-cloudy', + partly_cloudy_night: 'wx-bg-partly-cloudy', + cloudy: 'wx-bg-cloudy', + cloudy_night: 'wx-bg-cloudy', + thunderstorm: 'wx-bg-thunderstorm', + foggy: 'wx-bg-foggy', + rain: 'wx-bg-rain', + drizzle: 'wx-bg-rain', + showers: 'wx-bg-rain', + snow: 'wx-bg-snow', + windy: 'wx-bg-windy', + }; + + // Canvas-based conditions (particle systems drawn each frame) + const BG_CANVAS_CONDITIONS = new Set(['rain', 'drizzle', 'showers', 'snow', 'windy']); + + // Particle counts per condition — higher = denser effect + const BG_PARTICLE_COUNT_SNOW = 55; + const BG_PARTICLE_COUNT_WINDY = 35; + const BG_PARTICLE_COUNT_DRIZZLE = 45; + const BG_PARTICLE_COUNT_RAIN = 65; + + let _bgRafId = null; + + function _prefersReducedMotion() { + try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } + catch (e) { return false; } + } + + function _cancelBgAnim() { + if (_bgRafId !== null) { cancelAnimationFrame(_bgRafId); _bgRafId = null; } + } + + function _runCanvasBg(canvas, condition) { + if (_prefersReducedMotion()) return; + const isSnow = condition === 'snow'; + const isWindy = condition === 'windy'; + const isDrizzle = condition === 'drizzle'; + const COUNT = isSnow ? BG_PARTICLE_COUNT_SNOW + : isWindy ? BG_PARTICLE_COUNT_WINDY + : isDrizzle ? BG_PARTICLE_COUNT_DRIZZLE + : BG_PARTICLE_COUNT_RAIN; + + const particles = []; + function _initP(p) { + if (!p) p = {}; + p.x = Math.random(); + p.y = isSnow ? Math.random() : (Math.random() - 0.05); + if (isSnow) { + p.r = Math.random() * 2.2 + 0.9; + p.vy = Math.random() * 0.00075 + 0.0003; + p.vx = (Math.random() - 0.5) * 0.00025; + p.a = Math.random() * 0.45 + 0.2; + p.wb = Math.random() * Math.PI * 2; + p.ws = Math.random() * 0.018 + 0.007; + } else if (isWindy) { + p.r = Math.random() * 1.4 + 0.7; + p.vx = Math.random() * 0.0022 + 0.0009; + p.vy = (Math.random() - 0.5) * 0.0004; + p.a = Math.random() * 0.35 + 0.15; + p.len = Math.random() * 0.04 + 0.018; + } else { + p.r = isDrizzle ? Math.random() * 0.5 + 0.4 : Math.random() * 0.75 + 0.5; + p.len = isDrizzle ? Math.random() * 0.04 + 0.03 : Math.random() * 0.065 + 0.05; + p.vy = Math.random() * 0.003 + (isDrizzle ? 0.0012 : 0.0026); + p.vx = isDrizzle ? -0.00025 : -0.00075; + p.a = Math.random() * 0.32 + (isDrizzle ? 0.12 : 0.2); + } + return p; + } + for (let i = 0; i < COUNT; i++) particles.push(_initP(null)); + + function draw() { + const el = canvas.parentElement; + if (!el) return; // detached + const w = el.offsetWidth, h = el.offsetHeight; + if (!w || !h) { _bgRafId = requestAnimationFrame(draw); return; } + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; canvas.height = h; + } + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, w, h); + + for (let i = 0; i < particles.length; i++) { + const p = particles[i]; + const px = p.x * w, py = p.y * h; + ctx.globalAlpha = p.a; + if (isSnow) { + p.wb += p.ws; + ctx.fillStyle = '#d0e4ff'; + ctx.beginPath(); + ctx.arc(px + Math.sin(p.wb) * 3, py, p.r, 0, Math.PI * 2); + ctx.fill(); + p.x += p.vx; p.y += p.vy; + if (p.y > 1.04) { p.x = Math.random(); p.y = -0.02; } + if (p.x < -0.02 || p.x > 1.02) p.x = Math.random(); + } else if (isWindy) { + ctx.strokeStyle = 'rgba(190, 215, 255, 0.75)'; + ctx.lineWidth = p.r * 0.5; + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(px - p.len * w, py + p.vy * h * 18); + ctx.stroke(); + p.x += p.vx; p.y += p.vy; + if (p.x > 1.06) { p.x = -0.04; p.y = Math.random(); } + if (p.y < 0 || p.y > 1) p.y = Math.random(); + } else { + ctx.strokeStyle = 'rgba(155, 188, 240, 0.8)'; + ctx.lineWidth = p.r; + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(px + p.vx * w * 12, py + p.len * h); + ctx.stroke(); + p.x += p.vx; p.y += p.vy; + if (p.y > 1.08) { p.x = Math.random(); p.y = -0.05; } + if (p.x < -0.06) { p.x = 1.02; p.y = Math.random(); } + } + } + ctx.globalAlpha = 1; + _bgRafId = requestAnimationFrame(draw); + } + _cancelBgAnim(); + _bgRafId = requestAnimationFrame(draw); + } + + function _applyWeatherBackground(frame, condition) { + _cancelBgAnim(); + const bgDiv = document.createElement('div'); + bgDiv.className = 'vc-wx-bg-anim'; + const cls = BG_CSS_CLASS[condition]; + if (cls) bgDiv.classList.add(cls); + + if (condition === 'thunderstorm') { + const flash = document.createElement('div'); + flash.className = 'vc-wx-lightning-flash'; + bgDiv.appendChild(flash); + } + + if (BG_CANVAS_CONDITIONS.has(condition)) { + const canvas = document.createElement('canvas'); + canvas.className = 'vc-wx-bg-canvas'; + canvas.style.opacity = condition === 'windy' ? '0.5' : '0.45'; + bgDiv.appendChild(canvas); + // defer canvas start until it is in the DOM + requestAnimationFrame(function () { _runCanvasBg(canvas, condition); }); + } + + frame.insertBefore(bgDiv, frame.firstChild); + } + function ensureAssets() { if (!document.getElementById(STYLE_ID)) { const s = document.createElement('style'); @@ -708,17 +1114,77 @@ // ── Segment 3: Severe Weather Alerts ────────────────────────────────────── + function _alertClass(severity) { + const s = (severity || '').toLowerCase(); + if (s === 'extreme' || s === 'severe') return 'severe'; + if (s === 'moderate') return 'moderate'; + if (s === 'minor') return 'minor'; + return 'unknown'; + } + + function _alertBadgeLabel(severity) { + const s = (severity || '').toLowerCase(); + if (s === 'extreme') return 'EXTREME'; + if (s === 'severe') return 'SEVERE'; + if (s === 'moderate') return 'MODERATE'; + if (s === 'minor') return 'MINOR'; + return 'ALERT'; + } + + function _fmtExpires(isoStr) { + if (!isoStr) return ''; + try { + return 'Expires: ' + new Date(isoStr).toLocaleString([], { + month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit' + }); + } catch (e) { return ''; } + } + + function _escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + function renderAlerts(data, frame, bp) { + const alerts = Array.isArray(data?.alerts) ? data.alerts : []; const ticks = Array.isArray(data?.ticker) ? data.ticker : []; const iconHdr = Math.round(bp * ICON_RATIO_HDR); const iconLg = Math.round(bp * ICON_RATIO_LG); let bodyHtml; - if (ticks.length) { + if (alerts.length) { + const items = alerts.map(a => { + const cls = _alertClass(a.severity); + const badge = _alertBadgeLabel(a.severity); + const event = a.event ? _escHtml(a.event) : ''; + const headline = a.headline ? _escHtml(a.headline) : event; + const expires = _fmtExpires(a.expires); + return ` +
+
+ ${badge} + ${event} +
+ ${headline ? `
${headline}
` : ''} + ${expires ? `
${expires}
` : ''} +
`; + }).join(''); + bodyHtml = ` +
+ ${items} +
Source: NOAA/NWS
+
`; + } else if (ticks.length) { const items = ticks.map(t => `
-
-
${t}
+
+ + ${_escHtml(t)} +
`).join(''); bodyHtml = `
${items}
`; } else { @@ -729,6 +1195,7 @@
No Active Severe Weather Alerts
${data?.location || 'Local Area'}
+
Source: NOAA/NWS
`; } @@ -752,6 +1219,10 @@ frame.id = 'vc-wx-frame'; const now = data?.now || {}; + // Prefer the API-derived bg_condition (which may reflect an admin override), + // then fall back to the current icon, and ultimately to 'cloudy' as a safe + // neutral default that works for the unconfigured / stub state. + const bgCondition = data?.bg_condition || now.icon || 'cloudy'; // ── Not configured ────────────────────────────────────────────────────── if (now.condition === 'Not Configured') { @@ -784,6 +1255,7 @@ No location configured \u2022 `; + _applyWeatherBackground(frame, bgCondition); overlay.appendChild(frame); root.appendChild(overlay); return; @@ -794,13 +1266,17 @@ const bp = Math.max(FONT_MIN_PX, Math.min(fw / FONT_SCALE_DIVISOR, FONT_MAX_PX)); frame.style.fontSize = bp + 'px'; - // Dispatch to the correct segment renderer + // Dispatch to the correct segment renderer (sets frame.innerHTML) const segment = (data?.segment != null) ? data.segment : 0; if (segment === 1) { renderForecast(data, frame, bp); } else if (segment === 2) { renderRadar(data, frame, bp); } else if (segment === 3) { renderAlerts(data, frame, bp); } else { renderCurrent(data, frame, bp); } + // Apply animated background after content so insertBefore(firstChild) places + // it behind all rendered content in DOM order. + _applyWeatherBackground(frame, bgCondition); + overlay.appendChild(frame); root.appendChild(overlay); } @@ -838,9 +1314,9 @@ clearTimeout(_cycleTimer); _cycleTimer = null; } + _cancelBgAnim(); } window.OverlayEngine.register(TYPE, { fetch: fetchDataWithCycling, render }); window.OverlayEngine.onStop(_clearCycleTimer); })(); - diff --git a/templates/_header.html b/templates/_header.html index b04c78a..fff922b 100644 --- a/templates/_header.html +++ b/templates/_header.html @@ -35,6 +35,7 @@ {% endif %} @@ -189,6 +190,7 @@ {% endif %} diff --git a/templates/guide.html b/templates/guide.html index 6644953..a0dfaef 100644 --- a/templates/guide.html +++ b/templates/guide.html @@ -826,6 +826,15 @@

Program Info

const row = document.querySelector(`.guide-row[data-cid="${cid}"]`); if (!row) return; + // Retrieve channel logo from the chan-name element's data attribute + const escapedCid = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(cid) : cid.replace(/[^a-zA-Z0-9._-]/g, '\\$&'); + const chanNameEl = document.querySelector(`.chan-name[data-cid="${escapedCid}"]`); + const channelLogo = (chanNameEl && chanNameEl.dataset.logo) ? chanNameEl.dataset.logo : ''; + + const logoHTML = channelLogo + ? `` + : ''; + let currentProg = null; row.querySelectorAll('.program').forEach(prog => { const start = new Date(prog.dataset.start); @@ -838,11 +847,11 @@

Program Info

const desc = currentProg.dataset.desc || ''; const start = new Date(currentProg.dataset.start); const stop = new Date(currentProg.dataset.stop); - summary.innerHTML = `

${escapeHTML(title)}

+ summary.innerHTML = `
${logoHTML}

${escapeHTML(title)}

${escapeHTML(desc)}

${escapeHTML(start.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}))} - ${escapeHTML(stop.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}))}

`; } else { - summary.innerHTML = `

${escapeHTML(fallbackName)}

No Program Info

`; + summary.innerHTML = `
${logoHTML}

${escapeHTML(fallbackName)}

No Program Info

`; } // After summary content changes, re-run layout-sensitive code diff --git a/templates/manage_users.html b/templates/manage_users.html index e803280..cef42cb 100644 --- a/templates/manage_users.html +++ b/templates/manage_users.html @@ -122,9 +122,43 @@

Existing Users

+ + +
+ + {% set hidden = user.prefs.hidden_channels or [] %} + {% if hidden %} +

+ {{ hidden | length }} channel{{ 's' if hidden | length != 1 else '' }} hidden. +

+ {% else %} +

No channels hidden.

+ {% endif %} + {% if user.channel_list %} + {# Sentinel tells the server to update hidden_channels from the multi-select. + Placed inside the channel_list block so it is only submitted when the + select is actually rendered — avoids silently clearing hidden channels + when no channel list is available for the user's assigned tuner. #} + + + Hold Ctrl / ⌘ to select multiple. Deselect all to unhide everything. + {% else %} + No channels loaded for this user's assigned tuner. + {% endif %} +
+
diff --git a/templates/virtual_channels.html b/templates/virtual_channels.html index 085f926..181528b 100644 --- a/templates/virtual_channels.html +++ b/templates/virtual_channels.html @@ -166,7 +166,7 @@

Enabled Channels ({{ enabled_count.n }})

- Cycles: Current Conditions → 5-Day Forecast → Regional Radar → Severe Weather Alerts, then repeats. + Cycles: Current Conditions → 5-Day Forecast → Regional Radar → Severe Weather Alerts (NOAA/NWS), then repeats. All viewers stay in sync (wall-clock aligned).

@@ -174,6 +174,110 @@

Enabled Channels ({{ enabled_count.n }})

🌤 Preview Weather Page ↗ Save settings first. + +
+
+
+ 🎯 Test + Animated Background Preview + +
+

+ Force a specific animated background on the weather channel for testing, regardless of actual weather. + Click a condition to activate it; click Auto to restore real-weather backgrounds. +

+
+ + + + + + + + + + +
+
+
+ {% endif %} {% if ch.tvg_id == 'virtual.traffic' %}
@@ -224,7 +328,7 @@

Enabled Channels ({{ enabled_count.n }})

- + @@ -234,6 +338,7 @@

Enabled Channels ({{ enabled_count.n }})

+ @@ -254,6 +359,13 @@

Enabled Channels ({{ enabled_count.n }})

data-city-id="{{ city.id }}" style="width:4em;"> + {% endfor %} @@ -263,6 +375,54 @@

Enabled Channels ({{ enabled_count.n }})

Use Save above to persist the Rotation Mode and Interval settings. +
+ +
+
+ ZIP / Postal Code +
+ + +
+
+
+ City Name + +
+
+ State / Region + +
+
+ Latitude + +
+
+ Longitude + +
+
+ Population + +
+ + +
+
+ Enter a ZIP / postal code and click Lookup to auto-fill city details, or fill in manually. + Road data and basemap are downloaded automatically in the background after adding. +
+
🚗 Preview Traffic Page ↗ Save settings first. @@ -1202,6 +1362,184 @@

🎵 Audio Files

.finally(function() { pickRandomBtn.disabled = false; }); }); } + + // ── Delete City ──────────────────────────────────────────────────────────── + function attachDeleteHandlers(scope) { + (scope || document).querySelectorAll('.traffic-city-delete-btn').forEach(function(btn) { + if (btn.dataset.deleteListenerAttached) return; + btn.dataset.deleteListenerAttached = '1'; + btn.addEventListener('click', function() { + var cityId = this.dataset.cityId; + var cityName = this.dataset.cityName || ('City #' + cityId); + if (!confirm('Remove \u201c' + cityName + '\u201d from the rotation?\nThis cannot be undone.')) return; + btn.disabled = true; + fetch('/api/traffic/demo/cities/' + cityId, {method: 'DELETE', credentials: 'same-origin'}) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.ok) { + var row = document.querySelector('tr[data-city-id="' + cityId + '"]'); + if (row) row.remove(); + setActionStatus('\u2713 ' + cityName + ' removed', false); + } else { + setActionStatus('\u2717 ' + (d.error || 'Error'), true); + btn.disabled = false; + } + setTimeout(function() { setActionStatus(''); }, 3000); + }) + .catch(function() { + setActionStatus('\u2717 Network error', true); + btn.disabled = false; + }); + }); + }); + } + attachDeleteHandlers(); + + // ── Add City ─────────────────────────────────────────────────────────────── + var addCityBtn = document.getElementById('traffic-add-city-btn'); + var addCityStatus = document.getElementById('traffic-add-city-status'); + + function setAddStatus(msg, isError) { + if (!addCityStatus) return; + addCityStatus.textContent = msg; + addCityStatus.style.color = isError ? '#ff6666' : '#6eff6e'; + } + + // ── ZIP / Postal Code Lookup ─────────────────────────────────────────────── + var zipLookupBtn = document.getElementById('traffic-zip-lookup-btn'); + if (zipLookupBtn) { + zipLookupBtn.addEventListener('click', function() { + var zip = ((document.getElementById('traffic-add-city-zip') || {}).value || '').trim(); + if (!zip) { setAddStatus('\u2717 Enter a ZIP / postal code first', true); return; } + zipLookupBtn.disabled = true; + setAddStatus('\u23f3 Looking up\u2026', false); + fetch('/api/traffic/demo/zip_lookup?zip=' + encodeURIComponent(zip), {credentials: 'same-origin'}) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.ok) { + var setVal = function(id, val) { + var el = document.getElementById(id); + if (el) el.value = val; + }; + setVal('traffic-add-city-name', d.name || ''); + setVal('traffic-add-city-state', d.state || ''); + setVal('traffic-add-city-lat', d.lat != null ? d.lat.toFixed(4) : ''); + setVal('traffic-add-city-lon', d.lon != null ? d.lon.toFixed(4) : ''); + setAddStatus('\u2713 Found: ' + (d.name || zip) + ', ' + (d.state || ''), false); + } else { + setAddStatus('\u2717 ' + (d.error || 'Not found'), true); + } + setTimeout(function() { setAddStatus(''); }, 4000); + }) + .catch(function() { setAddStatus('\u2717 Network error', true); }) + .finally(function() { zipLookupBtn.disabled = false; }); + }); + // Allow pressing Enter in the ZIP field to trigger the lookup + var zipInput = document.getElementById('traffic-add-city-zip'); + if (zipInput) { + zipInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { e.preventDefault(); zipLookupBtn.click(); } + }); + } + } + + if (addCityBtn) { + addCityBtn.addEventListener('click', function() { + var name = (document.getElementById('traffic-add-city-name') || {}).value || ''; + var state = (document.getElementById('traffic-add-city-state') || {}).value || ''; + var lat = parseFloat((document.getElementById('traffic-add-city-lat') || {}).value); + var lon = parseFloat((document.getElementById('traffic-add-city-lon') || {}).value); + // Strip all common thousands-separator styles so regional formats work: + // 37,390 US/UK comma 37.390 European period + // 37 390 French/SI 37'390 Swiss apostrophe 37_390 Python-style + var popRaw = ((document.getElementById('traffic-add-city-pop') || {}).value || '') + .replace(/[,.\s'\u00a0\u202f_]/g, ''); + var pop = parseInt(popRaw, 10); + if (isNaN(pop) || pop < 0) pop = 0; + + if (!name.trim()) { setAddStatus('\u2717 City name required', true); return; } + if (!state.trim()) { setAddStatus('\u2717 State / region required', true); return; } + if (isNaN(lat) || isNaN(lon)) { setAddStatus('\u2717 Valid lat/lon required', true); return; } + + addCityBtn.disabled = true; + setAddStatus('\u23f3 Adding\u2026', false); + + fetch('/api/traffic/demo/cities/add', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name: name.trim(), state: state.trim(), lat: lat, lon: lon, population: pop}), + credentials: 'same-origin', + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.ok && d.city) { + var city = d.city; + var tbody = document.querySelector('#traffic-demo-city-table tbody'); + if (tbody) { + var tr = document.createElement('tr'); + tr.setAttribute('data-city-id', city.id); + tr.style.borderBottom = '1px solid rgba(48,88,216,0.3)'; + tr.innerHTML = [ + '
', + '', + '', + '', + '', + '', + ].join(''); + tbody.appendChild(tr); + // Wire up event listeners on the newly added row + attachDeleteHandlers(tr); + tr.querySelectorAll('.traffic-city-enabled, .traffic-city-weight').forEach(function(el) { + el.addEventListener('change', function() { + var cid = this.dataset.cityId; + var row2 = document.querySelector('tr[data-city-id="' + cid + '"]'); + if (!row2) return; + var enEl = row2.querySelector('.traffic-city-enabled'); + var wtEl = row2.querySelector('.traffic-city-weight'); + var en = enEl ? enEl.checked : true; + var wt = wtEl ? parseInt(wtEl.value, 10) || 1 : 1; + fetch('/api/traffic/demo/cities/' + cid, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({enabled: en, weight: wt}), + credentials: 'same-origin', + }) + .then(function(r) { return r.json(); }) + .then(function(d2) { + setActionStatus(d2.ok ? '\u2713 Saved' : ('\u2717 ' + (d2.error || 'Error')), !d2.ok); + setTimeout(function() { setActionStatus(''); }, 2500); + }) + .catch(function() { setActionStatus('\u2717 Network error', true); }); + }); + }); + } + // Clear form + ['traffic-add-city-zip','traffic-add-city-name','traffic-add-city-state', + 'traffic-add-city-lat','traffic-add-city-lon','traffic-add-city-pop'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.value = ''; + }); + setAddStatus('\u2713 ' + city.name + ' added — road data downloading in background', false); + } else { + setAddStatus('\u2717 ' + (d.error || 'Error'), true); + } + setTimeout(function() { setAddStatus(''); }, 5000); + }) + .catch(function() { setAddStatus('\u2717 Network error', true); }) + .finally(function() { addCityBtn.disabled = false; }); + }); + } })(); // Audio file upload and delete diff --git a/templates/weather.html b/templates/weather.html index 48cd09a..23f176b 100644 --- a/templates/weather.html +++ b/templates/weather.html @@ -46,6 +46,138 @@ pointer-events: none; z-index: 10; } + /* Keep all weather content above background layer */ + .wx-frame > :not(.wx-bg-anim) { + position: relative; + z-index: 1; + } + + /* ── Animated Background Layer ─────────────────────────────── */ + .wx-bg-anim { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; + } + .wx-bg-anim.wx-bg-sunny { + background: + radial-gradient(ellipse 110% 55% at 50% -8%, rgba(255,210,0,.25) 0%, transparent 55%), + linear-gradient(180deg, #1838cc 0%, #0a1a80 50%, #081060 100%); + } + .wx-bg-anim.wx-bg-sunny::before { + content: ''; + position: absolute; + top: -18%; left: 32%; + width: 36%; height: 55%; + border-radius: 50%; + background: radial-gradient(circle, rgba(255,210,0,.28) 0%, transparent 65%); + animation: wx-sun-pulse 5s ease-in-out infinite; + } + @keyframes wx-sun-pulse { + 0%,100% { opacity:.5; transform:scale(1); } + 50% { opacity:1; transform:scale(1.12); } + } + .wx-bg-anim.wx-bg-partly-cloudy { + background: + radial-gradient(ellipse 85% 45% at 62% -10%, rgba(255,200,0,.18) 0%, transparent 55%), + linear-gradient(180deg, #1535cc 0%, #0a1a80 55%, #081060 100%); + } + .wx-bg-anim.wx-bg-partly-cloudy::before { + content: ''; + position: absolute; + top: 3%; left: -2%; + width: 65%; height: 28%; + background: radial-gradient(ellipse 50% 60% at 38% 50%, rgba(155,175,225,.14) 0%, transparent 70%); + animation: wx-cloud-slow1 13s ease-in-out infinite alternate; + } + .wx-bg-anim.wx-bg-cloudy { + background: linear-gradient(180deg, #1a2258 0%, #0d1440 55%, #090e30 100%); + } + .wx-bg-anim.wx-bg-cloudy::before { + content: ''; + position: absolute; + top: 0%; left: -5%; + width: 80%; height: 42%; + background: + radial-gradient(ellipse 55% 55% at 35% 40%, rgba(140,155,210,.18) 0%, transparent 65%), + radial-gradient(ellipse 40% 40% at 78% 28%, rgba(120,140,200,.12) 0%, transparent 55%); + animation: wx-cloud-slow1 14s ease-in-out infinite alternate; + } + .wx-bg-anim.wx-bg-cloudy::after { + content: ''; + position: absolute; + top: 6%; right: -12%; + width: 62%; height: 36%; + background: radial-gradient(ellipse 50% 50% at 60% 55%, rgba(130,150,210,.11) 0%, transparent 60%); + animation: wx-cloud-slow2 19s ease-in-out infinite alternate; + } + @keyframes wx-cloud-slow1 { from { transform:translateX(0); } to { transform:translateX(5%); } } + @keyframes wx-cloud-slow2 { from { transform:translateX(0); } to { transform:translateX(-6%); } } + .wx-bg-anim.wx-bg-thunderstorm { + background: linear-gradient(180deg, #0e1530 0%, #070d20 65%, #050910 100%); + } + .wx-bg-anim.wx-bg-thunderstorm::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse 80% 50% at 50% 18%, rgba(80,90,130,.22) 0%, transparent 65%); + } + .wx-lightning-flash { + position: absolute; + inset: 0; + background: rgba(200,210,255,.06); + animation: wx-lightning-flash 7s steps(1) infinite; + } + @keyframes wx-lightning-flash { + 0%,88%,90.6%,92.6%,94%,100% { opacity:0; } + 89%,90% { opacity:1; } + 91%,92% { opacity:.7; } + 93%,93.6% { opacity:.4; } + } + .wx-bg-anim.wx-bg-foggy { + background: linear-gradient(180deg, #18202e 0%, #0e1620 65%, #080e18 100%); + } + .wx-bg-anim.wx-bg-foggy::before { + content: ''; + position: absolute; + top: 8%; left: -12%; + width: 125%; height: 32%; + background: linear-gradient(90deg, transparent 0%, rgba(155,170,200,.12) 25%, rgba(160,175,205,.16) 55%, rgba(155,170,200,.12) 78%, transparent 100%); + border-radius: 50%; + animation: wx-fog-drift1 22s linear infinite alternate; + } + .wx-bg-anim.wx-bg-foggy::after { + content: ''; + position: absolute; + top: 42%; left: -18%; + width: 135%; height: 28%; + background: linear-gradient(90deg, transparent 0%, rgba(140,155,185,.09) 22%, rgba(148,163,192,.13) 55%, rgba(140,155,185,.09) 82%, transparent 100%); + border-radius: 50%; + animation: wx-fog-drift2 30s linear infinite alternate; + } + @keyframes wx-fog-drift1 { from { transform:translateX(0); } to { transform:translateX(12%); } } + @keyframes wx-fog-drift2 { from { transform:translateX(0); } to { transform:translateX(-10%); } } + .wx-bg-anim.wx-bg-rain, + .wx-bg-anim.wx-bg-snow, + .wx-bg-anim.wx-bg-windy { + background: linear-gradient(180deg, #0e1a48 0%, #081030 55%, #050820 100%); + } + .wx-bg-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; + } + @media (prefers-reduced-motion: reduce) { + .wx-bg-anim, + .wx-bg-anim::before, + .wx-bg-anim::after, + .wx-lightning-flash { animation: none !important; } + .wx-bg-canvas { display: none; } + } /* ── Header bar ─────────────────────────────────────────────── */ .wx-header { @@ -625,6 +757,144 @@ foggy: 'icon-foggy', }; + const BG_CSS_CLASS = { + sunny: 'wx-bg-sunny', + partly_cloudy: 'wx-bg-partly-cloudy', + partly_cloudy_night: 'wx-bg-partly-cloudy', + cloudy: 'wx-bg-cloudy', + cloudy_night: 'wx-bg-cloudy', + thunderstorm: 'wx-bg-thunderstorm', + foggy: 'wx-bg-foggy', + rain: 'wx-bg-rain', + drizzle: 'wx-bg-rain', + showers: 'wx-bg-rain', + snow: 'wx-bg-snow', + windy: 'wx-bg-windy', + }; + const BG_CANVAS_CONDITIONS = new Set(['rain', 'drizzle', 'showers', 'snow', 'windy']); + const BG_PARTICLE_COUNT_SNOW = 55; + const BG_PARTICLE_COUNT_WINDY = 35; + const BG_PARTICLE_COUNT_DRIZZLE = 45; + const BG_PARTICLE_COUNT_RAIN = 65; + let _bgRafId = null; + + function prefersReducedMotion() { + try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } + catch (e) { return false; } + } + + function cancelBgAnim() { + if (_bgRafId !== null) { cancelAnimationFrame(_bgRafId); _bgRafId = null; } + } + + function _runCanvasBg(canvas, condition) { + if (prefersReducedMotion()) return; + const isSnow = condition === 'snow'; + const isWindy = condition === 'windy'; + const isDrizzle = condition === 'drizzle'; + const COUNT = isSnow ? BG_PARTICLE_COUNT_SNOW + : isWindy ? BG_PARTICLE_COUNT_WINDY + : isDrizzle ? BG_PARTICLE_COUNT_DRIZZLE + : BG_PARTICLE_COUNT_RAIN; + const particles = []; + function initP(p) { + if (!p) p = {}; + p.x = Math.random(); + p.y = isSnow ? Math.random() : (Math.random() - 0.05); + if (isSnow) { + p.r = Math.random() * 2.2 + 0.9; + p.vy = Math.random() * 0.00075 + 0.0003; + p.vx = (Math.random() - 0.5) * 0.00025; + p.a = Math.random() * 0.45 + 0.2; + p.wb = Math.random() * Math.PI * 2; + p.ws = Math.random() * 0.018 + 0.007; + } else if (isWindy) { + p.r = Math.random() * 1.4 + 0.7; + p.vx = Math.random() * 0.0022 + 0.0009; + p.vy = (Math.random() - 0.5) * 0.0004; + p.a = Math.random() * 0.35 + 0.15; + p.len = Math.random() * 0.04 + 0.018; + } else { + p.r = isDrizzle ? Math.random() * 0.5 + 0.4 : Math.random() * 0.75 + 0.5; + p.len = isDrizzle ? Math.random() * 0.04 + 0.03 : Math.random() * 0.065 + 0.05; + p.vy = Math.random() * 0.003 + (isDrizzle ? 0.0012 : 0.0026); + p.vx = isDrizzle ? -0.00025 : -0.00075; + p.a = Math.random() * 0.32 + (isDrizzle ? 0.12 : 0.2); + } + return p; + } + for (let i = 0; i < COUNT; i++) particles.push(initP(null)); + function draw() { + const el = canvas.parentElement; + if (!el) return; + const w = el.offsetWidth, h = el.offsetHeight; + if (!w || !h) { _bgRafId = requestAnimationFrame(draw); return; } + if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, w, h); + for (let i = 0; i < particles.length; i++) { + const p = particles[i]; + const px = p.x * w, py = p.y * h; + ctx.globalAlpha = p.a; + if (isSnow) { + p.wb += p.ws; + ctx.fillStyle = '#d0e4ff'; + ctx.beginPath(); + ctx.arc(px + Math.sin(p.wb) * 3, py, p.r, 0, Math.PI * 2); + ctx.fill(); + p.x += p.vx; p.y += p.vy; + if (p.y > 1.04) { p.x = Math.random(); p.y = -0.02; } + if (p.x < -0.02 || p.x > 1.02) p.x = Math.random(); + } else if (isWindy) { + ctx.strokeStyle = 'rgba(190, 215, 255, 0.75)'; + ctx.lineWidth = p.r * 0.5; + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(px - p.len * w, py + p.vy * h * 18); + ctx.stroke(); + p.x += p.vx; p.y += p.vy; + if (p.x > 1.06) { p.x = -0.04; p.y = Math.random(); } + if (p.y < 0 || p.y > 1) p.y = Math.random(); + } else { + ctx.strokeStyle = 'rgba(155, 188, 240, 0.8)'; + ctx.lineWidth = p.r; + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(px + p.vx * w * 12, py + p.len * h); + ctx.stroke(); + p.x += p.vx; p.y += p.vy; + if (p.y > 1.08) { p.x = Math.random(); p.y = -0.05; } + if (p.x < -0.06) { p.x = 1.02; p.y = Math.random(); } + } + } + ctx.globalAlpha = 1; + _bgRafId = requestAnimationFrame(draw); + } + cancelBgAnim(); + _bgRafId = requestAnimationFrame(draw); + } + + function _applyWeatherBackground(frame, condition) { + cancelBgAnim(); + const bgDiv = document.createElement('div'); + bgDiv.className = 'wx-bg-anim'; + const cls = BG_CSS_CLASS[condition]; + if (cls) bgDiv.classList.add(cls); + if (condition === 'thunderstorm') { + const flash = document.createElement('div'); + flash.className = 'wx-lightning-flash'; + bgDiv.appendChild(flash); + } + if (BG_CANVAS_CONDITIONS.has(condition)) { + const canvas = document.createElement('canvas'); + canvas.className = 'wx-bg-canvas'; + canvas.style.opacity = condition === 'windy' ? '0.5' : '0.45'; + bgDiv.appendChild(canvas); + requestAnimationFrame(function () { _runCanvasBg(canvas, condition); }); + } + frame.insertBefore(bgDiv, frame.firstChild); + } + function iconSvg(key, size) { const sym = ICON_MAP[key] || 'icon-cloudy'; return ``; @@ -809,6 +1079,7 @@ function build(data) { const frame = document.getElementById('wxFrame'); const now = data.now || {}; + const bgCondition = data.bg_condition || now.icon || 'cloudy'; // ── Not configured ──────────────────────────────────────────── if (now.condition === 'Not Configured') { @@ -838,6 +1109,7 @@ No location configured \u2022 `; + _applyWeatherBackground(frame, bgCondition); return; } @@ -847,6 +1119,7 @@ else if (segment === 2) { buildRadar(data, frame); } else if (segment === 3) { buildAlerts(data, frame); } else { buildCurrent(data, frame); } + _applyWeatherBackground(frame, bgCondition); } // ── Background music ──────────────────────────────────────────── @@ -899,6 +1172,7 @@ _refreshTimer = setTimeout(load, msUntilNext); } catch (e) { const frame = document.getElementById('wxFrame'); + cancelBgAnim(); frame.innerHTML = `
Unable to load weather data.
`; _refreshTimer = setTimeout(load, ERROR_RETRY_MS); } diff --git a/tests/test_current_program_logo.py b/tests/test_current_program_logo.py new file mode 100644 index 0000000..e4553ad --- /dev/null +++ b/tests/test_current_program_logo.py @@ -0,0 +1,129 @@ +"""Tests for the channel logo field in the /api/current_program endpoint (issue #198).""" +import os +import sys +from datetime import datetime, timezone, timedelta + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import app as app_module +from app import app, init_db, init_tuners_db, add_user + + +# ─── Fixtures ──────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def isolated_db(tmp_path, monkeypatch): + users_db = str(tmp_path / "users_test.db") + tuners_db = str(tmp_path / "tuners_test.db") + monkeypatch.setattr(app_module, "DATABASE", users_db) + monkeypatch.setattr(app_module, "TUNER_DB", tuners_db) + init_db() + init_tuners_db() + add_user("admin", "adminpass") + yield + + +@pytest.fixture() +def client(isolated_db): + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +def login(client, username="admin", password="adminpass"): + return client.post( + "/login", + data={"username": username, "password": password}, + follow_redirects=True, + ) + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def _make_channels(logo="https://example.com/logo.png"): + return [{"tvg_id": "test.ch1", "name": "Test Channel 1", "logo": logo, "number": "1"}] + + +def _make_epg(tvg_id="test.ch1"): + now = datetime.now(timezone.utc) + return { + tvg_id: [ + { + "title": "Test Show", + "desc": "A test description", + "start": now - timedelta(minutes=15), + "stop": now + timedelta(minutes=45), + } + ] + } + + +# ─── Tests ─────────────────────────────────────────────────────────────────── + +class TestCurrentProgramLogoField: + """The /api/current_program response must include a top-level 'logo' field.""" + + def test_logo_present_in_response(self, client, monkeypatch): + """Response includes 'logo' key with the channel's logo URL.""" + monkeypatch.setattr(app_module, "cached_channels", _make_channels()) + monkeypatch.setattr(app_module, "cached_epg", _make_epg()) + login(client) + resp = client.get("/api/current_program?tvg_id=test.ch1") + assert resp.status_code == 200 + body = resp.get_json() + assert body["ok"] is True + assert "logo" in body + assert body["logo"] == "https://example.com/logo.png" + + def test_logo_empty_string_when_no_logo(self, client, monkeypatch): + """When the channel has no logo, 'logo' is an empty string (not None/absent).""" + monkeypatch.setattr(app_module, "cached_channels", _make_channels(logo="")) + monkeypatch.setattr(app_module, "cached_epg", _make_epg()) + login(client) + resp = client.get("/api/current_program?tvg_id=test.ch1") + assert resp.status_code == 200 + body = resp.get_json() + assert "logo" in body + assert body["logo"] == "" + + def test_logo_empty_string_when_logo_is_none(self, client, monkeypatch): + """When the channel logo field is None, 'logo' is normalised to ''.""" + channels = [{"tvg_id": "test.ch1", "name": "Test Channel", "logo": None, "number": "1"}] + monkeypatch.setattr(app_module, "cached_channels", channels) + monkeypatch.setattr(app_module, "cached_epg", _make_epg()) + login(client) + resp = client.get("/api/current_program?tvg_id=test.ch1") + assert resp.status_code == 200 + body = resp.get_json() + assert "logo" in body + assert body["logo"] == "" + + def test_logo_present_when_no_guide_data(self, client, monkeypatch): + """'logo' is returned even when no EPG program is available.""" + monkeypatch.setattr(app_module, "cached_channels", _make_channels()) + monkeypatch.setattr(app_module, "cached_epg", {}) + login(client) + resp = client.get("/api/current_program?tvg_id=test.ch1") + assert resp.status_code == 200 + body = resp.get_json() + assert body["ok"] is True + assert "logo" in body + assert body["logo"] == "https://example.com/logo.png" + + def test_program_fields_still_present(self, client, monkeypatch): + """Existing program fields are not broken by the logo addition.""" + monkeypatch.setattr(app_module, "cached_channels", _make_channels()) + monkeypatch.setattr(app_module, "cached_epg", _make_epg()) + login(client) + resp = client.get("/api/current_program?tvg_id=test.ch1") + body = resp.get_json() + assert body["ok"] is True + assert body["channel"] == "Test Channel 1" + assert body["tvg_id"] == "test.ch1" + prog = body["program"] + assert prog["title"] == "Test Show" + assert prog["desc"] == "A test description" + assert prog["start_iso"] is not None + assert prog["stop_iso"] is not None diff --git a/tests/test_traffic_demo.py b/tests/test_traffic_demo.py index d498259..a128148 100644 --- a/tests/test_traffic_demo.py +++ b/tests/test_traffic_demo.py @@ -17,6 +17,7 @@ get_traffic_demo_config, save_traffic_demo_config, get_traffic_demo_cities, save_traffic_demo_city, set_all_traffic_demo_cities_enabled, pick_random_traffic_demo_pack, + add_traffic_demo_city, delete_traffic_demo_city, lookup_zip_city, _get_congestion_distribution, _build_traffic_demo_payload, _TRAFFIC_DEMO_CITIES_SEED, _TRAFFIC_DEMO_CACHE, get_traffic_demo_roads, _overpass_to_geojson, @@ -434,6 +435,374 @@ def test_pick_random_uses_default_pack_size(self, client): assert len(data['cities']) == 10 # default pack_size +# ─── add_traffic_demo_city / delete_traffic_demo_city helpers ───────────────── + +class TestAddDeleteTrafficDemoCity: + @pytest.fixture(autouse=True) + def no_bg_download(self): + """Prevent background download threads from making real network calls during unit tests.""" + with patch('app._download_city_offline_data'): + yield + + def test_add_city_inserts_row(self): + before = len(get_traffic_demo_cities()) + add_traffic_demo_city('Portland', 'OR', 45.5051, -122.6750, 652503) + after = get_traffic_demo_cities() + assert len(after) == before + 1 + names = [c['name'] for c in after] + assert 'Portland' in names + + def test_add_city_returns_id(self): + new_id = add_traffic_demo_city('Seattle', 'WA', 47.6062, -122.3321, 737255) + assert isinstance(new_id, int) + assert new_id > 0 + + def test_add_city_enabled_by_default(self): + new_id = add_traffic_demo_city('Denver', 'CO', 39.7392, -104.9903, 715522) + cities = get_traffic_demo_cities() + city = next(c for c in cities if c['id'] == new_id) + assert city['enabled'] is True + assert city['weight'] == 1 + + def test_add_city_validates_empty_name(self): + with pytest.raises(ValueError, match="name"): + add_traffic_demo_city('', 'CA', 34.0522, -118.2437, 100000) + + def test_add_city_validates_empty_state(self): + with pytest.raises(ValueError, match="[Ss]tate"): + add_traffic_demo_city('TestCity', '', 34.0522, -118.2437, 100000) + + def test_add_city_validates_lat_range(self): + with pytest.raises(ValueError, match="[Ll]atitude"): + add_traffic_demo_city('Bad', 'XX', 999.0, 0.0, 0) + + def test_add_city_validates_lon_range(self): + with pytest.raises(ValueError, match="[Ll]ongitude"): + add_traffic_demo_city('Bad', 'XX', 0.0, 999.0, 0) + + def test_add_city_validates_negative_population(self): + with pytest.raises(ValueError, match="[Pp]opulation"): + add_traffic_demo_city('Bad', 'XX', 0.0, 0.0, -1) + + def test_delete_city_removes_row(self): + new_id = add_traffic_demo_city('Austin', 'TX', 30.2672, -97.7431, 961855) + before = len(get_traffic_demo_cities()) + delete_traffic_demo_city(new_id) + after = len(get_traffic_demo_cities()) + assert after == before - 1 + names = [c['name'] for c in get_traffic_demo_cities()] + assert 'Austin' not in names + + def test_delete_nonexistent_city_raises(self): + with pytest.raises(ValueError): + delete_traffic_demo_city(99999) + + def test_delete_evicts_in_memory_cache(self, monkeypatch): + new_id = add_traffic_demo_city('Reno', 'NV', 39.5296, -119.8138, 255601) + # Seed a fake cache entry + monkeypatch.setitem(app_module._ROADS_CACHE, new_id, {'type': 'FeatureCollection', 'features': []}) + monkeypatch.setitem(app_module._ROADS_CACHE_TIME, new_id, 1.0) + delete_traffic_demo_city(new_id) + assert new_id not in app_module._ROADS_CACHE + assert new_id not in app_module._ROADS_CACHE_TIME + + +# ─── /api/traffic/demo/cities/add and DELETE /api/traffic/demo/cities/ ── + +class TestApiTrafficDemoCityAddDelete: + @pytest.fixture(autouse=True) + def no_bg_download(self): + """Prevent background download threads from making real network calls during unit tests.""" + with patch('app._download_city_offline_data'): + yield + + def test_add_city_requires_login(self, client): + resp = client.post('/api/traffic/demo/cities/add', + data=json.dumps({'name': 'Portland', 'state': 'OR', + 'lat': 45.5051, 'lon': -122.675, 'population': 652503}), + content_type='application/json') + assert resp.status_code in (302, 401) + + def test_add_city_requires_admin(self, client): + login(client) # logs in as non-admin 'testuser' + resp = client.post('/api/traffic/demo/cities/add', + data=json.dumps({'name': 'Portland', 'state': 'OR', + 'lat': 45.5051, 'lon': -122.675, 'population': 652503}), + content_type='application/json') + assert resp.status_code == 403 + + def test_add_city_success(self, client): + login(client, 'admin', 'adminpass') + before = len(get_traffic_demo_cities()) + resp = client.post('/api/traffic/demo/cities/add', + data=json.dumps({'name': 'Portland', 'state': 'OR', + 'lat': 45.5051, 'lon': -122.675, 'population': 652503}), + content_type='application/json') + assert resp.status_code == 200 + data = resp.get_json() + assert data['ok'] is True + assert data['city']['name'] == 'Portland' + assert data['city']['state'] == 'OR' + assert len(get_traffic_demo_cities()) == before + 1 + + def test_add_city_validation_error(self, client): + login(client, 'admin', 'adminpass') + resp = client.post('/api/traffic/demo/cities/add', + data=json.dumps({'name': '', 'state': 'OR', + 'lat': 45.5051, 'lon': -122.675, 'population': 0}), + content_type='application/json') + assert resp.status_code == 400 + assert resp.get_json()['ok'] is False + + def test_delete_city_requires_login(self, client): + cities = get_traffic_demo_cities() + city_id = cities[0]['id'] + resp = client.delete(f'/api/traffic/demo/cities/{city_id}') + assert resp.status_code in (302, 401) + + def test_delete_city_requires_admin(self, client): + login(client) # non-admin + cities = get_traffic_demo_cities() + city_id = cities[0]['id'] + resp = client.delete(f'/api/traffic/demo/cities/{city_id}') + assert resp.status_code == 403 + + def test_delete_city_success(self, client): + login(client, 'admin', 'adminpass') + # Add a city first so we don't delete a seed city in other tests + add_resp = client.post('/api/traffic/demo/cities/add', + data=json.dumps({'name': 'Boise', 'state': 'ID', + 'lat': 43.6150, 'lon': -116.2023, 'population': 235684}), + content_type='application/json') + new_id = add_resp.get_json()['city']['id'] + before = len(get_traffic_demo_cities()) + del_resp = client.delete(f'/api/traffic/demo/cities/{new_id}') + assert del_resp.status_code == 200 + assert del_resp.get_json()['ok'] is True + assert len(get_traffic_demo_cities()) == before - 1 + + def test_delete_nonexistent_city_returns_404(self, client): + login(client, 'admin', 'adminpass') + resp = client.delete('/api/traffic/demo/cities/99999') + assert resp.status_code == 404 + assert resp.get_json()['ok'] is False + + def test_add_city_triggers_offline_data_download(self, client): + """Verify that background download thread is started; mock to avoid real network calls.""" + login(client, 'admin', 'adminpass') + with patch('app._download_city_offline_data') as mock_dl, \ + patch('threading.Thread') as mock_thread: + mock_thread.return_value = MagicMock() + resp = client.post('/api/traffic/demo/cities/add', + data=json.dumps({'name': 'Tucson', 'state': 'AZ', + 'lat': 32.2226, 'lon': -110.9747, 'population': 542629}), + content_type='application/json') + assert resp.status_code == 200 + assert resp.get_json()['ok'] is True + # A background thread is started (threading.Thread was called) + assert mock_thread.called + + +# ─── virtual_channels.html UI — add/delete city elements ───────────────────── + +class TestChangeTunerTrafficAddDeleteUI: + def test_add_city_form_present(self, client): + save_virtual_channel_settings({'virtual.traffic': True}) + login(client, 'admin', 'adminpass') + resp = client.get('/virtual_channels') + assert b'traffic-add-city-btn' in resp.data + assert b'traffic-add-city-name' in resp.data + assert b'traffic-add-city-lat' in resp.data + assert b'traffic-add-city-lon' in resp.data + + def test_delete_buttons_present(self, client): + save_virtual_channel_settings({'virtual.traffic': True}) + login(client, 'admin', 'adminpass') + resp = client.get('/virtual_channels') + assert b'traffic-city-delete-btn' in resp.data + + def test_zip_lookup_field_present(self, client): + save_virtual_channel_settings({'virtual.traffic': True}) + login(client, 'admin', 'adminpass') + resp = client.get('/virtual_channels') + assert b'traffic-add-city-zip' in resp.data + assert b'traffic-zip-lookup-btn' in resp.data + + +# ─── lookup_zip_city helper and /api/traffic/demo/zip_lookup endpoint ───────── + +class TestLookupZipCity: + """Tests for the postal-code → city-info lookup helper and its API endpoint.""" + + _NOMINATIM_RESPONSE = [ + { + 'lat': '45.5194', + 'lon': '-122.6983', + 'address': { + 'city': 'Portland', + 'state': 'Oregon', + 'postcode': '97201', + 'country': 'United States', + 'country_code': 'us', + }, + } + ] + + def _mock_get(self, json_body, status_code=200): + mock_resp = MagicMock() + mock_resp.status_code = status_code + mock_resp.json.return_value = json_body + if status_code >= 400: + mock_resp.raise_for_status.side_effect = req_lib.exceptions.HTTPError( + f"{status_code} error" + ) + else: + mock_resp.raise_for_status.return_value = None + return mock_resp + + def test_successful_us_lookup(self, monkeypatch): + monkeypatch.setattr(app_module.requests, 'get', + MagicMock(return_value=self._mock_get(self._NOMINATIM_RESPONSE))) + result = lookup_zip_city('97201') + assert result['name'] == 'Portland' + assert result['state'] == 'OR' # full name mapped to abbreviation + assert abs(result['lat'] - 45.5194) < 0.001 + assert abs(result['lon'] - (-122.6983)) < 0.001 + + def test_state_name_mapped_to_abbreviation(self, monkeypatch): + resp = [{'lat': '30.27', 'lon': '-97.74', + 'address': {'city': 'Austin', 'state': 'Texas', 'country_code': 'us'}}] + monkeypatch.setattr(app_module.requests, 'get', + MagicMock(return_value=self._mock_get(resp))) + result = lookup_zip_city('78701', 'us') + assert result['state'] == 'TX' + + def test_empty_zip_raises(self): + with pytest.raises(ValueError, match="[Pp]ostal code"): + lookup_zip_city('') + + def test_no_results_raises(self, monkeypatch): + monkeypatch.setattr(app_module.requests, 'get', + MagicMock(return_value=self._mock_get([]))) + with pytest.raises(ValueError, match="No results"): + lookup_zip_city('99999') + + def test_network_error_raises(self, monkeypatch): + monkeypatch.setattr(app_module.requests, 'get', + MagicMock(side_effect=Exception("connection error"))) + with pytest.raises(ValueError, match="unavailable"): + lookup_zip_city('97201') + + def test_fallback_to_town_when_no_city(self, monkeypatch): + resp = [{'lat': '44.05', 'lon': '-123.09', + 'address': {'town': 'Springfield', 'state': 'Oregon', 'country_code': 'us'}}] + monkeypatch.setattr(app_module.requests, 'get', + MagicMock(return_value=self._mock_get(resp))) + result = lookup_zip_city('97477') + assert result['name'] == 'Springfield' + + def test_api_endpoint_requires_login(self, client): + resp = client.get('/api/traffic/demo/zip_lookup?zip=97201') + assert resp.status_code in (302, 401) + + def test_api_endpoint_requires_admin(self, client): + login(client) # non-admin + resp = client.get('/api/traffic/demo/zip_lookup?zip=97201') + assert resp.status_code == 403 + + def test_api_endpoint_success(self, client, monkeypatch): + login(client, 'admin', 'adminpass') + monkeypatch.setattr(app_module.requests, 'get', + MagicMock(return_value=self._mock_get(self._NOMINATIM_RESPONSE))) + resp = client.get('/api/traffic/demo/zip_lookup?zip=97201') + assert resp.status_code == 200 + data = resp.get_json() + assert data['ok'] is True + assert data['name'] == 'Portland' + assert data['state'] == 'OR' + + def test_api_endpoint_missing_zip_param(self, client): + login(client, 'admin', 'adminpass') + resp = client.get('/api/traffic/demo/zip_lookup') + assert resp.status_code == 400 + + def test_api_endpoint_not_found(self, client, monkeypatch): + login(client, 'admin', 'adminpass') + monkeypatch.setattr(app_module.requests, 'get', + MagicMock(return_value=self._mock_get([]))) + resp = client.get('/api/traffic/demo/zip_lookup?zip=99999') + assert resp.status_code == 404 + assert resp.get_json()['ok'] is False + + +# ─── Population comma parsing ───────────────────────────────────────────────── + +class TestPopulationCommaParsing: + @pytest.fixture(autouse=True) + def no_bg_download(self): + with patch('app._download_city_offline_data'): + yield + + def _add(self, pop): + """Helper: add a throwaway city with the given population value and return the DB record.""" + name = f'Town_{pop}' + new_id = add_traffic_demo_city(name, 'TX', 30.0, -97.0, pop) + cities = get_traffic_demo_cities() + return next(c for c in cities if c['id'] == new_id) + + def test_plain_integer(self): + assert self._add(37390)['population'] == 37390 + + def test_plain_integer_as_string(self): + assert self._add('37390')['population'] == 37390 + + def test_us_comma_separator(self): + assert self._add('37,390')['population'] == 37390 + + def test_european_period_separator(self): + assert self._add('37.390')['population'] == 37390 + + def test_french_space_separator(self): + assert self._add('37 390')['population'] == 37390 + + def test_swiss_apostrophe_separator(self): + assert self._add("37'390")['population'] == 37390 + + def test_python_underscore_separator(self): + assert self._add('37_390')['population'] == 37390 + + def test_non_breaking_space_separator(self): + """Unicode non-breaking space (U+00A0) used in some locales.""" + assert self._add('37\u00a0390')['population'] == 37390 + + def test_narrow_no_break_space_separator(self): + """Narrow no-break space (U+202F) used by French SI standard.""" + assert self._add('37\u202f390')['population'] == 37390 + + def test_zero_population(self): + assert self._add(0)['population'] == 0 + + def test_api_accepts_comma_population_via_json(self, client): + login(client, 'admin', 'adminpass') + resp = client.post('/api/traffic/demo/cities/add', + data=json.dumps({'name': 'SmallTown', 'state': 'OR', + 'lat': 45.0, 'lon': -122.0, 'population': '37,390'}), + content_type='application/json') + assert resp.status_code == 200 + data = resp.get_json() + assert data['ok'] is True + assert data['city']['population'] == 37390 + + def test_api_accepts_european_period_via_json(self, client): + login(client, 'admin', 'adminpass') + resp = client.post('/api/traffic/demo/cities/add', + data=json.dumps({'name': 'EuroTown', 'state': 'OR', + 'lat': 45.0, 'lon': -122.0, 'population': '37.390'}), + content_type='application/json') + assert resp.status_code == 200 + assert resp.get_json()['city']['population'] == 37390 + + # ─── Admin UI — change_tuner page ──────────────────────────────────────────── class TestChangeTunerTrafficDemoUI: diff --git a/tests/test_user_prefs.py b/tests/test_user_prefs.py index 04e0159..5afd064 100644 --- a/tests/test_user_prefs.py +++ b/tests/test_user_prefs.py @@ -49,7 +49,6 @@ def test_default_prefs_returned_for_new_user(self): prefs = get_user_prefs("newuser") assert prefs["auto_load_channel"] is None assert prefs["hidden_channels"] == [] - assert prefs["sizzle_reels_enabled"] is False def test_all_default_keys_present(self): prefs = get_user_prefs("nobody") @@ -60,23 +59,19 @@ def test_save_and_retrieve(self): save_user_prefs("testuser", { "auto_load_channel": {"id": "ch1", "name": "Channel 1"}, "hidden_channels": ["ch2", "ch3"], - "sizzle_reels_enabled": True, }) prefs = get_user_prefs("testuser") assert prefs["auto_load_channel"] == {"id": "ch1", "name": "Channel 1"} assert prefs["hidden_channels"] == ["ch2", "ch3"] - assert prefs["sizzle_reels_enabled"] is True def test_upsert_overwrites_existing(self): save_user_prefs("testuser", {"auto_load_channel": {"id": "old", "name": "Old"}, - "hidden_channels": [], "sizzle_reels_enabled": False}) + "hidden_channels": []}) save_user_prefs("testuser", {"auto_load_channel": None, - "hidden_channels": ["x"], - "sizzle_reels_enabled": True}) + "hidden_channels": ["x"]}) prefs = get_user_prefs("testuser") assert prefs["auto_load_channel"] is None assert prefs["hidden_channels"] == ["x"] - assert prefs["sizzle_reels_enabled"] is True def test_missing_keys_filled_with_defaults(self): """Partial JSON stored in DB should still return all expected keys.""" @@ -85,12 +80,11 @@ def test_missing_keys_filled_with_defaults(self): with sqlite3.connect(db) as conn: conn.execute( "INSERT INTO user_preferences (username, prefs) VALUES (?, ?)", - ("partial", json.dumps({"sizzle_reels_enabled": True})) + ("partial", json.dumps({"hidden_channels": ["x"]})) ) prefs = get_user_prefs("partial") assert prefs["auto_load_channel"] is None # filled from defaults - assert prefs["hidden_channels"] == [] # filled from defaults - assert prefs["sizzle_reels_enabled"] is True + assert prefs["hidden_channels"] == ["x"] def test_graceful_on_missing_table(self, monkeypatch, tmp_path): """get_user_prefs should return defaults if the table doesn't exist yet.""" @@ -112,8 +106,7 @@ def test_save_auto_heals_missing_table(self, monkeypatch, tmp_path): monkeypatch.setattr(app_module, "DATABASE", empty_db) # Should not raise — auto-heals by calling init_db() then retrying save_user_prefs("alice", {"auto_load_channel": None, - "hidden_channels": [], - "sizzle_reels_enabled": False}) + "hidden_channels": []}) prefs = get_user_prefs("alice") assert prefs["hidden_channels"] == [] @@ -132,17 +125,15 @@ def test_returns_defaults_for_new_user(self, client): data = resp.get_json() assert data["auto_load_channel"] is None assert data["hidden_channels"] == [] - assert data["sizzle_reels_enabled"] is False def test_returns_saved_prefs(self, client): login(client) save_user_prefs("testuser", {"auto_load_channel": {"id": "abc", "name": "ABC"}, - "hidden_channels": ["x"], "sizzle_reels_enabled": True}) + "hidden_channels": ["x"]}) resp = client.get("/api/user_prefs") data = resp.get_json() assert data["auto_load_channel"]["id"] == "abc" assert "x" in data["hidden_channels"] - assert data["sizzle_reels_enabled"] is True # ─── API endpoint: POST /api/user_prefs ────────────────────────────────────── @@ -168,7 +159,7 @@ def test_set_auto_load_channel(self, client): def test_clear_auto_load_channel(self, client): login(client) save_user_prefs("testuser", {"auto_load_channel": {"id": "ch1", "name": "News"}, - "hidden_channels": [], "sizzle_reels_enabled": False}) + "hidden_channels": []}) resp = client.post("/api/user_prefs", data=json.dumps({"auto_load_channel": None}), content_type="application/json") @@ -186,25 +177,25 @@ def test_add_hidden_channel(self, client): assert "news" in prefs["hidden_channels"] def test_toggle_sizzle_reels(self, client): + """sizzle_reels_enabled is no longer a valid pref — unknown keys are ignored.""" login(client) resp = client.post("/api/user_prefs", data=json.dumps({"sizzle_reels_enabled": True}), content_type="application/json") assert resp.status_code == 200 - assert get_user_prefs("testuser")["sizzle_reels_enabled"] is True + assert "sizzle_reels_enabled" not in get_user_prefs("testuser") def test_partial_update_preserves_other_keys(self, client): login(client) save_user_prefs("testuser", {"auto_load_channel": {"id": "ch1", "name": "News"}, - "hidden_channels": ["x"], "sizzle_reels_enabled": True}) - # Only update sizzle_reels_enabled + "hidden_channels": ["x"]}) + # Only update hidden_channels client.post("/api/user_prefs", - data=json.dumps({"sizzle_reels_enabled": False}), + data=json.dumps({"hidden_channels": ["y"]}), content_type="application/json") prefs = get_user_prefs("testuser") assert prefs["auto_load_channel"]["id"] == "ch1" # unchanged - assert prefs["hidden_channels"] == ["x"] # unchanged - assert prefs["sizzle_reels_enabled"] is False # updated + assert prefs["hidden_channels"] == ["y"] # updated def test_invalid_json_returns_400(self, client): login(client) @@ -216,7 +207,7 @@ def test_auto_load_missing_id_clears_pref(self, client): """auto_load_channel with no 'id' key should be treated as clearing it.""" login(client) save_user_prefs("testuser", {"auto_load_channel": {"id": "ch1", "name": "x"}, - "hidden_channels": [], "sizzle_reels_enabled": False}) + "hidden_channels": []}) client.post("/api/user_prefs", data=json.dumps({"auto_load_channel": {}}), content_type="application/json") @@ -276,7 +267,6 @@ def test_manage_users_includes_user_prefs_in_response(self, client): save_user_prefs("testuser", { "auto_load_channel": {"id": "ch99", "name": "Test Ch"}, "hidden_channels": [], - "sizzle_reels_enabled": False, }) login(client, "admin", "adminpass") resp = client.get("/manage_users") @@ -307,7 +297,6 @@ def test_assign_tuner_change_clears_auto_load_channel(self, client): save_user_prefs("testuser", { "auto_load_channel": {"id": "old-ch", "name": "Old Tuner Channel"}, "hidden_channels": [], - "sizzle_reels_enabled": False, }) # Give testuser an initial assigned_tuner login(client, "admin", "adminpass") @@ -324,7 +313,6 @@ def test_assign_tuner_same_tuner_keeps_auto_load_channel(self, client): save_user_prefs("testuser", { "auto_load_channel": {"id": "keep-ch", "name": "Keep This Channel"}, "hidden_channels": [], - "sizzle_reels_enabled": False, }) login(client, "admin", "adminpass") # First, assign Tuner 1 to testuser (tuner was None → Tuner 1, so auto_load clears here) @@ -337,7 +325,6 @@ def test_assign_tuner_same_tuner_keeps_auto_load_channel(self, client): save_user_prefs("testuser", { "auto_load_channel": {"id": "keep-ch", "name": "Keep This Channel"}, "hidden_channels": [], - "sizzle_reels_enabled": False, }) # Re-assign the SAME tuner — channel must be preserved client.post("/manage_users", data={ @@ -420,3 +407,117 @@ def test_active_combined_tuner_channels_shown_in_manage_users(self, client): resp = client.get("/manage_users") assert resp.status_code == 200 assert b"Active Combined Channel" in resp.data + + +# ─── Admin: manage_users hidden channels ───────────────────────────────────── + +class TestManageUsersHiddenChannels: + def test_hidden_channels_shown_in_admin_panel(self, client): + """Admin manage_users page shows count summary of a user's hidden channels.""" + save_user_prefs("testuser", { + "auto_load_channel": None, + "hidden_channels": ["sports", "news", "weather"], + }) + login(client, "admin", "adminpass") + resp = client.get("/manage_users") + assert resp.status_code == 200 + assert b"3 channels hidden" in resp.data + + def test_no_hidden_channels_shows_empty_message(self, client): + """Admin manage_users page shows 'No channels hidden' when the list is empty.""" + login(client, "admin", "adminpass") + resp = client.get("/manage_users") + assert resp.status_code == 200 + assert b"No channels hidden" in resp.data + + def test_admin_can_clear_hidden_channels_via_multiselect(self, client): + """Admin can clear hidden channels by submitting update_hidden_channels with no IDs.""" + save_user_prefs("testuser", { + "auto_load_channel": None, + "hidden_channels": ["ch1", "ch2"], + }) + login(client, "admin", "adminpass") + resp = client.post("/manage_users", data={ + "action": "set_user_prefs", + "username": "testuser", + "update_hidden_channels": "1", + # no hidden_channel_ids → selects nothing → empty list + }, follow_redirects=True) + assert resp.status_code == 200 + assert get_user_prefs("testuser")["hidden_channels"] == [] + + def test_admin_can_set_hidden_channels_via_multiselect(self, client): + """Admin can set specific hidden channels by submitting update_hidden_channels with IDs.""" + save_user_prefs("testuser", { + "auto_load_channel": None, + "hidden_channels": [], + }) + login(client, "admin", "adminpass") + resp = client.post("/manage_users", data={ + "action": "set_user_prefs", + "username": "testuser", + "update_hidden_channels": "1", + "hidden_channel_ids": ["sports", "news"], + }, follow_redirects=True) + assert resp.status_code == 200 + prefs = get_user_prefs("testuser") + assert "sports" in prefs["hidden_channels"] + assert "news" in prefs["hidden_channels"] + assert len(prefs["hidden_channels"]) == 2 + + def test_admin_can_replace_hidden_channels(self, client): + """Admin can replace existing hidden channels with a new selection.""" + save_user_prefs("testuser", { + "auto_load_channel": None, + "hidden_channels": ["old1", "old2"], + }) + login(client, "admin", "adminpass") + client.post("/manage_users", data={ + "action": "set_user_prefs", + "username": "testuser", + "update_hidden_channels": "1", + "hidden_channel_ids": ["new1"], + }, follow_redirects=True) + prefs = get_user_prefs("testuser") + assert prefs["hidden_channels"] == ["new1"] + + def test_save_prefs_without_update_sentinel_preserves_hidden_channels(self, client): + """Saving prefs without the update_hidden_channels sentinel must NOT wipe the hidden list.""" + save_user_prefs("testuser", { + "auto_load_channel": None, + "hidden_channels": ["keep-me"], + }) + login(client, "admin", "adminpass") + client.post("/manage_users", data={ + "action": "set_user_prefs", + "username": "testuser", + # no update_hidden_channels field + }, follow_redirects=True) + assert get_user_prefs("testuser")["hidden_channels"] == ["keep-me"] + + def test_no_channel_list_does_not_clear_hidden_channels(self, client): + """When the user has no channel list (no tuner loaded), saving prefs must + not send the update_hidden_channels sentinel and must preserve hidden channels.""" + save_user_prefs("testuser", { + "auto_load_channel": None, + "hidden_channels": ["preserved"], + }) + login(client, "admin", "adminpass") + # Submit the form as the template would when channel_list is empty — + # no update_hidden_channels sentinel, no hidden_channel_ids. + client.post("/manage_users", data={ + "action": "set_user_prefs", + "username": "testuser", + }, follow_redirects=True) + assert get_user_prefs("testuser")["hidden_channels"] == ["preserved"] + + def test_clear_hidden_channels_singular_label(self, client): + """When exactly 1 channel is hidden the label should be '1 channel hidden'.""" + save_user_prefs("testuser", { + "auto_load_channel": None, + "hidden_channels": ["solo"], + }) + login(client, "admin", "adminpass") + resp = client.get("/manage_users") + assert resp.status_code == 200 + assert b"1 channel hidden" in resp.data diff --git a/tests/test_weather.py b/tests/test_weather.py index 6b8c4ea..8d9120a 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -12,7 +12,7 @@ app, init_db, init_tuners_db, get_weather_config, save_weather_config, _wmo_label, _wmo_icon, _to_night_icon, _wind_dir, - _build_weather_payload, + _build_weather_payload, _fetch_nws_alerts, save_virtual_channel_settings, ) @@ -409,3 +409,351 @@ def test_api_weather_reflects_saved_location_name(self, client): }, follow_redirects=True) data = client.get('/api/weather').get_json() assert data['location'] == 'Austin, TX' + + +# ─── _fetch_nws_alerts ──────────────────────────────────────────────────────── + +class TestFetchNwsAlerts: + def test_returns_list_on_network_error(self, monkeypatch): + """_fetch_nws_alerts returns [] (not raises) when the NWS API is unavailable.""" + import requests as req_mod + + def _fail(*a, **kw): + raise req_mod.exceptions.ConnectionError("mocked failure") + + monkeypatch.setattr(req_mod, "get", _fail) + result = _fetch_nws_alerts('25.77', '-80.19') + assert result == [] + + def test_returns_list_on_bad_json(self, monkeypatch): + """_fetch_nws_alerts returns [] when the response body is not valid JSON.""" + import requests as req_mod + + class _BadResp: + status_code = 200 + def raise_for_status(self): pass + def json(self): raise ValueError("bad json") + + monkeypatch.setattr(req_mod, "get", lambda *a, **kw: _BadResp()) + result = _fetch_nws_alerts('25.77', '-80.19') + assert result == [] + + def test_parses_alert_fields(self, monkeypatch): + """_fetch_nws_alerts correctly maps NWS GeoJSON feature properties.""" + import requests as req_mod + + sample = { + "features": [ + { + "properties": { + "event": "Winter Storm Warning", + "headline": "Winter Storm Warning until Monday", + "description": "Heavy snow expected.", + "severity": "Severe", + "urgency": "Expected", + "certainty": "Likely", + "onset": "2026-01-10T06:00:00-05:00", + "expires": "2026-01-11T06:00:00-05:00", + } + } + ] + } + + class _OkResp: + status_code = 200 + def raise_for_status(self): pass + def json(self): return sample + + monkeypatch.setattr(req_mod, "get", lambda *a, **kw: _OkResp()) + result = _fetch_nws_alerts('40.71', '-74.00') + assert len(result) == 1 + a = result[0] + assert a['event'] == 'Winter Storm Warning' + assert a['headline'] == 'Winter Storm Warning until Monday' + assert a['severity'] == 'Severe' + assert a['urgency'] == 'Expected' + assert a['certainty'] == 'Likely' + assert a['expires'] == '2026-01-11T06:00:00-05:00' + + def test_empty_features_returns_empty_list(self, monkeypatch): + import requests as req_mod + + class _OkResp: + status_code = 200 + def raise_for_status(self): pass + def json(self): return {"features": []} + + monkeypatch.setattr(req_mod, "get", lambda *a, **kw: _OkResp()) + assert _fetch_nws_alerts('51.5', '-0.12') == [] + + def test_missing_features_key_returns_empty_list(self, monkeypatch): + import requests as req_mod + + class _OkResp: + status_code = 200 + def raise_for_status(self): pass + def json(self): return {"type": "FeatureCollection"} + + monkeypatch.setattr(req_mod, "get", lambda *a, **kw: _OkResp()) + assert _fetch_nws_alerts('25.77', '-80.19') == [] + + +# ─── _build_weather_payload – alerts key ───────────────────────────────────── + +class TestBuildWeatherPayloadAlerts: + def test_stub_payload_has_alerts_key(self): + """Stub (no lat/lon) payload must include an 'alerts' key.""" + cfg = {'lat': '', 'lon': '', 'location_name': 'Anywhere', 'units': 'F'} + p = _build_weather_payload(cfg) + assert 'alerts' in p + assert isinstance(p['alerts'], list) + + def test_stub_alerts_is_empty(self): + cfg = {'lat': '', 'lon': '', 'location_name': 'Anywhere', 'units': 'F'} + p = _build_weather_payload(cfg) + assert p['alerts'] == [] + + def test_alerts_from_nws_populate_ticker(self, monkeypatch): + """When NWS returns alerts, their headlines become the ticker entries.""" + import requests as req_mod + + nws_sample = { + "features": [ + {"properties": { + "event": "Flood Warning", + "headline": "Flood Warning in effect until Tuesday", + "description": "Significant flooding expected.", + "severity": "Severe", "urgency": "Immediate", + "certainty": "Observed", "onset": "", "expires": "", + }} + ] + } + open_meteo_sample = { + "current": { + "temperature_2m": 72, "apparent_temperature": 70, + "relative_humidity_2m": 60, "weather_code": 0, + "wind_speed_10m": 5, "wind_direction_10m": 90, + }, + "current_units": {"wind_speed_10m": "mph"}, + "hourly": {"time": [], "temperature_2m": [], "weather_code": []}, + "daily": { + "time": [], "temperature_2m_max": [], "temperature_2m_min": [], + "weather_code": [], "sunrise": [], "sunset": [], + }, + } + + call_log = [] + + def _mock_get(url, *a, **kw): + call_log.append(url) + + class _Resp: + status_code = 200 + def raise_for_status(self): pass + def json(self_): + if 'open-meteo' in url: + return open_meteo_sample + return nws_sample + + return _Resp() + + monkeypatch.setattr(req_mod, "get", _mock_get) + cfg = {'lat': '25.77', 'lon': '-80.19', 'location_name': 'Miami', 'units': 'F'} + p = _build_weather_payload(cfg) + + assert 'alerts' in p + assert len(p['alerts']) == 1 + assert p['alerts'][0]['event'] == 'Flood Warning' + # Ticker should use the NWS headline + assert any('Flood Warning' in t for t in p['ticker']) + + def test_wmo_ticker_used_when_no_nws_alerts(self, monkeypatch): + """When NWS returns no alerts and WMO code is thunderstorm, ticker still fires.""" + import requests as req_mod + + open_meteo_sample = { + "current": { + "temperature_2m": 78, "apparent_temperature": 76, + "relative_humidity_2m": 80, "weather_code": 95, + "wind_speed_10m": 15, "wind_direction_10m": 180, + }, + "current_units": {"wind_speed_10m": "mph"}, + "hourly": {"time": [], "temperature_2m": [], "weather_code": []}, + "daily": { + "time": [], "temperature_2m_max": [], "temperature_2m_min": [], + "weather_code": [], "sunrise": [], "sunset": [], + }, + } + + def _mock_get(url, *a, **kw): + class _Resp: + status_code = 200 + def raise_for_status(self): pass + def json(self_): + if 'open-meteo' in url: + return open_meteo_sample + return {"features": []} + return _Resp() + + monkeypatch.setattr(req_mod, "get", _mock_get) + cfg = {'lat': '25.77', 'lon': '-80.19', 'location_name': 'Miami', 'units': 'F'} + p = _build_weather_payload(cfg) + assert p['alerts'] == [] + assert any('Thunderstorm' in t or 'thunderstorm' in t.lower() for t in p['ticker']) + + +# ─── /api/weather – alerts key present ─────────────────────────────────────── + +class TestApiWeatherAlertsKey: + def test_api_weather_has_alerts_key(self, client): + login(client) + data = client.get('/api/weather').get_json() + assert 'alerts' in data + assert isinstance(data['alerts'], list) + + +# ─── Animated Background Override ──────────────────────────────────────────── + +class TestWeatherBgConditionOverride: + """Tests for bg_condition_override config key and /api/weather/bg_override endpoint.""" + + def test_default_bg_condition_override_is_empty(self): + cfg = get_weather_config() + assert cfg.get('bg_condition_override', '') == '' + + def test_save_valid_condition(self): + save_weather_config({'lat': '', 'lon': '', 'location_name': '', 'units': 'F', + 'bg_condition_override': 'rain'}) + assert get_weather_config()['bg_condition_override'] == 'rain' + + def test_save_empty_condition_clears_override(self): + save_weather_config({'lat': '', 'lon': '', 'location_name': '', 'units': 'F', + 'bg_condition_override': 'snow'}) + save_weather_config({'lat': '', 'lon': '', 'location_name': '', 'units': 'F', + 'bg_condition_override': ''}) + assert get_weather_config()['bg_condition_override'] == '' + + def test_invalid_bg_condition_raises(self): + with pytest.raises(ValueError): + save_weather_config({'lat': '', 'lon': '', 'location_name': '', 'units': 'F', + 'bg_condition_override': 'rainbow'}) + + def test_all_valid_conditions_accepted(self): + valid = ['', 'sunny', 'partly_cloudy', 'cloudy', 'rain', 'drizzle', + 'showers', 'snow', 'thunderstorm', 'foggy', 'windy'] + for cond in valid: + save_weather_config({'lat': '', 'lon': '', 'location_name': '', 'units': 'F', + 'bg_condition_override': cond}) + assert get_weather_config()['bg_condition_override'] == cond + + def test_payload_includes_bg_condition(self): + cfg = {'lat': '', 'lon': '', 'location_name': '', 'units': 'F', + 'bg_condition_override': ''} + p = _build_weather_payload(cfg) + assert 'bg_condition' in p + assert 'bg_condition_override' in p + + def test_override_sets_bg_condition_in_payload(self): + cfg = {'lat': '', 'lon': '', 'location_name': '', 'units': 'F', + 'bg_condition_override': 'snow'} + p = _build_weather_payload(cfg) + assert p['bg_condition'] == 'snow' + assert p['bg_condition_override'] == 'snow' + + def test_no_override_uses_default_condition(self): + cfg = {'lat': '', 'lon': '', 'location_name': '', 'units': 'F', + 'bg_condition_override': ''} + p = _build_weather_payload(cfg) + # Stub payload defaults to 'cloudy' icon, so bg_condition should be 'cloudy' + assert p['bg_condition'] == 'cloudy' + assert p['bg_condition_override'] == '' + + +class TestBgOverrideEndpoint: + """Tests for the /api/weather/bg_override AJAX endpoint.""" + + def test_get_returns_empty_by_default(self, client): + login(client) + r = client.get('/api/weather/bg_override') + assert r.status_code == 200 + data = r.get_json() + assert data['condition'] == '' + + def test_post_sets_condition(self, client): + login(client) + r = client.post('/api/weather/bg_override', + json={'condition': 'thunderstorm'}, + content_type='application/json') + assert r.status_code == 200 + data = r.get_json() + assert data['ok'] is True + assert data['condition'] == 'thunderstorm' + + def test_post_persists_condition(self, client): + login(client) + client.post('/api/weather/bg_override', + json={'condition': 'rain'}, + content_type='application/json') + r = client.get('/api/weather/bg_override') + assert r.get_json()['condition'] == 'rain' + + def test_post_auto_clears_override(self, client): + login(client) + client.post('/api/weather/bg_override', + json={'condition': 'snow'}, + content_type='application/json') + client.post('/api/weather/bg_override', + json={'condition': 'auto'}, + content_type='application/json') + r = client.get('/api/weather/bg_override') + assert r.get_json()['condition'] == '' + + def test_post_empty_string_clears_override(self, client): + login(client) + client.post('/api/weather/bg_override', + json={'condition': 'sunny'}, + content_type='application/json') + client.post('/api/weather/bg_override', + json={'condition': ''}, + content_type='application/json') + r = client.get('/api/weather/bg_override') + assert r.get_json()['condition'] == '' + + def test_post_invalid_condition_returns_400(self, client): + login(client) + r = client.post('/api/weather/bg_override', + json={'condition': 'tornado'}, + content_type='application/json') + assert r.status_code == 400 + assert r.get_json()['ok'] is False + + def test_delete_clears_override(self, client): + login(client) + client.post('/api/weather/bg_override', + json={'condition': 'foggy'}, + content_type='application/json') + r = client.delete('/api/weather/bg_override') + assert r.status_code == 200 + assert r.get_json()['condition'] == '' + r2 = client.get('/api/weather/bg_override') + assert r2.get_json()['condition'] == '' + + def test_requires_login_get(self, client): + r = client.get('/api/weather/bg_override') + assert r.status_code in (302, 401) + + def test_requires_login_post(self, client): + r = client.post('/api/weather/bg_override', + json={'condition': 'snow'}, + content_type='application/json') + assert r.status_code in (302, 401) + + def test_api_weather_reflects_override(self, client): + """After setting override, /api/weather returns that bg_condition.""" + login(client) + client.post('/api/weather/bg_override', + json={'condition': 'windy'}, + content_type='application/json') + data = client.get('/api/weather').get_json() + assert data['bg_condition'] == 'windy' + assert data['bg_condition_override'] == 'windy' From 9e8a544406b8da8f0bc945ac13d95fa7f9f360b6 Mon Sep 17 00:00:00 2001 From: thehack904 <35552907+thehack904@users.noreply.github.com> Date: Mon, 25 May 2026 08:30:45 -0500 Subject: [PATCH 3/3] Update CHANGELOG for v4.9.5 enhancements Added channel logo support, including API response updates and regression tests. Enhanced user experience with channel hiding features and admin controls. --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf2aa7..844c5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,12 @@ This project follows [Semantic Versioning](https://semver.org/). ## v4.9.5 - 2026-04-29 ### Added -- Added ability to hide channels per user (#190). +- Added channel logo to be displayed in "Current Program" information area. +- Added channel logo support to the `/api/current_program` response. + - The endpoint now returns a top-level `logo` field for the requested channel. + - If a channel has no logo, the endpoint returns an empty string instead of omitting the field. + - Added regression coverage in `tests/test_current_program_logo.py` to ensure the logo field is present without breaking existing program metadata. +- Added ability to hide channels per user. - Users can right-click any channel name in the guide grid to access a context menu with **Hide Channel** / **Unhide Channel** options; hidden channels are persisted server-side per user account. - Added **Show/Hide Hidden Channels** toggle to the **Guide Preferences** submenu in the Settings menu (desktop and mobile) so users can reveal or re-hide all their hidden channels with one click. - Admins can now view a summary of hidden channels per user and **set hidden channels directly** from the **Manage Users** preferences panel using a multi-select channel picker — selecting channels marks them as hidden, deselecting all clears the list.
State Population Frequency
+ +
', + ' ', + '' + city.name + '' + city.state + '' + city.population.toLocaleString() + '', + ' ', + '', + ' ', + '