diff --git a/CHANGELOG.md b/CHANGELOG.md index d653acfd..56fb8daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Library filters are remembered per instance.** The films and series pages now persist their last-used filter set (status, quality/genre/language/network, sort — deliberately not the typed search text or page number) in `localStorage`, keyed per instance and per library type, and restore it when you return via the sidebar or the Films/Series nav buttons: sidebar links are rewritten to carry the saved query directly, and a bare direct visit redirects once onto the saved set. The "reset filters" links clear the saved state. Deep links with explicit query params always win over the remembered set — including `?open=` quick-look deep links, which the script reads from the Navigation Timing entry so the page's own `history.replaceState` cleanup can't disguise them as bare visits. + ### Fixed - **Whole-server lockup on Unraid when the data volume sits on a FUSE share (`/mnt/user`)**. PHP's native file session handler holds an exclusive `flock` on the session file for the entire request, and the dashboard fires ~6 widget fragments in parallel, so they all serialised on that one lock while their slow Radarr/Sonarr calls ran. On Unraid the session file lives on the shfs/FUSE share, where `flock` contention is expensive enough to peg every core and freeze the whole machine (mapping the volume to `/mnt/cache` "fixed" it only by bypassing FUSE). A new `SessionLockReleaseSubscriber` now closes the session right after authentication on read-only GET requests, releasing the lock immediately so the parallel fragments stop fighting over it. POSTs, the setup wizard and internal routes keep the session open and write normally. Unraid users should still map the data volume to `/mnt/cache/...` rather than `/mnt/user/...`. - **Gluetun integration with API key set, and incorrect endpoints.** The Gluetun client authenticated using `Authorization: Bearer `, but Gluetun expects it as `X-API-Key`, so it would return a 401 error when an API key is required. Additionally, referring to the older [Control Server Docs](https://github.com/qdm12/gluetun-wiki/blob/7025b1c0e4427d4477e47d4bbd2ef3f1b5c4da71/setup/advanced/control-server.md#openvpn-and-wireguard), WireGuard doesn't get its own endpoint, so the `/v1/wireguard/status` and `/v1/wireguard/portforwarded` calls were incorrect. The client now sends `X-API-Key` and uses the unified `/v1/vpn/status` and `/v1/portforward` endpoints, with the legacy `/v1/openvpn/` paths as a fallback. With that, the protocol selector in the settings becomes redundant and was removed. diff --git a/symfony/public/static/js/library-filter-memory.js b/symfony/public/static/js/library-filter-memory.js new file mode 100644 index 00000000..ee63c358 --- /dev/null +++ b/symfony/public/static/js/library-filter-memory.js @@ -0,0 +1,138 @@ +// Persists per-tab library filters (films/series) in localStorage so each tab +// reopens with the user's last-used filters. Loaded once in ; all +// per-page state is read from a [data-libfilter-type] marker on the filter +// form each navigation, so there are no per-page scripts and no accumulating +// listeners. Design doc: +// docs/superpowers/specs/2026-07-12-persistent-library-filters-design.md +(function () { + 'use strict'; + + // Bind exactly once for the life of the document. Turbo keeps identical + // scripts across visits, but guard anyway so a stray re-eval can + // never double-bind the document-level listeners registered below. + if (window.PrismarrLibFilters) { return; } + + var EXCLUDE = { q: true, page: true, open: true }; + + function isExcluded(k) { + return Object.prototype.hasOwnProperty.call(EXCLUDE, k); + } + + function storageKey(type, slug) { + return 'prismarr_' + type + '_filters:' + slug; + } + + // Build the tracked query string: drop search/page/open + empty values. + function trackedQS(search) { + var params = new URLSearchParams(search || ''); + var out = new URLSearchParams(); + params.forEach(function (v, k) { + if (isExcluded(k)) { return; } + if (v === '' || v == null) { return; } + out.append(k, v); + }); + return out.toString(); + } + + function readSaved(key) { + try { return localStorage.getItem(key) || ''; } catch (e) { return ''; } + } + function writeSaved(key, qs) { + try { localStorage.setItem(key, qs); } catch (e) {} + } + function clearSaved(key) { + try { localStorage.removeItem(key); } catch (e) {} + } + + function bindReset(el, key) { + el.addEventListener('click', function () { clearSaved(key); }); + } + + // The query string this document was *navigated to*. Page scripts rewrite + // window.location with history.replaceState before our listeners run — the + // films/series deep-link handler strips ?open={id} that way — and reading + // the live URL would then mistake a deep link for a bare one and reload + // into the saved filters, killing the modal the deep link was opening. + // The Navigation Timing entry keeps the real landing URL; replaceState + // cannot touch it. Trust it only while it still describes this path: after + // a Turbo visit elsewhere it belongs to the previous document. + function landingSearch() { + try { + var nav = performance.getEntriesByType('navigation')[0]; + if (nav && nav.name) { + var landed = new URL(nav.name, window.location.origin); + if (landed.pathname === window.location.pathname) { return landed.search; } + } + } catch (e) {} + return window.location.search; + } + + // Save/restore for the current page if it is a library page. The page marks + // itself with data-libfilter-type / data-libfilter-slug on its filter form. + function syncCurrentPage() { + var el = document.querySelector('[data-libfilter-type][data-libfilter-slug]'); + if (!el) { return; } + var type = el.getAttribute('data-libfilter-type'); + var slug = el.getAttribute('data-libfilter-slug'); + if (!type || !slug) { return; } + + var key = storageKey(type, slug); + var landed = landingSearch(); + var qs = trackedQS(landed); + + if (qs) { + // Viewing a filtered URL — remember it. + writeSaved(key, qs); + } else if (!landed) { + // Completely empty URL — restore saved filters if any. + var saved = readSaved(key); + if (saved) { + window.location.replace(window.location.pathname + '?' + saved); + return; // navigating away; nothing else to wire on a dead page + } + } + + // Reset/clear controls wipe the saved state before their normal + // navigation. These elements are recreated on every Turbo body swap, so + // binding here each navigation never accumulates on a live element. + var resets = document.querySelectorAll('.js-libfilter-reset'); + for (var i = 0; i < resets.length; i++) { + bindReset(resets[i], key); + } + } + + // Rewrite marked library nav links to carry the saved filters, so the common + // navigation path lands filtered with no reload flash. Stateless and + // idempotent: re-reads the DOM/localStorage each call and skips links that + // already carry a query. + function rewriteNavLinks() { + var links = document.querySelectorAll('a.js-libfilter-link'); + for (var i = 0; i < links.length; i++) { + var a = links[i]; + var href = a.getAttribute('href') || ''; + if (href.indexOf('?') !== -1) { continue; } // already carries a query + var m = href.match(/\/medias\/([^/]+)\/(films|series)(?:[?#]|$)/); + if (!m) { continue; } + var saved = readSaved(storageKey(m[2], m[1])); + if (saved) { a.setAttribute('href', href + '?' + saved); } + } + } + + function onNavigate() { + rewriteNavLinks(); + syncCurrentPage(); + } + + // turbo:load fires on the initial load and after each Turbo visit; + // DOMContentLoaded covers a non-Turbo initial load. Both may fire on the + // first load — onNavigate is idempotent, so that is harmless. + document.addEventListener('turbo:load', onNavigate); + document.addEventListener('DOMContentLoaded', onNavigate); + + window.PrismarrLibFilters = { + onNavigate: onNavigate, + rewriteNavLinks: rewriteNavLinks, + _storageKey: storageKey, + _trackedQS: trackedQS + }; +})(); diff --git a/symfony/templates/base.html.twig b/symfony/templates/base.html.twig index 50c4c30e..32073278 100644 --- a/symfony/templates/base.html.twig +++ b/symfony/templates/base.html.twig @@ -269,6 +269,10 @@ + {# Library filter memory: loaded once in so Turbo runs it a single + time; it binds its own turbo:load/DOMContentLoaded listeners. #} + + {# Theme variables + user-picked accent — MUST be set after Tabler's own stylesheet, otherwise its own `:root { --tblr-primary }` rule wins the cascade and every page resets to Tabler's default blue. @@ -835,7 +839,7 @@ {% if _radarr_count <= 1 %} {# Single instance — keep the legacy URL so existing bookmarks survive. #}