diff --git a/CHANGELOG.md b/CHANGELOG.md
index c570d08..039f910 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,22 +6,30 @@ This project follows [Semantic Versioning](https://semver.org/).
---
-## v4.2.1 - 2025-11-10
+## v4.3.0 - 2025-11-14
### Added
-- Added horizontal scroll/refresh as time moves forward
-- Added API dynamic guide timing refresh
+- New mobile UI assets including mobile.css, mobile-header.css, mobile-popup.css, and mobile-submenu.css to improve layout and usability on mobile devices.
+- New mobile-related JavaScript files including mobile-nav.js, mobile-scroll-fix.js, and mobile-player-adapt.js.
+- Added tuner management enhancements through the new tuner-settings.js script.
+- New templates added: _header.html, about.html, change_tuner.html, manage_users.html, and logs.html to support expanded UI functionality.
+
+### Changed
+- Updated app.py to support the expanded template set, updated tuner handling, and new mobile behavior.
+- Updated bump_version.py and bump_version.sh for compatibility with the current file layout and new release processes.
+- Updated installation scripts: retroiptv_linux.sh, retroiptv_rpi.sh, retroiptv_windows.bat, and retroiptv_windows.ps1 to align with the new release structure.
---
-## [Unreleased]
+## v4.2.1 - 2025-11-10
-- Planned: add `.m3u8` tuner support.
-- Planned: move logs to SQLite DB.
-- Planned: log filtering and pagination.
+### Added
+- Added horizontal scroll/refresh as time moves forward
+- Added API dynamic guide timing refresh
---
+
## v4.2.0 - 2025-11-06
This version introduces mobile responsiveness, a new theme, refinements to auto-scroll, and backend API structures.
@@ -40,6 +48,7 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
- Resolved layout inconsistencies across themes and display sizes.
- General code cleanup and alignment for CI/CD consistency.
+---
## v4.1.0 - 2025-10-25
### New Features
@@ -71,6 +80,8 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
- Improved guide performance and browser compatibility with the new auto-scroll implementation.
- Minor visual and layout corrections across settings and guide pages.
+---
+
## v4.0.0 — 2025-10-19
**Status:** Public Release (Feature Complete)
@@ -100,6 +111,8 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
- PlutoTV / custom tuner aggregation features
- Enhanced guide refresh logic for long-running sessions
+---
+
## v3.3.0 - 2025-10-15
### Added
@@ -118,7 +131,6 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
---
-
## [v3.2.0] - 2025-10-11
### Added
- **Containerization & TrueNAS Deployment Support**
@@ -137,6 +149,7 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
### Fixed
- Corrected GHCR tag formatting for TrueNAS (eliminated `:latest:latest` errors).
- Fixed workflow permissions with explicit `packages: write` and PAT authentication.
+
---
## v3.1.0 - 2025-10-09
@@ -188,10 +201,8 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
- Prevented guide from displaying outdated EPG after tuner change.
- Corrected case where missing XML data produced empty grid.
-
---
-
## [3.0.0] - 2025-10-03
### Added
- **Windows Support**:
@@ -220,6 +231,8 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
- Consistent logging of user agreement and installer actions.
- Ensured firewall rule removal on Windows during uninstall.
+---
+
## [2.3.2] - 2025-09-26
### Added
- Introduced unified **Themes submenu** (Light, Dark, AOL/CompuServe, TV Guide Magazine) across all admin and user pages.
@@ -238,10 +251,8 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
- Theme persistence issues: selected theme now applies instantly and consistently on every page.
- AOL and Magazine themes now update **immediately** on About and other pages (previously only visible after navigating away).
-
---
-
## v2.3.1 - 2025-09-26
### Added
@@ -295,9 +306,9 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
- Fixed alignment of tuner forms with consistent dropdowns and validation.
- Ensured flash messages and logging work consistently across all tuner operations.
+---
-
-## [v2.0.0] – 2025-09-24
+## [v2.0.0] 2025-09-24
### Added
- Tuner URL validation: new validate_tuner_url() function checks XML/M3U inputs before saving.
- Detects invalid/empty URLs, unresolvable hostnames, and distinguishes between public vs. private IPs.
@@ -335,7 +346,7 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
---
-## [v1.x.x] – 2025-09-01 → 2025-09-23
+## [v1.x.x] 2025-09-01 - 2025-09-23
### Added
- Initial IPTV Flask application with:
- User authentication (login/logout, password change).
diff --git a/INSTALL.md b/INSTALL.md
index baad1a2..e3ded14 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -1,7 +1,7 @@
# Installation Guide
-**Version:** v4.2.1
-**Last Updated:** 2025-10-25
+**Version:** v4.3.0
+**Last Updated:** 2025-11-14
---
@@ -20,7 +20,6 @@
## 🐳 Quick Docker Run
-The fastest way to launch **RetroIPTVGuide v3.2.0**:
```bash
docker pull ghcr.io/thehack904/retroiptvguide:latest
@@ -110,7 +109,7 @@ sudo retroiptv_rpi.sh update
```
### 🪟 Windows
-**Alignment with Linux/Pi currently on track for v4.0.1 release**
+
```powershell
git fetch --all ; git reset --hard origin/main ; Restart-Service RetroIPTVGuide
```
diff --git a/README.md b/README.md
index 63191cd..d14cfee 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# 📺 RetroIPTVGuide v4.2.1
+# 📺 RetroIPTVGuide vv4.3.0
-
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 4644d2c..7c92c9a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,22 +1,21 @@
# 📌 RetroIPTVGuide — Roadmap
This document tracks **planned upgrades** and ideas for improving the IPTV Flask server.
-These are **not yet implemented**, but provide a development path for future releases.
+These are **not yet implemented**, partially implemented, or completed in previous releases.
---
-# Current Version: **v4.2.1 (2025-11-10)**
-This version adds horizontal scroll/refresh as time moves forward, API dynamic guide timing refresh
+# Current Version: **v4.3.0 (2025-11-14)**
## 🔮 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).
-- [ ] Support for **.m3u8 single-channel playlists** as tuner sources (planned v3.2.0).
+- [ ] Support for **.m3u8 single-channel playlists** as tuner sources.
- [x] Validate tuner URLs (ping/check format before saving) (v2.0.0).
-- [ ] Optional auto-refresh of tuner lineup on a schedule.
-- [ ] Add per-user tuner assignment and default tuner preferences. *(v4.3.x planned)*
-- [ ] Introduce combined tuner builder (custom tuner aggregation). *(v5.x.x planned)*
+- [x] Optional auto-refresh of tuner lineup on a schedule. *(v4.3.0)*
+- [ ] Add per-user tuner assignment and default tuner preferences.
+- [ ] Introduce combined tuner builder (custom tuner aggregation).
---
@@ -24,107 +23,98 @@ This version adds horizontal scroll/refresh as time moves forward, API dynamic g
- [ ] Move logs from flat file (`activity.log`) into **SQLite DB** for better querying.
- [ ] Add filtering and pagination in logs view.
- [ ] Add system health checks (tuner reachability, XMLTV freshness).
-- [x] **Admin log management**: clear logs + file size indicator (v2.3.1).
+- [x] Admin log management: clear logs + size indicator (v2.3.1).
- [x] Post-install HTTP service verification in Pi installer (v3.1.0).
-- [x] Unified “Refresh Guide” scheduler. *(v4.2.0)*
+- [x] Unified “Refresh Guide” scheduler. (v4.2.0)
---
### 3. Guide & Playback
-- [x] **Auto-Scroll feature** added for the Live Guide (v4.1.0).
- - Uses `requestAnimationFrame` for smooth scroll with fallback watchdog.
- - Deterministic looping and localStorage preference tracking.
+- [x] Auto-Scroll feature added for the Live Guide (v4.1.0).
- [x] Improved auto-scroll performance and modular handling (v4.2.0).
- [x] Added responsive layout for mobile devices (v4.2.0).
- [ ] Add search/filter box to guide.
- [ ] Add ability to set favorites.
- [x] Fallback message for missing EPG info (v3.0.1).
- [ ] Add reminders/notifications for upcoming programs.
-- [ ] Add EPG caching for faster guide reloads. *(v5.x.x planned)*
+- [ ] Add EPG caching for faster guide reloads.
---
### 4. User Management
-- [x] Add **manage_users.html** for integrated user control panel. *(v4.0.0)*
-- [ ] Role-based access control (admin/user/read-only).
+- [x] Add manage_users.html (v4.0.0)
+- [-] Role-based access control (basic admin-only gates exist, no RBAC roles).
- [ ] Add email or 2FA support for login.
- [ ] Show last login time in admin panel.
-- [ ] User role/channel restrictions. *(v5.x.x planned)*
+- [ ] User role/channel restrictions.
---
### 5. UI/UX Improvements
- [x] Unified theming across all templates (v2.3.2).
-- [x] Android / Fire / Google TV optimized. *(v4.0.0)*
-- [x] Consolidated UI templates (`guide.html`, `login.html`, etc.). *(v4.0.0)*
-- [x] **Refactored UI templates into shared `base.html` and `_header.html` (v4.1.0)**.
-- [x] **Modular CSS and JS added (v4.1.0)** – per-page styling and script loading.
+- [x] Android / Fire / Google TV optimized (v4.0.0).
+- [x] Consolidated UI templates (`guide.html`, `login.html`, etc.). (v4.0.0)
+- [x] Refactored UI templates into shared `base.html` and `_header.html` (v4.1.0).
+- [x] Modular CSS and JS added (v4.1.0).
- [x] Introduced new JS modules: `auto-scroll.js`, `tuner-settings.js`.
-- [x] **Mobile responsive layout and navigation (v4.2.0)**.
+- [x] Mobile responsive layout and navigation (v4.2.0).
- [ ] Add dark/light theme auto-detect.
-- [ ] Frozen header timeline to prevent scrolling with channel listing.
+- [x] Frozen header timeline to prevent scrolling with channel listing (v4.3.0).
- [x] About page under Settings menu (v2.3.1).
+- [x] Added new mobile-specific CSS and JS (v4.3.0).
+- [x] Added new templates: change_tuner.html, manage_users.html, logs.html. (v4.3.0)
---
### 6. Cross-Platform
-- [x] Unified Linux, Windows, and Raspberry Pi installers. *(v4.0.0)*
-- [x] Windows update/uninstall parity implemented. *(v4.1.0)*
+- [x] Unified Linux, Windows, and Raspberry Pi installers. (v4.0.0)
+- [x] Windows update/uninstall parity implemented. (v4.1.0)
- [ ] Create MacOS install/executable.
- [x] Validate/test installers on all Windows environments.
-- [ ] Explore TrueNAS SCALE App Catalog certification. *(v5.x.x planned)*
+- [ ] Explore TrueNAS SCALE App Catalog certification.
---
### 7. New Features
-- [ ] Add auto-play stream on login (ErsatzTV integration).
-- [ ] Default auto-play source selection.
-- [ ] Begin integration path for **PlutoTV / external IPTV services**. *(v5.x.x)*
+- [-] Add auto-play stream on login. *(partial JS scaffolding in tuner-settings.js)*
+- [-] Default auto-play source selection. *(partial JS only, not wired to UI)*
+- [ ] Begin integration path for PlutoTV / external IPTV services.
---
### 8. Planned Enhancements
-- [ ] Add safety checks in `add_tuner()` (duplicate prevention + URL validation).
+- [ ] Add safety checks in add_tuner() (duplicate prevention + URL validation).
- [x] GPU verification after `raspi-config` (v3.1.0).
- [x] Suppress `rfkill` Wi-Fi message during GPU config (v3.1.0).
- [x] Adaptive HTTP check loop (v3.1.0).
-- [x] **Project structure and documentation reorganized** *(v4.0.0–4.2.0)*
+- [x] Project structure and documentation reorganized (v4.0.0–4.2.0).
---
## ⚙️ Technical Improvements
- [x] Add uninstall.sh (v2.3.0).
- [ ] Validate/test uninstall script fully on Windows.
-- [ ] Add HTTPS + optional token-based authentication. *(v4.5.x)*
-- [x] Refactor tuner handling for unified DB. *(v4.0.0)*
-- [x] **Updated bump_version and installer scripts to auto-track new version (v4.1.0)**
+- [ ] Add HTTPS + optional token-based authentication.
+- [x] Refactor tuner handling for unified DB. (v4.0.0)
+- [x] Updated bump_version and installer scripts for new structure. (v4.3.0)
- [x] Containerize app (Dockerfile + Compose).
- [ ] Add migrations for DB schema changes.
-- [ ] CI/CD automation for official builds. *(v5.x.x)*
-- [ ] Add test suite for tuner parsing, authentication, and logging.
+- [ ] CI/CD automation for official builds.
+- [-] Add test suite for tuner parsing, authentication, and logging. *(placeholder file only)*
---
## 🍓 Installer Enhancements
-- [x] Unified installer architecture. *(v4.0.0)*
-- [x] Windows update/uninstall parity complete. *(v4.1.0)*
+- [x] Unified installer architecture. (v4.0.0)
+- [x] Windows update/uninstall parity complete. (v4.1.0)
- [ ] Add kiosk/headless mode selector.
- [ ] Add `--mode kiosk` flag for non-interactive installs.
- [ ] Validate update/uninstall paths on all OSes.
---
+
## User Submitted Enhancements
-- [ ] Casting Support - (Chromecast Support)
-- [ ] Resize Pop Out Video - (pop out the video player resize)
-- [ ] Resize video on page (able to resize or change the layout of the video on the program guide page)
+- [ ] Casting Support (Chromecast)
+- [ ] Resize Pop Out Video
+- [ ] Resize video on page
- [ ] Auto load Channel from Guide / Hidden Channel / Sizzle Reels
-
-
-## ✅ Completed (v4.2.0)
-- [x] Added mobile responsive layout.
-- [x] Improved auto-scroll handling and modular JS design.
-- [x] Updated documentation (CHANGELOG, README, INSTALL, ROADMAP).
-- [x] Added RetroIPTV Theme (default).
-- [x] Release tagged as **v4.2.0**
-
-
diff --git a/app.py b/app.py
index ae27266..1123e9e 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,6 @@
# app.py — merged version (features from both sources)
-APP_VERSION = "v4.2.1"
-APP_RELEASE_DATE = "2025-11-10"
+APP_VERSION = "v4.3.0"
+APP_RELEASE_DATE = "2025-11-14"
from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
@@ -12,6 +12,7 @@
import os
import datetime
import requests
+import time
import xml.etree.ElementTree as ET
from urllib.parse import urlparse, urljoin
import socket
@@ -19,6 +20,7 @@
import logging
import subprocess
from datetime import datetime, timezone, timedelta
+import threading
# New import: vlc control helper (optional - keep existing integration compatibility)
try:
@@ -212,6 +214,21 @@ def rename_tuner(old_name, new_name):
c.execute("UPDATE tuners SET name=? WHERE name=?", (new_name, old_name))
conn.commit()
+
+# ------------------- Template context helpers -------------------
+@app.context_processor
+def inject_tuner_context():
+ """Inject tuner info into all templates (for header fly-outs)."""
+ try:
+ tuners = get_tuners()
+ tuner_names = list(tuners.keys())
+ except Exception:
+ tuner_names = []
+ return {
+ "current_tuner": get_current_tuner(),
+ "tuner_names": tuner_names
+ }
+
# ------------------- Global cache -------------------
cached_channels = []
cached_epg = {}
@@ -588,6 +605,48 @@ def about():
return render_template("about.html", info=info)
+
+@app.route('/set_tuner/')
+@login_required
+def set_tuner(name):
+ """Quick-switch the active tuner from the header fly-out.
+ Admin-only, mirrors the behaviour of the 'switch_tuner' action in /change_tuner.
+ """
+ if current_user.username != 'admin':
+ log_event(current_user.username, f"Unauthorized quick tuner switch attempt to {name}")
+ flash("Unauthorized access.", "warning")
+ return redirect(url_for('guide'))
+
+ tuners = get_tuners()
+ if name not in tuners:
+ flash(f"Tuner '{name}' does not exist.", "warning")
+ return redirect(request.referrer or url_for('change_tuner'))
+
+ # Update current tuner
+ set_current_tuner(name)
+
+ # Refresh cached guide data
+ global cached_channels, cached_epg
+ m3u_url = tuners[name].get("m3u")
+ xml_url = tuners[name].get("xml")
+
+ cached_channels = parse_m3u(m3u_url) if m3u_url else []
+ cached_epg = parse_epg(xml_url) if xml_url else {}
+ cached_epg = apply_epg_fallback(cached_channels, cached_epg)
+
+ log_event(current_user.username, f"Quick switched active tuner to {name}")
+ flash(f"Active tuner switched to {name}", "success")
+
+ # Try to redirect back to where the user came from, falling back to guide
+ dest = request.referrer or url_for('guide')
+ try:
+ if not is_safe_url(dest):
+ dest = url_for('guide')
+ except Exception:
+ dest = url_for('guide')
+ return redirect(dest)
+
+
@app.route('/change_tuner', methods=['GET', 'POST'])
@login_required
def change_tuner():
@@ -664,20 +723,83 @@ def change_tuner():
log_event(current_user.username, f"Added tuner {name}")
flash(f"Tuner {name} added successfully.")
+ elif action == "update_auto_refresh":
+ # Expect form fields: auto_refresh_enabled ('0' or '1') and auto_refresh_interval_hours (2/4/6/12/24)
+ enabled = request.form.get("auto_refresh_enabled", "0")
+ interval = request.form.get("auto_refresh_interval_hours", "")
+ if enabled not in ("0", "1"):
+ enabled = "0"
+ if interval:
+ try:
+ intval = int(interval)
+ except:
+ intval = None
+ else:
+ intval = None
+
+ # only allow preset intervals
+ AUTO_REFRESH_PRESETS = [2, 4, 6, 12, 24]
+ if intval and intval not in AUTO_REFRESH_PRESETS:
+ flash(f"Invalid interval. Allowed: {AUTO_REFRESH_PRESETS}", "warning")
+ else:
+ # persist using existing settings table
+ try:
+ with sqlite3.connect(TUNER_DB, timeout=10) as conn:
+ c = conn.cursor()
+ c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", ("auto_refresh_enabled", "1" if enabled == "1" else "0"))
+ if intval:
+ c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", ("auto_refresh_interval_hours", str(intval)))
+ else:
+ c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", ("auto_refresh_interval_hours", ""))
+ conn.commit()
+ except Exception:
+ logging.exception("Failed to persist auto-refresh settings")
+ flash("Failed to save auto-refresh settings.", "warning")
+ else:
+ log_event(current_user.username, f"Updated auto-refresh: enabled={enabled} interval={interval}")
+ flash("Auto-refresh settings updated.", "success")
+
tuners = get_tuners()
current_tuner = get_current_tuner()
+
+ # read auto-refresh status for template display
+ def _get_setting_inline(key, default=None):
+ try:
+ with sqlite3.connect(TUNER_DB, timeout=10) as conn:
+ c = conn.cursor()
+ c.execute("SELECT value FROM settings WHERE key=?", (key,))
+ row = c.fetchone()
+ return row[0] if row else default
+ except Exception:
+ return default
+
+ auto_refresh_enabled = _get_setting_inline("auto_refresh_enabled", "0")
+ auto_refresh_interval_hours = _get_setting_inline("auto_refresh_interval_hours", "")
+ last_auto_refresh = None
+ if current_tuner:
+ last_auto_refresh = _get_setting_inline(f"last_auto_refresh:{current_tuner}", None)
+
return render_template(
"change_tuner.html",
tuners=tuners.keys(),
current_tuner=current_tuner,
current_urls=tuners[current_tuner],
- TUNERS=tuners
+ TUNERS=tuners,
+ auto_refresh_enabled=auto_refresh_enabled,
+ auto_refresh_interval_hours=auto_refresh_interval_hours,
+ last_auto_refresh=last_auto_refresh
)
@app.route('/guide')
@login_required
def guide():
log_event(current_user.username, "Loaded guide page")
+ # Check and run auto-refresh if due (minimal preset-based approach)
+ try:
+ refresh_if_due()
+ except Exception:
+ logging.exception("refresh_if_due from guide() failed")
+
now = datetime.now(timezone.utc)
grid_start = now.replace(minute=(0 if now.minute < 30 else 30), second=0, microsecond=0)
slots = int((HOURS_SPAN * 60) / SLOT_MINUTES)
@@ -832,6 +954,29 @@ def api_start_stream():
log_event(current_user.username, f"Requested start_stream {url} (id={instance}, hide_cursor={hide_cursor})")
return jsonify({"ok": True, "message": "started", "id": instance})
+@app.route('/api/auto_refresh/status', methods=['GET'])
+@login_required
+def api_auto_refresh_status():
+ """
+ Return auto-refresh status for the current tuner:
+ { tuner, enabled (bool), interval_hours (int|null), last_run (string|null) }
+ """
+ try:
+ tuner = get_current_tuner()
+ enabled = get_setting("auto_refresh_enabled", "0")
+ interval = get_setting("auto_refresh_interval_hours", "")
+ last = get_setting(f"last_auto_refresh:{tuner}", None) if tuner else None
+
+ return jsonify({
+ "tuner": tuner,
+ "enabled": bool(str(enabled) in ("1", "true", "True")),
+ "interval_hours": int(interval) if interval not in (None, "") else None,
+ "last_run": last
+ })
+ except Exception as e:
+ logging.exception("api_auto_refresh_status failed: %s", e)
+ return jsonify({"error": str(e)}), 500
+
@app.route('/api/stop_stream', methods=['POST'])
@login_required
def api_stop_stream():
@@ -1287,6 +1432,12 @@ def api_guide_snapshot():
Supports ?hours=N (0.5–8) to control window size.
"""
try:
+ # Run auto-refresh if due so CRT clients get fresh data when they poll
+ try:
+ refresh_if_due()
+ except Exception:
+ logging.exception("refresh_if_due from api_guide_snapshot failed")
+
now = datetime.now(timezone.utc)
# Read optional hours parameter
@@ -1458,6 +1609,228 @@ def api_qr_show():
return jsonify({"status": "visible"})
+def check_url_reachable(url, timeout=5):
+ try:
+ r = requests.head(url, timeout=timeout)
+ return r.status_code < 400
+ except:
+ return False
+
+def check_xmltv_freshness(xml_url, max_age_hours=6):
+ try:
+ r = requests.get(xml_url, timeout=10)
+ r.raise_for_status()
+
+ root = ET.fromstring(r.content)
+
+ now = datetime.now(timezone.utc)
+ past_starts = []
+
+ for prog in root.findall(".//programme"):
+ start = prog.get("start")
+ if not start:
+ continue
+
+ # example format: "20251115051031 +0000"
+ parts = start.split()
+ ts = parts[0] # 20251115051031
+ if len(ts) >= 14:
+ try:
+ dt = datetime.strptime(ts[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
+ if dt <= now:
+ past_starts.append(dt)
+ except:
+ pass
+
+ if not past_starts:
+ # If no past events, assume fresh
+ return (True, 0.0)
+
+ latest_past = max(past_starts)
+ age_hours = (now - latest_past).total_seconds() / 3600.0
+
+ return (age_hours <= max_age_hours, age_hours)
+
+ except Exception as e:
+ print("XMLTV freshness error:", e)
+ return (False, None)
+
+# ------------------- Minimal Auto-refresh (preset-based, no scheduler) -------------------
+AUTO_REFRESH_PRESETS = [2, 4, 6, 12, 24] # allowed hours
+_auto_refresh_locks = {} # in-memory locks (OK for single-process)
+
+def get_setting(key, default=None):
+ """Read key from tuners.settings table (existing settings table)."""
+ try:
+ with sqlite3.connect(TUNER_DB, timeout=10) as conn:
+ c = conn.cursor()
+ c.execute("SELECT value FROM settings WHERE key=?", (key,))
+ row = c.fetchone()
+ return row[0] if row else default
+ except Exception:
+ return default
+
+def set_setting(key, value):
+ try:
+ with sqlite3.connect(TUNER_DB, timeout=10) as conn:
+ c = conn.cursor()
+ c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, str(value)))
+ conn.commit()
+ except Exception:
+ logging.exception("set_setting failed for %s", key)
+
+def _acquire_lock(name):
+ lock = _auto_refresh_locks.setdefault(name, threading.Lock())
+ return lock.acquire(blocking=False)
+
+def _release_lock(name):
+ lock = _auto_refresh_locks.get(name)
+ try:
+ if lock and lock.locked():
+ lock.release()
+ except RuntimeError:
+ # already released or not owned
+ pass
+
+def refresh_current_tuner(tuner_name=None):
+ """Perform the same refresh logic you already run on login/tuner switch.
+ Returns True on success, False on failure/skip.
+ """
+ try:
+ if not tuner_name:
+ tuner_name = get_current_tuner()
+ if not tuner_name:
+ logging.info("refresh_current_tuner: no current tuner set")
+ return False
+
+ if not _acquire_lock(tuner_name):
+ logging.info("refresh_current_tuner: lock busy for %s", tuner_name)
+ return False
+
+ logging.info("refresh_current_tuner: refreshing tuner %s", tuner_name)
+ tuners = get_tuners()
+ info = tuners.get(tuner_name)
+ if not info:
+ logging.warning("refresh_current_tuner: tuner %s not found", tuner_name)
+ return False
+
+ m3u_url = info.get('m3u')
+ xml_url = info.get('xml')
+
+ new_channels = parse_m3u(m3u_url) if m3u_url else []
+ new_epg = parse_epg(xml_url) if xml_url else {}
+ new_epg = apply_epg_fallback(new_channels, new_epg)
+
+ # atomic swap
+ global cached_channels, cached_epg
+ cached_channels = new_channels
+ cached_epg = new_epg
+
+ now_iso = datetime.now(timezone.utc).isoformat()
+ set_setting(f"last_auto_refresh:{tuner_name}", f"success|{now_iso}")
+
+ logging.info("refresh_current_tuner: finished %s", tuner_name)
+ return True
+
+ except Exception as e:
+ logging.exception("refresh_current_tuner error for %s: %s", tuner_name, e)
+ now_iso = datetime.now(timezone.utc).isoformat()
+ set_setting(f"last_auto_refresh:{tuner_name}", f"failed|{now_iso}|{str(e)[:200]}")
+ return False
+ finally:
+ try:
+ _release_lock(tuner_name)
+ except Exception:
+ pass
+
+def refresh_if_due(tuner_name=None):
+ """Check settings and last-run timestamp; refresh if interval elapsed."""
+ try:
+ # global enabling (simple): stored as 'auto_refresh_enabled' = "1" or "0"
+ enabled = get_setting("auto_refresh_enabled", "0")
+ if str(enabled) not in ("1", "true", "True"):
+ return False
+
+ interval_value = get_setting("auto_refresh_interval_hours", None)
+ try:
+ interval_hours = int(interval_value) if interval_value is not None and interval_value != "" else None
+ except:
+ interval_hours = None
+
+ # Only allow preset intervals for simplicity/safety
+ if interval_hours not in AUTO_REFRESH_PRESETS:
+ logging.debug("refresh_if_due: interval %s not in presets %s", interval_hours, AUTO_REFRESH_PRESETS)
+ return False
+
+ if not tuner_name:
+ tuner_name = get_current_tuner()
+ if not tuner_name:
+ return False
+
+ last_raw = get_setting(f"last_auto_refresh:{tuner_name}", None)
+ if last_raw:
+ # stored as "success|{ISO}" or "failed|{ISO}|msg"
+ try:
+ last_iso = last_raw.split("|")[1]
+ last_dt = datetime.fromisoformat(last_iso)
+ except Exception:
+ last_dt = None
+ else:
+ last_dt = None
+
+ now = datetime.now(timezone.utc)
+ if last_dt is None:
+ due = True
+ else:
+ elapsed_hours = (now - last_dt).total_seconds() / 3600.0
+ due = (elapsed_hours >= interval_hours)
+
+ if due:
+ logging.info("refresh_if_due: due for tuner %s (interval=%s)", tuner_name, interval_hours)
+ return refresh_current_tuner(tuner_name)
+ return False
+
+ except Exception:
+ logging.exception("refresh_if_due unexpected error")
+ return False
+
+# ------------------- QR Visibility Control (with auto-restore) -------------------
+
+# (rest of the file unchanged)
+# ------------------- Health endpoint and the remainder of the script -------------------
+
+@app.route('/api/health')
+@login_required
+def api_health():
+ tuners = get_tuners()
+ curr = get_current_tuner()
+ t = tuners.get(curr, {})
+
+ m3u_url = t.get("m3u", "")
+ xml_url = t.get("xml", "")
+
+ # Reachability checks
+ m3u_ok = check_url_reachable(m3u_url) if m3u_url else False
+ xml_ok = check_url_reachable(xml_url) if xml_url else False
+
+ # Freshness check (keep your current function)
+ # If you don't compute age yet, return None so "Unknown" shows
+ xml_fresh, xml_age_hours = check_xmltv_freshness(xml_url)
+
+
+ return jsonify({
+ "tuner": curr,
+ "m3u_reachable": m3u_ok,
+ "xml_reachable": xml_ok,
+ "xmltv_fresh": xml_fresh,
+
+ # ADD THESE:
+ "tuner_m3u": m3u_url,
+ "tuner_xml": xml_url,
+ "xmltv_age_hours": xml_age_hours
+ })
+
+
if __name__ == '__main__':
init_db()
add_user('admin', 'strongpassword123')
@@ -1474,4 +1847,5 @@ def api_qr_show():
cached_channels = parse_m3u(tuners[current_tuner]["m3u"])
cached_epg = parse_epg(tuners[current_tuner]["xml"])
+ # No background scheduler — auto-refresh is triggered lazily on page/API hits.
app.run(host='0.0.0.0', port=5000, debug=False)
diff --git a/bump_version.py b/bump_version.py
index 83b3f40..fa92daf 100644
--- a/bump_version.py
+++ b/bump_version.py
@@ -3,150 +3,316 @@
bump_version.py - automate version bumps for RetroIPTVGuide
Usage:
- python bump_version.py 2.4.0
- python bump_version.py 2.4.0 --commit
+ python bump_version.py 4.3.0
+ python bump_version.py v4.3.0 --commit
"""
import sys
import re
from datetime import datetime
-import subprocess
from pathlib import Path
+import subprocess
-# --- FILE TARGETS ---
+# Target files
APP_FILE = Path("app.py")
CHANGELOG_FILE = Path("CHANGELOG.md")
-INSTALL_WIN = Path("install_windows.ps1")
-UNINSTALL_WIN = Path("uninstall_windows.ps1")
-UNINSTALL_SH = Path("uninstall.sh")
-INSTALL_SH = Path("install.sh")
-RPI_INSTALL_SH = Path("retroiptv_rpi.sh")
-
-# -------------------------------------------------------
-def update_app_py(new_version: str):
- """Update APP_VERSION in app.py, add if missing"""
- content = APP_FILE.read_text().splitlines()
- updated = []
- found = False
- for line in content:
- if line.strip().startswith("APP_VERSION"):
- updated.append(f'APP_VERSION = "v{new_version}"')
- found = True
- else:
- updated.append(line)
- if not found:
- updated.insert(0, f'APP_VERSION = "v{new_version}"')
- print("ℹ️ APP_VERSION not found in app.py, added at top")
- APP_FILE.write_text("\n".join(updated) + "\n")
- print(f"✅ Updated app.py to v{new_version}")
-
-def update_changelog(new_version: str):
- """Insert new version section after the Unreleased block"""
- changelog = CHANGELOG_FILE.read_text().splitlines()
- today = datetime.today().strftime("%Y-%m-%d")
+INSTALL_MD = Path("INSTALL.md")
+README_MD = Path("README.md")
+ROADMAP_MD = Path("ROADMAP.md")
+LINUX_SH = Path("retroiptv_linux.sh")
+RPI_SH = Path("retroiptv_rpi.sh")
+WIN_BAT = Path("retroiptv_windows.bat")
+WIN_PS1 = Path("retroiptv_windows.ps1")
+
+
+def normalize_version(raw: str) -> tuple[str, str]:
+ """
+ Returns (base_version, v_version)
+ raw may be "4.3.0" or "v4.3.0"
+ """
+ base = raw.lstrip("vV").strip()
+ if not re.fullmatch(r"\d+\.\d+\.\d+", base):
+ raise SystemExit(f"Invalid version '{raw}'. Expected format: 4.3.0 or v4.3.0")
+ return base, f"v{base}"
+
+
+def update_app_py(base_version: str, v_version: str, date_str: str) -> None:
+ if not APP_FILE.exists():
+ print(f"[-] {APP_FILE} not found, skipping")
+ return
+
+ text = APP_FILE.read_text(encoding="utf-8")
+ # APP_VERSION line
+ if "APP_VERSION" in text:
+ text, n = re.subn(
+ r'^APP_VERSION\s*=.*$',
+ f'APP_VERSION = "{v_version}"',
+ text,
+ flags=re.MULTILINE,
+ )
+ else:
+ text = f'APP_VERSION = "{v_version}"\n' + text
+ n = 1
+ print(f"[+] app.py: set APP_VERSION = \"{v_version}\" (updated {n} line{'s' if n != 1 else ''})")
+
+ # APP_RELEASE_DATE line
+ if "APP_RELEASE_DATE" in text:
+ text, n2 = re.subn(
+ r'^APP_RELEASE_DATE\s*=.*$',
+ f'APP_RELEASE_DATE = "{date_str}"',
+ text,
+ flags=re.MULTILINE,
+ )
+ else:
+ # insert just after APP_VERSION
+ text = re.sub(
+ r'^(APP_VERSION\s*=.*\n)',
+ rf'\1APP_RELEASE_DATE = "{date_str}"\n',
+ text,
+ count=1,
+ flags=re.MULTILINE,
+ )
+ n2 = 1
+ print(f"[+] app.py: set APP_RELEASE_DATE = \"{date_str}\" (updated {n2} line{'s' if n2 != 1 else ''})")
+
+ APP_FILE.write_text(text, encoding="utf-8")
+
+
+def update_changelog(base_version: str, v_version: str, date_str: str) -> None:
+ if not CHANGELOG_FILE.exists():
+ print(f"[-] {CHANGELOG_FILE} not found, skipping")
+ return
+
+ lines = CHANGELOG_FILE.read_text(encoding="utf-8").splitlines()
+ try:
+ idx = next(i for i, line in enumerate(lines) if line.strip() == "---")
+ except StopIteration:
+ raise SystemExit("Could not find top '---' separator in CHANGELOG.md")
new_block = [
- f"## v{new_version} - {today}",
+ "",
+ f"## {v_version} - {date_str}",
+ "",
"### Added",
"- (empty)",
"",
+ "### Changed",
+ "- (empty)",
+ "",
"### Fixed",
"- (empty)",
"",
"---",
- "",
]
- updated = []
- inserted = False
- for i, line in enumerate(changelog):
- updated.append(line)
- if line.strip() == "---" and not inserted:
- if any("## [Unreleased]" in l for l in changelog[:i]):
- updated.append("") # spacing
- updated.extend(new_block)
- inserted = True
-
- if not inserted:
- print("⚠️ Could not find end of [Unreleased] section in CHANGELOG.md")
- sys.exit(1)
-
- CHANGELOG_FILE.write_text("\n".join(updated) + "\n")
- print(f"✅ Inserted v{new_version} section into CHANGELOG.md")
-
-def update_script_version(file: Path, new_version: str, is_bash: bool):
- """Update or insert version string in shell or PowerShell scripts"""
- if not file.exists():
- print(f"⚠️ {file} not found, skipping")
+ # Insert immediately after the first '---'
+ updated = lines[: idx + 1] + new_block + lines[idx + 1 :]
+ CHANGELOG_FILE.write_text("\n".join(updated) + "\n", encoding="utf-8")
+ print(f"[+] CHANGELOG.md: inserted section for {v_version} - {date_str}")
+
+
+def update_install_md(v_version: str) -> None:
+ if not INSTALL_MD.exists():
+ print(f"[-] {INSTALL_MD} not found, skipping")
+ return
+
+ text = INSTALL_MD.read_text(encoding="utf-8")
+ new_text, n = re.subn(
+ r'^\*\*Version:\*\* v\d+\.\d+\.\d+',
+ f"**Version:** {v_version}",
+ text,
+ flags=re.MULTILINE,
+ )
+ if n == 0:
+ print("[!] INSTALL.md: no '**Version:** vX.Y.Z' line found (no change)")
+ else:
+ INSTALL_MD.write_text(new_text, encoding="utf-8")
+ print(f"[+] INSTALL.md: updated version line to {v_version}")
+
+
+def update_readme_md(v_version: str) -> None:
+ if not README_MD.exists():
+ print(f"[-] {README_MD} not found, skipping")
+ return
+
+ text = README_MD.read_text(encoding="utf-8")
+
+ # Header line
+ text, n1 = re.subn(
+ r'^(# 📺 RetroIPTVGuide v)\d+\.\d+\.\d+',
+ rf"\g<1>{v_version}",
+ text,
+ flags=re.MULTILINE,
+ )
+
+ # Version badge
+ text, n2 = re.subn(
+ r'(version-v)\d+\.\d+\.\d+(-blue)',
+ rf"\1{v_version}\2",
+ text,
+ )
+
+ README_MD.write_text(text, encoding="utf-8")
+ print(f"[+] README.md: updated header ({n1} line) and badge ({n2} match{'es' if n2 != 1 else ''})")
+
+
+def update_roadmap_md(v_version: str, date_str: str) -> None:
+ if not ROADMAP_MD.exists():
+ print(f"[-] {ROADMAP_MD} not found, skipping")
+ return
+
+ text = ROADMAP_MD.read_text(encoding="utf-8")
+
+ # Current Version line
+ text, n1 = re.subn(
+ r'^# Current Version: \*\*v\d+\.\d+\.\d+ \(\d{4}-\d{2}-\d{2}\)\*\*',
+ f"# Current Version: **{v_version} ({date_str})**",
+ text,
+ flags=re.MULTILINE,
+ )
+
+ # "Release tagged as" line
+ text, n2 = re.subn(
+ r'Release tagged as \*\*v\d+\.\d+\.\d+\*\*',
+ f"Release tagged as **{v_version}**",
+ text,
+ count=1,
+ )
+
+ ROADMAP_MD.write_text(text, encoding="utf-8")
+ print(f"[+] ROADMAP.md: updated current version line ({n1}) and release tag ({n2})")
+
+
+def update_shell_script(path: Path, base_version: str) -> None:
+ if not path.exists():
+ print(f"[-] {path} not found, skipping")
+ return
+
+ text = path.read_text(encoding="utf-8")
+ new_text, n = re.subn(
+ r'^VERSION="\d+\.\d+\.\d+"',
+ f'VERSION="{base_version}"',
+ text,
+ flags=re.MULTILINE,
+ )
+ if n == 0:
+ print(f"[!] {path}: no VERSION=\"X.Y.Z\" line found (no change)")
+ else:
+ path.write_text(new_text, encoding="utf-8")
+ print(f"[+] {path}: set VERSION=\"{base_version}\"")
+
+
+def update_win_ps1(base_version: str) -> None:
+ if not WIN_PS1.exists():
+ print(f"[-] {WIN_PS1} not found, skipping")
+ return
+
+ # Windows script may not be UTF-8; use latin-1 to be safe
+ text = WIN_PS1.read_text(encoding="latin-1")
+
+ # Header Version: X.Y.Z
+ text, n1 = re.subn(
+ r'^(Version:\s*)\d+\.\d+\.\d+',
+ rf"\g<1>{base_version}",
+ text,
+ flags=re.MULTILINE,
+ )
+
+ # $VERSION = "X.Y.Z"
+ text, n2 = re.subn(
+ r'^\$VERSION\s*=\s*"\d+\.\d+\.\d+"',
+ f'$VERSION = "{base_version}"',
+ text,
+ flags=re.MULTILINE,
+ )
+
+ WIN_PS1.write_text(text, encoding="latin-1")
+ print(f"[+] {WIN_PS1}: updated header Version ({n1}) and $VERSION ({n2})")
+
+
+def update_win_bat(base_version: str, v_version: str) -> None:
+ if not WIN_BAT.exists():
+ print(f"[-] {WIN_BAT} not found, skipping")
+ return
+
+ # Same encoding concern as PS1
+ text = WIN_BAT.read_text(encoding="latin-1")
+
+ # REM Version: vX.Y.Z
+ text, n1 = re.subn(
+ r'^(REM Version:\s*)v\d+\.\d+\.\d+',
+ rf"\g<1>{v_version}",
+ text,
+ flags=re.MULTILINE,
+ )
+
+ # set "VERSION=X.Y.Z"
+ text, n2 = re.subn(
+ r'^(set\s+"VERSION=)\d+\.\d+\.\d+"',
+ rf'\g<1>{base_version}"',
+ text,
+ flags=re.MULTILINE,
+ )
+
+ WIN_BAT.write_text(text, encoding="latin-1")
+ print(f"[+] {WIN_BAT}: updated REM Version ({n1}) and set VERSION ({n2})")
+
+
+def git_commit(v_version: str) -> None:
+ files = [
+ APP_FILE,
+ CHANGELOG_FILE,
+ INSTALL_MD,
+ README_MD,
+ ROADMAP_MD,
+ LINUX_SH,
+ RPI_SH,
+ WIN_BAT,
+ WIN_PS1,
+ ]
+ existing = [str(f) for f in files if f.exists()]
+ if not existing:
+ print("[!] No files to add to git")
return
- content = file.read_text().splitlines()
- updated = []
- found = False
-
- if is_bash:
- pattern = re.compile(r'^\s*VERSION\s*=\s*".*"')
- replacement = f'VERSION="{new_version}"'
- else: # PowerShell
- pattern = re.compile(r'^\s*\$?VERSION\s*=\s*".*"')
- replacement = f'$VERSION = "{new_version}"'
-
- for line in content:
- if pattern.match(line):
- updated.append(replacement)
- found = True
- else:
- updated.append(line)
-
- if not found:
- updated.insert(0, replacement)
- print(f"ℹ️ VERSION not found in {file}, added at top")
-
- file.write_text("\n".join(updated) + "\n")
- print(f"✅ Updated {file} to v{new_version}")
-
-def git_commit(new_version: str):
- """Commit changes with git"""
try:
- subprocess.run(
- ["git", "add",
- str(APP_FILE),
- str(CHANGELOG_FILE),
- str(INSTALL_WIN),
- str(UNINSTALL_WIN),
- str(UNINSTALL_SH),
- str(INSTALL_SH),
- str(RPI_INSTALL_SH)],
- check=True
- )
- subprocess.run(
- ["git", "commit", "-m", f"Bump version to v{new_version}"],
- check=True
- )
- print("✅ Changes committed to git")
+ subprocess.run(["git", "add", *existing], check=True)
+ subprocess.run(["git", "commit", "-m", f"Bump version to {v_version}"], check=True)
+ print("[+] Git commit created")
except subprocess.CalledProcessError:
- print("⚠️ Git commit failed (maybe no repo?)")
+ print("[!] Git commit failed (is this a git repo?)")
-def main():
- if len(sys.argv) < 2:
+
+def main(argv: list[str]) -> None:
+ if len(argv) < 2:
print("Usage: python bump_version.py [--commit]")
- sys.exit(1)
+ raise SystemExit(1)
+
+ raw_version = argv[1]
+ do_commit = "--commit" in argv[2:]
- new_version = sys.argv[1].lstrip("v")
- do_commit = "--commit" in sys.argv
+ base_version, v_version = normalize_version(raw_version)
+ date_str = datetime.today().strftime("%Y-%m-%d")
- update_app_py(new_version)
- update_changelog(new_version)
+ print("== RetroIPTVGuide version bump ==")
+ print(f" New version: {base_version} ({v_version})")
+ print(f" Release date: {date_str}")
+ print("")
- # Update all installers/uninstallers
- update_script_version(INSTALL_WIN, new_version, is_bash=False)
- update_script_version(UNINSTALL_WIN, new_version, is_bash=False)
- update_script_version(UNINSTALL_SH, new_version, is_bash=True)
- update_script_version(INSTALL_SH, new_version, is_bash=True)
- update_script_version(RPI_INSTALL_SH, new_version, is_bash=True)
+ update_app_py(base_version, v_version, date_str)
+ update_changelog(base_version, v_version, date_str)
+ update_install_md(v_version)
+ update_readme_md(v_version)
+ update_roadmap_md(v_version, date_str)
+ update_shell_script(LINUX_SH, base_version)
+ update_shell_script(RPI_SH, base_version)
+ update_win_ps1(base_version)
+ update_win_bat(base_version, v_version)
if do_commit:
- git_commit(new_version)
+ git_commit(v_version)
+
if __name__ == "__main__":
- main()
+ main(sys.argv)
+
diff --git a/bump_version.sh b/bump_version.sh
deleted file mode 100644
index 1a3dc93..0000000
--- a/bump_version.sh
+++ /dev/null
@@ -1,123 +0,0 @@
-#!/usr/bin/env bash
-# ────────────────────────────────────────────────────────────────
-# bump_version.sh — Version bump utility for RetroIPTVGuide
-# Author: RetroIPTVGuide Dev Team
-# License: CC BY-NC-SA 4.0
-# Usage:
-# ./bump_version.sh 4.0.0
-# ./bump_version.sh 4.0.0 --commit
-# ────────────────────────────────────────────────────────────────
-
-set -euo pipefail
-# macOS locale workaround for sed "illegal byte sequence"
-export LC_CTYPE=C
-export LANG=C
-
-# --- Color setup ---
-RED=$(tput setaf 1)
-GREEN=$(tput setaf 2)
-YELLOW=$(tput setaf 3)
-RESET=$(tput sgr0)
-
-# --- Argument parsing ---
-NEW_VERSION="${1:-}"
-DO_COMMIT="${2:-}"
-TODAY=$(date +%Y-%m-%d)
-
-if [[ -z "$NEW_VERSION" ]]; then
- echo "${YELLOW}Usage:${RESET} $0 [--commit]"
- exit 1
-fi
-
-# Strip leading v
-NEW_VERSION="${NEW_VERSION#v}"
-
-# --- File targets ---
-FILES=(
- "retroiptv_linux.sh"
- "retroiptv_rpi.sh"
- "retroiptv_windows.ps1"
- "app.py"
-)
-
-CHANGELOG="CHANGELOG.md"
-
-# --- Detect sed flavor ---
-if sed --version >/dev/null 2>&1; then
- # GNU sed
- SED_INPLACE=(-i)
-else
- # BSD/macOS sed
- SED_INPLACE=(-i "")
-fi
-
-echo "🔢 Bumping version to v${NEW_VERSION}"
-echo "───────────────────────────────────────────────"
-
-# --- Update app.py ---
-if [[ -f "app.py" ]]; then
- if grep -q '^APP_VERSION' app.py; then
- sed "${SED_INPLACE[@]}" "s/^APP_VERSION.*/APP_VERSION = \"v${NEW_VERSION}\"/" app.py
- else
- sed "${SED_INPLACE[@]}" "1iAPP_VERSION = \"v${NEW_VERSION}\"" app.py
- fi
- echo "${GREEN}✔ Updated app.py${RESET}"
-fi
-
-# --- Update version in scripts ---
-for FILE in "${FILES[@]}"; do
- [[ ! -f "$FILE" ]] && continue
-
- case "$FILE" in
- *.sh)
- if grep -q '^VERSION=' "$FILE"; then
- sed "${SED_INPLACE[@]}" "s/^VERSION=.*/VERSION=\"${NEW_VERSION}\"/" "$FILE"
- else
- sed "${SED_INPLACE[@]}" "1iVERSION=\"${NEW_VERSION}\"" "$FILE"
- fi
- ;;
- *.ps1|*.bat)
- if grep -q 'VERSION' "$FILE"; then
- sed "${SED_INPLACE[@]}" "s/^[\$]*VERSION.*/\$VERSION = \"${NEW_VERSION}\"/" "$FILE"
- else
- sed "${SED_INPLACE[@]}" "1i\$VERSION = \"${NEW_VERSION}\"" "$FILE"
- fi
- ;;
- esac
- echo "${GREEN}✔ Updated ${FILE}${RESET}"
-done
-
-# --- Update CHANGELOG.md ---
-if [[ -f "$CHANGELOG" ]]; then
- TMP_FILE=$(mktemp)
- awk -v ver="v${NEW_VERSION}" -v date="$TODAY" '
- /^---$/ && !inserted {
- print ""
- print "## " ver " - " date
- print "### Added"
- print "- (empty)\n"
- print "### Fixed"
- print "- (empty)\n"
- print "---\n"
- inserted=1
- }
- {print}
- ' "$CHANGELOG" > "$TMP_FILE"
- mv "$TMP_FILE" "$CHANGELOG"
- echo "${GREEN}✔ Updated CHANGELOG.md${RESET}"
-fi
-
-# --- Optional Git commit ---
-if [[ "$DO_COMMIT" == "--commit" ]]; then
- git add app.py "$CHANGELOG" "${FILES[@]}" || true
- if git diff --cached --quiet; then
- echo "${YELLOW}⚠ No changes to commit.${RESET}"
- else
- git commit -m "Bump version to v${NEW_VERSION}" || true
- echo "${GREEN}✔ Git commit created${RESET}"
- fi
-fi
-
-echo "───────────────────────────────────────────────"
-echo "${GREEN}🎉 Version bump complete → v${NEW_VERSION}${RESET}"
-
diff --git a/retroiptv_linux.sh b/retroiptv_linux.sh
index 225f961..a1720aa 100644
--- a/retroiptv_linux.sh
+++ b/retroiptv_linux.sh
@@ -3,7 +3,7 @@
# License: Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)
set -euo pipefail
-VERSION="4.2.0"
+VERSION="4.3.0"
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
LOGFILE="retroiptv_${TIMESTAMP}.log"
exec > >(tee -a "$LOGFILE") 2>&1
diff --git a/retroiptv_rpi.sh b/retroiptv_rpi.sh
index 3a3084c..d06dbce 100644
--- a/retroiptv_rpi.sh
+++ b/retroiptv_rpi.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-VERSION="4.2.0"
+VERSION="4.3.0"
# RetroIPTVGuide Raspberry Pi Installer (Headless, Pi3/4/5)
# Installs to /home/iptv/iptv-server for consistency with Debian/Windows
# Logs to /var/log/retroiptvguide/install-YYYYMMDD-HHMMSS.log
diff --git a/retroiptv_windows.bat b/retroiptv_windows.bat
index 06cad5b..edf890b 100644
--- a/retroiptv_windows.bat
+++ b/retroiptv_windows.bat
@@ -3,7 +3,7 @@ mode con: cols=160 lines=50
REM ============================================================
REM RetroIPTVGuide Windows Unified Installer / Uninstaller
-REM Version: v4.0.0
+REM Version: v4.3.0
REM License: Creative Commons BY-NC-SA 4.0
REM ============================================================
@@ -31,7 +31,7 @@ if /i "%choice%"=="Y" (
:continue
setlocal
-set "VERSION=4.0.0"
+set "VERSION=4.3.0"
set "REPO_URL=https://github.com/thehack904/RetroIPTVGuide.git"
set "ZIP_URL=https://github.com/thehack904/RetroIPTVGuide/archive/refs/heads/main.zip"
set "INSTALL_DIR=%~dp0RetroIPTVGuide"
diff --git a/retroiptv_windows.ps1 b/retroiptv_windows.ps1
index 7e49187..d6fc090 100644
--- a/retroiptv_windows.ps1
+++ b/retroiptv_windows.ps1
@@ -1,7 +1,7 @@
<#
RetroIPTVGuide Windows Installer/Uninstaller
Filename: retroiptv_windows.ps1
-Version: 4.1.0
+Version: 4.3.0
License: Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
https://creativecommons.org/licenses/by-nc-sa/4.0/
@@ -55,7 +55,7 @@ if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdent
$ErrorActionPreference = 'Stop'
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
-$VERSION = "4.2.0"
+$VERSION = "4.3.0"
$ScriptDir = Split-Path -Parent -Path $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
diff --git a/static/css/about.css b/static/css/about.css
index 9a99a65..57dc62f 100644
--- a/static/css/about.css
+++ b/static/css/about.css
@@ -65,3 +65,104 @@ body.retro-magazine .about-box { background: #fff; border: 1px solid #000; }
.about-box li { padding: 8px 6px; display:block; }
.about-box li strong { display:block; margin-bottom:6px; }
}
+
+/* --- Collapsible System Health Drawer --- */
+
+.health-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ cursor: pointer;
+ user-select: none;
+ margin-bottom: 10px;
+}
+
+.health-header .arrow {
+ font-size: 1.1rem;
+ transition: transform 0.25s ease;
+}
+
+/* Hidden drawer area */
+.diag-box {
+ overflow: hidden;
+ max-height: 0;
+ opacity: 0;
+ transition: max-height 0.35s ease, opacity 0.25s ease;
+ margin-top: 10px;
+ padding-top: 0; /* padding removed when collapsed */
+}
+
+/* Expanded drawer */
+.diag-box.open {
+ max-height: 600px; /* large enough to hold content */
+ opacity: 1;
+ padding-top: 10px; /* restore padding */
+ border-top: 1px solid rgba(255,255,255,0.04);
+}
+
+/* Light theme version */
+body.light .diag-box.open {
+ border-top: 1px solid rgba(0,0,0,0.08);
+}
+
+:root {
+ --arrow-collapsed: "▶";
+ --arrow-expanded: "▼";
+}
+
+/* Use the themed arrow automatically */
+.arrow::after {
+ content: var(--arrow-collapsed);
+}
+
+/* When open */
+.health-header.open .arrow::after {
+ content: var(--arrow-expanded);
+}
+
+/* Collapsible drawer repeated styles de-duplicated (kept for compatibility) */
+.health-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ cursor: pointer;
+ user-select: none;
+ margin-bottom: 10px;
+}
+.diag-box {
+ overflow: hidden;
+ max-height: 0;
+ opacity: 0;
+ transition: max-height 0.35s ease, opacity 0.25s ease;
+ margin-top: 10px;
+ padding-top: 0;
+}
+.diag-box.open {
+ max-height: 600px;
+ opacity: 1;
+ padding-top: 10px;
+ border-top: 1px solid rgba(255,255,255,0.04);
+}
+body.light .diag-box.open {
+ border-top: 1px solid rgba(0,0,0,0.08);
+}
+:root{--arrow-collapsed:"▶";--arrow-expanded:"▼";}
+.arrow::after{content:var(--arrow-collapsed);transition:transform .2s ease;}
+.health-header.open .arrow::after{content:var(--arrow-expanded);}
+
+body.retroiptv{--arrow-collapsed:"►";--arrow-expanded:"▼";}
+body.directv{--arrow-collapsed:"▸";--arrow-expanded:"▾";}
+body.comcast{--arrow-collapsed:"›";--arrow-expanded:"⌄";}
+body.retro-aol{--arrow-collapsed:"+";--arrow-expanded:"-";}
+body.retro-magazine{--arrow-collapsed:"▶";--arrow-expanded:"▼";}
+body.dark{--arrow-collapsed:"▶";--arrow-expanded:"▼";}
+body.light{--arrow-collapsed:"▶";--arrow-expanded:"▼";}
+
+/* Styles for the auto-refresh list to visually match the diagnostics rows */
+.auto-refresh-list { margin: 8px 0 0 0; padding: 0; list-style: none; }
+.auto-refresh-list li { padding: 6px 0; display: flex; justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,0.03); }
+.auto-refresh-list li:last-child { border-bottom: none; }
+.diag-auto-refresh p { margin: 8px 0 6px 0; font-weight: 600; }
+
+/* small helper to hide an element if needed */
+.hidden { display: none !important; }
diff --git a/static/css/base.css b/static/css/base.css
index 9cfc668..2a28899 100644
--- a/static/css/base.css
+++ b/static/css/base.css
@@ -258,6 +258,8 @@ body.light .program { background:#ddd; border-color:#aaa; color:#000; }
body.light .program.now { background:#cfe8cf; border-color:#090; }
body.light .now-line { background:#090; }
body.light .header #clock { color: #ffffff !important; }
+/* compact: make hamburger rects white in Light theme */
+body.light .hamburger svg rect,body.light #mobileHamburger svg rect{fill:#fff!important;stroke:#fff!important}
/* Retro-AOL */
body.retro-aol { --timebar-bg:#004466; --timebar-border:#33cccc; --timebar-color:#33cccc; --chan-col-bg:#004466; background:#2f4f4f; color:#f0f0f0; font-family:"Tahoma","Arial",sans-serif; }
@@ -497,4 +499,29 @@ body.retroiptv a{color:var(--timebar-border)}body.retroiptv a:hover{color:var(--
body { transition: background-color .3s ease,color .3s ease; }
body.fade-switch { opacity:0; transition:opacity .25s ease; }
+
+:root{--arrow-collapsed:"▶";--arrow-expanded:"▼";}
+.arrow::after{content:var(--arrow-collapsed);transition:transform .2s ease;}
+.health-header.open .arrow::after{content:var(--arrow-expanded);}
+
+body.retroiptv{--arrow-collapsed:"►";--arrow-expanded:"▼";}
+body.directv{--arrow-collapsed:"▸";--arrow-expanded:"▾";}
+body.comcast{--arrow-collapsed:"›";--arrow-expanded:"⌄";}
+body.retro-aol{--arrow-collapsed:"+";--arrow-expanded:"-";}
+body.retro-magazine{--arrow-collapsed:"▶";--arrow-expanded:"▼";}
+body.dark{--arrow-collapsed:"▶";--arrow-expanded:"▼";}
+body.light{--arrow-collapsed:"▶";--arrow-expanded:"▼";}
+
+
/* End of base.css */
+
+
+/* Theme-aware separator for tuner fly-outs */
+.separator {
+ border-top: 1px solid currentColor;
+ opacity: 0.25;
+ margin: 6px 0;
+ padding: 0;
+ list-style: none;
+ height: 0;
+}
diff --git a/static/css/change_password.css b/static/css/change_password.css
index f489616..06942ef 100644
--- a/static/css/change_password.css
+++ b/static/css/change_password.css
@@ -1,61 +1,125 @@
-/* Per-page styles for change_password (keeps the original compact centered form look) */
+/* Full replacement: Change Password page stylesheet
+ *
+ * Goal: exactly match the Change Tuner high-contrast surface treatment,
+ * cover both .box and .card markup, and robustly override problematic theme rules.
+ *
+ * IMPORTANT:
+ * - Replace the existing static/css/change_password.css with this file.
+ * - Hard-refresh the browser (Ctrl+F5) to ensure no cached CSS remains.
+ */
-/* Container centers the single panel */
+/* ====================================================================== */
+/* Page-scoped CSS variables (mirror change_tuner) */
+/* ====================================================================== */
+.container-change-password {
+ --panel-bg: rgba(10,12,14,0.72);
+ --panel-text: #e8f6f6;
+ --panel-border: rgba(255,255,255,0.06);
+ --card-head-bg: rgba(255,255,255,0.03);
+ --heading-color: var(--panel-text);
+ --heading-weight: 800;
+
+ --label-color: rgba(240,240,240,0.92);
+ --muted-color: rgba(220,220,220,0.65);
+
+ --input-bg: rgba(255,255,255,0.04);
+ --input-border: rgba(160,200,200,0.18);
+ --input-color: #e9f7f7;
+ --placeholder-color: rgba(230,230,230,0.42);
+
+ --primary-color: #1ed3ce;
+ --primary-contrast: #ffffff;
+
+ --focus-border: rgba(30,211,206,0.95);
+ --focus-ring: rgba(30,211,206,0.12);
+
+ --box-shadow: 0 8px 22px rgba(0,0,0,0.40);
+ --card-radius: 8px;
+
+ color: var(--panel-text);
+ background: transparent;
+}
+
+/* ====================================================================== */
+/* Light-theme page-scoped overrides (only this page) */
+/* ====================================================================== */
+body.light .container-change-password {
+ --panel-bg: #f3f3f3;
+ --panel-text: #111;
+ --panel-border: rgba(0,0,0,0.06);
+ --card-head-bg: rgba(0,0,0,0.02);
+ --heading-color: #111;
+ --heading-weight: 700;
+
+ --label-color: #333;
+ --muted-color: rgba(0,0,0,0.56);
+
+ --input-bg: #fff;
+ --input-border: #aaa;
+ --input-color: #111;
+ --placeholder-color: rgba(0,0,0,0.36);
+
+ --primary-color: #06c;
+ --primary-contrast: #fff;
+ --box-shadow: 0 3px 12px rgba(0,0,0,0.04);
+}
+
+/* ====================================================================== */
+/* Layout */
+/* ====================================================================== */
.container-change-password {
max-width: 360px;
margin: 60px auto 80px;
- padding: 0 12px;
+ padding: 12px;
box-sizing: border-box;
}
-/* Panel (card) */
-.container-change-password .box {
+/* Map both .box and .card so markup variants are covered */
+.container-change-password .box,
+.container-change-password .card {
margin: 0;
- padding: 18px 18px;
- border-radius: 8px;
+ padding: 18px;
+ border-radius: var(--card-radius);
width: 100%;
box-sizing: border-box;
- background: rgba(0,0,0,0.12);
- border: 1px solid rgba(255,255,255,0.03);
- box-shadow: 0 2px 0 rgba(255,255,255,0.02) inset, 0 6px 12px rgba(0,0,0,0.12);
+ background: var(--panel-bg);
+ color: var(--panel-text);
+ border: 1px solid var(--panel-border);
+ box-shadow: var(--box-shadow);
}
/* Heading */
-.container-change-password .box h2 {
+.container-change-password .box h2,
+.container-change-password .card h2 {
margin: 0 0 12px;
font-size: 1.25rem;
font-weight: 700;
- color: inherit;
+ color: var(--heading-color);
}
/* Inputs */
-.container-change-password input[type="password"] {
+.container-change-password input[type="password"],
+.container-change-password input[type="text"] {
width: 100%;
padding: 8px 10px;
margin: 6px 0;
- border-radius: 4px;
- border: 1px solid rgba(255,255,255,0.08);
- background: rgba(255,255,255,0.03);
- color: inherit;
+ border-radius: 6px;
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ color: var(--input-color);
box-sizing: border-box;
outline: none;
+ transition: box-shadow .12s ease, border-color .12s ease;
+ font-size: 0.96rem;
}
-/* Light-theme overrides */
-body.light .container-change-password .box {
- background: #f3f3f3;
- border-color: rgba(0,0,0,0.06);
-}
-body.light .container-change-password input {
- background: #fff;
- color: #000;
- border-color: #aaa;
-}
+/* Placeholder color */
+.container-change-password ::placeholder { color: var(--placeholder-color); }
/* Focus */
.container-change-password input:focus {
- border-color: rgba(30,211,206,0.95);
- box-shadow: 0 0 0 4px rgba(30,211,206,0.06);
+ border-color: var(--focus-border);
+ box-shadow: 0 0 0 5px var(--focus-ring);
}
/* Button */
@@ -63,27 +127,75 @@ body.light .container-change-password input {
width: 100%;
padding: 10px;
margin-top: 10px;
- border-radius: 4px;
+ border-radius: 6px;
border: none;
- background: #1ed3ce;
- color: #002e2c;
+ background: var(--primary-color);
+ color: var(--primary-contrast);
font-weight: 700;
cursor: pointer;
box-shadow: 0 2px 0 rgba(0,0,0,0.12);
}
-
-/* Button hover */
.container-change-password button.primary:hover {
transform: translateY(-1px);
- background: #16cfc9;
+ filter: brightness(0.96);
}
/* Flash messages */
.flash-messages { margin-top: 12px; }
-.flash { color: #ffd; background: transparent; margin: 6px 0; }
+.flash { color: var(--panel-text); background: transparent; margin: 6px 0; }
+
+/* ====================================================================== */
+/* Theme-specific overrides: explicit, robust and page-scoped */
+/* The user requested exact colors for the .box area in each theme. */
+/* We apply them to both .box and .card and include !important to beat */
+/* theme CSS that previously forced blending. */
+/* ====================================================================== */
+
+/* RetroIPTV: box background #48494a, text white */
+body.retroiptv .container-change-password .box,
+body.retroiptv .container-change-password .card {
+ background: #48494a !important;
+ color: #ffffff !important;
+ border-color: rgba(59,15,26,0.15) !important;
+}
+body.retroiptv .container-change-password .box h2,
+body.retroiptv .container-change-password .card h2 {
+ color: #ffffff !important;
+}
+
+/* Retro Magazine: box background #48494a, text white */
+body.retro-magazine .container-change-password .box,
+body.retro-magazine .container-change-password .card {
+ background: #48494a !important;
+ color: #ffffff !important;
+ border-color: rgba(59,15,26,0.15) !important;
+}
+body.retro-magazine .container-change-password .box h2,
+body.retro-magazine .container-change-password .card h2 {
+ color: #ffffff !important;
+}
-/* Accessibility / small screens */
+/* DirecTV: box background #0c3451, text white */
+body.directv .container-change-password .box,
+body.directv .container-change-password .card {
+ background: #0c3451 !important;
+ color: #ffffff !important;
+ border-color: rgba(0,0,0,0.12) !important;
+}
+body.directv .container-change-password .box h2,
+body.directv .container-change-password .card h2 {
+ color: #ffffff !important;
+}
+
+/* ====================================================================== */
+/* Misc responsive & accessibility */
+/* ====================================================================== */
@media (max-width: 480px) {
.container-change-password { padding: 0 14px; }
.container-change-password .box { padding: 14px; }
}
+
+/* Smooth transitions but non-invasive */
+.container-change-password * {
+ transition: background-color .12s ease, color .12s ease, border-color .12s ease !important;
+}
diff --git a/static/css/change_tuner.css b/static/css/change_tuner.css
index f6c99cc..033b956 100644
--- a/static/css/change_tuner.css
+++ b/static/css/change_tuner.css
@@ -1,153 +1,301 @@
-/* change_tuner page stylesheet — theme-aware via CSS variables
- Falls back to original values if variables are not provided by theme CSS.
-*/
+/* Full replacement: change_tuner page stylesheet
+ *
+ * Goals:
+ * - Scoped to .container-change-tuner so changes don't ripple site-wide.
+ * - Strong, readable defaults for cards, headings, labels, inputs, buttons.
+ * - Explicit per-theme overrides for the lead text you requested.
+ * - Accessible focus outlines and improved contrast for danger area.
+ *
+ * Replace the existing static/css/change_tuner.css with this file and hard-refresh (Ctrl+F5).
+ */
-/* Layout: center column and stack boxes */
+/* ====================================================================== */
+/* Page-scoped CSS variables (sane, higher-contrast defaults) */
+/* All variables are scoped to .container-change-tuner to avoid globals. */
+/* ====================================================================== */
.container-change-tuner {
- max-width: 520px;
- margin: 28px auto 80px;
- padding: 0 12px;
- box-sizing: border-box;
+ /* surface & text */
+ --panel-bg: rgba(10,12,14,0.72); /* strong dark panel fallback */
+ --panel-text: #e8f6f6; /* readable pale text on dark panels */
+ --panel-border: rgba(255,255,255,0.06);
+ --card-head-bg: rgba(255,255,255,0.03);
+ --heading-color: #ffffff;
+ --heading-weight: 800;
+
+ /* labels & muted text */
+ --label-color: rgba(240,240,240,0.92);
+ --muted-color: rgba(220,220,220,0.65);
+
+ /* inputs */
+ --input-bg: rgba(255,255,255,0.04);
+ --input-border: rgba(160,200,200,0.18);
+ --input-color: #e9f7f7;
+ --placeholder-color: rgba(230,230,230,0.42);
+
+ /* primary / danger */
+ --primary-color: #1ed3ce;
+ --primary-hover: #16cfc9;
+ --primary-contrast: #ffffff; /* force white for reliable contrast */
+ --danger-color: #ff4d4f;
+
+ /* focus & visual */
+ --focus-border: rgba(30,211,206,0.95);
+ --focus-ring: rgba(30,211,206,0.12);
+ --box-shadow: 0 8px 22px rgba(0,0,0,0.40);
+ --card-border-radius: 10px;
+
+ /* dropdown / submenu defaults */
+ --dropdown-bg: rgba(22,22,22,0.95);
+ --dropdown-color: #edf7f7;
+
+ /* page defaults */
+ color: var(--panel-text);
+ background: transparent;
}
-/* Panel (card) used for each section */
-.box {
- margin: 28px 0;
- padding: 18px 20px;
- border-radius: 8px;
+/* ====================================================================== */
+/* Light-theme page-scoped overrides (only affect this page) */
+/* ====================================================================== */
+body.light .container-change-tuner {
+ --panel-bg: #fff;
+ --panel-text: #111;
+ --panel-border: rgba(0,0,0,0.08);
+ --card-head-bg: rgba(0,0,0,0.02);
+ --heading-color: #111;
+ --heading-weight: 700;
+
+ --label-color: #333;
+ --muted-color: rgba(0,0,0,0.56);
+
+ --input-bg: #fff;
+ --input-border: rgba(0,0,0,0.12);
+ --input-color: #111;
+ --placeholder-color: rgba(0,0,0,0.38);
- /* Use CSS variables so theme files can override these.
- Fallback to previous values when variables are not defined. */
- background: var(--panel-bg, rgba(0,0,0,0.18));
- box-shadow: 0 2px 0 rgba(255,255,255,0.02) inset, 0 6px 18px rgba(0,0,0,0.18);
- border: 1px solid var(--panel-border, rgba(255,255,255,0.03));
- color: var(--text-color, #eaf6f6);
+ --primary-color: #06c;
+ --primary-hover: #058;
+ --primary-contrast: #ffffff;
+ --box-shadow: 0 6px 18px rgba(0,0,0,0.06);
+
+ --dropdown-bg: #fff;
+ --dropdown-color: #111;
}
-/* Headings */
-.box h2 {
- margin: 0 0 12px;
- font-size: 1.35rem;
- font-weight: 700;
- color: var(--heading-color, #fff);
- text-shadow: 0 1px 0 rgba(0,0,0,0.25);
+/* ====================================================================== */
+/* Layout */
+/* ====================================================================== */
+.container-change-tuner {
+ max-width: 1100px;
+ margin: 28px auto 80px;
+ padding: 18px;
+ box-sizing: border-box;
}
-/* Labels */
-.box label {
- display: block;
- font-size: 0.9rem;
- color: var(--label-color, rgba(255,255,255,0.9));
- margin: 8px 0 6px;
+/* Header */
+.page-header h1 {
+ margin: 0 0 6px;
+ font-size: 1.6rem;
+ color: var(--panel-text);
+ font-weight: var(--heading-weight);
+}
+.page-header .lead {
+ margin: 0 0 18px;
+ color: var(--muted-color);
+ font-size: 0.98rem;
+ line-height: 1.45;
}
-/* Form rows - stack fields with gap */
-.form-row {
- display: block;
- margin-bottom: 10px;
+/* ====================================================================== */
+/* Grid & Cards */
+/* ====================================================================== */
+.card-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 18px;
}
+.card-danger { grid-column: 1 / -1; }
-/* Inputs & selects: full width, use themed borders/background/text */
-select, input[type="text"], input[type="url"], input[type="email"], input[type="password"], textarea {
- width: 100%;
- padding: 10px 12px;
- border-radius: 6px;
- border: 1px solid var(--input-border, rgba(100,200,200,0.25));
- background: var(--input-bg, rgba(255,255,255,0.03));
- color: var(--input-color, #eaf6f6);
- box-sizing: border-box;
- outline: none;
+.card {
+ background: var(--panel-bg);
+ color: var(--panel-text);
+ border: 1px solid var(--panel-border);
+ border-radius: var(--card-border-radius);
+ overflow: hidden;
+ box-shadow: var(--box-shadow);
+}
+.card-head {
+ padding: 12px 16px;
+ background: var(--card-head-bg);
+ border-bottom: 1px solid color-mix(in srgb, var(--panel-border) 85%, transparent 15%);
+}
+.card-head h2 {
+ margin: 0;
+ font-size: 1.05rem;
+ color: var(--heading-color);
+ font-weight: var(--heading-weight);
+ text-shadow: none;
+}
+.card-body { padding: 14px 16px; }
+
+/* ====================================================================== */
+/* Form controls & readability */
+/* ====================================================================== */
+.compact-form label {
+ display:block;
+ margin:8px 0 6px;
+ font-size:.95rem;
+ color: var(--label-color);
+ font-weight:600;
+}
+.compact-form input[type="text"],
+.compact-form select,
+.compact-form textarea {
+ width:100%;
+ padding:9px 11px;
+ border-radius:6px;
+ border:1px solid var(--input-border);
+ background: var(--input-bg);
+ color: var(--input-color);
+ box-sizing:border-box;
transition: box-shadow .12s ease, border-color .12s ease, background-color .12s ease;
+ font-size:0.96rem;
}
-/* slightly lighter placeholder text so it reads on dark backgrounds */
-::placeholder { color: var(--placeholder-color, rgba(255,255,255,0.35)); }
+/* placeholder color */
+.container-change-tuner ::placeholder { color: var(--placeholder-color); }
-/* Focus styles */
-select:focus, input:focus, textarea:focus {
- border-color: var(--focus-border, rgba(36,220,210,0.95));
- box-shadow: 0 0 0 4px var(--focus-ring, rgba(36,220,210,0.06));
- background: var(--input-bg-focus, rgba(255,255,255,0.02));
- color: var(--input-color-focus, #fff);
+/* focus */
+.compact-form input:focus,
+.compact-form select:focus,
+.compact-form textarea:focus {
+ border-color: var(--focus-border);
+ box-shadow: 0 0 0 5px var(--focus-ring);
+ outline: none;
}
-/* Primary button — full width pill */
-button.primary, input[type="submit"].primary {
- display: block;
- width: 100%;
- padding: 10px 12px;
- border-radius: 6px;
- background: var(--primary-color, #1ed3ce);
- color: var(--primary-text-color, #002e2c);
- font-weight: 700;
+/* ====================================================================== */
+/* Buttons */
+/* ====================================================================== */
+button.primary {
+ background: var(--primary-color);
+ color: var(--primary-contrast);
border: none;
- cursor: pointer;
+ padding: 8px 12px;
+ border-radius: 6px;
+ font-weight:700;
+ cursor:pointer;
box-shadow: 0 2px 0 rgba(0,0,0,0.12);
- transition: transform .06s ease, box-shadow .06s ease, background .08s ease;
}
+button.primary:hover { background: var(--primary-hover); transform: translateY(-1px); }
-/* Hover / active */
-button.primary:hover { transform: translateY(-1px); background: var(--primary-color-hover, #16cfc9); }
-button.primary:active { transform: translateY(0); box-shadow: 0 1px 0 rgba(0,0,0,0.12); }
-
-/* Secondary small inline buttons (e.g., Rename button row) */
-.btn-inline {
- display: inline-block;
- padding: 6px 10px;
- border-radius: 4px;
- background: var(--btn-inline-bg, rgba(255,255,255,0.92));
- color: var(--btn-inline-color, #002e2c);
- border: 1px solid var(--btn-inline-border, rgba(0,0,0,0.06));
- cursor: pointer;
- font-weight: 600;
- margin-left: 8px;
+button.secondary {
+ background: transparent;
+ border: 1px solid color-mix(in srgb, var(--panel-border) 70%, transparent 30%);
+ padding: 8px 12px;
+ border-radius: 6px;
+ color: var(--panel-text);
+ cursor:pointer;
+}
+button.danger {
+ background: var(--danger-color);
+ color: #fff;
+ border:none;
+ padding:8px 12px;
+ border-radius:6px;
+ cursor:pointer;
}
-/* Tuning select & inputs layout in a row for small screens only: vertical stack on mobile */
-.row-inline {
- display: flex;
- gap: 10px;
- align-items: center;
+/* ====================================================================== */
+/* Rows, subgrid, divider */
+/* ====================================================================== */
+.form-row-inline { display:flex; gap:8px; align-items:center; margin-top:12px; }
+.row-inline { display:flex; gap:12px; align-items:flex-start; }
+.row-inline .col { flex:1 1 0; }
+.subgrid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
+.divider { border: none; height: 1px; background: color-mix(in srgb, var(--panel-border) 85%, transparent 15%); margin: 12px 0; }
+
+/* Muted helper text */
+.muted { color: var(--muted-color); font-size:0.92rem; }
+.small { font-size:0.88rem; }
+
+/* ====================================================================== */
+/* Danger card improvements */
+/* ====================================================================== */
+.card-danger {
+ border-left: 4px solid var(--danger-color);
+ background: color-mix(in srgb, var(--panel-bg) 80%, var(--danger-color) 6%);
}
-.row-inline .row-item {
- flex: 1 1 auto;
+.card-danger .card-head {
+ border-bottom-color: color-mix(in srgb, var(--danger-color) 12%, transparent 88%);
}
+.confirm-label { font-size:.9rem; margin-top:8px; color: var(--label-color); }
+#confirm_delete { letter-spacing: 0.02em; }
-/* Small screens: keep stacked */
-@media (max-width: 640px) {
- .container-change-tuner { padding: 0 18px; max-width: 100%; }
- .row-inline { flex-direction: column; align-items: stretch; }
+/* ====================================================================== */
+/* Dropdowns & flyouts (scoped to this page) */
+/* ====================================================================== */
+.container-change-tuner .dropdown-content,
+.container-change-tuner .submenu-content {
+ background: var(--dropdown-bg);
+ color: var(--dropdown-color, var(--panel-text));
+ border: 1px solid color-mix(in srgb, var(--panel-border) 85%, transparent 15%);
}
-/* Additional spacing for the delete / rename sections to match original spacing */
-.box .sub-heading {
- margin-top: 12px;
- margin-bottom: 8px;
- color: var(--sub-heading-color, rgba(255,255,255,0.9));
- font-weight: 600;
+/* ====================================================================== */
+/* Table header contrasts (if table used) */
+/* ====================================================================== */
+.container-change-tuner th,
+.container-change-tuner .table thead th {
+ color: var(--panel-text);
+ background: color-mix(in srgb, var(--panel-bg) 90%, transparent 10%);
}
-/* Theme-aware light adjustments:
- Match how the theme script applies themes: html[data-theme="light"] */
-html[data-theme="light"] .box {
- background: var(--panel-bg-light, rgba(255,255,255,0.95));
- color: var(--text-color-light, #111);
+/* ====================================================================== */
+/* Accessibility focus outlines */
+/* ====================================================================== */
+button:focus, select:focus, input:focus, textarea:focus {
+ outline: 3px solid color-mix(in srgb, var(--primary-color) 30%, transparent 70%);
+ outline-offset: 2px;
}
-html[data-theme="light"] .box h2 {
- color: var(--heading-color-light, #111);
- text-shadow: none;
+
+/* ====================================================================== */
+/* Responsive */
+/* ====================================================================== */
+@media (max-width: 920px) {
+ .card-grid { grid-template-columns: 1fr; }
+ .subgrid { grid-template-columns: 1fr; }
+ .row-inline { flex-direction: column; }
+}
+
+/* ====================================================================== */
+/* Theme-specific, exact overrides you asked for */
+/* - RetroIPTV and TV Guide: lead text color #48494a */
+/* - DirecTV: lead text color #153a56 */
+/* These are intentionally page-scoped and use !important to override */
+/* theme rules that previously caused blending. */
+/* ====================================================================== */
+
+/* RetroIPTV */
+body.retroiptv .container-change-tuner .page-header h1,
+body.retroiptv .container-change-tuner .page-header .lead {
+ color: #48494a !important;
}
-html[data-theme="light"] select,
-html[data-theme="light"] input,
-html[data-theme="light"] textarea {
- color: var(--input-color-light, #111);
- background: var(--input-bg-light, #fff);
- border-color: var(--input-border-light, rgba(0,0,0,0.12));
+
+/* TV Guide (retro-magazine) */
+body.retro-magazine .container-change-tuner .page-header h1,
+body.retro-magazine .container-change-tuner .page-header .lead {
+ color: #48494a !important;
}
-html[data-theme="light"] button.primary {
- background: var(--primary-color-light, #06c);
- color: var(--primary-text-color-light, #fff);
+
+/* DirecTV */
+body.directv .container-change-tuner .page-header h1,
+body.directv .container-change-tuner .page-header .lead {
+ color: #153a56 !important;
}
-/* small helper to hide an element if needed */
-.hidden { display: none !important; }
+/* ====================================================================== */
+/* Smooth, conservative transitions */
+/* ====================================================================== */
+.container-change-tuner * {
+ transition: background-color .12s ease, color .12s ease, border-color .12s ease !important;
+}
diff --git a/static/css/manage_users.css b/static/css/manage_users.css
index 062b4af..ae92e9d 100644
--- a/static/css/manage_users.css
+++ b/static/css/manage_users.css
@@ -1,6 +1,74 @@
-/* Per-page styles for Manage Users page — scoped to this page */
+/* Full replacement: Manage Users page stylesheet
+ *
+ * Purpose:
+ * - Match the Change Tuner visual system (same scoped variables) so cards/boxes,
+ * inputs, select, buttons, table headers, and focus states are consistent.
+ * - Scoped to .container-manage-users so site-wide CSS is unaffected.
+ * - Theme-specific header overrides for RetroIPTV, Retro Magazine, and DirecTV.
+ */
+
+/* Page-scoped variables (same tokens as change_tuner.css) */
+.container-manage-users {
+ --panel-bg: rgba(10,12,14,0.72);
+ --panel-text: #e8f6f6;
+ --panel-border: rgba(255,255,255,0.06);
+ --card-head-bg: rgba(255,255,255,0.03);
+ --heading-color: #ffffff;
+ --heading-weight: 800;
+
+ --label-color: rgba(240,240,240,0.92);
+ --muted-color: rgba(220,220,220,0.65);
+
+ --input-bg: rgba(255,255,255,0.04);
+ --input-border: rgba(160,200,200,0.18);
+ --input-color: #e9f7f7;
+ --placeholder-color: rgba(230,230,230,0.42);
+
+ --primary-color: #1ed3ce;
+ --primary-hover: #16cfc9;
+ --primary-contrast: #ffffff;
+
+ --danger-color: #c00;
+
+ --focus-border: rgba(30,211,206,0.95);
+ --focus-ring: rgba(30,211,206,0.12);
+ --box-shadow: 0 8px 22px rgba(0,0,0,0.40);
+ --card-border-radius: 10px;
-/* Outer container to center content */
+ --dropdown-bg: rgba(22,22,22,0.95);
+ --dropdown-color: #edf7f7;
+
+ color: var(--panel-text);
+ background: transparent;
+}
+
+/* Light-theme page-scoped overrides */
+body.light .container-manage-users {
+ --panel-bg: #f3f3f3;
+ --panel-text: #111;
+ --panel-border: rgba(0,0,0,0.06);
+ --card-head-bg: rgba(0,0,0,0.02);
+ --heading-color: #111;
+ --heading-weight: 700;
+
+ --label-color: #333;
+ --muted-color: rgba(0,0,0,0.56);
+
+ --input-bg: #fff;
+ --input-border: rgba(0,0,0,0.08);
+ --input-color: #111;
+ --placeholder-color: rgba(0,0,0,0.36);
+
+ --primary-color: #06c;
+ --primary-hover: #058;
+ --primary-contrast: #ffffff;
+ --box-shadow: 0 6px 18px rgba(0,0,0,0.06);
+
+ --dropdown-bg: #fff;
+ --dropdown-color: #111;
+}
+
+/* Layout */
.container-manage-users {
max-width: 720px;
margin: 60px auto;
@@ -8,21 +76,32 @@
box-sizing: border-box;
}
-/* Panel */
-.container-manage-users .box {
+/* Panel / Card surface */
+.container-manage-users .box,
+.container-manage-users .card {
padding: 18px;
- border-radius: 8px;
- background: rgba(0,0,0,0.06);
- border: 1px solid rgba(255,255,255,0.03);
- box-shadow: 0 2px 0 rgba(255,255,255,0.02) inset;
+ border-radius: var(--card-border-radius);
+ background: var(--panel-bg);
+ border: 1px solid var(--panel-border);
+ box-shadow: var(--box-shadow);
+ color: var(--panel-text);
}
/* Headings */
-.container-manage-users h2 { margin: 0 0 12px; font-size:1.25rem; font-weight:700; color:inherit; }
-.container-manage-users h3 { margin: 10px 0; font-size:1rem; color:inherit; }
+.container-manage-users h2 {
+ margin: 0 0 12px;
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--heading-color);
+}
+.container-manage-users h3 {
+ margin: 10px 0;
+ font-size: 1rem;
+ color: var(--heading-color);
+}
/* Form rows */
-.form-row { margin-bottom: 8px; }
+.container-manage-users .form-row { margin-bottom: 8px; }
/* Inputs / selects */
.container-manage-users input[type="text"],
@@ -30,68 +109,78 @@
.container-manage-users select {
width: 100%;
padding: 8px 10px;
- border-radius: 4px;
- border: 1px solid rgba(255,255,255,0.06);
- background: rgba(255,255,255,0.03);
- color: inherit;
+ border-radius: 6px;
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ color: var(--input-color);
box-sizing: border-box;
outline: none;
+ font-size: 0.96rem;
}
-.container-manage-users input:focus {
- border-color: rgba(30,211,206,0.95);
- box-shadow: 0 0 0 4px rgba(30,211,206,0.06);
+
+/* Placeholder and focus */
+.container-manage-users ::placeholder { color: var(--placeholder-color); }
+.container-manage-users input:focus,
+.container-manage-users select:focus {
+ border-color: var(--focus-border);
+ box-shadow: 0 0 0 5px var(--focus-ring);
}
/* Buttons */
-.btn { padding: 8px 12px; border-radius:4px; border:none; cursor:pointer; font-weight:700; }
-.btn.add { background: #0a0; color: #fff; display: inline-block; }
-.btn.add:hover { background: #070; }
-.btn.danger { background: #c00; color: #fff; }
-.btn.danger:hover { background: #900; }
+.btn { padding: 8px 12px; border-radius:6px; border:none; cursor:pointer; font-weight:700; }
+.btn.add { background: var(--primary-color); color: var(--primary-contrast); display: inline-block; }
+.btn.add:hover { background: var(--primary-hover); }
+.btn.danger { background: var(--danger-color); color: #fff; }
.btn.warn { background: #d2691e; color: #fff; }
-.btn.warn:hover { background: #a0522d; }
-/* For the add user button make it full width in the form */
+/* Make add-user button full width in add-user form */
.add-user-form .btn.add { width: 100%; }
/* Table */
.table-wrap { overflow-x: auto; margin-top: 12px; }
.users-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
-.users-table thead th { background: rgba(0,0,0,0.6); color: #fff; padding: 8px; text-align: left; position: sticky; top: 0; z-index: 5; }
-.users-table tbody td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.06); vertical-align: middle; }
+.users-table thead th {
+ background: color-mix(in srgb, var(--panel-bg) 85%, rgba(0,0,0,0.06) 15%);
+ color: var(--panel-text);
+ padding: 8px;
+ text-align: left;
+ position: sticky;
+ top: 0;
+ z-index: 5;
+}
+.users-table tbody td {
+ padding: 10px 8px;
+ border-bottom: 1px solid color-mix(in srgb, var(--panel-border) 75%, transparent 25%);
+ vertical-align: middle;
+ color: var(--panel-text);
+}
-/* Actions cell: inline forms */
+/* Actions & inline forms */
.actions { display:flex; gap:8px; }
-
-/* Inline form reset so buttons sit next to each other */
.inline-form { display:inline; margin:0; padding:0; }
/* Flash messages */
.flash-messages { margin-top: 12px; }
-.flash { color: #ffd; background: transparent; margin: 6px 0; }
-
-/* Theme overrides */
-body.dark .container-manage-users .box { background: #222; border-color: rgba(255,255,255,0.03); }
-body.dark .users-table thead th { background: #333; color: #fff; }
-body.dark .users-table tbody tr:nth-child(even) { background: #222; color: #fff; }
+.flash { color: var(--panel-text); background: transparent; margin: 6px 0; }
-body.light .container-manage-users .box { background: #f3f3f3; border-color: rgba(0,0,0,0.06); color: #000; }
-body.light .users-table thead th { background: #ddd; color: #000; }
-body.light .users-table tbody tr:nth-child(even) { background: #fff; color: #000; }
-
-body.retro-aol .container-manage-users .box {
- padding: 18px;
- border-radius: 8px;
- background: #004466;
- border:2px solid #33cccc;
- box-shadow: 0 2px 0 rgba(255,255,255,0.02) inset;
+/* Theme-specific header overrides to match change_tuner exact colors */
+/* RetroIPTV */
+body.retroiptv .container-manage-users h2,
+body.retroiptv .container-manage-users h3 {
+ color: #ffffff !important;
}
-body.retro-magazine .container-manage-users .box { border:2px solid #000000; }
-
-body.directv .container-manage-users .box { background:linear-gradient(to bottom,#e7e4ff,#d2f3ff); border-bottom:2px solid #003090; }
+/* Retro Magazine (tv guide look) */
+body.retro-magazine .container-manage-users h2,
+body.retro-magazine .container-manage-users h3 {
+ color: #ffffff !important;
+}
-body.comcast .container-manage-users .box { background:linear-gradient(to bottom,#0055cc,#001b50); border-bottom:2px solid #003090; }
+/* DirecTV */
+body.directv .container-manage-users h2,
+body.directv .container-manage-users h3 {
+ color: #ffffff !important;
+}
/* Responsive */
@media (max-width: 640px) {
diff --git a/static/css/mobile.css b/static/css/mobile.css
index a0aab31..390314e 100644
--- a/static/css/mobile.css
+++ b/static/css/mobile.css
@@ -162,3 +162,14 @@ html, body {
/* Ensure grid-content transform is GPU-friendly */
.grid-content { will-change: transform; -webkit-transform-origin: left top; transform-origin: left top; }
}
+
+
+/* Theme-aware separator for tuner fly-outs */
+.separator {
+ border-top: 1px solid currentColor;
+ opacity: 0.25;
+ margin: 6px 0;
+ padding: 0;
+ list-style: none;
+ height: 0;
+}
diff --git a/templates/_header.html b/templates/_header.html
index f7fe684..57e7253 100644
--- a/templates/_header.html
+++ b/templates/_header.html
@@ -8,15 +8,23 @@
Active Tuner: {{ current_tuner }}
HOME
- {% if current_user is defined and current_user.username == 'admin' %}
-
- {% endif %}
-