Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>`, 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.
Expand Down
138 changes: 138 additions & 0 deletions symfony/public/static/js/library-filter-memory.js
Original file line number Diff line number Diff line change
@@ -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 <head>; 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
// <head> 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
};
})();
16 changes: 10 additions & 6 deletions symfony/templates/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@
<link rel="stylesheet" href="{{ asset('static/tabler/css/tabler.min.css') }}"/>
<link rel="stylesheet" href="{{ asset('static/tabler/css/tabler-themes.min.css') }}"/>

{# Library filter memory: loaded once in <head> so Turbo runs it a single
time; it binds its own turbo:load/DOMContentLoaded listeners. #}
<script defer src="{{ asset('static/js/library-filter-memory.js') }}"></script>

{# 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.
Expand Down Expand Up @@ -835,15 +839,15 @@
{% if _radarr_count <= 1 %}
{# Single instance — keep the legacy URL so existing bookmarks survive. #}
<li class="nav-item">
<a class="nav-link {{ _radarr_active ? 'active' }}" href="{{ instance_path('app_media_films') }}">
<a class="nav-link {{ _radarr_active ? 'active' }} js-libfilter-link" href="{{ instance_path('app_media_films') }}">
{{ _radarr_icon|raw }}
<span class="nav-link-title">Radarr</span>
</a>
</li>
{% elseif _radarr_count <= 3 %}
{% for _inst in _radarr_instances %}
<li class="nav-item">
<a class="nav-link {{ _radarr_active and _radarr_slug == _inst.slug ? 'active' }}"
<a class="nav-link {{ _radarr_active and _radarr_slug == _inst.slug ? 'active' }} js-libfilter-link"
href="{{ instance_path('app_media_films', { slug: _inst.slug }) }}">
{{ _radarr_icon|raw }}
<span class="nav-link-title">{{ _inst.name }}</span>
Expand All @@ -860,7 +864,7 @@
</a>
<div class="dropdown-menu" id="radarr-instances-menu">
{% for _inst in _radarr_instances %}
<a class="dropdown-item {{ _radarr_active and _radarr_slug == _inst.slug ? 'active' }}"
<a class="dropdown-item {{ _radarr_active and _radarr_slug == _inst.slug ? 'active' }} js-libfilter-link"
href="{{ instance_path('app_media_films', { slug: _inst.slug }) }}">
{{ _inst.name }}
</a>
Expand All @@ -887,15 +891,15 @@

{% if _sonarr_count <= 1 %}
<li class="nav-item">
<a class="nav-link {{ _sonarr_active ? 'active' }}" href="{{ instance_path('app_media_series') }}">
<a class="nav-link {{ _sonarr_active ? 'active' }} js-libfilter-link" href="{{ instance_path('app_media_series') }}">
{{ _sonarr_icon|raw }}
<span class="nav-link-title">Sonarr</span>
</a>
</li>
{% elseif _sonarr_count <= 3 %}
{% for _inst in _sonarr_instances %}
<li class="nav-item">
<a class="nav-link {{ _sonarr_active and _sonarr_slug == _inst.slug ? 'active' }}"
<a class="nav-link {{ _sonarr_active and _sonarr_slug == _inst.slug ? 'active' }} js-libfilter-link"
href="{{ instance_path('app_media_series', { slug: _inst.slug }) }}">
{{ _sonarr_icon|raw }}
<span class="nav-link-title">{{ _inst.name }}</span>
Expand All @@ -912,7 +916,7 @@
</a>
<div class="dropdown-menu" id="sonarr-instances-menu">
{% for _inst in _sonarr_instances %}
<a class="dropdown-item {{ _sonarr_active and _sonarr_slug == _inst.slug ? 'active' }}"
<a class="dropdown-item {{ _sonarr_active and _sonarr_slug == _inst.slug ? 'active' }} js-libfilter-link"
href="{{ instance_path('app_media_series', { slug: _inst.slug }) }}">
{{ _inst.name }}
</a>
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/media/_radarr_nav.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<div class="d-flex gap-2 align-items-center" style="position:relative;">

<a href="{{ instance_path('app_media_films') }}"
class="btn btn-sm btn-primary"
class="btn btn-sm btn-primary js-libfilter-link"
title="{{ 'media.nav.library_films'|trans }}">
{{ 'media.nav.films'|trans }}
</a>
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/media/_sonarr_nav.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<div class="d-flex gap-2 align-items-center" style="position:relative;">

<a href="{{ instance_path('app_media_series') }}"
class="btn btn-sm btn-primary"
class="btn btn-sm btn-primary js-libfilter-link"
title="{{ 'media.nav.library_series'|trans }}">
{{ 'media.nav.series'|trans }}
</a>
Expand Down
4 changes: 2 additions & 2 deletions symfony/templates/media/films.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ body.density-compact #modal-film .film-action-btn { font-size: .78rem; padding:
<div class="col-12 col-xl-9">
<div class="card h-100 films-toolbar-filters">
<div class="card-body py-2">
<form method="get" id="films-filter-form" action="{{ path('app_media_films', { slug: current_instance.slug }) }}" class="d-flex flex-column gap-2">
<form method="get" id="films-filter-form" action="{{ path('app_media_films', { slug: current_instance.slug }) }}" class="d-flex flex-column gap-2" data-libfilter-type="films" data-libfilter-slug="{{ current_instance.slug }}">
<input type="hidden" name="status" id="films-filter-status" value="{{ query.status }}">
{# Row 1: search input (fixed width) + 5 status buttons sharing the remaining width via flex-fill. #}
<div class="d-flex align-items-center gap-2 flex-wrap flex-md-nowrap">
Expand Down Expand Up @@ -361,7 +361,7 @@ body.density-compact #modal-film .film-action-btn { font-size: .78rem; padding:
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
{{ 'media.films.actions.search_filtered'|trans }}
</button>
<a href="{{ path('app_media_films', { slug: current_instance.slug }) }}" class="btn btn-sm btn-ghost-secondary ms-auto">{{ 'media.films.pagination.reset_filters'|trans }}</a>
<a href="{{ path('app_media_films', { slug: current_instance.slug }) }}" class="btn btn-sm btn-ghost-secondary ms-auto js-libfilter-reset">{{ 'media.films.pagination.reset_filters'|trans }}</a>
</div>
{% endif %}
</div>
Expand Down
4 changes: 2 additions & 2 deletions symfony/templates/media/series.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ body.density-compact #modal-serie .modal-body .card-header { padding-top: .4rem
<div class="col-12 col-xl-9">
<div class="card h-100 series-toolbar-filters">
<div class="card-body py-2">
<form method="get" id="series-filter-form" action="{{ path('app_media_series', { slug: current_instance.slug }) }}" class="d-flex flex-column gap-2">
<form method="get" id="series-filter-form" action="{{ path('app_media_series', { slug: current_instance.slug }) }}" class="d-flex flex-column gap-2" data-libfilter-type="series" data-libfilter-slug="{{ current_instance.slug }}">
<input type="hidden" name="status" id="series-filter-status" value="{{ query.status }}">
{# Row 1: search input (fixed width) + 7 status buttons sharing the remaining width via flex-fill. #}
<div class="d-flex align-items-center gap-2 flex-wrap flex-md-nowrap">
Expand Down Expand Up @@ -394,7 +394,7 @@ body.density-compact #modal-serie .modal-body .card-header { padding-top: .4rem
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
{{ 'media.series.actions.search_filtered'|trans }}
</button>
<a href="{{ path('app_media_series', { slug: current_instance.slug }) }}" class="btn btn-sm btn-ghost-secondary ms-auto">{{ 'media.series.pagination.reset_filters'|trans }}</a>
<a href="{{ path('app_media_series', { slug: current_instance.slug }) }}" class="btn btn-sm btn-ghost-secondary ms-auto js-libfilter-reset">{{ 'media.series.pagination.reset_filters'|trans }}</a>
</div>
{% endif %}
</div>
Expand Down