mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-28 10:06:47 +00:00
891ab0dd5b
* 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)
179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
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.60–0.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),
|
||
);
|
||
}
|
||
}
|
||
}
|