mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 11:44:30 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c53fd1f384 | ||
|
|
ee33ac7ba2 |
@@ -90,7 +90,7 @@ Best practices:
|
||||
- `extract_meta()` — metadata extraction callable
|
||||
- `meta_db` — shared MetadataDB instance
|
||||
- `library_providers` — shared library provider registry for source-aware browsing
|
||||
- `register_library_provider(provider)` — register a plugin-provided library source. Providers expose `id`, `label`, optional `kind`/`capabilities`, and callable `query_page`, `query_artists`, `query_stats`, and `tuning_names` methods. Providers with `art.read` may also expose `get_art(song_id)` returning one of: a `Response` object (any media type, served as-is); raw `bytes` or `bytearray` (**assumed PNG** — use a `Response` or a `dict` with `content`+`media_type` keys for JPEG/WebP or other formats); a URL string (http/https → 302 redirect; other schemes are rejected with 400); a filesystem path string or `Path` (served as a file with auto-detected media type); or a `dict` with a `url`, `path`, or `content` key. Providers with `song.sync` may expose `sync_song(song_id)` returning `None` (success with no local file) or a `dict` — the dict is passed through as the JSON response and should include `filename`/`local_filename` if a local playable file was produced.
|
||||
- `register_library_provider(provider)` — register a plugin-provided library source. Providers expose `id`, `label`, optional `kind`/`capabilities`, optional `slow: true` (or `is_slow`/`slow_mode`) when responses may take a while, and callable `query_page`, `query_artists`, `query_stats`, and `tuning_names` methods. Providers with `art.read` may also expose `get_art(song_id)` returning one of: a `Response` object (any media type, served as-is); raw `bytes` or `bytearray` (**assumed PNG** — use a `Response` or a `dict` with `content`+`media_type` keys for JPEG/WebP or other formats); a URL string (http/https → 302 redirect; other schemes are rejected with 400); a filesystem path string or `Path` (served as a file with auto-detected media type); or a `dict` with a `url`, `path`, or `content` key. Providers with `song.sync` may expose `sync_song(song_id)` returning `None` (success with no local file) or a `dict` — the dict is passed through as the JSON response and should include `filename`/`local_filename` if a local playable file was produced.
|
||||
- `unregister_library_provider(provider_id)` — remove a plugin-provided library source by id. The built-in `local` provider cannot be removed.
|
||||
- `get_sloppak_cache_dir()` — sloppak cache path
|
||||
- `load_sibling(name)` — loads a sibling module from this plugin's directory under a unique, namespaced module name. See "Sibling imports" below.
|
||||
@@ -585,12 +585,13 @@ a local pointer + code map.
|
||||
pytest # Run all tests
|
||||
pytest tests/test_song.py -v # Specific file
|
||||
pytest -k "round_trip" -v # Pattern match
|
||||
uv run pytest # Run through the locked local uv environment
|
||||
```
|
||||
|
||||
- Framework: pytest
|
||||
- Config: `pyproject.toml` sets `pythonpath = [".", "lib"]` and `testpaths = ["tests"]`
|
||||
- CI: GitHub Actions runs pytest on push/PR to main (Python 3.12)
|
||||
- Test dependencies: `requirements-test.txt`
|
||||
- Test dependencies: `requirements-test.txt`; uv reads the mirrored `test` dependency group in `pyproject.toml`
|
||||
|
||||
## Tuning the note_detect plugin
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ A plugin that only reads public events should declare `observer` and no command
|
||||
|
||||
A plugin that registers a remote client or generated library source declares itself as a `library` provider. The backend registration call is still made from `routes.py` with `context["register_library_provider"](...)`; the native browser library capability turns the provider registry into runtime provider participants. A thin server wrapper that only exposes the local library over HTTP should not declare `library` as a provider unless it also registers a provider in the library registry.
|
||||
|
||||
Provider objects may set `slow: true` when their backing connection can take a while to answer. Core exposes that flag through `/api/library/providers` and the browser library capability so library views show explicit loading indicators instead of looking idle during the wait.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "remote_library_client",
|
||||
|
||||
@@ -5038,6 +5038,7 @@ class LibraryProviderRegistry:
|
||||
"kind": self.provider_field(provider, "kind", "local" if provider_id == "local" else "remote"),
|
||||
"capabilities": sorted(self.provider_capabilities(provider)),
|
||||
"owner_plugin_id": owner_plugin_id,
|
||||
"slow": self.provider_slow(provider),
|
||||
"default": provider_id == "local",
|
||||
}
|
||||
|
||||
@@ -5058,6 +5059,13 @@ class LibraryProviderRegistry:
|
||||
return ""
|
||||
return label.strip()
|
||||
|
||||
def provider_slow(self, provider: object) -> bool:
|
||||
return bool(
|
||||
self.provider_field(provider, "slow", False)
|
||||
or self.provider_field(provider, "is_slow", False)
|
||||
or self.provider_field(provider, "slow_mode", False)
|
||||
)
|
||||
|
||||
def _declared_capabilities(self, provider: object) -> set[str]:
|
||||
"""Return only the capabilities explicitly declared on the provider object."""
|
||||
raw = self.provider_field(provider, "capabilities", ())
|
||||
|
||||
+7
-1
@@ -1871,7 +1871,10 @@ function _setLibraryLoadingMessage(containerId, countId, message) {
|
||||
const count = document.getElementById(countId);
|
||||
if (count) count.textContent = 'Loading source...';
|
||||
if (container) {
|
||||
container.innerHTML = `<div class="rounded-xl border border-gray-800/50 bg-dark-700/30 px-4 py-6 text-sm text-gray-300">${esc(message || 'Loading library...')}</div>`;
|
||||
container.innerHTML = `<div role="status" aria-live="polite" class="rounded-xl border border-gray-800/50 bg-dark-700/30 px-4 py-6 text-sm text-gray-300 flex items-center gap-3">
|
||||
<span class="inline-block h-4 w-4 rounded-full border-2 border-gray-600 border-t-accent animate-spin" aria-hidden="true"></span>
|
||||
<span>${esc(message || 'Loading library...')}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1880,6 +1883,9 @@ function _libraryLoadingText() {
|
||||
if (!provider || provider.id === 'local' || provider.kind === 'local') {
|
||||
return 'Loading library...';
|
||||
}
|
||||
if (provider.slow === true) {
|
||||
return `Loading ${provider.label || provider.id}... this source may take a while.`;
|
||||
}
|
||||
return `Connecting to ${provider.label || provider.id}...`;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
// operation is still completing through the provider.
|
||||
const COMMAND_TIMEOUTS_MS = {
|
||||
'audio-mix': { 'get-fader-value': 2100, 'set-fader-value': 2100 },
|
||||
'library': { 'refresh-providers': 15000, 'sync-song': 15000 },
|
||||
'midi-input': { 'discover': 15000, 'open-source': 15000 },
|
||||
};
|
||||
function _commandTimeoutFor(capability, commandName) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
label: 'My Library',
|
||||
kind: 'local',
|
||||
capabilities: ['library.read', 'art.read', 'song.play', 'favorite.write', 'metadata.write', 'retune.write'],
|
||||
slow: false,
|
||||
default: true,
|
||||
});
|
||||
const PROVIDER_OPERATIONS = Object.freeze({
|
||||
@@ -72,6 +73,7 @@
|
||||
label: String(provider.label || provider.name || providerId),
|
||||
kind: String(provider.kind || (providerId === 'local' ? 'local' : 'remote')),
|
||||
capabilities: _strings(provider.capabilities),
|
||||
slow: provider.slow === true || provider.is_slow === true || provider.slow_mode === true,
|
||||
default: provider.default === true || providerId === 'local',
|
||||
};
|
||||
}
|
||||
@@ -141,6 +143,7 @@
|
||||
label: provider.label || providerId,
|
||||
kind: provider.kind || (providerId === 'local' ? 'local' : 'remote'),
|
||||
capabilities: _strings(provider.capabilities),
|
||||
slow: provider.slow === true,
|
||||
ownerPluginId: _ownerPluginId(provider) || null,
|
||||
default: provider.default === true || providerId === 'local',
|
||||
},
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+28
-3
@@ -64,6 +64,7 @@
|
||||
|
||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] },
|
||||
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [],
|
||||
providers: [],
|
||||
artistCatalog: [], renderedHash: '',
|
||||
scrollBound: false,
|
||||
songsById: {}, selectMode: false, selected: new Set(),
|
||||
@@ -442,6 +443,24 @@
|
||||
|
||||
async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } }
|
||||
|
||||
function activeProvider() {
|
||||
return (state.providers || []).find((provider) => provider.id === state.provider) || { id: state.provider || 'local', label: 'My Library', kind: 'local' };
|
||||
}
|
||||
|
||||
function providerLoadingText() {
|
||||
const provider = activeProvider();
|
||||
if (!provider || provider.id === 'local' || provider.kind === 'local') return 'Loading library...';
|
||||
if (provider.slow === true) return 'Loading ' + (provider.label || provider.id) + '... this source may take a while.';
|
||||
return 'Connecting to ' + (provider.label || provider.id) + '...';
|
||||
}
|
||||
|
||||
function loadingPanelHtml() {
|
||||
return '<div role="status" aria-live="polite" class="rounded-lg border border-fb-border/50 bg-fb-card/50 px-4 py-5 text-sm text-fb-textDim flex items-center gap-3" style="grid-column:1/-1">' +
|
||||
'<span class="inline-block h-4 w-4 rounded-full border-2 border-fb-border border-t-fb-primary animate-spin" aria-hidden="true"></span>' +
|
||||
'<span>' + esc(providerLoadingText()) + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── Provider-aware song helpers ────────────────────────────────────────
|
||||
// Remote library providers (feedBack-plugin-remote-library-*) expose songs
|
||||
// by provider-owned id with their own art/sync/play flow. Reuse the legacy
|
||||
@@ -2090,6 +2109,10 @@
|
||||
grid.style.top = '0px';
|
||||
const sizer = _sizerEl();
|
||||
if (sizer) sizer.style.height = '0px';
|
||||
const countEl = document.getElementById('v3-songs-count');
|
||||
if (countEl) countEl.textContent = 'Loading source...';
|
||||
grid.innerHTML = loadingPanelHtml();
|
||||
if (sizer) sizer.style.height = grid.offsetHeight + 'px';
|
||||
}
|
||||
state.loading = true;
|
||||
await _loadPage(0);
|
||||
@@ -2385,7 +2408,7 @@
|
||||
async function loadAlbums() {
|
||||
const host = document.getElementById('v3-songs-albums');
|
||||
if (!host) return;
|
||||
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
||||
host.innerHTML = '<div role="status" aria-live="polite" class="text-fb-textDim text-sm flex items-center gap-3"><span class="inline-block h-4 w-4 rounded-full border-2 border-fb-border border-t-fb-primary animate-spin" aria-hidden="true"></span><span>' + esc(providerLoadingText()) + '</span></div>';
|
||||
const data = await jget('/api/library/albums?' + queryParams().toString());
|
||||
const albums = (data && data.albums) || [];
|
||||
if (!albums.length) { host.innerHTML = '<p class="text-fb-textDim text-sm py-8 text-center">No albums match.</p>'; return; }
|
||||
@@ -2770,7 +2793,7 @@
|
||||
// (e.g. toggling select mode) restores them instead of collapsing all.
|
||||
const openArtists = new Set(
|
||||
[...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist')));
|
||||
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
||||
host.innerHTML = '<div role="status" aria-live="polite" class="text-fb-textDim text-sm flex items-center gap-3"><span class="inline-block h-4 w-4 rounded-full border-2 border-fb-border border-t-fb-primary animate-spin" aria-hidden="true"></span><span>' + esc(providerLoadingText()) + '</span></div>';
|
||||
// Page through ALL artists — the endpoint clamps size to 100, so a
|
||||
// single request would silently truncate libraries with >100 artists.
|
||||
const artists = [];
|
||||
@@ -3457,12 +3480,14 @@
|
||||
const snap = await fn.call(lp);
|
||||
if (snap && Array.isArray(snap.providers)) {
|
||||
state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local';
|
||||
state.providers = snap.providers;
|
||||
return snap.providers;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* */ }
|
||||
const data = await jget('/api/library/providers');
|
||||
return (data && data.providers) || [{ id: 'local', label: 'My Library' }];
|
||||
state.providers = (data && data.providers) || [{ id: 'local', label: 'My Library', kind: 'local' }];
|
||||
return state.providers;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
|
||||
@@ -77,4 +77,34 @@ test('failed no-op registrations do not block reload and rehydrate replacement',
|
||||
assert.equal(participants.length, 1);
|
||||
assert.equal(result.status, 'applied');
|
||||
assert.equal(result.payload.generation, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('library long-running commands override the default handler timeout', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.feedBack.capabilities;
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
api.registerParticipant('stems', {
|
||||
stems: {
|
||||
roles: ['owner'],
|
||||
commands: ['slow-probe'],
|
||||
handlers: { 'slow-probe': async () => { await delay(300); return { outcome: 'handled' }; } },
|
||||
runtime: true,
|
||||
},
|
||||
});
|
||||
const timedOut = await api.dispatch({ capability: 'stems', command: 'slow-probe', source: 'test' });
|
||||
assert.equal(timedOut.outcome, 'failed');
|
||||
assert.match(timedOut.reason, /timed out after 250 ms/);
|
||||
|
||||
api.registerParticipant('core.library', {
|
||||
library: {
|
||||
roles: ['owner'],
|
||||
commands: ['sync-song'],
|
||||
handlers: { 'sync-song': async () => { await delay(300); return { outcome: 'handled', payload: { ok: true } }; } },
|
||||
runtime: true,
|
||||
},
|
||||
});
|
||||
const synced = await api.dispatch({ capability: 'library', command: 'sync-song', source: 'test' });
|
||||
assert.equal(synced.status, 'applied');
|
||||
assert.equal(synced.payload.ok, true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Pins the slow-library-provider loading states in the classic and v3 library
|
||||
// surfaces. Providers can declare `slow: true`; the UI should show explicit
|
||||
// wait copy plus a status region instead of silently blanking while fetches run.
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
|
||||
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
|
||||
|
||||
test('v3 Songs slow providers get explicit loading copy', () => {
|
||||
assert.match(SONGS, /function providerLoadingText\(\)[\s\S]*?provider\.slow\s*===\s*true[\s\S]*?this source may take a while\./,
|
||||
'v3 providerLoadingText must branch on provider.slow and explain the longer wait');
|
||||
assert.match(SONGS, /function loadingPanelHtml\(\)[\s\S]*?providerLoadingText\(\)/,
|
||||
'v3 grid loading panel must render the provider-aware loading message');
|
||||
});
|
||||
|
||||
test('v3 loading indicators are announced as polite status regions', () => {
|
||||
assert.match(SONGS, /function loadingPanelHtml\(\)[\s\S]*?role="status"\s+aria-live="polite"/,
|
||||
'v3 grid loading panel must be a polite status region');
|
||||
assert.match(SONGS, /loadAlbums[\s\S]*role="status"\s+aria-live="polite"[\s\S]*providerLoadingText\(\)/,
|
||||
'v3 albums loading state must be a polite status region');
|
||||
assert.match(SONGS, /loadTree[\s\S]*role="status"\s+aria-live="polite"[\s\S]*providerLoadingText\(\)/,
|
||||
'v3 list loading state must be a polite status region');
|
||||
});
|
||||
|
||||
test('classic library loading indicator is announced and uses slow provider copy', () => {
|
||||
assert.match(APP, /function _setLibraryLoadingMessage[\s\S]*?role="status"\s+aria-live="polite"/,
|
||||
'classic library loading card must be a polite status region');
|
||||
assert.match(APP, /function _libraryLoadingText\(\)[\s\S]*?provider\.slow\s*===\s*true[\s\S]*?this source may take a while\./,
|
||||
'classic library loading text must branch on provider.slow');
|
||||
});
|
||||
@@ -50,6 +50,7 @@ class FakeLibraryProvider:
|
||||
id = "remote:frodo"
|
||||
label = "Frodo's Library"
|
||||
kind = "remote"
|
||||
slow = True
|
||||
capabilities = ("library.read", "art.read", "song.sync")
|
||||
|
||||
def __init__(self):
|
||||
@@ -170,6 +171,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
|
||||
assert remote["label"] == "Frodo's Library"
|
||||
assert remote["kind"] == "remote"
|
||||
assert remote["default"] is False
|
||||
assert remote["slow"] is True
|
||||
assert remote["capabilities"] == ["art.read", "library.read", "song.sync"]
|
||||
|
||||
songs = client.get("/api/library", params={
|
||||
|
||||
Reference in New Issue
Block a user