Files
Psychotoxical-psysonic/src/api/subsonic.ts
T
cucadmuh 891ab0dd5b feat(now-playing): OpenSubsonic playbackReport for live now-playing (#1080)
* feat(now-playing): adopt OpenSubsonic playbackReport for live now-playing

Drive a small playback state machine (starting → playing ↔ paused → stopped)
on the Subsonic-server channel when the server advertises the OpenSubsonic
`playbackReport` extension (Navidrome ≥ 0.62), giving `getNowPlaying` a real
transport state and an extrapolated position. Reports send `ignoreScrobble=true`
so play counts stay on the existing `scrobble.view` 50% path (no double count),
and the effective playback speed is included so the server extrapolates position
correctly with the speed feature on.

- New `playbackReportSession` FSM mirrors the existing `playListenSession`
  lifecycle hooks and is wired at the same player call sites (start / gapless
  switch / queue restore / resume / 15s heartbeat / pause / seek / stop / ended /
  error / app quit). Servers without the extension degrade to the unchanged
  legacy `scrobble.view?submission=false` presence call.
- Gated through the existing serverCapabilities framework: a new
  `FEATURE_PLAYBACK_REPORT` (auto, extension-detected). The OpenSubsonic
  extensions probe now stores the full advertised list once and serves both
  AudioMuse `sonicSimilarity` and `playbackReport` from it, without disturbing
  the legacy Instant Mix opt-in on pre-0.62 servers.
- Now Playing dropdown shows a live position bar and a paused indicator.
- reportPlayback uses the real request params (mediaId / mediaType / positionMs).

Tests: FSM transitions + gating + legacy fallback, capability resolution,
extension-list probe storage/decoupling. Full suite green; no Tauri-boundary
changes.

* feat(now-playing): glide the Live position bar between polls

Extrapolate the position of `playing` entries locally (elapsed × reported
playbackRate from the last 10 s poll) and re-render once a second, so the Live
progress bar advances smoothly instead of jumping on each refresh. Paused and
position-less entries stay frozen. A linear width transition matched to the tick
keeps the fill gliding. Only applies to clients that report a position via the
playbackReport extension.

* fix(now-playing): tighten Live timer layout and report resume immediately

Keep the progress-bar width stable without a wide empty gap before the
timer: reserve ~2ch inside the current-time span only (right-aligned), not
on the whole clock block. Report `playing` to the server as soon as
resume() runs, matching the immediate `paused` report on pause instead of
waiting for the Rust `audio:playing` event.

* docs: CHANGELOG and credits for playbackReport live now-playing (PR #1080)
2026-06-13 05:31:26 +03:00

179 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios';
import md5 from 'md5';
import { useAuthStore } from '../store/authStore';
import {
type InstantMixProbeResult,
type SubsonicServerIdentity,
} from '../utils/server/subsonicServerIdentity';
import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic';
import { buildCapabilityContext } from '../serverCapabilities/context';
import {
PROBE_LEGACY_INSTANT_MIX,
PROBE_OPENSUBSONIC_EXTENSIONS,
SERVER_CAPABILITY_CATALOG,
SONIC_SIMILARITY_EXTENSION,
} from '../serverCapabilities/catalog';
import { neededProbeIds } from '../serverCapabilities/resolve';
import {
SUBSONIC_CLIENT,
api,
apiWithCredentials,
secureRandomSalt,
} from './subsonicClient';
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
export async function ping(): Promise<boolean> {
try {
await api('ping.view');
return true;
} catch {
return false;
}
}
/** Test a connection with explicit credentials — does NOT depend on store state. */
export async function pingWithCredentials(
serverUrl: string,
username: string,
password: string,
): Promise<PingWithCredentialsResult> {
try {
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
const salt = secureRandomSalt();
const token = md5(password + salt);
const resp = await axios.get(`${base}/rest/ping.view`, {
params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' },
paramsSerializer: { indexes: null },
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
const ok = data?.status === 'ok';
return {
ok,
type: typeof data?.type === 'string' ? data.type : undefined,
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
openSubsonic: data?.openSubsonic === true,
};
} catch (err) {
console.warn('[psysonic] pingWithCredentials failed:', serverUrl, err);
return { ok: false };
}
}
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
/**
* Probes whether `getSimilarSongs` returns any tracks (Instant Mix / Navidrome agent chain).
* Does not pass `musicFolderId` — probes the whole library as seen by the account.
* Note: if `ND_AGENTS` includes Last.fm, a positive result does not prove AudioMuse alone.
*/
export async function probeInstantMixWithCredentials(
serverUrl: string,
username: string,
password: string,
): Promise<InstantMixProbeResult> {
try {
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverUrl,
username,
password,
'getRandomSongs.view',
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
12000,
);
const raw = data.randomSongs?.song;
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
if (songs.length === 0) return 'skipped';
let anyError = false;
for (const song of songs.slice(0, INSTANT_MIX_PROBE_MAX_TRACKS)) {
try {
const simData = await apiWithCredentials<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverUrl,
username,
password,
'getSimilarSongs.view',
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
12000,
);
const sRaw = simData.similarSongs?.song;
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
if (list.some(s => s.id !== song.id)) return 'ok';
} catch {
anyError = true;
}
}
return anyError ? 'error' : 'empty';
} catch {
return 'error';
}
}
/**
* After a successful ping, run the server-capability probes needed by the catalog
* (`serverCapabilities/`). Which probes run is decided by the strategies eligible
* for this server generation — not by inline version checks here.
*
* Currently: Navidrome ≥ 0.62 → `getOpenSubsonicExtensions` (sonicSimilarity);
* Navidrome 0.600.61 → legacy `getSimilarSongs` Instant Mix probe.
*
* Idempotent: a server's advertised capabilities are static within a session, so
* once a definitive result is cached the probe is skipped. This is called on every
* 120 s connection poll, so re-fetching each time would be wasteful and would flip
* the resolved status (and the routed endpoint) through a `probing` window. Pass
* `force` for user-initiated refreshes (add/edit/test server); a server version or
* type change clears the cache (see `setSubsonicServerIdentity`), forcing a re-probe.
*/
export function scheduleInstantMixProbeForServer(
serverId: string,
serverUrl: string,
username: string,
password: string,
identity: SubsonicServerIdentity,
force = false,
): void {
const ctx = buildCapabilityContext(identity);
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
const store = useAuthStore.getState();
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
// The AudioMuse `sonicSimilarity` lifecycle (with its opt-in side effects) is
// only driven on Navidrome ≥ 0.62, so broadening the probe to all OpenSubsonic
// servers for `playbackReport` does not disturb the legacy Instant Mix opt-in.
const audiomuseEligible = ctx.isNavidrome && ctx.semverGte([0, 62, 0]);
const cached = store.audiomusePluginProbeByServer[serverId];
const listMissing = store.openSubsonicExtensionsByServer[serverId] === undefined;
// Re-probe without a definitive cached result, on force / prior error, or when
// the extension list is missing (self-heal for state persisted before it was
// captured). `probing` means an in-flight fetch — skip to avoid a duplicate.
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
if (force || listMissing || audiomuseStale) {
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing');
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
const st = useAuthStore.getState();
if (extensions === null) {
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
return;
}
st.setOpenSubsonicExtensions(serverId, extensions);
if (audiomuseEligible) {
st.setAudiomusePluginProbe(serverId, extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent');
}
});
}
}
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
const cached = store.instantMixProbeByServer[serverId];
if (force || cached === undefined || cached === 'error') {
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
useAuthStore.getState().setInstantMixProbe(serverId, result),
);
}
}
}