Adds the bundled Folder Library plugin (browse the DLC library by its on-disk folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter, live search), wired into the classic v2 toolbar and the v3 Songs page. Includes the screen.js IIFE dedup (unified surface factory) and review fixes: path-traversal guard on /song/move, folder-delete data-loss fix, plural /api/plugins/<id> namespace, loose-folder song recognition, error-text escaping, v3 setLibView null-guard, and tests. Co-authored-by: Kyle <kyle.j.t@live.co.uk> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21 KiB
Folder Library — AI Agent Guide
A FeedBack (fee[dB]ack) plugin that adds a Folders nav screen showing your .sloppak / .feedpak DLC songs grouped by the folder tree on disk. Create, rename, and delete folders (including nested subfolders) directly in the UI, move songs by drag-and-drop, and browse with sort and metadata filters.
The host app is FeedBack (formerly "Slopsmith"). The frontend talks to the host through
window.feedBack;window.slopsmithis a back-compat alias the host still exposes (window.slopsmith = window.feedBackinstatic/app.js). New code should preferwindow.feedBack.
⚠️ Status — bundled core plugin. This plugin began as a standalone plugin and is now a bundled core plugin.
screen.jshas been unified into a single surface factory driving two entry points: the v3 library Folder view (host chrome — host search#v3-search/#lib-filter, host filter params, renders into#lib-folder-tree) and the classic v2 standalone Folders nav-tab (its own#fb-search+ toolbar, renders into#fb-tree). Folder search works on both surfaces — typing in the relevant search box re-renders the tree. Loose-folder songs (directories with audio + an arrangement XML) are recognised as songs via the hostloosefolder.is_loose_songpredicate, so they appear in the tree alongside.sloppak/.feedpakbundles. Folder management, nested subfolders, collapsible folders + expand/collapse-all, drag-and-drop, move-song, sort, filters, and the hover metadata badges are wired on both surfaces; verify against a running build before relying on any of it.
File Structure
plugin.json Plugin manifest — id, name, nav entry, file declarations ("bundled": true core plugin)
routes.py FastAPI backend — recursive DLC scan, folder tree + filters, folder/song mutations, two-level cache
screen.html Plugin screen content — injected by the host into the plugin div automatically
screen.js Frontend logic — recursive folder tree, search, sort, filters, drag-and-drop, modals
README.md User-facing docs
Architecture
This plugin follows the standard FeedBack plugin pattern (see the repo-root CLAUDE.md for the full plugin system reference).
- Backend (
routes.py) — registers routes underGET/POST /api/plugins/folder_library/. Usescontext["get_dlc_dir"](),context["extract_meta"](), andcontext["log"]. Scans<dlc>/sloppak/if it exists, otherwise<dlc>/. Recursively walks the tree and handles create/rename/delete folder and move-song operations on slash-separated folder paths. - Frontend (
screen.js) — plain vanilla JS in an IIFE. Fetches the tree from the backend on screen load, recursively renders collapsible folder sections (any depth) and song rows or cards (grid view). Useswindow.feedBack.on('screen:changed', ...)(via thewindow.slopsmithalias) to trigger load when the user navigates here. Callswindow.playSong(filename)on song click with the full relative path from the DLC root. - No dependencies — no npm, no build step. Tailwind utility classes available globally from the host; the plugin uses only core-guaranteed utilities and inline styles, so it ships no
stylesmanifest key.
Critical Layout Lessons (Hard-Won)
These are non-obvious behaviours of the FeedBack desktop app (Electron) that took significant debugging to discover. They still apply unchanged.
1. Do NOT put an outer wrapper div in screen.html
The host automatically creates <div id="plugin-folder_library" class="screen"> and injects screen.html content inside it. If you add your own outer div with class="screen", you get a nested screen element which gets display:none applied, hiding all content.
Wrong:
<div id="plugin-folder_library" class="screen">
<div>toolbar</div>
<div>content</div>
</div>
Correct:
<!-- no outer wrapper — the host provides it -->
<div>toolbar</div>
<div>content</div>
2. The .screen CSS class sets display:none by default
.screen { display: none } and .screen.active { display: block }. There is no height set. The screen div gets its height purely from its content. Do not try to set height via CSS classes — use inline styles or JS if needed.
3. The host navbar is position:fixed with z-index:50
The navbar sits at top:0, z-index:50. Plugin toolbars must use position:fixed; top:64px; z-index:40 to sit below the navbar. Use a solid background-color (not Tailwind bg classes — those may not apply correctly) to prevent content showing through.
4. Content must have padding-top to clear the fixed toolbar
Since the toolbar is position:fixed, it floats above the content. The content container needs enough padding-top (~120px) to ensure the first item isn't hidden behind the toolbar — the host navbar (64px) plus the plugin toolbar height (~56px). Adding more toolbar buttons increases this height, so if content is clipped, increase the padding further.
5. Electron blocks window.prompt() and window.confirm()
The desktop app is built on Electron, which throws Error: prompt() is not supported. Use a custom inline modal instead. See _showModal() in screen.js — it returns a Promise and supports both text input and confirm modes.
6. The nav plugin dropdown has z-index:50 and blocks clicks
When navigating to a plugin screen via the Plugins dropdown, the dropdown stays open and sits on top of the screen. Call _closeDropdown() on screen load to dismiss it. The dropdown element id is plugin-dropdown.
7. playSong() expects a relative path from the DLC root
window.playSong() expects the path relative to the DLC root with forward slashes, e.g. sloppak/CH/Artist - Title.sloppak. Not just the filename. The backend builds this in _meta() via "/".join(p.relative_to(dlc).parts) and returns it as each song's filename.
8. FastAPI POST routes need from fastapi import Request
Routes that receive a JSON body must import Request from fastapi explicitly and use async def route(request: Request) with body = await request.json(). Missing this import crashes the server on plugin load.
9. Plugin id must be consistent everywhere
The plugin id (folder_library) must match in:
plugin.json→"id"and"nav.screen"screen.js→PLUGIN_IDconstant andAPIconstant (/api/plugins/folder_library)routes.py→APIRouter(prefix="/api/plugins/folder_library")
A mismatch in any of these causes silent failures (blank screen, 404 API calls).
10. Use inline styles for grid layout, not Tailwind
Tailwind's grid and grid-cols-* classes may not apply reliably inside the plugin div. Use element.style.cssText with explicit display:grid; grid-template-columns:... for the grid container.
Key Conventions
- IIFE +
'use strict'— all frontend code wrapped in(function(){ 'use strict'; ... })(); - localStorage prefixes — plugin keys are prefixed
fo:(e.g.fo:view,fo:sort,fo:filters); host-library-synced filter state usesfo:lib:. Open-folder state is tracked by folder path (so nested folders each remember their own state). - Safe storage access — all
localStoragereads/writes wrapped in try/catch - Logging — backend uses
context["log"], neverprint() - Sibling imports — use
context["load_sibling"]("name")not bareimport name(none needed today; keep this in mind if you add helper modules)
Song Formats
The plugin treats both .sloppak and .feedpak as songs (_is_song() in routes.py). feedpak is the published name for the same on-disk format the codebase still calls sloppak internally — see the repo-root CLAUDE.md. Both file form (.sloppak/.feedpak zip) and directory form (*.sloppak/ folder) are recognized.
Backend Routes
| Method | Path | Description |
|---|---|---|
| GET | /api/plugins/folder_library/tree |
Returns the folder tree. Accepts optional filter query params (below) applied server-side. |
| POST | /api/plugins/folder_library/folder/create |
Body: {name, parent?} — creates a subfolder; parent (slash path) nests it inside an existing folder, omit/empty for top level |
| POST | /api/plugins/folder_library/folder/rename |
Body: {old, new} — old is a slash path, new is a bare name; renames within the same parent |
| POST | /api/plugins/folder_library/folder/delete |
Body: {name} (slash path) — moves all songs at any depth to the scan root, then removes the folder |
| POST | /api/plugins/folder_library/song/move |
Body: {filename, folder} — moves a song to folder (slash path; empty = scan root / "Unsorted") |
/tree filter query params
All optional, applied server-side over the cached full tree by _apply_tree_filters(). Comma-separated, case-insensitive:
arrangements_has,arrangements_lacks— include/exclude by arrangement namestems_has,stems_lacks— include/exclude by stem namehas_lyrics—""(any),"1", or"0"tunings— comma-separated tuning names to include
The frontend forwards the host library's active filter params here (via window.feedBackLibFilterParams() when present, with window.slopsmithLibFilterParams() as a legacy fallback) so the Folders view can stay in sync with the main library filters, falling back to its own filter panel state otherwise.
Path safety
_safe_name() rejects empty names, leading/trailing whitespace, the characters \ / : * ? " < > |, and ./... _safe_path() applies _safe_name() to every slash-separated segment, so traversal (..) and absolute paths are rejected before any filesystem op. Always validate user-supplied folder paths through these before touching disk.
Tree Shape
/tree returns:
{
"folders": [
{
"name": "CH",
"path": "CH",
"songs": [ /* song objects */ ],
"children": [
{ "name": "Live", "path": "CH/Live", "songs": [], "children": [] }
]
}
],
"root_songs": [ /* songs sitting directly in the scan root — shown as "Unsorted" */ ]
}
Folder nodes are recursive: each has name, path (slash-separated, relative to the scan root), songs, and children. The frontend renders any depth — _findFolderByPath(), _countDeep(), and _countFoldersDeep() walk the children arrays.
Song Metadata Format
Each song object (built by _meta()):
{
"filename": "sloppak/CH/Artist - Title.sloppak",
"title": "Title",
"artist": "Artist",
"album": "Album Name",
"duration": 213.5,
"year": 1993,
"tuning": "E Standard",
"added": 1748132400.0,
"arrangements": ["Lead", "Rhythm", "Bass"],
"stems": ["Drums", "Bass", "Vocals"],
"lyrics": true
}
filenameis the full relative path from the DLC root — pass it directly towindow.playSong().addedis a Unix timestamp (float, seconds) fromstat().st_mtime— convert withnew Date(added * 1000). Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.arrangements/stemsare flat lists of strings, even thoughextract_meta()returns them as objects.
extract_meta returns arrangements/stems as objects, not strings
context["extract_meta"]() returns arrangements as a list of objects {index, name, notes}, not plain strings; stems similarly. _meta() normalizes to .name:
raw_arr = raw.get("arrangements") or []
m["arrangements"] = [
a["name"] if isinstance(a, dict) else str(a)
for a in raw_arr
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
]
lyrics is coerced to a bool from several possible keys (lyrics, hasLyrics, has_lyrics, …). If you add new metadata fields from extract_meta, check the raw shape before assuming it's a plain value.
Two-Level Cache
routes.py keeps two caches inside setup():
_meta_cache— expensiveextract_meta()results keyed by absolute POSIX path. Never cleared. When files move (rename/delete/move), the keys are rewritten in-place so the warm data survives the operation._cache— the assembled tree structure (folders/root_songs). Cleared by_invalidate()on every mutation so the next/treerebuilds it — but the rebuild is fast because_meta_cacheis still warm.
filename and added are deliberately not stored in _meta_cache (they depend on the file's current location) — they're recomputed on every _meta() call and merged onto the cached copy. When you add a mutation route, mirror the existing key-rewrite logic (see rename_folder, delete_folder, move_song) so the metadata cache stays valid.
Folder Scan Logic
routes.py scans recursively starting at <dlc>/sloppak/ (or <dlc>/ if no sloppak subdir exists):
- Files/dirs matching
.sloppakor.feedpak→ song entries (root-level ones go toroot_songs, shown as "Unsorted") - Subdirectories → recursive folder nodes with their own
songs+children - Dot-prefixed entries are skipped; empty folders are still included (shown with a 0 count)
To add more grouping options (by artist, album, etc.), build an alternative projection over the scanned songs rather than the on-disk tree.
Library provider (future, not implemented)
This plugin surfaces folders as a dedicated view over the existing library;
it does not (yet) register itself as a selectable library source/provider.
If you want a "Folders" entry to appear in the host's main library-source
picker (mapping top-level folder → "artist", subfolder → "album"), implement a
provider exposing the source-aware contract (query_page, query_artists,
query_stats, tuning_names) and register it in setup() via
context["register_library_provider"](...), unregistering on teardown. (An
earlier inert FolderLibraryProvider scaffold was removed — it was never wired
and only duplicated the scan logic; re-add it only alongside real registration
and tests.)
View Modes (List / Grid)
The toolbar has a list/grid toggle. Current view is stored in localStorage under fo:view ('list' or 'grid').
- List view —
_songRow(), rendered inside aml-5 space-y-0div - Grid view —
_songCard(), rendered inside a CSS grid div (auto-fill, minmax(150px,1fr)) - Both the folder and unsorted section renderers branch on
_viewto pick the right renderer and container - Album art is fetched via
/api/song/<encoded-path>/artwhere each path segment is individuallyencodeURIComponent-encoded. On error the<img>is hidden and a placeholder SVG is shown - The collapse/expand toggle restores
display:grid(not justdisplay:'') when reopening a folder in grid mode — always check this when changing toggle logic
Lazy folder rendering
Folders do not render their song list on initial load. The folder renderer sets a _listPopulated flag and only populates the list the first time a folder is opened, keeping the initial render fast with large libraries. When search is active all folders are forced open and populated immediately (search overrides lazy loading).
Sort System
The toolbar has a sort select (#fb-sort) and a direction toggle (#fb-sort-dir). State is stored under fo:sort and fo:sortDir.
_sort—'default' | 'title' | 'artist' | 'duration' | 'year' | 'tuning' | 'added'_sortDir—'asc' | 'desc'_sortSongs(songs)returns a sorted copy; direction is applied by reversing after sort. Returns the array unchanged when_sort === 'default'.- The sort direction button is dimmed (
opacity: 0.35) and non-interactive when sort is'default'.
Filter System
Client-side filters are stored under fo:filters as a JSON object. (The server /tree endpoint can also filter — see Backend Routes — used to sync with the host library.)
Filter state shape
_filters = {
arrangements: { Lead: 'on', Bass: 'exclude', Rhythm: 'off' },
stems: { Drums: 'off' },
lyrics: 'off', // 'off' | 'on' | 'exclude'
tunings: ['E Standard', 'Eb Standard'],
}
Each arrangement/stem value is 'off' | 'on' | 'exclude'.
Include vs exclude logic
_matchFilters(song) uses OR logic for includes, AND logic for excludes:
- Include (
'on') — song passes if it has at least one selected arrangement/stem. More includes widens the result set. - Exclude (
'exclude') — each excluded tag independently removes songs that have it. More excludes narrows the result set.
This matches standard multi-select filter UX (Spotify/library style).
Data-driven filter panel
All filter sections are built from the actual library data — nothing is hardcoded:
_getArrangements()— unique arrangement names sorted by frequency (most common first), then alphabetically_getStems()— same pattern for stem names_getAvailableFilters()— returns{ arrangements, stems, lyrics, tuning }booleans gating the lyrics/tuning sections
Non-standard arrangement names (e.g. "Bonus") appear as pills automatically — no constants to update. The stems section only appears if at least one song has stems data.
Split pill UI
_makeSplitPill(label, state, onChange) renders a two-zone pill:
- Left zone (label) — toggles
'off' ↔ 'on'(include, blue) - Right zone (
✕) — toggles'off' ↔ 'exclude'(exclude, red)
The filter badge (#fb-filter-badge) shows the active filter count via _activeFilterCount().
Hover Badges
Each song row/card has two hidden hover-reveal layers, built once and toggled via CSS max-height + opacity transitions.
_badge(text, active, type)
Renders a single metadata badge. Type controls the inactive colour:
| type | inactive border | inactive text |
|---|---|---|
'arrangement' |
amber #92400e |
amber #fcd34d |
'stem' |
violet #5b21b6 |
violet #c4b5fd |
'lyrics' |
rose #9f1239 |
rose #fda4af |
'tuning' |
teal #0f766e |
teal #5eead4 |
Active state is always blue (#1d4ed8 fill, #3b82f6 border, white text) regardless of type.
_buildSongBadges(song)
Builds the badge row (arrangements, stems, lyrics, tuning), deduplicating within each category. Clicking a badge toggles that filter on/off and re-renders. Returns null if the song has no filterable metadata.
_buildSongDateInfo(song)
Builds a separate plain-text hover line showing year · date added (e.g. 1993 · 24 May 2026), #cbd5e1 text. Always shown on hover regardless of filter state.
Reveal / hide
_revealBadges(el) // max-height:120px, opacity:1, margin-top:4px
_hideBadges(el) // max-height:0, opacity:0, margin-top:0
Both badge layers (badges + date-info) are wired to the same mouseenter/mouseleave events on the row or card element.
Drag-and-Drop
Drag-and-drop uses pointer events (mousedown/mousemove/mouseup), not the HTML5 DnD API. HTML5 DnD blocks wheel events and gives unreliable edge positions inside Electron — pointer events give full control.
_makeDraggable(el, song, folderName)— attaches amousedownlistener. A drag goes "live" only after the pointer moves more than_DRAG_THRESH(5 px), preventing accidental drags on clicks.- Once live, a ghost
divfollows the cursor. Auto-scroll activates when the pointer is within_DRAG_ZONE(150 px) of the viewport top/bottom. _makeDropTarget(el, targetFolder)— setsdata-dropFolderso an element can receive drops. Both folder headers and song-list containers are drop targets — including nested folders (drop onto a subfolder header moves the song there)._dragFindTarget(x, y)— usesdocument.elementsFromPointto find the topmost element withdata-dropFolderunder the cursor.- Esc to cancel —
_onDragKeyDowncalls_endPointerDrag()onEscape, removing the ghost and clearing state without dropping. - On a successful drop,
_executeDrop()does an optimistic UI update (moves the song in the in-memory tree and re-renders) then calls/song/move. On API failure it reloads the full tree. - A one-time
clickcapture listener after mouseup suppresses the post-drag click so it doesn't trigger playback.
Modal Behaviour
_showModal(msg, withInput, defaultVal) is the custom modal used for all prompts and confirms (Electron blocks window.prompt() / window.confirm()). It returns a Promise.
_confirm(msg)— resolvestrueon OK,nullon cancel_prompt(msg, default)— resolves the trimmed input string on OK,nullon cancel- Esc cancels — resolves with
null, same as Cancel (applies to rename, delete, create folder/subfolder, move song) - Enter confirms — submits, equivalent to OK
Roadmap
Implemented since the original release: nested subfolders (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache.
Not yet implemented, in rough priority order:
- Auto-play on hover — with an on/off toggle saved to localStorage.
- Bulk move — multi-select songs and move them all at once.
- Thumbnail performance — faster loading and smoother scrolling with large libraries.
- Adjustable thumbnail/row sizes — user-resizable song cards and list rows.
- Custom themes — switchable colour schemes.
- Favoriting songs — likely a new backend route plus a
fo:favoriteslocalStorage key. - Editing song metadata — edit title, artist, album etc. in-plugin; needs new backend write routes.
- Folders as a library source — register a library provider so a "Folders" entry appears in the host's main library-source picker (see "Library provider (future)" above).