Merge pull request 'Add Navidrome adapter for low-star triage' (#7) from feat/navidrome-adapter into main
CI / Unit tests (push) Successful in 17s
CI / Gitleaks (push) Successful in 11s

This commit was merged in pull request #7.
This commit is contained in:
2026-09-21 16:08:50 -05:00
10 changed files with 1071 additions and 11 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
+574
View File
@@ -0,0 +1,574 @@
'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)';
/** Format seconds as m:ss (or h:mm:ss when ≥ 1 hour). */
function formatDuration(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const ss = String(s).padStart(2, '0');
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${ss}`;
return `${m}:${ss}`;
}
/**
* 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: 'minRating',
label: 'Include ratings from',
type: 'select',
default: '0',
options: [
{ value: '0', label: 'Unrated / 0★' },
{ value: '1', label: '1★' },
{ value: '2', label: '2★' },
],
},
{
key: 'maxRating',
label: 'Include ratings up to',
type: 'select',
default: '2',
options: [
{ value: '0', label: 'Unrated / 0★' },
{ value: '1', label: '1★' },
{ value: '2', label: '2★' },
],
},
{
key: 'includeUnderSeconds',
label: 'Also include any song shorter than (seconds, 0 = off)',
type: 'number',
default: 0,
},
{
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.minRating = Math.min(2, Math.max(0, Number(settings.minRating)));
if (Number.isNaN(this.minRating)) this.minRating = 0;
if (this.minRating > this.maxRating) this.minRating = this.maxRating;
this.includeUnderSeconds = Number(settings.includeUnderSeconds) > 0 ? Number(settings.includeUnderSeconds) : 0;
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));
}
/**
* Load songs with ratings in [minRating, maxRating].
* Unrated (null) counts as 0 when minRating is 0.
*/
async _fetchRatingExact(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}&missing=false`
);
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) break;
}
return out;
}
async _fetchUnrated() {
const out = [];
const page = 200;
let start = 0;
for (;;) {
const res = await this._api(
`/api/song?_start=${start}&_end=${start + page}&_sort=rating&_order=ASC&missing=false`
);
const total = Number(res.headers.get('X-Total-Count') || 0);
const batch = await res.json();
if (!batch || batch.length === 0) break;
let pastUnrated = false;
for (const song of batch) {
if (song.rating != null && Number(song.rating) > 0) {
pastUnrated = true;
break;
}
out.push(song);
}
start += page;
if (pastUnrated || start >= total) break;
}
return out;
}
/** Songs shorter than maxSeconds (any rating), shortest first. */
async _fetchUnderDuration(maxSeconds) {
const out = [];
const page = 200;
let start = 0;
for (;;) {
const res = await this._api(
`/api/song?_start=${start}&_end=${start + page}&_sort=duration&_order=ASC&missing=false`
);
const total = Number(res.headers.get('X-Total-Count') || 0);
const batch = await res.json();
if (!batch || batch.length === 0) break;
let past = false;
for (const song of batch) {
const dur = Number(song.duration) || 0;
if (dur <= 0) continue;
if (dur >= maxSeconds) {
past = true;
break;
}
out.push(song);
}
start += page;
if (past || start >= total) break;
}
return out;
}
async _fetchLowRated() {
const out = [];
if (this.minRating === 0) {
out.push(...(await this._fetchUnrated()));
out.push(...(await this._fetchRatingExact(0)));
}
for (let r = Math.max(1, this.minRating); r <= this.maxRating; r += 1) {
out.push(...(await this._fetchRatingExact(r)));
}
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 byId = new Map();
for (const song of await this._fetchLowRated()) {
byId.set(song.id, song);
}
if (this.includeUnderSeconds > 0) {
for (const song of await this._fetchUnderDuration(this.includeUnderSeconds)) {
byId.set(song.id, song);
}
}
const songs = [...byId.values()].sort((a, b) => {
const da = Number(a.duration) || 0;
const db = Number(b.duration) || 0;
const shortA = this.includeUnderSeconds > 0 && da > 0 && da < this.includeUnderSeconds ? 0 : 1;
const shortB = this.includeUnderSeconds > 0 && db > 0 && db < this.includeUnderSeconds ? 0 : 1;
if (shortA !== shortB) return shortA - shortB;
// Known short first; unknown duration (0/missing) last within a band
const knownA = da > 0 ? 0 : 1;
const knownB = db > 0 ? 0 : 1;
if (shortA === 0 && knownA !== knownB) return knownA - knownB;
const ra = a.rating == null ? -1 : Number(a.rating);
const rb = b.rating == null ? -1 : Number(b.rating);
if (ra !== rb) return ra - rb;
if (da !== db) return (da || 1e9) - (db || 1e9);
return String(a.title || '').localeCompare(String(b.title || ''));
});
const items = [];
for (const song of songs) {
if (rejected.has(song.id)) continue;
const rating = song.rating == null ? 0 : Number(song.rating);
const durSec = Number(song.duration) || 0;
const inRating = rating >= this.minRating && rating <= this.maxRating;
// duration 0/missing is NOT "short" — bad tags, not under-a-minute tracks
// Short tracks are included at any rating. The rating window only
// applies to the normal queue, not the under-duration union.
const shortOk =
this.includeUnderSeconds > 0 &&
durSec > 0 &&
durSec < this.includeUnderSeconds;
if (!inRating && !shortOk) continue;
this._byId.set(song.id, song);
const dur = durSec > 0 ? formatDuration(durSec) : 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: song.rating == null ? 'unrated' : `${rating}★`,
...(dur ? { duration: dur } : {}),
year: song.year || undefined,
path: song.path || undefined,
},
});
if (items.length >= this.take) break;
}
return items;
}
async streamPreview(itemId, res, req) {
const auth = this._subsonicAuth();
const qs = new URLSearchParams({ ...auth, id: itemId });
const url = `${this.serverUrl}/rest/stream.view?${qs}`;
const headers = {};
const range = req && req.headers && req.headers.range;
if (range) headers.Range = range;
let upstream;
try {
upstream = await fetch(url, { headers });
} catch {
return false;
}
// 200 full body or 206 partial (needed so <audio> can scrub)
if (!(upstream.status === 200 || upstream.status === 206) || !upstream.body) return false;
res.status(upstream.status);
const type = upstream.headers.get('content-type') || 'audio/mpeg';
res.set('Content-Type', type);
res.set('Accept-Ranges', upstream.headers.get('accept-ranges') || 'bytes');
res.set('Cache-Control', 'no-store');
const len = upstream.headers.get('content-length');
if (len) res.set('Content-Length', len);
const cr = upstream.headers.get('content-range');
if (cr) res.set('Content-Range', cr);
const nodeStream = Readable.fromWeb(upstream.body);
nodeStream.on('error', () => {
if (!res.headersSent) res.status(502);
res.end();
});
res.on('close', () => {
nodeStream.destroy();
});
nodeStream.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 != null ? formatDuration(song.duration) : 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() {
const short =
this.includeUnderSeconds > 0 ? ` + <${this.includeUnderSeconds}s` : '';
return `${this.serverUrl} (Navidrome ${this.minRating}–${this.maxRating}★${short})`;
}
}
module.exports = { NavidromeAdapter, REJECT_PLAYLIST, formatDuration };
+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) {
+136 -4
View File
@@ -155,11 +155,139 @@
break;
}
case 'audio': {
// Custom seek UI (native <audio controls> scrubber fights card swipe /
// shadow-DOM hit targets and often jumps back to 0:00).
const audio = document.createElement('audio');
audio.controls = true;
audio.src = src;
wrap.style.padding = '40px 10px';
audio.preload = 'auto';
wrap.classList.add('audio-preview');
wrap.style.padding = '24px 16px';
wrap.style.width = '100%';
wrap.style.boxSizing = 'border-box';
const status = document.createElement('div');
status.className = 'audio-status';
status.textContent = 'Loading audio…';
const timeLabel = document.createElement('div');
timeLabel.className = 'audio-time';
timeLabel.textContent = '0:00 / ?:??';
const seek = document.createElement('input');
seek.type = 'range';
seek.min = '0';
seek.max = '1000';
seek.value = '0';
seek.step = '1';
seek.disabled = true;
seek.setAttribute('aria-label', 'Seek');
seek.className = 'audio-seek';
const btnRow = document.createElement('div');
btnRow.className = 'audio-btns';
const playBtn = document.createElement('button');
playBtn.type = 'button';
playBtn.textContent = 'Play';
const backBtn = document.createElement('button');
backBtn.type = 'button';
backBtn.textContent = '−10s';
const fwdBtn = document.createElement('button');
fwdBtn.type = 'button';
fwdBtn.textContent = '+10s';
for (const b of [backBtn, playBtn, fwdBtn]) btnRow.appendChild(b);
wrap.appendChild(status);
wrap.appendChild(timeLabel);
wrap.appendChild(seek);
wrap.appendChild(btnRow);
wrap.appendChild(audio);
// Never let card-swipe steal pointer events from the player.
for (const evt of ['pointerdown', 'pointermove', 'pointerup', 'click', 'touchstart']) {
wrap.addEventListener(evt, (e) => e.stopPropagation());
}
const fmt = (sec) => {
if (!Number.isFinite(sec) || sec < 0) return '?:??';
const s = Math.floor(sec);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
let objectUrl = null;
let scrubbing = false;
const revoke = () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
};
const obs = new MutationObserver(() => {
if (!document.body.contains(wrap)) {
revoke();
obs.disconnect();
}
});
obs.observe(document.body, { childList: true, subtree: true });
const syncLabel = () => {
timeLabel.textContent = `${fmt(audio.currentTime)} / ${fmt(audio.duration)}`;
};
seek.addEventListener('pointerdown', () => {
scrubbing = true;
});
seek.addEventListener('pointerup', () => {
scrubbing = false;
});
seek.addEventListener('input', () => {
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return;
audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
syncLabel();
});
audio.addEventListener('timeupdate', () => {
if (scrubbing) return;
if (Number.isFinite(audio.duration) && audio.duration > 0) {
seek.value = String(Math.round((audio.currentTime / audio.duration) * 1000));
}
syncLabel();
playBtn.textContent = audio.paused ? 'Play' : 'Pause';
});
audio.addEventListener('loadedmetadata', () => {
seek.disabled = !Number.isFinite(audio.duration);
syncLabel();
});
playBtn.addEventListener('click', () => {
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
});
backBtn.addEventListener('click', () => {
audio.currentTime = Math.max(0, audio.currentTime - 10);
});
fwdBtn.addEventListener('click', () => {
if (Number.isFinite(audio.duration)) {
audio.currentTime = Math.min(audio.duration, audio.currentTime + 10);
}
});
fetch(src)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.arrayBuffer().then((buf) => {
const type = r.headers.get('content-type') || 'audio/mpeg';
return new Blob([buf], { type });
});
})
.then((blob) => {
if (!document.body.contains(wrap)) return;
revoke();
objectUrl = URL.createObjectURL(blob);
audio.src = objectUrl;
status.remove();
return audio.play().catch(() => {});
})
.catch((err) => {
status.textContent = `Could not load audio (${err.message || err})`;
});
break;
}
case 'video': {
@@ -246,7 +374,10 @@
card.appendChild(stamp);
}
attachDrag(card, actions);
// Audio/video: no card-swipe drag — it steals seek/scrub gestures.
if (item.previewType !== 'audio' && item.previewType !== 'video') {
attachDrag(card, actions);
}
return card;
}
@@ -258,6 +389,7 @@
function onPointerDown(e) {
if (busy) return;
if (e.target.closest('audio, video, input, textarea, select, button, a, .preview')) return;
dragging = { startX: e.clientX, startY: e.clientY, dx: 0 };
card.setPointerCapture(e.pointerId);
}
+1 -1
View File
@@ -70,6 +70,6 @@
</div>
</div>
<script src="app.js"></script>
<script src="app.js?v=seek-fix-3"></script>
</body>
</html>
+44
View File
@@ -193,6 +193,50 @@ h1 {
justify-content: center;
}
.card .preview.audio-preview {
flex-direction: column;
gap: 10px;
max-height: none;
overflow: visible;
background: transparent;
align-items: stretch;
}
.card .preview.audio-preview .audio-status,
.card .preview.audio-preview .audio-time {
color: var(--sub);
font-size: 12px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.card .preview.audio-preview .audio-seek {
width: 100%;
accent-color: var(--accent, #6cf);
cursor: pointer;
}
.card .preview.audio-preview .audio-btns {
display: flex;
gap: 8px;
justify-content: center;
}
.card .preview.audio-preview .audio-btns button {
font: inherit;
font-size: 13px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--border, #333);
background: var(--panel, #1a1a1c);
color: inherit;
cursor: pointer;
}
.card .preview.audio-preview audio {
display: none;
}
.card .preview img,
.card .preview video {
width: 100%;
+1 -1
View File
@@ -173,7 +173,7 @@ app.get('/api/preview/:itemId', async (req, res) => {
const s = await ensureSession().catch(() => null);
if (!s) return res.status(409).end();
try {
const handled = await s.adapter.streamPreview(req.params.itemId, res);
const handled = await s.adapter.streamPreview(req.params.itemId, res, req);
if (!handled) res.status(404).end();
} catch (err) {
res.status(400).json({ error: err.message });
+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 () => {
+265
View File
@@ -0,0 +1,265 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { NavidromeAdapter, REJECT_PLAYLIST, formatDuration } = 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('_sort=rating')) {
return jsonResponse(
[{ id: 'unrated', title: 'Bare', artist: 'X', album: 'Y', rating: null, duration: 30 }],
true,
200,
{ total: 1 }
);
}
if (url.includes('/api/song') && url.includes('rating=0')) {
return jsonResponse([], true, 200, { total: 0 });
}
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 }
);
}
return jsonResponse({}, false, 404);
},
async () => {
const adapter = new NavidromeAdapter({
serverUrl: 'https://navi.example.com',
username: 'u',
password: 'p',
minRating: '0',
maxRating: '1',
take: 50,
});
await adapter.init();
const items = await adapter.list();
assert.equal(items.length, 2);
assert.equal(items[0].id, 'unrated');
assert.equal(items[0].meta.rating, 'unrated');
assert.equal(items[1].id, 'keep-me');
assert.equal(items[1].previewType, 'audio');
assert.equal(items[1].meta.rating, '1★');
assert.match(items[1].subtitle, /Artist/);
}
);
});
test('list() with minRating=1 skips unrated', 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([]);
if (url.includes('rating=1')) {
return jsonResponse([{ id: 'one', title: 'One', artist: 'A', album: 'B', rating: 1, duration: 10 }], true, 200, { total: 1 });
}
if (url.includes('rating=2')) {
return jsonResponse([{ id: 'two', title: 'Two', artist: 'C', album: 'D', rating: 2, duration: 20 }], true, 200, { total: 1 });
}
return jsonResponse({}, false, 404);
},
async () => {
const adapter = new NavidromeAdapter({
serverUrl: 'https://navi.example.com',
username: 'u',
password: 'p',
minRating: '1',
maxRating: '2',
take: 50,
});
await adapter.init();
const items = await adapter.list();
assert.deepEqual(
items.map((i) => i.id),
['one', 'two']
);
}
);
});
test('list() with includeUnderSeconds unions short high-rated 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([]);
if (url.includes('_sort=rating')) {
return jsonResponse([{ id: 'u', title: 'U', artist: 'A', rating: null, duration: 200 }], true, 200, { total: 1 });
}
if (url.includes('rating=0')) return jsonResponse([], true, 200, { total: 0 });
if (url.includes('rating=1')) {
return jsonResponse([{ id: 'one', title: 'One', artist: 'A', rating: 1, duration: 200 }], true, 200, { total: 1 });
}
if (url.includes('rating=2')) return jsonResponse([], true, 200, { total: 0 });
if (url.includes('_sort=duration')) {
return jsonResponse(
[
{ id: 'short5', title: 'Twist', artist: 'Korn', rating: 5, duration: 51 },
{ id: 'long', title: 'Long', artist: 'X', rating: 5, duration: 200 },
],
true,
200,
{ total: 2 }
);
}
return jsonResponse({}, false, 404);
},
async () => {
const adapter = new NavidromeAdapter({
serverUrl: 'https://navi.example.com',
username: 'u',
password: 'p',
minRating: '0',
maxRating: '2',
includeUnderSeconds: 60,
take: 50,
});
await adapter.init();
const items = await adapter.list();
const ids = items.map((i) => i.id);
assert.ok(ids.includes('u'));
assert.ok(ids.includes('one'));
assert.ok(ids.includes('short5'));
assert.ok(!ids.includes('long'));
}
);
});
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('formatDuration uses m:ss (and h:mm:ss when needed)', () => {
assert.equal(formatDuration(0), '0:00');
assert.equal(formatDuration(65), '1:05');
assert.equal(formatDuration(3723), '1:02:03');
});
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);
}
);
});