Merge pull request 'Add Navidrome adapter for low-star triage' (#7) from feat/navidrome-adapter into main
This commit was merged in pull request #7.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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
@@ -70,6 +70,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="app.js?v=seek-fix-3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user