mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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)
This commit is contained in:
@@ -22,6 +22,7 @@ function reset() {
|
||||
instantMixProbeByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
audiomuseNavidromeIssueByServer: {},
|
||||
openSubsonicExtensionsByServer: {},
|
||||
} as never);
|
||||
}
|
||||
|
||||
@@ -57,6 +58,26 @@ describe('scheduleInstantMixProbeForServer (idempotency)', () => {
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stores the full extension list and caches both sonicSimilarity and playbackReport', async () => {
|
||||
fetchMock.mockResolvedValue(['sonicSimilarity', 'playbackReport']);
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
await flush();
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.openSubsonicExtensionsByServer[SID]).toEqual(['sonicSimilarity', 'playbackReport']);
|
||||
expect(s.audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
});
|
||||
|
||||
it('stores the list on a non-Navidrome OpenSubsonic server without driving the AudioMuse probe', async () => {
|
||||
fetchMock.mockResolvedValue(['playbackReport']);
|
||||
const idGonic: SubsonicServerIdentity = { type: 'gonic', serverVersion: '0.16.0', openSubsonic: true };
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', idGonic);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await flush();
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.openSubsonicExtensionsByServer[SID]).toEqual(['playbackReport']);
|
||||
expect(s.audiomusePluginProbeByServer[SID]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSubsonicServerIdentity (version-change cache invalidation)', () => {
|
||||
|
||||
+21
-8
@@ -140,16 +140,29 @@ export function scheduleInstantMixProbeForServer(
|
||||
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];
|
||||
// Re-probe only without a definitive cached result (or on force / prior error).
|
||||
// `probing` means an in-flight fetch — skip to avoid a duplicate request.
|
||||
if (force || cached === undefined || cached === 'error') {
|
||||
store.setAudiomusePluginProbe(serverId, 'probing');
|
||||
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 result = extensions === null
|
||||
? 'error'
|
||||
: extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent';
|
||||
useAuthStore.getState().setAudiomusePluginProbe(serverId, result);
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { SubsonicNowPlaying } from './subsonicTypes';
|
||||
import type { PlaybackReportState, SubsonicNowPlaying } from './subsonicTypes';
|
||||
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
@@ -39,6 +39,45 @@ export async function reportNowPlaying(id: string, serverId: string): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReportPlaybackParams {
|
||||
mediaId: string;
|
||||
positionMs: number;
|
||||
state: PlaybackReportState;
|
||||
/** Effective playback speed; lets the server extrapolate position correctly. */
|
||||
playbackRate?: number;
|
||||
/**
|
||||
* When true, the server records live presence only and skips its scrobble /
|
||||
* play-count side effects. psysonic keeps those on the dedicated `scrobble.view`
|
||||
* channel (50% rule), so the timeline never double-counts a play.
|
||||
*/
|
||||
ignoreScrobble?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSubsonic `playbackReport` extension (Navidrome ≥ 0.62): report a point on
|
||||
* the playback timeline for rich, live now-playing. Best-effort and gated by the
|
||||
* same reachability guard as presence scrobbles; callers route through
|
||||
* `playbackReportSession` which only invokes this when the server advertises the
|
||||
* extension (otherwise the legacy `reportNowPlaying` presence call is used).
|
||||
*/
|
||||
export async function reportPlayback(serverId: string, params: ReportPlaybackParams): Promise<void> {
|
||||
if (!serverId) return;
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) return;
|
||||
const query: Record<string, unknown> = {
|
||||
mediaId: params.mediaId,
|
||||
mediaType: 'song',
|
||||
positionMs: Math.max(0, Math.floor(params.positionMs)),
|
||||
state: params.state,
|
||||
};
|
||||
if (params.playbackRate !== undefined) query.playbackRate = params.playbackRate;
|
||||
if (params.ignoreScrobble !== undefined) query.ignoreScrobble = params.ignoreScrobble;
|
||||
try {
|
||||
await apiForServer(serverId, 'reportPlayback.view', query);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
|
||||
try {
|
||||
const data = await api<{ nowPlaying: { entry?: SubsonicNowPlaying | SubsonicNowPlaying[] } }>('getNowPlaying.view', { _t: Date.now() });
|
||||
|
||||
@@ -142,11 +142,20 @@ export interface SubsonicPlaylist {
|
||||
coverArt?: string;
|
||||
}
|
||||
|
||||
/** OpenSubsonic `playbackReport` lifecycle state, per the extension spec. */
|
||||
export type PlaybackReportState = 'starting' | 'playing' | 'paused' | 'stopped';
|
||||
|
||||
export interface SubsonicNowPlaying extends SubsonicSong {
|
||||
username: string;
|
||||
minutesAgo: number;
|
||||
playerId: number;
|
||||
playerName: string;
|
||||
/** OpenSubsonic `playbackReport`: live transport state for this stream. */
|
||||
state?: PlaybackReportState;
|
||||
/** OpenSubsonic `playbackReport`: server-extrapolated position in milliseconds. */
|
||||
positionMs?: number;
|
||||
/** OpenSubsonic `playbackReport`: effective playback speed (1.0 = normal). */
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicArtist {
|
||||
|
||||
Reference in New Issue
Block a user