mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
feat(library): smart collections as a library provider (#641)
Implements feedBack#636 item 2 (P1) — saved library filters that stay live, the homelab primitive FeedBack was missing (Plex smart collections / Navidrome .nsp / *arr custom filters). A collection is a saved /api/library query surfaced as a registered library provider, so it appears in the v3 source picker and inherits the whole Songs UI (paging, stats, A–Z rail, art) with no new screen. - Storage reuses the playlist subsystem: a `playlists.rules` JSON column (additive, idempotent migration). A row with rules != NULL is a smart collection; list_playlists + get_playlist filter `rules IS NULL`, so collections are excluded from the manual-playlist list and read-only to every playlist mutation that gates on get_playlist. - SmartCollectionProvider (kind="local" — matched songs are local rows, so the client's play/art paths stay on the local branch) delegates query_page/ query_stats/query_artists to the local DB with the stored rules applied; tuning_names/get_art delegate straight through. Registered via a boot scan + on create/update (replace=True) / delete. - Rules mirror the raw /api/library query params; `_sanitize_collection_rules` drops unknown keys and is applied at API ingress AND on provider load, so a hand-edited / imported bad value can't crash a query. - API: GET/POST/PUT/DELETE /api/collections. Frontend: a "+ Save as collection" action in the v3 filter drawer (local provider + active filters only) that names the current filter set and switches to it. Reviewed by Codex; 3 findings fixed (local-kind playback path, save gated to local provider, re-sanitize persisted rules). Tests: tests/test_collections_api.py (CRUD, provider filtering, restart re-registration, kind=local, corrupt-rule tolerance, playlist isolation), tests/js/v3_collections.test.js. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
331857ff2a
commit
5ed6f454e7
@@ -202,6 +202,50 @@
|
||||
return p;
|
||||
}
|
||||
|
||||
// The active filter set as a smart-collection rule object (raw query-param
|
||||
// format the backend stores). Mirrors queryParams' filter fields, minus
|
||||
// provider/page/size. Empty object → nothing worth saving as a collection.
|
||||
function currentFilterRules() {
|
||||
const f = state.filters, r = {};
|
||||
if (state.q) r.q = state.q;
|
||||
if (state.format) r.format = state.format;
|
||||
if (state.artist) r.artist = state.artist;
|
||||
if (state.album) r.album = state.album;
|
||||
if (f.arr_has.length) r.arrangements_has = f.arr_has.join(',');
|
||||
if (f.arr_lacks.length) r.arrangements_lacks = f.arr_lacks.join(',');
|
||||
if (f.stem_has.length) r.stems_has = f.stem_has.join(',');
|
||||
if (f.stem_lacks.length) r.stems_lacks = f.stem_lacks.join(',');
|
||||
if (f.lyrics) r.has_lyrics = f.lyrics;
|
||||
if (f.tunings.length) r.tunings = f.tunings.join(',');
|
||||
if (state.sort && state.sort !== 'artist') r.sort = state.sort;
|
||||
return r;
|
||||
}
|
||||
|
||||
// Save the current filter set as a smart collection (a saved live query that
|
||||
// shows up as a source in the picker). #636 item 2.
|
||||
async function saveCurrentAsCollection() {
|
||||
const rules = currentFilterRules();
|
||||
if (!Object.keys(rules).length) return;
|
||||
const name = ((await window.uiPrompt({
|
||||
title: 'Save as collection',
|
||||
label: 'A live view of the current filters, in the source picker.',
|
||||
okLabel: 'Save',
|
||||
placeholder: 'Collection name',
|
||||
})) || '').trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await fetch('/api/collections', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, rules }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const col = (await res.json()).collection;
|
||||
closeDrawer();
|
||||
if (col && col.id != null) state.provider = 'collection:' + col.id;
|
||||
await render(); // rebuilds the toolbar (provider picker now lists + selects it)
|
||||
} catch (e) { /* offline / aborted — leave the drawer as-is */ }
|
||||
}
|
||||
|
||||
function albumsForArtist(name) {
|
||||
const a = (state.artistCatalog || []).find((x) => x.name === name);
|
||||
return a ? (a.albums || []) : [];
|
||||
@@ -1162,6 +1206,11 @@
|
||||
}
|
||||
return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any');
|
||||
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
|
||||
// Collections always replay against the LOCAL library, so only offer
|
||||
// "save" when browsing local with a non-empty filter set.
|
||||
(state.provider === 'local' && Object.keys(currentFilterRules()).length
|
||||
? '<div class="pt-3 border-t border-fb-border/50"><button data-drawer-save class="w-full text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md py-2">+ Save as collection</button></div>'
|
||||
: '') +
|
||||
'<div class="flex justify-between pt-3 border-t border-fb-border/50"><button data-drawer-clear class="text-sm text-fb-textDim hover:text-fb-text">Clear all</button>' +
|
||||
'<button data-drawer-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Done</button></div></div>';
|
||||
|
||||
@@ -1173,6 +1222,7 @@
|
||||
renderDrawer();
|
||||
}));
|
||||
d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); }));
|
||||
d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection);
|
||||
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
|
||||
d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
|
||||
state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] };
|
||||
|
||||
Reference in New Issue
Block a user