diff --git a/.gitignore b/.gitignore index cff5543..0a16c91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env __pycache__/ *.pyc +.DS_Store diff --git a/app.py b/app.py index 2f8f1ae..faa589e 100644 --- a/app.py +++ b/app.py @@ -15,6 +15,14 @@ from flask import Flask, render_template, request, jsonify, redirect, url_for, s # Suppress SSL warnings for Unraid's self-signed certificate urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +# Build identity, shown in the dashboard footer. Bump these when cutting a +# deploy so the running container is identifiable at a glance — the Unraid +# Docker tab caches stale container IDs, so the footer is the reliable way +# to confirm a `docker compose up -d --build` actually took effect. +# Both are env-overridable if the deploy pipeline ever injects them. +APP_VERSION = os.environ.get("APP_VERSION", "1.1.0") +APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-26") + # Must point at a path that survives `docker compose up --build` — the # container filesystem does not. See the data volume in docker-compose.yml. SETTINGS_FILE = os.environ.get( @@ -116,6 +124,12 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) if _script_name: app.wsgi_app = ScriptNameMiddleware(app.wsgi_app, _script_name) + +@app.context_processor +def inject_version(): + """Make the build identity available to every template.""" + return {"app_version": APP_VERSION, "app_version_date": APP_VERSION_DATE} + # ── Authentication ────────────────────────────────────────────────────────── LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "") _runtime_password = None # overrides env var after change-password call diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..1ae39c2 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,74 @@ +/* ═══════════════════════════════════════════════════════ + Media Centre — shared design tokens + base reset. + + Single source of truth for the palette. Loaded by + dashboard.html, login.html and settings.html; page-specific + rules live in dashboard.css / login.css / settings.css. + + To reshade the whole app, change the four hues below — + every dim/border variant is derived from them via color-mix. + ═══════════════════════════════════════════════════════ */ +:root { + /* ── Surfaces ── */ + --bg: #141416; + --surface: #1a1a1d; + --surface-2: #202024; + --border: #2c2c31; + --border-lt: #38383f; + + /* ── Text (all ≥ 4.5:1 on every surface above) ── */ + --text: #edecf1; + --text-2: #a8a7b0; + --text-3: #7c7b84; + + /* ── Accent + semantics (the four source hues) ── */ + --accent: #7c86ff; + --ok: #68b98a; + --warn: #d0a75a; + --danger: #dc5b52; + + /* ── Derived tints — never hardcode these as literals ── */ + --accent-dim: color-mix(in srgb, var(--accent) 14%, transparent); + --ok-dim: color-mix(in srgb, var(--ok) 14%, transparent); + --warn-dim: color-mix(in srgb, var(--warn) 14%, transparent); + --danger-dim: color-mix(in srgb, var(--danger) 14%, transparent); + --danger-border: color-mix(in srgb, var(--danger) 35%, transparent); + + /* ── Type scale (6 steps, no one-offs) ── */ + --fs-xs: 0.75rem; /* 12px — smallest permitted */ + --fs-sm: 0.8125rem; /* 13px */ + --fs-base: 0.9375rem; /* 15px */ + --fs-lg: 1.125rem; /* 18px */ + --fs-xl: 1.5rem; /* 24px */ + --fs-2xl: 2rem; /* 32px */ + + /* ── Spacing / shape ── */ + --gap: 1rem; + --gap-lg: 1.5rem; + --radius: 10px; + --radius-sm: 7px; + --tap: 44px; /* minimum touch target */ + + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: var(--fs-base); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); } + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000..a692e8a --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,604 @@ + +/* ── Header ─────────────────────────────────────────── */ +header { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + gap: var(--gap); + flex-wrap: wrap; + position: sticky; + top: 0; + z-index: 100; +} +.brand { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 0; + flex: 0 0 auto; +} +.brand svg { flex-shrink: 0; display: block; } +.brand h1 { + margin: 0; + font-size: var(--fs-lg); + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; +} + +/* Service status pills — wrap onto as many rows as needed so every + service stays visible at any window width. Never scroll or clip: + a hidden pill is a service you can't see the status of. */ +.svc-strip { + display: flex; + align-items: center; + gap: 0.4rem; + flex: 1 1 auto; + flex-wrap: wrap; + justify-content: center; + min-width: 0; +} +.svc-pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.35rem 0.7rem; + min-height: 34px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 100px; + font-size: var(--fs-xs); + color: var(--text-2); + text-decoration: none; + white-space: nowrap; + transition: border-color 0.2s, color 0.2s; +} +.svc-pill:hover { border-color: var(--border-lt); color: var(--text); } +.svc-pill.down { color: var(--danger); border-color: var(--danger-border); } +.svc-name { font-weight: 600; } +.svc-stat { color: var(--text-3); } +.svc-pill.down .svc-stat { color: var(--danger); } + +.status-light { + width: 7px; height: 7px; border-radius: 50%; + flex-shrink: 0; background: var(--text-3); +} +.status-light.up { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-dim); } +.status-light.down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-dim); } + +.header-right { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: auto; + flex: 0 0 auto; +} +.header-clock { + font-variant-numeric: tabular-nums; + font-size: var(--fs-sm); + color: var(--text-2); + white-space: nowrap; +} +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-width: var(--tap); + min-height: var(--tap); + padding: 0 0.85rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-2); + font-size: var(--fs-sm); + font-family: inherit; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: color 0.2s, border-color 0.2s, background 0.2s; +} +.icon-btn:hover { color: var(--text); border-color: var(--border-lt); background: var(--surface); } + +/* ── Layout ─────────────────────────────────────────── */ +main { + padding: var(--gap-lg); + max-width: 1360px; + margin: 0 auto; +} +.section { + margin-bottom: 2rem; +} +.section > h2 { + margin: 0 0 0.85rem; + font-size: var(--fs-xs); + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-3); +} +.row { + display: grid; + gap: var(--gap); +} +.row.cols-3 { grid-template-columns: repeat(3, 1fr); } +.row.cols-2 { grid-template-columns: repeat(2, 1fr); } + +/* ── Card ───────────────────────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + min-width: 0; +} +.card-header { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.85rem 1.1rem; + border-bottom: 1px solid var(--border); +} +.card-header h3 { + margin: 0; + font-size: var(--fs-sm); + font-weight: 600; + color: var(--text); +} +.card-header h3 a { color: inherit; text-decoration: none; } +.card-header h3 a:hover { color: var(--accent); } +.card-meta { + margin-left: auto; + font-size: var(--fs-xs); + color: var(--text-3); + white-space: nowrap; +} +.card-body { padding: 1.1rem; flex: 1; } +.card-body > :first-child { margin-top: 0; } +#prowlarr-body { display: flex; flex-direction: column; justify-content: center; } + +/* ── "Right now" hero strip ─────────────────────────── */ +.hero .card { border-color: var(--border-lt); } +.hero-figure { + font-size: var(--fs-2xl); + font-weight: 600; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.hero-sub { + font-size: var(--fs-sm); + color: var(--text-3); + margin-top: 0.3rem; +} + +/* Now-playing list */ +.stream { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.55rem 0; + border-top: 1px solid var(--border); +} +.stream:first-child { border-top: none; padding-top: 0; } +.stream-icon { font-size: var(--fs-base); flex-shrink: 0; } +.stream-info { min-width: 0; flex: 1; } +.stream-title { + font-size: var(--fs-sm); font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.stream-user { font-size: var(--fs-xs); color: var(--text-3); } +.live-dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--ok); flex-shrink: 0; + animation: pulse 2s ease-in-out infinite; +} +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } +@media (prefers-reduced-motion: reduce) { + .live-dot { animation: none; } + * { transition-duration: 0.01ms !important; } +} + +/* ── Meter (storage / progress) ─────────────────────── */ +.meter { + height: 7px; + background: var(--surface-2); + border-radius: 4px; + overflow: hidden; + margin-top: 0.65rem; +} +.meter-fill { + height: 100%; + border-radius: 4px; + background: var(--ok); + transition: width 0.5s ease; +} +.meter-fill.warn { background: var(--warn); } +.meter-fill.danger { background: var(--danger); } +.meter-legend { + display: flex; + justify-content: space-between; + font-size: var(--fs-xs); + color: var(--text-3); + margin-top: 0.45rem; +} + +/* ── Stats (semantic definition list) ───────────────── */ +dl.stats { + display: grid; + gap: 0.6rem; + margin: 0; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); +} +dl.stats > div { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.65rem 0.75rem; + min-width: 0; +} +dl.stats dt { + font-size: var(--fs-xs); + color: var(--text-3); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +dl.stats dd { + margin: 0.2rem 0 0; + font-size: var(--fs-lg); + font-weight: 600; + font-variant-numeric: tabular-nums; +} +dd.is-warn { color: var(--warn); } +dd.is-danger { color: var(--danger); } +dd.is-ok { color: var(--ok); } +dd.is-accent { color: var(--accent); } + +/* ── Buttons ────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--tap); + padding: 0 1rem; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--bg); + font-family: inherit; + font-size: var(--fs-sm); + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s, background 0.15s; +} +.btn:hover:not(:disabled) { opacity: 0.88; } +.btn:disabled { opacity: 0.5; cursor: default; } +.btn-quiet { + background: var(--surface-2); + border-color: var(--border); + color: var(--text-2); +} +.btn-quiet:hover:not(:disabled) { color: var(--text); border-color: var(--border-lt); } + +/* Compact add button — still meets 44px on touch (see media query) */ +.btn-add { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 0.7rem; + border: 1px solid var(--border-lt); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font-family: inherit; + font-size: var(--fs-xs); + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.btn-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +/* Armed = awaiting the confirming second tap */ +.btn-add.armed { + background: var(--warn); + border-color: var(--warn); + color: var(--bg); +} +.btn-add.done { + background: var(--ok-dim); + border-color: transparent; + color: var(--ok); + cursor: default; +} +.btn-add:disabled { cursor: default; } + +/* ── Search ─────────────────────────────────────────── */ +.search-bar { display: flex; gap: 0.5rem; margin-bottom: 0.85rem; } +.search-bar input { + flex: 1; + min-width: 0; + min-height: var(--tap); + padding: 0 0.85rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: inherit; + font-size: var(--fs-sm); +} +.search-bar input::placeholder { color: var(--text-3); } +.search-bar input:focus { border-color: var(--accent); outline: none; } + +.search-result { + display: none; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 0.85rem; + margin-bottom: 0.85rem; + background: var(--accent-dim); + border: 1px solid rgba(122,162,247,0.3); + border-radius: var(--radius-sm); +} +.search-result.visible { display: flex; } +.search-result-info { min-width: 0; flex: 1; } +.search-result-title { + font-size: var(--fs-sm); font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.search-result-meta { font-size: var(--fs-xs); color: var(--text-3); } +.search-msg { + font-size: var(--fs-sm); + color: var(--text-2); + padding: 0.5rem 0; +} +.search-msg.error { color: var(--danger); } + +/* ── Discover (RT) list ─────────────────────────────── */ +.rt-list { display: flex; flex-direction: column; gap: 0.5rem; } +.rt-item { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.6rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + transition: border-color 0.2s, opacity 0.3s, transform 0.3s; +} +.rt-item:hover { border-color: var(--border-lt); } +.rt-item.removing { opacity: 0; transform: scale(0.97); pointer-events: none; } +.rt-item.is-hidden { display: none; } +.rt-rank { + font-size: var(--fs-xs); + color: var(--text-3); + font-variant-numeric: tabular-nums; + width: 1.2rem; + text-align: right; + flex-shrink: 0; +} +.rt-poster { + width: 38px; height: 56px; + object-fit: cover; + border-radius: 4px; + background: var(--surface); + flex-shrink: 0; +} +.rt-info { min-width: 0; flex: 1; } +.rt-title { + font-size: var(--fs-sm); + font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.rt-year { color: var(--text-3); font-weight: 400; } +.rt-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + font-size: var(--fs-xs); + margin-top: 0.15rem; +} +.score.certified { color: var(--ok); } +.score.critics { color: var(--warn); } +.score.audience { color: var(--text-3); } +/* Description is inline + clamped — readable on touch, no hover required */ +.rt-desc { + font-size: var(--fs-xs); + color: var(--text-3); + margin-top: 0.25rem; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.rt-desc:empty { display: none; } +.show-more { + margin-top: 0.6rem; + width: 100%; + min-height: 38px; +} + +/* ── Indexers ───────────────────────────────────────── */ +.indexer-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 0.65rem; +} +.indexer-item { + display: flex; + align-items: center; + gap: 0.65rem; + min-height: var(--tap); + padding: 0.75rem 0.9rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: var(--fs-base); + min-width: 0; +} +.indexer-item.down { border-color: var(--danger-border); } +.indexer-item .status-light { width: 9px; height: 9px; } +.indexer-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-2); } + +/* ── Requests (Ombi) ────────────────────────────────── */ +.req-list { display: flex; flex-direction: column; gap: 0.5rem; } +.req-item.is-hidden { display: none; } +.req-item { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.6rem 0.7rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.badge { + font-size: var(--fs-xs); + font-weight: 600; + padding: 0.2rem 0.5rem; + border-radius: 100px; + white-space: nowrap; + flex-shrink: 0; +} +.badge.movie { background: var(--accent-dim); color: var(--accent); } +.badge.tv { background: var(--ok-dim); color: var(--ok); } +.badge.pending { background: var(--warn-dim); color: var(--warn); } +.badge.available, .badge.approved { background: var(--ok-dim); color: var(--ok); } +.badge.denied { background: var(--danger-dim); color: var(--danger); } +.badge.idle { background: var(--surface); color: var(--text-3); } +.badge.downloading { background: var(--accent-dim); color: var(--accent); } +.badge.paused { background: var(--warn-dim); color: var(--warn); } +.req-info { min-width: 0; flex: 1; } +.req-title { + font-size: var(--fs-sm); font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.req-sub { font-size: var(--fs-xs); color: var(--text-3); } + +/* ── Insights top lists ─────────────────────────────── */ +.top-list { display: flex; flex-direction: column; gap: 0.4rem; } +.top-item { + display: flex; + align-items: center; + gap: 0.65rem; + min-height: 3.25rem; + padding: 0.5rem 0.65rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.top-rank { + font-size: var(--fs-xs); + color: var(--text-3); + font-variant-numeric: tabular-nums; + width: 1.1rem; + flex-shrink: 0; +} +.top-info { min-width: 0; flex: 1; } +.top-name { + font-size: var(--fs-sm); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.top-sub { font-size: var(--fs-xs); color: var(--text-3); } +.top-plays { + font-size: var(--fs-xs); + color: var(--text-2); + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.empty { color: var(--text-3); font-size: var(--fs-sm); } + +/* ── Errors ─────────────────────────────────────────── */ +.error { + background: var(--surface-2); + border: 1px solid var(--border); + border-left: 3px solid var(--danger); + border-radius: var(--radius-sm); + padding: 0.8rem 0.9rem; + color: var(--text-2); + font-size: var(--fs-sm); +} +.error-detail { margin-top: 0.5rem; } +.error-detail summary { + cursor: pointer; + font-size: var(--fs-xs); + color: var(--text-3); + list-style: none; + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0; +} +.error-detail summary::-webkit-details-marker { display: none; } +.error-detail summary::before { content: "▸"; } +.error-detail[open] summary::before { content: "▾"; } +.error-detail summary:hover { color: var(--text-2); } +.error-detail pre { + margin: 0.4rem 0 0; + padding: 0.6rem 0.7rem; + background: rgba(0,0,0,0.3); + border-radius: 6px; + font-family: var(--mono); + font-size: var(--fs-xs); + line-height: 1.5; + color: var(--text-3); + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; +} + +footer { + padding: 1.5rem; + text-align: center; + font-size: var(--fs-xs); + color: var(--text-3); + border-top: 1px solid var(--border); +} +/* Build identity — dim, so it reads as metadata rather than content. */ +footer .version { + display: block; + margin-top: 0.3rem; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + opacity: 0.75; +} + +.visually-hidden { + position: absolute; width: 1px; height: 1px; + padding: 0; margin: -1px; overflow: hidden; + clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +/* ── Responsive ─────────────────────────────────────── */ +@media (max-width: 1100px) { + .row.cols-3 { grid-template-columns: 1fr 1fr; } + .row.cols-3 > :first-child { grid-column: 1 / -1; } +} +@media (max-width: 820px) { + .row.cols-2, .row.cols-3 { grid-template-columns: 1fr; } + .row.cols-3 > :first-child { grid-column: auto; } +} +@media (max-width: 700px) { + header { padding: 0.7rem 1rem; gap: 0.6rem; } + .brand { order: 1; flex: 1 1 auto; min-width: 0; } + .brand h1 { font-size: var(--fs-base); } + .header-right { order: 2; } + .header-clock { display: none; } + .svc-strip { order: 3; width: 100%; flex: 1 0 100%; justify-content: flex-start; } + main { padding: 1rem; } + .card-body { padding: 0.9rem; } + .card-header { padding: 0.75rem 0.9rem; } + .search-bar { flex-direction: column; } + .search-bar .btn { width: 100%; } + /* Full touch targets once there's no mouse */ + .btn-add { min-height: var(--tap); padding: 0 0.9rem; } + .search-bar input { font-size: 16px; } /* prevent iOS zoom on focus */ +} diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..d2e306b --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,83 @@ +/* Login page — tokens and base reset come from base.css */ + +/* Centre the card in the viewport (overrides base.css body). */ +body { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +.wrap { width: 100%; max-width: 360px; } + +.brand { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + margin-bottom: 1.75rem; +} +.brand h1 { + margin: 0; + font-size: var(--fs-lg); + font-weight: 600; + letter-spacing: -0.01em; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; +} + +label { + display: block; + font-size: var(--fs-xs); + font-weight: 600; + color: var(--text-2); + margin-bottom: 0.4rem; +} +input { + width: 100%; + min-height: var(--tap); + padding: 0 0.85rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: inherit; + font-size: 16px; /* prevents iOS zoom on focus */ + outline: none; + transition: border-color 0.2s; +} +input:focus { border-color: var(--accent); } +input::placeholder { color: var(--text-3); } + +.error-msg { + background: var(--surface-2); + border: 1px solid var(--border); + border-left: 3px solid var(--danger); + border-radius: var(--radius-sm); + padding: 0.7rem 0.85rem; + color: var(--text-2); + font-size: var(--fs-sm); + margin-bottom: 1rem; +} + +button { + width: 100%; + min-height: var(--tap); + margin-top: 1rem; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius-sm); + font-family: inherit; + font-size: var(--fs-sm); + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +button:hover { opacity: 0.88; } diff --git a/static/css/settings.css b/static/css/settings.css new file mode 100644 index 0000000..8be2553 --- /dev/null +++ b/static/css/settings.css @@ -0,0 +1,138 @@ +/* Settings page — tokens and base reset come from base.css */ + +header { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + gap: 0.65rem; + position: sticky; + top: 0; + z-index: 100; +} +header h1 { + margin: 0; + font-size: var(--fs-lg); + font-weight: 600; + letter-spacing: -0.01em; +} +.back-btn { + margin-left: auto; + display: inline-flex; + align-items: center; + min-height: var(--tap); + padding: 0 0.9rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-2); + font-size: var(--fs-sm); + text-decoration: none; + white-space: nowrap; + transition: color 0.2s, border-color 0.2s; +} +.back-btn:hover { color: var(--text); border-color: var(--border-lt); } + +main { padding: 1.5rem; max-width: 900px; margin: 0 auto; } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: var(--gap); +} +.full { grid-column: 1 / -1; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.card-header { + padding: 0.85rem 1.1rem; + border-bottom: 1px solid var(--border); +} +.card-header h2 { + margin: 0; + font-size: var(--fs-sm); + font-weight: 600; +} +.card-body { padding: 1.1rem; } + +.field { margin-bottom: 0.9rem; } +label { + display: block; + font-size: var(--fs-xs); + font-weight: 600; + color: var(--text-2); + margin-bottom: 0.35rem; +} +input, select { + width: 100%; + min-height: var(--tap); + padding: 0 0.8rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: inherit; + font-size: 16px; /* prevents iOS zoom on focus */ + outline: none; + transition: border-color 0.2s; +} +input:focus, select:focus { border-color: var(--accent); } +input::placeholder { color: var(--text-3); } + +.hint { + font-size: var(--fs-xs); + color: var(--text-3); + margin-top: 0.35rem; +} + +button.save { + width: 100%; + min-height: var(--tap); + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius-sm); + font-family: inherit; + font-size: var(--fs-sm); + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +button.save:hover:not(:disabled) { opacity: 0.88; } +button.save:disabled { background: var(--border); color: var(--text-3); cursor: default; } + +.error-inline { + font-size: var(--fs-xs); + color: var(--danger); + display: none; + margin-top: 0.35rem; +} + +.toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--surface); + border: 1px solid var(--ok); + color: var(--ok); + padding: 0.7rem 1.1rem; + border-radius: var(--radius-sm); + font-size: var(--fs-sm); + font-weight: 500; + opacity: 0; + transition: opacity 0.3s; + pointer-events: none; + max-width: calc(100vw - 3rem); +} +.toast.error { border-color: var(--danger); color: var(--danger); } +.toast.show { opacity: 1; } + +@media (max-width: 600px) { + header { padding: 0.7rem 1rem; } + main { padding: 1rem; } + .toast { left: 1rem; right: 1rem; bottom: 1rem; } +} diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..f26a518 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..f27da46 --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,400 @@ +/* Server-rendered config, read off (see dashboard.html). + URL_BASE carries the reverse-proxy prefix so /api/* fetches still + resolve when the app is mounted on a subpath via SCRIPT_NAME. */ +const URL_BASE = document.body.dataset.urlBase || ""; +const TIMEZONE = document.body.dataset.timezone || "UTC"; + +let sonarrSearchResult = null; +let radarrSearchResult = null; + +/* ── Clock ── */ +function updateClock() { + const el = document.getElementById('header-clock'); + if (!el) return; + try { + el.textContent = new Date().toLocaleTimeString('en-US', { + timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit' + }); + } catch (e) { + el.textContent = new Date().toLocaleTimeString(); + } +} +updateClock(); +setInterval(updateClock, 1000); + +/* ── Discover: show more / show less ── */ +function toggleMore(btn, listId) { + const list = document.getElementById(listId); + if (!list) return; + const items = Array.from(list.children); + const hidden = items.filter(el => el.classList.contains('is-hidden')); + if (hidden.length) { + hidden.forEach(el => el.classList.remove('is-hidden')); + btn.textContent = 'Show less'; + btn.setAttribute('aria-expanded', 'true'); + } else { + items.forEach((el, i) => { + if (i >= 5) el.classList.add('is-hidden'); + }); + const n = items.length - 5; + btn.textContent = 'Show ' + n + ' more'; + btn.setAttribute('aria-expanded', 'false'); + } +} + +/* ── Add flow: first tap arms, second tap commits ── + Adding queues a real download, so a single stray tap shouldn't do it. */ +let armedBtn = null; +let armedTimer = null; + +function disarm() { + if (armedBtn) { + armedBtn.classList.remove('armed'); + armedBtn.textContent = '+ Add'; + armedBtn = null; + } + clearTimeout(armedTimer); +} + +function armAdd(btn) { + if (armedBtn === btn) { disarm(); return doAdd(btn); } + disarm(); + armedBtn = btn; + btn.classList.add('armed'); + btn.textContent = 'Confirm?'; + armedTimer = setTimeout(disarm, 4000); +} + +function removeRtItem(btn) { + const item = btn.closest('.rt-item'); + if (!item) return; + item.classList.add('removing'); + setTimeout(() => { + const list = item.parentElement; + item.remove(); + if (list) { + let n = 0; + list.querySelectorAll('.rt-item').forEach(el => { + el.querySelector('.rt-rank').textContent = ++n; + }); + } + }, 320); +} + +async function doAdd(btn) { + const title = btn.dataset.title; + const target = btn.dataset.target; // 'sonarr' | 'radarr' + btn.disabled = true; + btn.textContent = 'Adding…'; + try { + const resp = await fetch(URL_BASE + '/add-to-' + target, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ title }) + }); + const data = await resp.json(); + if ((resp.ok && data.success) || resp.status === 409) { + removeRtItem(btn); + } else { + btn.disabled = false; + btn.textContent = '+ Add'; + alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error')); + } + } catch (e) { + btn.disabled = false; + btn.textContent = '+ Add'; + alert('Network error: ' + e.message); + } +} + +// A tap anywhere else cancels a pending confirm. +document.addEventListener('click', e => { + if (armedBtn && !e.target.closest('.btn-add')) disarm(); +}); + +/* ── SABnzbd live polling (never cached server-side) ── */ +const sabSpeed = document.getElementById('sab-speed'); +const sabQueue = document.getElementById('sab-queue'); +const sabRemaining = document.getElementById('sab-remaining'); +const sabBadge = document.getElementById('sab-badge'); +const sabTimeleft = document.getElementById('sab-timeleft'); +const sabTimeleftRow = document.getElementById('sab-timeleft-row'); + +async function pollSabnzbd() { + try { + const resp = await fetch(URL_BASE + '/api/sabnzbd', { cache: 'no-store', credentials: 'include' }); + if (!resp.ok) return; + const d = await resp.json(); + if (d.status !== 'ok') return; + if (sabSpeed) sabSpeed.textContent = d.speed; + if (sabQueue) sabQueue.textContent = d.queue_count; + if (sabRemaining) sabRemaining.textContent = d.sizeleft; + if (sabBadge) { + const s = (d.sab_status || '').toLowerCase(); + sabBadge.className = 'badge ' + (s === 'downloading' ? 'downloading' : s === 'paused' ? 'paused' : 'idle'); + sabBadge.textContent = d.sab_status; + } + const active = (d.sab_status || '').toLowerCase() === 'downloading' + && d.timeleft && d.timeleft !== '0:00:00'; + if (sabTimeleftRow) sabTimeleftRow.style.display = active ? 'inline' : 'none'; + if (sabTimeleft) sabTimeleft.textContent = d.timeleft; + const pill = document.getElementById('svc-stat-sabnzbd'); + if (pill) pill.textContent = d.speed; + } catch (e) { /* transient network error — try again next tick */ } +} +pollSabnzbd(); +setInterval(pollSabnzbd, 3000); + +/* ── RT descriptions arrive asynchronously from IMDB ── */ +async function loadRtDescriptions() { + try { + const resp = await fetch(URL_BASE + '/api/rt/descriptions', { credentials: 'include' }); + if (!resp.ok) return; + const d = await resp.json(); + for (const [title, desc] of Object.entries(d.descriptions || {})) { + const item = document.querySelector(`.rt-item[data-rt-title="${CSS.escape(title)}"]`); + if (!item || !desc) continue; + const el = item.querySelector('.rt-desc'); + if (!el || el.textContent.trim()) continue; // already filled + el.textContent = desc.length > 180 ? desc.slice(0, 180) + '…' : desc; + } + if (d.pending) setTimeout(loadRtDescriptions, 4000); + } catch (e) { /* silently skip */ } +} +setTimeout(loadRtDescriptions, 1500); + +/* ── Prowlarr indexer polling ── */ +function updateServicePill(name, isUp) { + const pill = document.getElementById('svc-' + name); + const light = document.getElementById('svc-light-' + name); + if (pill) { pill.classList.toggle('up', isUp); pill.classList.toggle('down', !isUp); } + if (light) { light.classList.toggle('up', isUp); light.classList.toggle('down', !isUp); } +} + +async function pollProwlarr() { + try { + const resp = await fetch(URL_BASE + '/api/prowlarr', { credentials: 'include' }); + if (!resp.ok) return; + const d = await resp.json(); + if (d.status !== 'ok') return; + const upEl = document.getElementById('prowlarr-up-count'); + const totEl = document.getElementById('prowlarr-total'); + if (upEl) upEl.textContent = d.up_count; + if (totEl) totEl.textContent = d.total; + d.indexers.forEach(idx => { + const row = document.getElementById('idx-' + idx.id); + const light = document.getElementById('idx-light-' + idx.id); + if (row) { row.classList.toggle('up', idx.up); row.classList.toggle('down', !idx.up); } + if (light) { light.classList.toggle('up', idx.up); light.classList.toggle('down', !idx.up); } + }); + updateServicePill('prowlarr', d.up_count === d.total && d.total > 0); + const stat = document.getElementById('svc-stat-prowlarr'); + if (stat) stat.textContent = d.up_count + '/' + d.total + ' up'; + } catch (e) { /* silently skip */ } +} +pollProwlarr(); +setInterval(pollProwlarr, 30000); + +/* ── Header service lights ── */ +async function pollServices() { + try { + const resp = await fetch(URL_BASE + '/api/services', { credentials: 'include' }); + if (!resp.ok) return; + const d = await resp.json(); + for (const [name, isUp] of Object.entries(d)) updateServicePill(name, isUp); + } catch (e) { /* silently skip */ } +} +pollServices(); +setInterval(pollServices, 60000); + +/* ── Tautulli live streams ── */ +async function pollTautulli() { + try { + const resp = await fetch(URL_BASE + '/api/tautulli', { credentials: 'include' }); + if (!resp.ok) return; + const d = await resp.json(); + if (d.status !== 'ok') return; + + const count = d.stream_count; + const sessions = d.sessions || []; + + const stat = document.getElementById('svc-stat-tautulli'); + if (stat) stat.textContent = count > 0 ? count + ' streaming' : '0 streams'; + + const countEl = document.getElementById('tautulli-count'); + if (countEl) countEl.textContent = count + ' active'; + + const body = document.getElementById('now-playing'); + if (!body) return; + if (sessions.length) { + body.innerHTML = sessions.map(s => { + const icon = s.media_type === 'movie' ? '🎬' : '📺'; + const esc = t => String(t).replace(/[&<>"']/g, c => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + return `
+ + + + ${esc(s.title)} + ${esc(s.user)} + +
`; + }).join(''); + } else { + body.innerHTML = '

Nothing streaming right now.

'; + } + } catch (e) { /* silently skip */ } +} +pollTautulli(); +setInterval(pollTautulli, 15000); + +/* ── Clear search results when the field is emptied ── */ +document.getElementById('sonarr-search-input').addEventListener('input', function () { + if (!this.value.trim()) { + document.getElementById('sonarr-search-result').classList.remove('visible'); + document.getElementById('sonarr-search-msg').style.display = 'none'; + sonarrSearchResult = null; + } +}); +document.getElementById('radarr-search-input').addEventListener('input', function () { + if (!this.value.trim()) { + document.getElementById('radarr-search-result').classList.remove('visible'); + document.getElementById('radarr-search-msg').style.display = 'none'; + radarrSearchResult = null; + } +}); + +/* ── Sonarr search ── */ +async function searchSonarr() { + const input = document.getElementById('sonarr-search-input'); + const btn = document.getElementById('sonarr-search-btn'); + const resultEl = document.getElementById('sonarr-search-result'); + const msgEl = document.getElementById('sonarr-search-msg'); + const title = input.value.trim(); + if (!title) return; + + btn.disabled = true; btn.textContent = 'Searching…'; + resultEl.classList.remove('visible'); + msgEl.style.display = 'none'; + sonarrSearchResult = null; + + try { + const resp = await fetch(URL_BASE + '/api/sonarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' }); + if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) { + msgEl.textContent = 'Session expired — please refresh the page.'; + msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; + btn.disabled = false; btn.textContent = 'Search'; return; + } + const d = await resp.json(); + if (!resp.ok || d.error) { + msgEl.textContent = d.error || 'Search failed.'; + msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; + } else if (!d.title) { + msgEl.textContent = 'No results found.'; + msgEl.className = 'search-msg'; msgEl.style.display = 'block'; + } else { + sonarrSearchResult = d; + document.getElementById('sonarr-result-title').textContent = d.title; + document.getElementById('sonarr-result-meta').textContent = + (d.year || '') + (d.network ? ' · ' + d.network : '') + (d.status ? ' · ' + d.status : ''); + const addBtn = document.getElementById('sonarr-add-btn'); + if (d.exists) { + addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done'; + } else { + addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add'; + } + resultEl.classList.add('visible'); + } + } catch (e) { + msgEl.textContent = 'Search failed: ' + e.message; + msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; + } + btn.disabled = false; btn.textContent = 'Search'; +} + +async function confirmAddSonarr() { + if (!sonarrSearchResult) return; + const addBtn = document.getElementById('sonarr-add-btn'); + addBtn.disabled = true; addBtn.textContent = 'Adding…'; + try { + const resp = await fetch(URL_BASE + '/add-to-sonarr', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ title: sonarrSearchResult.title }) + }); + const d = await resp.json(); + if ((resp.ok && d.success) || resp.status === 409) { + addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done'; + } else { + addBtn.disabled = false; addBtn.textContent = '+ Add'; + alert('Failed: ' + (d.error || 'Unknown error')); + } + } catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; } +} + +/* ── Radarr search ── */ +async function searchRadarr() { + const input = document.getElementById('radarr-search-input'); + const btn = document.getElementById('radarr-search-btn'); + const resultEl = document.getElementById('radarr-search-result'); + const msgEl = document.getElementById('radarr-search-msg'); + const title = input.value.trim(); + if (!title) return; + + btn.disabled = true; btn.textContent = 'Searching…'; + resultEl.classList.remove('visible'); + msgEl.style.display = 'none'; + radarrSearchResult = null; + + try { + const resp = await fetch(URL_BASE + '/api/radarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' }); + if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) { + msgEl.textContent = 'Session expired — please refresh the page.'; + msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; + btn.disabled = false; btn.textContent = 'Search'; return; + } + const d = await resp.json(); + if (!resp.ok || d.error) { + msgEl.textContent = d.error || 'Search failed.'; + msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; + } else if (!d.title) { + msgEl.textContent = 'No results found.'; + msgEl.className = 'search-msg'; msgEl.style.display = 'block'; + } else { + radarrSearchResult = d; + document.getElementById('radarr-result-title').textContent = d.title; + document.getElementById('radarr-result-meta').textContent = + (d.year || '') + (d.studio ? ' · ' + d.studio : '') + (d.runtime ? ' · ' + d.runtime + ' min' : ''); + const addBtn = document.getElementById('radarr-add-btn'); + if (d.exists) { + addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done'; + } else { + addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add'; + } + resultEl.classList.add('visible'); + } + } catch (e) { + msgEl.textContent = 'Search failed: ' + e.message; + msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; + } + btn.disabled = false; btn.textContent = 'Search'; +} + +async function confirmAddRadarr() { + if (!radarrSearchResult) return; + const addBtn = document.getElementById('radarr-add-btn'); + addBtn.disabled = true; addBtn.textContent = 'Adding…'; + try { + const resp = await fetch(URL_BASE + '/add-to-radarr', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ title: radarrSearchResult.title }) + }); + const d = await resp.json(); + if ((resp.ok && d.success) || resp.status === 409) { + addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done'; + } else { + addBtn.disabled = false; addBtn.textContent = '+ Add'; + alert('Failed: ' + (d.error || 'Unknown error')); + } + } catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; } +} diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000..d89005b --- /dev/null +++ b/static/js/settings.js @@ -0,0 +1,61 @@ +/* Reverse-proxy prefix, so /api/* still resolves on a subpath mount. */ +const URL_BASE = document.body.dataset.urlBase || ""; + +function showToast(msg, isError) { + const t = document.getElementById('toast'); + t.textContent = msg; + t.classList.toggle('error', !!isError); + t.classList.add('show'); + setTimeout(() => t.classList.remove('show'), 2800); +} + +async function post(url, payload) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload) + }); +} + +async function saveGeneral() { + try { + const resp = await post(URL_BASE + '/api/settings', { + timezone: document.getElementById('timezone').value, + arr_host: document.getElementById('arr_host').value.trim() + }); + showToast(resp.ok ? 'General settings saved — reload the dashboard to apply.' + : 'Save failed.', !resp.ok); + } catch (e) { showToast('Network error — not saved.', true); } +} + +async function saveService(name) { + try { + const key = document.getElementById(name + '_api_key').value.trim(); + const resp = await post(URL_BASE + '/api/settings', { [name + '_api_key']: key }); + const label = name.charAt(0).toUpperCase() + name.slice(1); + showToast(resp.ok ? label + ' key saved — restart the container to apply.' + : 'Save failed.', !resp.ok); + } catch (e) { showToast('Network error — not saved.', true); } +} + +async function changePassword() { + const errEl = document.getElementById('pw-error'); + errEl.style.display = 'none'; + try { + const resp = await post(URL_BASE + '/api/change-password', { + current: document.getElementById('pw_current').value, + new: document.getElementById('pw_new').value, + confirm: document.getElementById('pw_confirm').value + }); + const data = await resp.json(); + if (resp.ok) { + ['pw_current', 'pw_new', 'pw_confirm'].forEach(id => document.getElementById(id).value = ''); + showToast('Password updated.'); + } else { + errEl.textContent = data.error || 'Failed to update password.'; + errEl.style.display = 'block'; + showToast(data.error || 'Failed to update password.', true); + } + } catch (e) { showToast('Network error — password not changed.', true); } +} diff --git a/templates/dashboard.html b/templates/dashboard.html index 8820d88..c6ff28d 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -4,672 +4,12 @@ Media Centre - + - + + {#- Degraded service: human-readable cause, raw exception behind a disclosure with any API key redacted. -#} @@ -694,7 +34,7 @@ {{ stat }} {% endmacro %} - +
@@ -1153,406 +493,12 @@ -
Media Centre
+
+ Media Centre + v{{ app_version }} · {{ app_version_date }} +
- + diff --git a/templates/login.html b/templates/login.html index 4064bbd..2aaecd7 100644 --- a/templates/login.html +++ b/templates/login.html @@ -4,122 +4,12 @@ Sign in — Media Centre - + - + + diff --git a/templates/settings.html b/templates/settings.html index 9a810ff..502eee0 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -4,187 +4,14 @@ Settings — Media Centre - + - + + - +
- +