Add Navidrome adapter for low-star music triage.
CI / Gitleaks (pull_request) Successful in 7s
CI / Unit tests (pull_request) Successful in 9s

Swipe 0–2★ tracks with keep/rate/reject (reject playlist + optional quarantine) and autoplay audio previews.
This commit is contained in:
2026-09-06 15:36:40 -04:00
parent 4aa0014d70
commit bee93e95cc
7 changed files with 640 additions and 5 deletions
+4
View File
@@ -6,6 +6,10 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Navidrome adapter** (`adapters/navidrome.js`): swipe low-rated tracks
(0–2★ by default). Keep / keys `3`–`5` set stars; Reject adds songs to
playlist **Swipe Rejected (low stars)** (optional local quarantine when
`musicRoot` is set). Audio cards autoplay.
- **PDF / RAW / ZIP previews** on the folder adapter: PDFs show a first-page
Quick Look thumb (iframe fallback); camera RAW (`dng`, `cr2`, `nef`, …)
uses QL thumbs; `.zip` lists contained files (no extract) via a zero-dep
+43 -5
View File
@@ -19,10 +19,10 @@ what's behind them. Write a new adapter and you get the whole UI for free.
## Status
Two adapters ship today — **local folder** and **Immich** (self-hosted photo
library) — proving the framework works for both a filesystem and a remote
API. Email, database-row, and other adapters are welcome as contributions;
see [CONTRIBUTING.md](CONTRIBUTING.md).
Three adapters ship today — **local folder**, **Immich** (self-hosted photo
library), and **Navidrome** (music library low-star triage) — proving the
framework works for filesystem and remote APIs. Email, database-row, and
other adapters are welcome as contributions; see [CONTRIBUTING.md](CONTRIBUTING.md).
## Features
@@ -38,7 +38,7 @@ see [CONTRIBUTING.md](CONTRIBUTING.md).
- Generic settings UI: each adapter declares config fields and gets a form without custom frontend code
- Accessibility: live region announcements, labeled buttons, focus-trapped shortcuts dialog, visible focus rings, `role="progressbar"`
- Zero build step: Node.js, Express, vanilla HTML/CSS/JS
- `npm test` covers folder adapter, Immich adapter (mocked API), and HTTP API end-to-end
- `npm test` covers folder, Immich, and Navidrome adapters (mocked API) plus HTTP API end-to-end
- CI on every push/PR (Gitea Actions): `npm test` + gitleaks
## Quickstart
@@ -116,6 +116,43 @@ This is an early adapter — tested against the documented API shape, not
every Immich version, so please open an issue/PR if something doesn't match
your server.
### Navidrome
Point at a [Navidrome](https://www.navidrome.org) server with username /
password. Loads songs rated **0–2★** (configurable), autoplays each track,
and lets you:
| Action | Effect |
|---|---|
| Keep → / `3` | Set **3★** |
| `4` / `5` | Set **4★** / **5★** |
| Reject ← | Add to playlist **Swipe Rejected (low stars)** (optional: quarantine file under `musicRoot`) |
| Skip ↓ | Leave rating alone |
Songs already on the reject playlist are skipped on the next session. Undo
restores the previous rating or removes the song from that playlist.
Example `swipeanything.config.json`:
```json
{
"adapter": "navidrome",
"settings": {
"serverUrl": "https://navi.example.com",
"username": "ilia",
"password": "…",
"maxRating": "2",
"take": 150,
"musicRoot": "/mnt/media/music",
"trashDirName": ".swipe-music-trash"
}
}
```
`musicRoot` is optional. Without it, reject is playlist-only (nothing deleted
from disk). With it mounted locally (NFS/SMB), reject can also move the file
into the quarantine folder; Empty trash permanently deletes that folder.
## How adapters work
Every adapter implements a small contract (`adapters/base.js`): declare a
@@ -137,6 +174,7 @@ server.js Express app: API + static file serving
adapters/base.js Adapter contract every adapter implements
adapters/folder.js Reference adapter: local files/folders
adapters/immich.js Reference adapter: remote API (Immich)
adapters/navidrome.js Navidrome low-star music triage
adapters/registry.js Adapter registration
lib/thumbnails.js macOS Quick Look-based thumbnail cache
public/ Vanilla HTML/CSS/JS swipe UI + settings UI
+419
View File
@@ -0,0 +1,419 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { Readable } = require('stream');
const { Adapter } = require('./base');
const REJECT_PLAYLIST = 'Swipe Rejected (low stars)';
/**
* Triage low-rated Navidrome tracks (0–2★ by default): listen, bump stars,
* skip, or reject. Reject adds the song to a recoverable "Swipe Rejected"
* playlist (and optionally moves the file when musicRoot is set).
*/
class NavidromeAdapter extends Adapter {
static id = 'navidrome';
static label = 'Navidrome';
static description =
'Swipe low-rated songs from a Navidrome library. Listen, set 3–5★, skip, or reject (reject playlist + optional quarantine folder).';
static configSchema = [
{
key: 'serverUrl',
label: 'Server URL',
type: 'text',
required: true,
placeholder: 'https://navi.example.com',
},
{ key: 'username', label: 'Username', type: 'text', required: true },
{ key: 'password', label: 'Password', type: 'password', required: true },
{
key: 'maxRating',
label: 'Include ratings up to',
type: 'select',
default: '2',
options: [
{ value: '0', label: 'Unrated / 0★ only' },
{ value: '1', label: '0–1★' },
{ value: '2', label: '0–2★' },
],
},
{
key: 'take',
label: 'How many to load per session',
type: 'number',
default: 150,
},
{
key: 'musicRoot',
label: 'Local music root (optional, for file quarantine)',
type: 'folder',
required: false,
placeholder: '/Volumes/music or /mnt/media/music',
},
{
key: 'trashDirName',
label: 'Quarantine folder name (under music root)',
type: 'text',
default: '.swipe-music-trash',
},
];
static actions = [
{ id: 'keep', label: 'Keep (3★)', key: 'ArrowRight', direction: 'right', group: 'primary' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true, group: 'primary' },
{ id: 'skip', label: 'Skip', key: 'ArrowDown', direction: 'down', group: 'primary' },
{ id: 'rate3', label: '3★', key: '3', group: 'organize' },
{ id: 'rate4', label: '4★', key: '4', group: 'organize' },
{ id: 'rate5', label: '5★', key: '5', group: 'organize' },
];
constructor(settings) {
super(settings);
this.serverUrl = String(settings.serverUrl || '').replace(/\/+$/, '');
this.username = settings.username || '';
this.password = settings.password || '';
this.maxRating = Math.min(2, Math.max(0, Number(settings.maxRating)));
if (Number.isNaN(this.maxRating)) this.maxRating = 2;
this.take = Number(settings.take) > 0 ? Number(settings.take) : 150;
this.musicRoot = settings.musicRoot ? path.resolve(String(settings.musicRoot)) : '';
this.trashDirName = String(settings.trashDirName || '.swipe-music-trash').replace(/[/\\]/g, '') || '.swipe-music-trash';
this._token = null;
this._rejectPlaylistId = null;
this._byId = new Map();
}
_subsonicAuth() {
const salt = crypto.randomBytes(6).toString('hex');
const token = crypto.createHash('md5').update(this.password + salt).digest('hex');
return { u: this.username, t: token, s: salt, v: '1.16.1', c: 'SwipeAnything', f: 'json' };
}
async _login() {
if (!this.serverUrl) throw new Error('Server URL is required');
if (!this.username || !this.password) throw new Error('Username and password are required');
let res;
try {
res = await fetch(`${this.serverUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.username, password: this.password }),
});
} catch (err) {
throw new Error(`Could not reach Navidrome at ${this.serverUrl}: ${err.message}`);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Navidrome login failed: ${res.status} ${body.slice(0, 200)}`);
}
const data = await res.json();
this._token = data.token;
if (!this._token) throw new Error('Navidrome login returned no token');
return this._token;
}
async _api(pathname, options = {}) {
if (!this._token) await this._login();
const url = `${this.serverUrl}${pathname}`;
let res;
try {
res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'x-nd-authorization': `Bearer ${this._token}`,
...(options.headers || {}),
},
});
} catch (err) {
throw new Error(`Navidrome request failed: ${err.message}`);
}
if (res.status === 401) {
this._token = null;
await this._login();
return this._api(pathname, options);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Navidrome ${pathname} failed: ${res.status} ${body.slice(0, 200)}`);
}
return res;
}
async _subsonic(view, params = {}) {
const auth = this._subsonicAuth();
const qs = new URLSearchParams({ ...auth, ...params });
const url = `${this.serverUrl}/rest/${view}?${qs}`;
let res;
try {
res = await fetch(url);
} catch (err) {
throw new Error(`Navidrome Subsonic ${view} failed: ${err.message}`);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Navidrome Subsonic ${view} failed: ${res.status} ${body.slice(0, 200)}`);
}
const data = await res.json();
const sub = data['subsonic-response'] || data;
if (sub.status && sub.status !== 'ok') {
throw new Error(`Navidrome Subsonic ${view}: ${sub.error?.message || sub.status}`);
}
return sub;
}
async _ensureRejectPlaylist() {
const res = await this._api('/api/playlist');
const playlists = await res.json();
const existing = (playlists || []).find((p) => p.name === REJECT_PLAYLIST);
if (existing) {
this._rejectPlaylistId = existing.id;
return existing.id;
}
const created = await this._api('/api/playlist', {
method: 'POST',
body: JSON.stringify({
name: REJECT_PLAYLIST,
comment: 'Songs rejected during low-star triage in SwipeAnything. Remove from this playlist to review again.',
public: false,
}),
});
const body = await created.json();
this._rejectPlaylistId = body.id;
return body.id;
}
async _rejectedIds() {
const pid = await this._ensureRejectPlaylist();
const res = await this._api(`/api/playlist/${pid}/tracks`);
const tracks = await res.json();
return new Set((tracks || []).map((t) => t.id));
}
async _fetchRating(rating) {
const out = [];
const page = 200;
let start = 0;
for (;;) {
const res = await this._api(`/api/song?_start=${start}&_end=${start + page}&_sort=title&_order=ASC&rating=${rating}`);
const total = Number(res.headers.get('X-Total-Count') || 0);
const batch = await res.json();
out.push(...(batch || []));
start += page;
if (!batch || batch.length === 0 || start >= total || out.length >= this.take * 3) break;
}
return out;
}
async init() {
await this._login();
await this._ensureRejectPlaylist();
if (this.musicRoot) {
const st = await fsp.stat(this.musicRoot).catch(() => null);
if (!st || !st.isDirectory()) {
throw new Error(`Music root not found or not a directory: ${this.musicRoot}`);
}
await fsp.mkdir(path.join(this.musicRoot, this.trashDirName), { recursive: true });
}
}
async list() {
const rejected = await this._rejectedIds();
const songs = [];
for (let r = 0; r <= this.maxRating; r += 1) {
const batch = await this._fetchRating(r);
songs.push(...batch);
}
const items = [];
for (const song of songs) {
if (rejected.has(song.id)) continue;
const rating = song.rating == null ? 0 : Number(song.rating);
if (rating > this.maxRating) continue;
this._byId.set(song.id, song);
const dur = song.duration ? `${Math.round(song.duration)}s` : undefined;
items.push({
id: song.id,
title: song.title || song.path || song.id,
subtitle: [song.artist, song.album].filter(Boolean).join(' — ') || undefined,
previewType: 'audio',
meta: {
rating: `${rating}★`,
duration: dur,
year: song.year || undefined,
path: song.path || undefined,
},
});
if (items.length >= this.take) break;
}
return items;
}
async streamPreview(itemId, res) {
const auth = this._subsonicAuth();
const qs = new URLSearchParams({ ...auth, id: itemId });
const url = `${this.serverUrl}/rest/stream.view?${qs}`;
let upstream;
try {
upstream = await fetch(url);
} catch {
return false;
}
if (!upstream.ok || !upstream.body) return false;
res.set('Content-Type', upstream.headers.get('content-type') || 'audio/mpeg');
const len = upstream.headers.get('content-length');
if (len) res.set('Content-Length', len);
Readable.fromWeb(upstream.body).pipe(res);
return true;
}
async streamThumbnail() {
return false;
}
async getDetails(itemId) {
const song = this._byId.get(itemId);
if (!song) return { fields: [], actions: [] };
const fields = [
{ label: 'Artist', value: song.artist },
{ label: 'Album', value: song.album },
{ label: 'Title', value: song.title },
{ label: 'Rating', value: song.rating == null ? '0 (unrated)' : `${song.rating}★` },
{ label: 'Duration', value: song.duration ? `${Math.round(song.duration)}s` : undefined },
{ label: 'Year', value: song.year },
{ label: 'Path', value: song.path },
{ label: 'Id', value: song.id },
];
return { fields: fields.filter((f) => f.value != null && f.value !== ''), actions: [] };
}
uiHints() {
return { showDetailsByDefault: true, supportsDetails: true };
}
async _setRating(songId, rating) {
await this._subsonic('setRating.view', { id: songId, rating: String(rating) });
}
async _addToRejectPlaylist(songId) {
const pid = await this._ensureRejectPlaylist();
await this._subsonic('updatePlaylist.view', { playlistId: pid, songIdToAdd: songId });
}
async _removeFromRejectPlaylist(songId) {
const pid = await this._ensureRejectPlaylist();
const res = await this._api(`/api/playlist/${pid}/tracks`);
const tracks = await res.json();
const index = (tracks || []).findIndex((t) => t.id === songId);
if (index < 0) return;
await this._subsonic('updatePlaylist.view', {
playlistId: pid,
songIndexToRemove: String(index),
});
}
async _quarantineFile(song) {
if (!this.musicRoot || !song.path) return null;
const src = path.join(this.musicRoot, song.path);
try {
await fsp.access(src);
} catch {
return null;
}
const trashRoot = path.join(this.musicRoot, this.trashDirName);
const dest = path.join(trashRoot, song.path);
await fsp.mkdir(path.dirname(dest), { recursive: true });
await fsp.rename(src, dest);
return { src, dest };
}
async _restoreFile(move) {
if (!move) return;
await fsp.mkdir(path.dirname(move.src), { recursive: true });
await fsp.rename(move.dest, move.src);
}
async applyAction(item, actionId) {
const song = this._byId.get(item.id) || { id: item.id };
if (actionId === 'skip') return null;
if (actionId === 'keep' || actionId === 'rate3') {
const prev = song.rating == null ? 0 : Number(song.rating);
await this._setRating(item.id, 3);
return { type: 'rate', songId: item.id, prev, next: 3 };
}
if (actionId === 'rate4') {
const prev = song.rating == null ? 0 : Number(song.rating);
await this._setRating(item.id, 4);
return { type: 'rate', songId: item.id, prev, next: 4 };
}
if (actionId === 'rate5') {
const prev = song.rating == null ? 0 : Number(song.rating);
await this._setRating(item.id, 5);
return { type: 'rate', songId: item.id, prev, next: 5 };
}
if (actionId === 'reject') {
await this._addToRejectPlaylist(item.id);
const move = await this._quarantineFile(song);
return { type: 'reject', songId: item.id, move };
}
return null;
}
async undo(record) {
if (!record) return;
if (record.type === 'rate') {
await this._setRating(record.songId, record.prev);
return;
}
if (record.type === 'reject') {
await this._removeFromRejectPlaylist(record.songId);
await this._restoreFile(record.move);
}
}
async describeTrash() {
if (!this.musicRoot) {
const rejected = await this._rejectedIds();
return {
label: REJECT_PLAYLIST,
count: rejected.size,
hint: 'Reject adds songs to a Navidrome playlist (no files deleted). Clear the playlist in Navidrome to restore.',
};
}
const trashRoot = path.join(this.musicRoot, this.trashDirName);
let count = 0;
async function walk(dir) {
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const ent of entries) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) await walk(p);
else count += 1;
}
}
await walk(trashRoot);
return { label: this.trashDirName, count };
}
async emptyTrash() {
if (!this.musicRoot) {
throw new Error('Set a local music root to permanently delete quarantined files. Reject playlist entries are cleared in Navidrome.');
}
const trashRoot = path.join(this.musicRoot, this.trashDirName);
await fsp.rm(trashRoot, { recursive: true, force: true });
await fsp.mkdir(trashRoot, { recursive: true });
}
describeSource() {
return `${this.serverUrl} (Navidrome ≤${this.maxRating}★)`;
}
}
module.exports = { NavidromeAdapter, REJECT_PLAYLIST };
+2
View File
@@ -2,11 +2,13 @@
const { FolderAdapter } = require('./folder');
const { ImmichAdapter } = require('./immich');
const { NavidromeAdapter } = require('./navidrome');
// Register new adapters here. See CONTRIBUTING.md for the full guide.
const ADAPTERS = {
[FolderAdapter.id]: FolderAdapter,
[ImmichAdapter.id]: ImmichAdapter,
[NavidromeAdapter.id]: NavidromeAdapter,
};
function getAdapter(id) {
+1
View File
@@ -157,6 +157,7 @@
case 'audio': {
const audio = document.createElement('audio');
audio.controls = true;
audio.autoplay = true;
audio.src = src;
wrap.style.padding = '40px 10px';
wrap.appendChild(audio);
+1
View File
@@ -46,6 +46,7 @@ test('GET /api/adapters lists the registered adapters', async () => {
assert.equal(status, 200);
assert.ok(body.some((a) => a.id === 'folder'));
assert.ok(body.some((a) => a.id === 'immich'));
assert.ok(body.some((a) => a.id === 'navidrome'));
});
test('GET /api/queue returns 409 before configuration', async () => {
+170
View File
@@ -0,0 +1,170 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { NavidromeAdapter, REJECT_PLAYLIST } = require('../adapters/navidrome');
async function withMockFetch(handler, fn) {
const original = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), options });
return handler(String(url), options);
};
try {
await fn(calls);
} finally {
global.fetch = original;
}
}
function jsonResponse(body, ok = true, status = 200, headers = {}) {
return {
ok,
status,
statusText: ok ? 'OK' : 'Error',
headers: {
get: (name) => {
const key = String(name).toLowerCase();
if (key === 'x-total-count' && headers.total != null) return String(headers.total);
if (key === 'content-type') return 'application/json';
return headers[key] || null;
},
},
json: async () => body,
text: async () => JSON.stringify(body),
body: null,
};
}
test('init() logs in and ensures reject playlist', async () => {
await withMockFetch(
(url, options) => {
if (url.endsWith('/auth/login')) {
assert.equal(options.method, 'POST');
return jsonResponse({ token: 'tok-1' });
}
if (url.endsWith('/api/playlist') && (!options.method || options.method === 'GET')) {
return jsonResponse([]);
}
if (url.endsWith('/api/playlist') && options.method === 'POST') {
const body = JSON.parse(options.body);
assert.equal(body.name, REJECT_PLAYLIST);
return jsonResponse({ id: 'pl-reject' });
}
return jsonResponse({}, false, 404);
},
async () => {
const adapter = new NavidromeAdapter({
serverUrl: 'https://navi.example.com',
username: 'ilia',
password: 'secret',
});
await adapter.init();
assert.equal(adapter._token, 'tok-1');
assert.equal(adapter._rejectPlaylistId, 'pl-reject');
}
);
});
test('list() loads ≤maxRating songs and skips reject-playlist tracks', async () => {
await withMockFetch(
(url) => {
if (url.endsWith('/auth/login')) return jsonResponse({ token: 't' });
if (url.endsWith('/api/playlist')) return jsonResponse([{ id: 'pl-reject', name: REJECT_PLAYLIST }]);
if (url.includes('/api/playlist/pl-reject/tracks')) {
return jsonResponse([{ id: 'skip-me' }]);
}
if (url.includes('/api/song') && url.includes('rating=1')) {
return jsonResponse(
[
{ id: 'skip-me', title: 'Nope', artist: 'A', album: 'B', rating: 1, duration: 10 },
{ id: 'keep-me', title: 'Song', artist: 'Artist', album: 'Album', rating: 1, duration: 120 },
],
true,
200,
{ total: 2 }
);
}
if (url.includes('/api/song') && url.includes('rating=0')) {
return jsonResponse([], true, 200, { total: 0 });
}
if (url.includes('/api/song') && url.includes('rating=2')) {
return jsonResponse([], true, 200, { total: 0 });
}
return jsonResponse({}, false, 404);
},
async () => {
const adapter = new NavidromeAdapter({
serverUrl: 'https://navi.example.com',
username: 'u',
password: 'p',
maxRating: '1',
take: 50,
});
await adapter.init();
const items = await adapter.list();
assert.equal(items.length, 1);
assert.equal(items[0].id, 'keep-me');
assert.equal(items[0].previewType, 'audio');
assert.equal(items[0].meta.rating, '1★');
assert.match(items[0].subtitle, /Artist/);
}
);
});
test('applyAction rate/keep calls setRating; reject adds to playlist', async () => {
await withMockFetch(
(url, options) => {
if (url.endsWith('/auth/login')) return jsonResponse({ token: 't' });
if (url.endsWith('/api/playlist')) return jsonResponse([{ id: 'pl-reject', name: REJECT_PLAYLIST }]);
if (url.includes('/rest/setRating.view')) {
assert.ok(url.includes('rating=4') || url.includes('rating=3'));
return jsonResponse({ 'subsonic-response': { status: 'ok' } });
}
if (url.includes('/rest/updatePlaylist.view') && url.includes('songIdToAdd')) {
return jsonResponse({ 'subsonic-response': { status: 'ok' } });
}
return jsonResponse({}, false, 404);
},
async (calls) => {
const adapter = new NavidromeAdapter({
serverUrl: 'https://navi.example.com',
username: 'u',
password: 'p',
});
adapter._byId.set('s1', { id: 's1', rating: 1, path: 'A/B.mp3' });
adapter._rejectPlaylistId = 'pl-reject';
adapter._token = 't';
const rate = await adapter.applyAction({ id: 's1' }, 'rate4');
assert.deepEqual(rate, { type: 'rate', songId: 's1', prev: 1, next: 4 });
const keep = await adapter.applyAction({ id: 's1' }, 'keep');
assert.equal(keep.next, 3);
const rejected = await adapter.applyAction({ id: 's1' }, 'reject');
assert.equal(rejected.type, 'reject');
assert.ok(calls.some((c) => c.url.includes('songIdToAdd=s1')));
}
);
});
test('streamPreview requests Subsonic stream.view for the song id', async () => {
await withMockFetch(
(url) => {
assert.ok(url.includes('/rest/stream.view'));
assert.ok(url.includes('id=abc'));
return { ok: true, headers: { get: () => 'audio/flac' }, body: null };
},
async () => {
const adapter = new NavidromeAdapter({
serverUrl: 'https://navi.example.com',
username: 'u',
password: 'p',
});
const handled = await adapter.streamPreview('abc', { set() {} });
assert.equal(handled, false);
}
);
});