From 9a8eb8ac452edc2d86de8cc3a9fa03a2e0324f42 Mon Sep 17 00:00:00 2001 From: ndandan Date: Sun, 12 Jul 2026 20:19:10 -0500 Subject: [PATCH 1/2] feat(library): remember film/series filters per instance Persists each instance's last-used filter set (search, status, quality/ genre/language/network, sort) in localStorage keyed by instance + type, and restores it on return via the sidebar or Films/Series nav. A head- loaded script rehydrates the form before first paint; the reset link clears saved state; explicit query params still win. --- CHANGELOG.md | 3 + .../public/static/js/library-filter-memory.js | 118 ++++++++++++++++++ symfony/templates/base.html.twig | 16 ++- symfony/templates/media/_radarr_nav.html.twig | 2 +- symfony/templates/media/_sonarr_nav.html.twig | 2 +- symfony/templates/media/films.html.twig | 4 +- symfony/templates/media/series.html.twig | 4 +- 7 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 symfony/public/static/js/library-filter-memory.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d653acfd..e0fa45f2 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 (search text, status, quality/genre/language/network, sort) in `localStorage`, keyed per instance and per library type, and restore it when you return via the sidebar or the Films/Series nav buttons. A small head-loaded script rehydrates the form before first paint so the list renders already-filtered, and the "reset filters" link clears the saved state. Deep links with explicit query params always win over the remembered set. + ### 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..eb8ff586 --- /dev/null +++ b/symfony/public/static/js/library-filter-memory.js @@ -0,0 +1,118 @@ +// 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); }); + } + + // 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 qs = trackedQS(window.location.search); + + if (qs) { + // Viewing a filtered URL — remember it. + writeSaved(key, qs); + } else if (!window.location.search) { + // 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. #}