mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
11974e1438
* feat(analysis): align index settings and per-server strategies Rebuild the local index UX to live under Servers with per-server analytics strategies, and scope analysis queue hints/pruning by playback server so priorities stay isolated across profiles. * feat(analysis): add progress tracking and server analysis deletion functionality Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features. * feat(server): implement server index key migration and enhance server ID resolution Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations. * refactor(library): simplify server ID handling in sync progress and idle subscriptions Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality. * refactor(analytics): rename advanced strategy to aggressive and update descriptions Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code. * fix(audio): update server ID handling in audio progress functions Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure. * refactor(analysis): update server ID handling and drop legacy keys Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability. * refactor(migration): switch to strategy C dual-db flow Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior. * fix(migration): harden runtime db switch and startup gate Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows. * feat(migration): enhance migration reporting with skipped server rows tracking Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status. * fix(migration): avoid startup blocking modal on no-op runs Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed. * fix(migration): enforce startup precheck and purge unknown rows Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB. * fix(migration): block UI during done-flag precheck Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed. * fix(migration): hide precheck modal when no migration is needed Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users. * fix(migration): cleanup legacy db files after path migration Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration. * docs(changelog): add PR #864 release notes and contributor credit Document the full index-key rebuild scope for 1.47.0 and add the corresponding settings credit entry for PR #864. * test(analysis): raise hot-path coverage for analysis cache Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths. * fix(migration): make rebind pass resilient to foreign key ordering Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates. * feat(backup): add dual-database backup flow and blocking UX Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs. * docs(changelog): add PR #864 backup notes and contributor credit Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh. * docs(changelog): sort 1.47.0 entries from old to new Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block. * fix(playback): align offline/hot cache lookup with indexKey scope Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
import { reportNowPlaying } from '../api/subsonicScrobble';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { lastfmGetTrackLoved, lastfmUpdateNowPlaying } from '../api/lastfm';
|
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
|
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
|
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
|
|
import {
|
|
bindQueueServerForPlayback,
|
|
getPlaybackCacheServerKey,
|
|
getPlaybackIndexKey,
|
|
getPlaybackServerId,
|
|
shouldBindQueueServerForPlay,
|
|
} from '../utils/playback/playbackServer';
|
|
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
|
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
|
import { useAuthStore } from './authStore';
|
|
import {
|
|
bumpPlayGeneration,
|
|
getPlayGeneration,
|
|
setIsAudioPaused,
|
|
} from './engineState';
|
|
import {
|
|
clearPreloadingIds,
|
|
getLastGaplessSwitchTime,
|
|
} from './gaplessPreloadState';
|
|
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
|
import {
|
|
isReplayGainActive,
|
|
loudnessGainDbForEngineBind,
|
|
} from './loudnessGainCache';
|
|
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
|
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
|
import { useOrbitStore } from './orbitStore';
|
|
import {
|
|
playbackSourceHintForResolvedUrl,
|
|
recordEnginePlayUrl,
|
|
} from './playbackUrlRouting';
|
|
import type { PlayerState, Track } from './playerStoreTypes';
|
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
|
import { syncQueueToServer } from './queueSync';
|
|
import { playListenSessionFinalize } from './playListenSession';
|
|
import { pushQueueUndoFromGetter } from './queueUndo';
|
|
import { stopRadio } from './radioPlayer';
|
|
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
|
import { clearSeekDebounce } from './seekDebounce';
|
|
import {
|
|
clearSeekFallbackRetry,
|
|
getSeekFallbackVisualTarget,
|
|
setSeekFallbackRestartAt,
|
|
setSeekFallbackTrackId,
|
|
setSeekFallbackVisualTarget,
|
|
} from './seekFallbackState';
|
|
import {
|
|
clearSeekTarget,
|
|
setSeekTarget,
|
|
} from './seekTargetState';
|
|
import { refreshWaveformForTrack } from './waveformRefresh';
|
|
|
|
type SetState = (
|
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
|
) => void;
|
|
type GetState = () => PlayerState;
|
|
|
|
/**
|
|
* Play a track, optionally replacing the queue and/or jumping to an
|
|
* explicit slot. Three guard layers run before the actual play body:
|
|
*
|
|
* 1. **Orbit bulk-gate** — when `queue.length > 1` and isn't a no-op
|
|
* replace of the current queue, prompt via `orbitBulkGuard`; on
|
|
* confirm, hosts/guests append (Orbit semantics — bulk replace
|
|
* would drop guest suggestions) and non-Orbit users replace as
|
|
* normal.
|
|
* 2. **Orbit-host single-track protection** — a `playTrack(track,
|
|
* [track])` from a host would blow away the shared queue; re-route
|
|
* to append-and-jump so guest suggestions survive.
|
|
* 3. **Ghost-command guard** — a playTrack arriving within 500 ms of
|
|
* the last gapless switch is almost certainly a stale IPC echo.
|
|
*
|
|
* The play body itself: clears all scheduled timers + seek state,
|
|
* resolves the URL, updates store + normalization snapshot
|
|
* optimistically, invokes the Rust engine, and on success seeks to
|
|
* the visual target if there was a pending one. Falls back to
|
|
* `next(false)` 500 ms after an `audio_play` failure. Same-track
|
|
* replays first flush the previous play's `stream_completed_cache`
|
|
* to hot disk so `fetch_data` doesn't re-run an HTTP range request.
|
|
*/
|
|
export function runPlayTrack(
|
|
set: SetState,
|
|
get: GetState,
|
|
track: Track,
|
|
queue: Track[] | undefined,
|
|
manual: boolean,
|
|
_orbitConfirmed: boolean,
|
|
targetQueueIndex: number | undefined,
|
|
): void {
|
|
// Orbit bulk-gate: only gate when the `queue` argument *replaces*
|
|
// the current queue (Play All / Play Album / Play Playlist / Hero
|
|
// play buttons). Navigation calls — queue-row click, next(),
|
|
// previous() — pass the existing queue back through playTrack just
|
|
// to move the index; they are not bulk operations and must not
|
|
// trigger the confirm dialog (#234 regression).
|
|
if (!_orbitConfirmed && queue && queue.length > 1) {
|
|
const current = get().queue;
|
|
const sameAsCurrent = queue.length === current.length
|
|
&& queue.every((t, i) => sameQueueTrackId(current[i]?.id, t.id));
|
|
if (!sameAsCurrent) {
|
|
void orbitBulkGuard(queue.length).then(ok => {
|
|
if (!ok) return;
|
|
// Inside an Orbit session a bulk replace would discard guest
|
|
// suggestions mid-listen. Append instead — the dialog's
|
|
// "Add them all" copy already matches that semantic. Outside
|
|
// Orbit, proceed as a normal replace.
|
|
const role = useOrbitStore.getState().role;
|
|
if (role === 'host' || role === 'guest') {
|
|
get().enqueue(queue, true);
|
|
} else {
|
|
get().playTrack(track, queue, manual, true);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Orbit-host single-track protection. The host's `playerStore.queue`
|
|
// *is* the shared Orbit queue. A `playTrack(track, [track])` call
|
|
// (e.g. OfflineLibrary's "Play this album" on a single-track album,
|
|
// or any other surface that explicitly passes a 1-track replacement
|
|
// queue) would otherwise blow away every guest suggestion + every
|
|
// upcoming track. Re-route to append + jump so the queue survives.
|
|
// Guest stays unguarded — a guest clicking Play locally is choosing
|
|
// to opt out of host-sync, which is the existing "guest is running
|
|
// their own show" path. `useOrbitGuest`'s `syncToHost` is also a
|
|
// guest-only call site, so it's never intercepted here.
|
|
if (!_orbitConfirmed && queue && queue.length === 1) {
|
|
const orbitRole = useOrbitStore.getState().role;
|
|
if (orbitRole === 'host') {
|
|
const current = get().queue;
|
|
const currentTrackId = current[get().queueIndex]?.id;
|
|
if (track.id !== currentTrackId) {
|
|
const existsAt = current.findIndex(t => sameQueueTrackId(t.id, track.id));
|
|
if (existsAt >= 0) {
|
|
get().playTrack(track, current, manual, true, existsAt);
|
|
} else {
|
|
const newQueue = [...current, track];
|
|
get().playTrack(track, newQueue, manual, true, newQueue.length - 1);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ghost-command guard: if a gapless switch happened within 500 ms,
|
|
// this playTrack call is likely a stale IPC echo — suppress it.
|
|
if (Date.now() - getLastGaplessSwitchTime() < 500) {
|
|
return;
|
|
}
|
|
|
|
void playListenSessionFinalize('skip');
|
|
|
|
clearAllPlaybackScheduleTimers();
|
|
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
|
|
|
const gen = bumpPlayGeneration();
|
|
setIsAudioPaused(false);
|
|
clearPreloadingIds(); // new track — allow fresh preload for next
|
|
clearSeekDebounce(); clearSeekTarget();
|
|
clearSeekFallbackRetry();
|
|
setSeekFallbackRestartAt(0);
|
|
|
|
// If a radio stream is active, stop it before the new track starts so
|
|
// the PlayerBar clears radio mode immediately and the stream is released.
|
|
if (get().currentRadio) {
|
|
stopRadio();
|
|
}
|
|
|
|
const state = get();
|
|
const prevTrack = state.currentTrack;
|
|
if (prevTrack?.id !== track.id) {
|
|
setSeekFallbackTrackId(null);
|
|
}
|
|
const visualOnEntry = getSeekFallbackVisualTarget();
|
|
if (visualOnEntry?.trackId !== track.id) {
|
|
setSeekFallbackVisualTarget(null);
|
|
}
|
|
const newQueue = queue ?? state.queue;
|
|
if (shouldBindQueueServerForPlay(state.queue, newQueue, queue)) {
|
|
bindQueueServerForPlayback();
|
|
}
|
|
// Prefer an explicit target index from the caller (next/previous/queue-row
|
|
// click already know the exact slot). `findIndex` returns the *first*
|
|
// matching id, which jumps backwards when the queue contains the same
|
|
// track twice — breaking radio playback (issue #500).
|
|
const explicitIdxValid =
|
|
typeof targetQueueIndex === 'number'
|
|
&& targetQueueIndex >= 0
|
|
&& targetQueueIndex < newQueue.length
|
|
&& sameQueueTrackId(newQueue[targetQueueIndex]?.id, track.id);
|
|
const idx = explicitIdxValid
|
|
? (targetQueueIndex as number)
|
|
: newQueue.findIndex(t => sameQueueTrackId(t.id, track.id));
|
|
if (manual) {
|
|
pushQueueUndoFromGetter(get);
|
|
}
|
|
const visualForInitial = getSeekFallbackVisualTarget();
|
|
const pendingVisualTarget = visualForInitial?.trackId === track.id
|
|
? visualForInitial.seconds
|
|
: null;
|
|
const initialTime = pendingVisualTarget !== null
|
|
? Math.max(0, Math.min(pendingVisualTarget, track.duration || pendingVisualTarget))
|
|
: 0;
|
|
const initialProgress =
|
|
track.duration && track.duration > 0 ? Math.max(0, Math.min(1, initialTime / track.duration)) : 0;
|
|
|
|
const authState = useAuthStore.getState();
|
|
// Same-track replay: Rust `fetch_data` consumes `stream_completed_cache` with
|
|
// `take()` once; a second replay would full HTTP-range again unless we flush
|
|
// RAM to hot disk first (promote was only run when switching to another track).
|
|
const needSameTrackHotPromote =
|
|
Boolean(
|
|
prevTrack
|
|
&& sameQueueTrackId(prevTrack.id, track.id)
|
|
&& authState.hotCacheEnabled
|
|
&& getPlaybackCacheServerKey(),
|
|
);
|
|
|
|
const runPlayTrackBody = () => {
|
|
const authStateNow = useAuthStore.getState();
|
|
const playbackSid = getPlaybackServerId();
|
|
const playbackCacheSid = getPlaybackCacheServerKey();
|
|
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
|
|
recordEnginePlayUrl(track.id, url);
|
|
const preloadedTrackId = get().enginePreloadedTrackId;
|
|
const keepPreloadHint = preloadedTrackId === track.id;
|
|
const playbackSourceHint = playbackSourceHintForResolvedUrl(
|
|
track.id,
|
|
playbackCacheSid,
|
|
url,
|
|
);
|
|
if (import.meta.env.DEV) {
|
|
console.info('[psysonic][playTrack-source]', {
|
|
trackId: track.id,
|
|
resolvedUrl: url,
|
|
preloadedTrackId,
|
|
keepPreloadHint,
|
|
playbackSourceHint,
|
|
});
|
|
}
|
|
|
|
// Set state immediately so the UI updates before the download completes.
|
|
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
|
set({
|
|
currentTrack: track,
|
|
currentRadio: null,
|
|
waveformBins: null,
|
|
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
|
|
queue: newQueue,
|
|
queueIndex: idx >= 0 ? idx : 0,
|
|
progress: initialProgress,
|
|
buffered: 0,
|
|
currentTime: initialTime,
|
|
scrobbled: false,
|
|
lastfmLoved: false,
|
|
// HTTP stream: wait for Rust `audio:playing` so the seekbar does not
|
|
// extrapolate while RangedHttpSource / legacy reader is still buffering.
|
|
isPlaying: playbackSourceHint !== 'stream',
|
|
isPlaybackBuffering: playbackSourceHint === 'stream',
|
|
currentPlaybackSource: playbackSourceHint,
|
|
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
|
});
|
|
|
|
if (
|
|
prevTrack
|
|
&& !sameQueueTrackId(prevTrack.id, track.id)
|
|
&& authStateNow.hotCacheEnabled
|
|
) {
|
|
const prevPromoteSid = getPlaybackCacheServerKey();
|
|
if (prevPromoteSid) {
|
|
void promoteCompletedStreamToHotCache(
|
|
prevTrack,
|
|
prevPromoteSid,
|
|
authStateNow.hotCacheDownloadDir || null,
|
|
);
|
|
}
|
|
}
|
|
void refreshWaveformForTrack(track.id);
|
|
void refreshLoudnessForTrack(track.id);
|
|
setDeferHotCachePrefetch(true);
|
|
const playIdx = idx >= 0 ? idx : 0;
|
|
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
|
|
const replayGainDb = resolveReplayGainDb(
|
|
track, prevTrack, nextNeighbour,
|
|
isReplayGainActive(), authStateNow.replayGainMode,
|
|
);
|
|
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
|
invoke('audio_play', {
|
|
url,
|
|
volume: state.volume,
|
|
durationHint: track.duration,
|
|
replayGainDb,
|
|
replayGainPeak,
|
|
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
|
preGainDb: authStateNow.replayGainPreGainDb,
|
|
fallbackDb: authStateNow.replayGainFallbackDb,
|
|
manual,
|
|
hiResEnabled: authStateNow.enableHiRes,
|
|
analysisTrackId: track.id,
|
|
serverId: getPlaybackIndexKey() || null,
|
|
streamFormatSuffix: track.suffix ?? null,
|
|
})
|
|
.then(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
if (keepPreloadHint) {
|
|
set({ enginePreloadedTrackId: null });
|
|
}
|
|
const durSeek = track.duration && track.duration > 0 ? track.duration : null;
|
|
const seekTo = initialTime;
|
|
const canSeekAfterPlay =
|
|
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
|
|
if (canSeekAfterPlay) {
|
|
void invoke('audio_seek', { seconds: seekTo })
|
|
.then(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setSeekTarget(seekTo);
|
|
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
|
|
setSeekFallbackVisualTarget(null);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
|
|
setSeekFallbackVisualTarget(null);
|
|
}
|
|
});
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setDeferHotCachePrefetch(false);
|
|
console.error('[psysonic] audio_play failed:', err);
|
|
set({ isPlaying: false });
|
|
setTimeout(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
get().next(false);
|
|
}, 500);
|
|
});
|
|
|
|
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
|
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
|
if (npEnabled) reportNowPlaying(track.id, getPlaybackServerId());
|
|
if (lfmKey) {
|
|
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
|
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
|
const cacheKey = `${track.title}::${track.artist}`;
|
|
set(s => ({
|
|
lastfmLoved: loved,
|
|
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
|
}));
|
|
});
|
|
}
|
|
syncQueueToServer(newQueue, track, initialTime);
|
|
touchHotCacheOnPlayback(track.id, playbackCacheSid);
|
|
};
|
|
|
|
const hotPromoteSid = getPlaybackCacheServerKey();
|
|
if (needSameTrackHotPromote && hotPromoteSid) {
|
|
void promoteCompletedStreamToHotCache(
|
|
track,
|
|
hotPromoteSid,
|
|
authState.hotCacheDownloadDir || null,
|
|
)
|
|
.then(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
runPlayTrackBody();
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setDeferHotCachePrefetch(false);
|
|
console.error('[psysonic] same-track hot promote / play body failed:', err);
|
|
set({ isPlaying: false });
|
|
});
|
|
} else {
|
|
runPlayTrackBody();
|
|
}
|
|
}
|