Add a rating floor and short-track union to the Navidrome queue.
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:
+167
-23
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user