Add a rating floor and short-track union to the Navidrome queue.
CI / Unit tests (pull_request) Successful in 17s
CI / Gitleaks (pull_request) Successful in 11s

Short songs under the duration cutoff stay in the queue even when they are rated above the max star filter.
This commit is contained in:
2026-09-21 17:01:20 -04:00
parent 295f790a8f
commit c55acd5274
6 changed files with 450 additions and 34 deletions
+167 -23
View File
@@ -9,6 +9,17 @@ 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"
@@ -30,17 +41,34 @@ class NavidromeAdapter extends Adapter {
},
{ 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★ only' },
{ value: '1', label: 'Unrated–1★' },
{ value: '2', label: 'Unrated–2★' },
{ 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',
@@ -78,6 +106,10 @@ class NavidromeAdapter extends Adapter {
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';
@@ -194,10 +226,27 @@ class NavidromeAdapter extends Adapter {
}
/**
* Songs with no annotation sort first when ordered by rating ASC (rating=null),
* then 0, 1, 2… Stop once we pass maxRating.
* Load songs with ratings in [minRating, maxRating].
* Unrated (null) counts as 0 when minRating is 0.
*/
async _fetchLowRated() {
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;
@@ -208,17 +257,56 @@ class NavidromeAdapter extends Adapter {
const total = Number(res.headers.get('X-Total-Count') || 0);
const batch = await res.json();
if (!batch || batch.length === 0) break;
let pastMax = false;
let pastUnrated = false;
for (const song of batch) {
const rating = song.rating == null ? 0 : Number(song.rating);
if (rating > this.maxRating) {
pastMax = true;
if (song.rating != null && Number(song.rating) > 0) {
pastUnrated = true;
break;
}
out.push(song);
}
start += page;
if (pastMax || start >= total || out.length >= this.take * 4) break;
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;
}
@@ -237,14 +325,50 @@ class NavidromeAdapter extends Adapter {
async list() {
const rejected = await this._rejectedIds();
const songs = await this._fetchLowRated();
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);
if (rating > this.maxRating) continue;
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 = song.duration ? `${Math.round(song.duration)}s` : undefined;
const dur = durSec > 0 ? formatDuration(durSec) : undefined;
items.push({
id: song.id,
title: song.title || song.path || song.id,
@@ -252,7 +376,7 @@ class NavidromeAdapter extends Adapter {
previewType: 'audio',
meta: {
rating: song.rating == null ? 'unrated' : `${rating}★`,
duration: dur,
...(dur ? { duration: dur } : {}),
year: song.year || undefined,
path: song.path || undefined,
},
@@ -262,21 +386,39 @@ class NavidromeAdapter extends Adapter {
return items;
}
async streamPreview(itemId, res) {
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);
upstream = await fetch(url, { headers });
} catch {
return false;
}
if (!upstream.ok || !upstream.body) return false;
res.set('Content-Type', upstream.headers.get('content-type') || 'audio/mpeg');
// 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);
Readable.fromWeb(upstream.body).pipe(res);
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;
}
@@ -292,7 +434,7 @@ class NavidromeAdapter extends Adapter {
{ 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: 'Duration', value: song.duration != null ? formatDuration(song.duration) : undefined },
{ label: 'Year', value: song.year },
{ label: 'Path', value: song.path },
{ label: 'Id', value: song.id },
@@ -423,8 +565,10 @@ class NavidromeAdapter extends Adapter {
}
describeSource() {
return `${this.serverUrl} (Navidrome ≤${this.maxRating}★)`;
const short =
this.includeUnderSeconds > 0 ? ` + <${this.includeUnderSeconds}s` : '';
return `${this.serverUrl} (Navidrome ${this.minRating}–${this.maxRating}★${short})`;
}
}
module.exports = { NavidromeAdapter, REJECT_PLAYLIST };
module.exports = { NavidromeAdapter, REJECT_PLAYLIST, formatDuration };
+136 -5
View File
@@ -155,12 +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.autoplay = 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': {
@@ -247,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;
}
@@ -259,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 });
+101 -4
View File
@@ -2,7 +2,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { NavidromeAdapter, REJECT_PLAYLIST } = require('../adapters/navidrome');
const { NavidromeAdapter, REJECT_PLAYLIST, formatDuration } = require('../adapters/navidrome');
async function withMockFetch(handler, fn) {
const original = global.fetch;
@@ -76,16 +76,25 @@ test('list() loads ≤maxRating songs and skips reject-playlist tracks', async (
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: 'unrated', title: 'Bare', artist: 'X', album: 'Y', rating: null, duration: 30 },
{ 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 },
{ id: 'too-high', title: 'Good', artist: 'Z', album: 'Z', rating: 3, duration: 90 },
],
true,
200,
{ total: 4 }
{ total: 2 }
);
}
return jsonResponse({}, false, 404);
@@ -95,6 +104,7 @@ test('list() loads ≤maxRating songs and skips reject-playlist tracks', async (
serverUrl: 'https://navi.example.com',
username: 'u',
password: 'p',
minRating: '0',
maxRating: '1',
take: 50,
});
@@ -111,6 +121,87 @@ test('list() loads ≤maxRating songs and skips reject-playlist tracks', async (
);
});
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) => {
@@ -148,6 +239,12 @@ test('applyAction rate/keep calls setRating; reject adds to playlist', async ()
);
});
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) => {