Plex Integration + Music Source Integration Improvements (#37)

* plex integration

* The big one - Full Music Source page rework + Playlist importing + Full Plex Integration + Discovery Options + More Like This/Surprise Me/Instant Mix + More...

* Music source track page - Play all / shuffle fixes

* lint

* format

* fix type checks

* format
This commit is contained in:
Harvey
2026-04-13 23:39:01 +01:00
committed by GitHub
parent 90b7b67a10
commit 0f25ebc26d
177 changed files with 21156 additions and 769 deletions
+12 -2
View File
@@ -7,6 +7,10 @@ import { updateDiscoveryCacheTTL } from '$lib/stores/discoveryCache';
import { updateDiscoverQueueCacheTTL } from '$lib/utils/discoverQueueCache';
import { updateSearchCacheTTL } from '$lib/stores/search';
import { updateJellyfinSidebarCacheTTL } from '$lib/utils/jellyfinLibraryCache';
import {
updatePlexSidebarCacheTTL,
updatePlexAlbumsListCacheTTL
} from '$lib/utils/plexLibraryCache';
import { updateLocalFilesSidebarCacheTTL } from '$lib/utils/localFilesCache';
import { libraryStore } from '$lib/stores/library';
import { recentlyAddedStore } from '$lib/stores/recentlyAdded';
@@ -20,6 +24,7 @@ export interface CacheTTLs {
search: number;
localFilesSidebar: number;
jellyfinSidebar: number;
plexSidebar: number;
playlistSources: number;
discoverQueuePollingInterval: number;
discoverQueueAutoGenerate: boolean;
@@ -34,6 +39,7 @@ const DEFAULTS: CacheTTLs = {
search: CACHE_TTL.SEARCH,
localFilesSidebar: CACHE_TTL.LOCAL_FILES_SIDEBAR,
jellyfinSidebar: CACHE_TTL.JELLYFIN_SIDEBAR,
plexSidebar: CACHE_TTL.PLEX_SIDEBAR,
playlistSources: CACHE_TTL.PLAYLIST_SOURCES,
discoverQueuePollingInterval: 4000,
discoverQueueAutoGenerate: true
@@ -52,6 +58,8 @@ function applyTTLs(ttls: CacheTTLs): void {
updateSearchCacheTTL(ttls.search);
updateLocalFilesSidebarCacheTTL(ttls.localFilesSidebar);
updateJellyfinSidebarCacheTTL(ttls.jellyfinSidebar);
updatePlexSidebarCacheTTL(ttls.plexSidebar);
updatePlexAlbumsListCacheTTL(ttls.plexSidebar);
}
export async function initCacheTTLs(): Promise<void> {
@@ -69,6 +77,7 @@ export async function initCacheTTLs(): Promise<void> {
search: (data.search as number) ?? DEFAULTS.search,
localFilesSidebar: (data.local_files_sidebar as number) ?? DEFAULTS.localFilesSidebar,
jellyfinSidebar: (data.jellyfin_sidebar as number) ?? DEFAULTS.jellyfinSidebar,
plexSidebar: (data.plex_sidebar as number) ?? DEFAULTS.plexSidebar,
playlistSources: (data.playlist_sources as number) ?? DEFAULTS.playlistSources,
discoverQueuePollingInterval:
(data.discover_queue_polling_interval as number) ?? DEFAULTS.discoverQueuePollingInterval,
@@ -76,8 +85,9 @@ export async function initCacheTTLs(): Promise<void> {
(data.discover_queue_auto_generate as boolean) ?? DEFAULTS.discoverQueueAutoGenerate
};
applyTTLs(resolved);
} catch (e) {
console.warn('[cacheTtl] Failed to load cache TTL settings, using defaults', e);
} catch {
resolved = { ...DEFAULTS };
applyTTLs(resolved);
}
}
+3
View File
@@ -6,6 +6,7 @@ interface IntegrationStatus {
lidarr: boolean;
jellyfin: boolean;
navidrome: boolean;
plex: boolean;
listenbrainz: boolean;
youtube: boolean;
youtube_api: boolean;
@@ -19,6 +20,7 @@ function createIntegrationStore() {
lidarr: false,
jellyfin: false,
navidrome: false,
plex: false,
listenbrainz: false,
youtube: false,
youtube_api: false,
@@ -41,6 +43,7 @@ function createIntegrationStore() {
lidarr: false,
jellyfin: false,
navidrome: false,
plex: false,
listenbrainz: false,
youtube: false,
youtube_api: false,
@@ -0,0 +1,133 @@
import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte';
import { playerStore } from '$lib/stores/player.svelte';
import type { NowPlayingSession } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
type SourceKey = 'jellyfin' | 'navidrome' | 'plex';
const GRACE_MS: Record<SourceKey, number> = {
jellyfin: 30_000,
navidrome: 180_000,
plex: 30_000
};
type OwnedEntry = { track: string; idleSince: number };
function buildLocalSession(): NowPlayingSession | null {
const state = playerStore.playbackState;
if (state === 'idle' || state === 'error') return null;
const np = playerStore.nowPlaying;
if (!np) return null;
const src = np.sourceType;
if (src !== 'jellyfin' && src !== 'navidrome' && src !== 'plex') return null;
return {
id: `local-${src}-${np.trackSourceId ?? np.albumId}`,
user_name: '',
track_name: np.trackName ?? '',
artist_name: np.artistName,
album_name: np.albumName,
cover_url: np.coverUrl ?? '',
device_name: 'MusicSeerr',
is_paused: state === 'paused' || state === 'buffering' || state === 'loading',
source: src,
progress_ms: playerStore.progress * 1000,
duration_ms: playerStore.duration * 1000,
_isLocal: true
};
}
const ownedSessions = new SvelteMap<SourceKey, OwnedEntry>();
let currentOwnedSource: SourceKey | null = null;
function createMergedStore() {
const mergedSessions = $derived.by(() => {
const local = buildLocalSession();
const server = nowPlayingStore.sessions;
if (local) {
const src = local.source as SourceKey;
if (currentOwnedSource && currentOwnedSource !== src) {
const prev = ownedSessions.get(currentOwnedSource);
if (prev && !prev.idleSince) {
ownedSessions.set(currentOwnedSource, { ...prev, idleSince: Date.now() });
}
}
currentOwnedSource = src;
ownedSessions.set(src, { track: local.track_name, idleSince: 0 });
} else if (currentOwnedSource) {
const entry = ownedSessions.get(currentOwnedSource);
if (entry && !entry.idleSince) {
ownedSessions.set(currentOwnedSource, { ...entry, idleSince: Date.now() });
}
currentOwnedSource = null;
}
const now = Date.now();
for (const [src, entry] of ownedSessions) {
if (entry.idleSince && now - entry.idleSince >= GRACE_MS[src]) {
ownedSessions.delete(src);
}
}
if (!local) {
const hasGraceEntries = ownedSessions.size > 0;
if (hasGraceEntries) {
return server.filter((s) => {
const src = s.source as SourceKey;
const owned = ownedSessions.get(src);
if (owned && owned.idleSince && s.track_name === owned.track) return false;
return true;
});
}
return server;
}
const localSource = local.source as SourceKey;
const filtered = server.filter((s) => {
if (s.source === localSource && s.track_name === local.track_name) return false;
const src = s.source as SourceKey;
const owned = ownedSessions.get(src);
if (owned && owned.idleSince && s.track_name === owned.track) return false;
return true;
});
return [local, ...filtered];
});
const activeSessions = $derived(mergedSessions.filter((s) => !s.is_paused));
const primarySession = $derived(activeSessions[0] ?? mergedSessions[0] ?? null);
function isSourcePlaying(source: SourceKey): boolean {
return mergedSessions.some((s) => s.source === source && !s.is_paused);
}
function sourceHasSessions(source: SourceKey): boolean {
return mergedSessions.some((s) => s.source === source);
}
function sessionsForSource(source: SourceKey): NowPlayingSession[] {
return mergedSessions.filter((s) => s.source === source);
}
return {
get sessions() {
return mergedSessions;
},
get activeSessions() {
return activeSessions;
},
get primarySession() {
return primarySession;
},
isSourcePlaying,
sourceHasSessions,
sessionsForSource,
start: nowPlayingStore.start,
stop: nowPlayingStore.stop,
refresh: nowPlayingStore.refresh
};
}
export const nowPlayingMerged = createMergedStore();
@@ -0,0 +1,281 @@
import { API } from '$lib/constants';
import { integrationStore } from '$lib/stores/integration';
import { get } from 'svelte/store';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type {
NowPlayingSession,
JellyfinSessionInfo,
JellyfinSessionsResponse,
NavidromeNowPlayingEntry,
NavidromeNowPlayingResponse,
PlexSessionInfo,
PlexSessionsResponse
} from '$lib/types';
const POLL_INTERVAL_MS = 3_000;
const STALE_SESSION_EXPIRY_MS = 30_000;
const STALE_PROGRESS_THRESHOLD_MS = POLL_INTERVAL_MS * 2.5;
const MAX_INTERPOLATION_ADVANCE_MS = POLL_INTERVAL_MS * 3;
const FROZEN_BASIS_MS = 15_000;
type SourceKey = 'jellyfin' | 'navidrome' | 'plex';
type InterpolationBasis = { serverProgress: number; updatedAt: number };
function jellyfinToSession(s: JellyfinSessionInfo): NowPlayingSession {
return {
id: s.session_id,
user_name: s.user_name,
track_name: s.track_name,
artist_name: s.artist_name,
album_name: s.album_name,
cover_url: s.cover_url,
device_name: s.device_name,
is_paused: s.is_paused,
source: 'jellyfin',
progress_ms: s.position_seconds * 1000,
duration_ms: s.duration_seconds * 1000,
audio_codec: s.audio_codec,
bitrate: s.bitrate
};
}
function navidromeToSession(e: NavidromeNowPlayingEntry): NowPlayingSession {
const progressMs =
e.estimated_position_seconds != null && e.estimated_position_seconds > 0
? e.estimated_position_seconds * 1000
: e.minutes_ago > 0
? Math.max(0, e.duration_seconds * 1000 - e.minutes_ago * 60_000)
: 0;
return {
id: `${e.user_name}-${e.player_name}-${e.album_id}-${e.track_name}`,
user_name: e.user_name,
track_name: e.track_name,
artist_name: e.artist_name,
album_name: e.album_name,
cover_url: e.cover_art_id ? `/api/v1/navidrome/cover/${e.cover_art_id}` : '',
device_name: e.player_name,
is_paused: false,
source: 'navidrome',
progress_ms: progressMs,
duration_ms: e.duration_seconds * 1000
};
}
function plexToSession(s: PlexSessionInfo): NowPlayingSession {
return {
id: s.session_id,
user_name: s.user_name,
track_name: s.track_title,
artist_name: s.artist_name,
album_name: s.album_name,
cover_url: s.cover_url,
device_name: s.player_device,
is_paused: s.player_state === 'paused',
source: 'plex',
progress_ms: s.progress_ms,
duration_ms: s.duration_ms,
audio_codec: s.audio_codec,
bitrate: s.bitrate
};
}
const FETCH_FAILED = Symbol('fetch_failed');
function createNowPlayingStore() {
let sessions = $state<NowPlayingSession[]>([]);
let pollTimer: ReturnType<typeof setInterval> | undefined;
let tickTimer: ReturnType<typeof setInterval> | undefined;
let running = false;
const lastGoodSessions = new SvelteMap<SourceKey, NowPlayingSession[]>();
const interpBasis = new SvelteMap<string, InterpolationBasis>();
const activeSessions = $derived(sessions.filter((s) => !s.is_paused));
const primarySession = $derived(activeSessions[0] ?? sessions[0] ?? null);
async function fetchSource<T>(
url: string,
mapper: (data: T) => NowPlayingSession[],
source: SourceKey
): Promise<NowPlayingSession[] | typeof FETCH_FAILED> {
try {
const r = await fetch(url);
if (!r.ok) return FETCH_FAILED;
const data: T = await r.json();
const mapped = mapper(data);
lastGoodSessions.set(source, mapped);
return mapped;
} catch {
return FETCH_FAILED;
}
}
async function fetchAll() {
if (typeof document !== 'undefined' && document.hidden) return;
const integrations = get(integrationStore);
const fetches: Promise<{
source: SourceKey;
result: NowPlayingSession[] | typeof FETCH_FAILED;
}>[] = [];
if (integrations.jellyfin) {
fetches.push(
fetchSource<JellyfinSessionsResponse>(
API.jellyfinLibrary.sessions(),
(d) => (d?.sessions ?? []).map(jellyfinToSession),
'jellyfin'
).then((result) => ({ source: 'jellyfin' as SourceKey, result }))
);
}
if (integrations.navidrome) {
fetches.push(
fetchSource<NavidromeNowPlayingResponse>(
API.navidromeLibrary.nowPlaying(),
(d) => (d?.entries ?? []).map(navidromeToSession),
'navidrome'
).then((result) => ({ source: 'navidrome' as SourceKey, result }))
);
}
if (integrations.plex) {
fetches.push(
fetchSource<PlexSessionsResponse>(
API.plexLibrary.sessions(),
(d) => (d?.sessions ?? []).map(plexToSession),
'plex'
).then((result) => ({ source: 'plex' as SourceKey, result }))
);
}
if (fetches.length === 0) {
sessions = [];
return;
}
const results = await Promise.all(fetches);
const now = Date.now();
const incoming: NowPlayingSession[] = [];
for (const { source, result } of results) {
if (result === FETCH_FAILED) {
const stale = lastGoodSessions.get(source);
if (stale && stale.length > 0) {
const basis = interpBasis.get(stale[0].id);
if (!basis || now - basis.updatedAt < STALE_SESSION_EXPIRY_MS) {
incoming.push(...stale);
}
}
} else {
incoming.push(...result);
}
}
const newIds = new SvelteSet<string>();
for (const s of incoming) {
newIds.add(s.id);
const prev = interpBasis.get(s.id);
if (s.progress_ms != null) {
if (!prev || prev.serverProgress !== s.progress_ms) {
interpBasis.set(s.id, { serverProgress: s.progress_ms, updatedAt: now });
}
}
}
for (const key of interpBasis.keys()) {
if (!newIds.has(key)) interpBasis.delete(key);
}
for (const s of incoming) {
if (s.is_paused) continue;
const basis = interpBasis.get(s.id);
if (basis && now - basis.updatedAt > STALE_PROGRESS_THRESHOLD_MS) {
s.is_paused = true;
}
}
sessions = incoming;
}
function tick() {
if (typeof document !== 'undefined' && document.hidden) return;
const now = Date.now();
const updated = sessions.map((s) => {
if (s.is_paused || !s.duration_ms || s.progress_ms == null) return s;
const basis = interpBasis.get(s.id);
if (!basis) {
const next = Math.min(s.progress_ms + 1000, s.duration_ms);
return next === s.progress_ms ? s : { ...s, progress_ms: next };
}
const basisAge = now - basis.updatedAt;
if (basisAge > FROZEN_BASIS_MS) return s;
const elapsed = Math.min(basisAge, MAX_INTERPOLATION_ADVANCE_MS);
const interpolated = Math.min(basis.serverProgress + elapsed, s.duration_ms);
if (interpolated === s.progress_ms) return s;
return { ...s, progress_ms: interpolated };
});
sessions = updated;
}
function start() {
if (running) return;
running = true;
fetchAll();
pollTimer = setInterval(fetchAll, POLL_INTERVAL_MS);
tickTimer = setInterval(tick, 1000);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibility);
}
}
function stop() {
running = false;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = undefined;
}
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = undefined;
}
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibility);
}
}
function onVisibility() {
if (!document.hidden && running) {
fetchAll();
}
}
function isSourcePlaying(source: SourceKey): boolean {
return sessions.some((s) => s.source === source && !s.is_paused);
}
function sourceHasSessions(source: SourceKey): boolean {
return sessions.some((s) => s.source === source);
}
function sessionsForSource(source: SourceKey): NowPlayingSession[] {
return sessions.filter((s) => s.source === source);
}
return {
get sessions() {
return sessions;
},
get activeSessions() {
return activeSessions;
},
get primarySession() {
return primarySession;
},
start,
stop,
refresh: fetchAll,
isSourcePlaying,
sourceHasSessions,
sessionsForSource
};
}
export const nowPlayingStore = createNowPlayingStore();
+83 -18
View File
@@ -15,8 +15,14 @@ import {
} from '$lib/player/jellyfinPlaybackApi';
import {
reportNavidromeScrobble,
reportNavidromeNowPlaying
reportNavidromeNowPlaying,
reportNavidromeStopped
} from '$lib/player/navidromePlaybackApi';
import {
reportPlexScrobble,
reportPlexNowPlaying,
reportPlexStopped
} from '$lib/player/plexPlaybackApi';
import { playbackToast } from '$lib/stores/playbackToast.svelte';
import {
getStoredVolume,
@@ -118,7 +124,8 @@ function createPlayerStore() {
const handleBeforeUnload = createBeforeUnloadHandler(
() => ({ jellyfinItem: getJellyfinItem(), currentItem: queue[currentIndex] ?? null, progress }),
API.stream.jellyfinStop,
API.stream.navidromeScrobble
API.stream.navidromeScrobble,
API.stream.plexScrobble
);
function getNextIndex(): number | null {
@@ -131,6 +138,9 @@ function createPlayerStore() {
const item = queue[currentIndex];
return item?.sourceType === 'jellyfin' ? item : null;
}
function getCurrentItem(): QueueItem | null {
return queue[currentIndex] ?? null;
}
function persist(): void {
doPersistSession(nowPlaying, queue, currentIndex, progress, shuffleEnabled, shuffleOrder);
}
@@ -145,11 +155,17 @@ function createPlayerStore() {
window.removeEventListener('beforeunload', handleBeforeUnload);
beforeUnloadRegistered = false;
}
async function stopJellyfinSession(item: QueueItem | null, posSeconds: number): Promise<void> {
async function stopPreviousSession(item: QueueItem | null, posSeconds: number): Promise<void> {
progressReporter.stop();
unregisterBeforeUnload();
if (!item || item.sourceType !== 'jellyfin' || !item.playSessionId) return;
await reportJellyfinStop(item.trackSourceId, item.playSessionId, posSeconds);
if (!item) return;
if (item.sourceType === 'jellyfin' && item.playSessionId) {
await reportJellyfinStop(item.trackSourceId, item.playSessionId, posSeconds);
} else if (item.sourceType === 'navidrome') {
void reportNavidromeStopped(item.trackSourceId);
} else if (item.sourceType === 'plex' && item.plexRatingKey) {
void reportPlexStopped(item.plexRatingKey);
}
}
function applyResetState(): void {
@@ -191,6 +207,14 @@ function createPlayerStore() {
loadUrl: url
};
}
if (item.sourceType === 'plex') {
isSeekable = true;
if (item.plexRatingKey) void reportPlexNowPlaying(item.plexRatingKey);
return {
source: createPlaybackSource('plex', { url: url!, seekable: true }),
loadUrl: url
};
}
isSeekable = true;
return {
source: createPlaybackSource('jellyfin', { url: url!, seekable: true }),
@@ -227,7 +251,7 @@ function createPlayerStore() {
playbackState = 'loading';
progress = 0;
duration = 0;
await stopJellyfinSession(prevItem, prevProgress);
await stopPreviousSession(prevItem, prevProgress);
currentSource?.destroy();
const gen = ++loadGeneration;
let source: PlaybackSource,
@@ -264,13 +288,13 @@ function createPlayerStore() {
playbackState = 'error';
const trackName = nowPlaying?.trackName ?? 'Unknown track';
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
playbackToast.show('Multiple tracks failed playback stopped', 'error');
playbackToast.show('Several tracks failed, so playback stopped.', 'error');
applyResetState();
return;
}
const nextIdx = getNextIndex();
if (nextIdx !== null) {
playbackToast.show(`"${trackName}" unavailable skipping`, 'warning');
playbackToast.show(`"${trackName}" is unavailable, skipping...`, 'warning');
errorSkipTimeout = setTimeout(() => {
errorSkipTimeout = null;
if (gen === loadGeneration) void loadQueueItem(nextIdx);
@@ -309,9 +333,12 @@ function createPlayerStore() {
void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true);
}
if (state === 'ended') {
const ci = queue[currentIndex] ?? null;
void stopJellyfinSession(getJellyfinItem(), progress);
if (ci?.sourceType === 'navidrome') void reportNavidromeScrobble(ci.trackSourceId);
const endedItem = getCurrentItem();
void stopPreviousSession(endedItem, progress);
if (endedItem?.sourceType === 'plex' && endedItem.plexRatingKey)
void reportPlexScrobble(endedItem.plexRatingKey);
else if (endedItem?.sourceType === 'navidrome')
void reportNavidromeScrobble(endedItem.trackSourceId);
const nextIdx = getNextIndex();
if (nextIdx !== null) {
void loadQueueItem(nextIdx).then(() => {
@@ -414,7 +441,7 @@ function createPlayerStore() {
},
playAlbum(source: PlaybackSource, metadata: NowPlaying): void {
void stopJellyfinSession(getJellyfinItem(), progress);
void stopPreviousSession(getCurrentItem(), progress);
currentSource?.destroy();
const gen = ++loadGeneration;
currentSource = source;
@@ -502,6 +529,27 @@ function createPlayerStore() {
showQueueMutationToast('queue', items.length);
},
appendQueueSilent(items: QueueItem[]): void {
if (items.length === 0) return;
const r = addMultipleItems(queue, items, shuffleEnabled, shuffleOrder);
queue = r.newQueue;
shuffleOrder = r.newShuffleOrder;
persist();
},
regenerateShuffleOrder(): void {
if (!shuffleEnabled || queue.length === 0) return;
const allIndices = Array.from({ length: queue.length }, (_, i) => i);
const upcoming = allIndices.filter((i) => i !== currentIndex && i > currentIndex);
const played = allIndices.filter((i) => i < currentIndex);
for (let i = upcoming.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
}
shuffleOrder = [...played, currentIndex, ...upcoming];
persist();
},
playNext(item: QueueItem): void {
if (queue.length === 0) {
this.playQueue([stampSingleOrigin(item, 'manual')], 0, false);
@@ -590,7 +638,8 @@ function createPlayerStore() {
playlistTrackId: string,
newSourceType: SourceType,
newTrackSourceId: string,
newFormat?: string
newFormat?: string,
plexRatingKey?: string
): void {
const r = updateItemByPlaylistTrackId(
queue,
@@ -598,7 +647,8 @@ function createPlayerStore() {
currentIndex,
newSourceType,
newTrackSourceId,
newFormat
newFormat,
plexRatingKey
);
if (r) {
queue = r;
@@ -615,12 +665,27 @@ function createPlayerStore() {
const jf = getJellyfinItem();
if (jf?.playSessionId)
void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true);
const item = getCurrentItem();
if (item?.sourceType === 'plex' && item.plexRatingKey)
void reportPlexStopped(item.plexRatingKey);
if (item?.sourceType === 'navidrome') void reportNavidromeStopped(item.trackSourceId);
persist();
},
togglePlay(): void {
if (isPlaying) currentSource?.pause();
else currentSource?.play();
if (isPlaying) {
currentSource?.pause();
const jf = getJellyfinItem();
if (jf?.playSessionId)
void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true);
const item = getCurrentItem();
if (item?.sourceType === 'plex' && item.plexRatingKey)
void reportPlexStopped(item.plexRatingKey);
if (item?.sourceType === 'navidrome') void reportNavidromeStopped(item.trackSourceId);
persist();
} else {
currentSource?.play();
}
},
seekTo(seconds: number): void {
currentSource?.seekTo(seconds);
@@ -636,7 +701,7 @@ function createPlayerStore() {
},
stop(): void {
void stopJellyfinSession(getJellyfinItem(), progress);
void stopPreviousSession(getCurrentItem(), progress);
if (errorSkipTimeout) {
clearTimeout(errorSkipTimeout);
errorSkipTimeout = null;
@@ -660,7 +725,7 @@ function createPlayerStore() {
shuffleOrder = resume.shuffleOrder;
isPlayerVisible = true;
consecutiveErrors = 0;
void stopJellyfinSession(getJellyfinItem(), progress);
void stopPreviousSession(getCurrentItem(), progress);
currentSource?.destroy();
currentIndex = resume.currentIndex;
playbackState = 'loading';
@@ -1,4 +1,5 @@
import type { QueueItem } from '$lib/player/types';
import { isPlexScrobbleEnabled } from '$lib/player/plexPlaybackApi';
export interface ProgressReporterState {
jellyfinItem: QueueItem | null;
@@ -76,7 +77,8 @@ export function createBeforeUnloadHandler(
progress: number;
},
jellyfinStopUrl: (trackSourceId: string) => string,
navidromeScrobbleUrl: (trackSourceId: string) => string
navidromeScrobbleUrl: (trackSourceId: string) => string,
plexScrobbleUrl: (ratingKey: string) => string
): () => void {
return () => {
if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
@@ -96,5 +98,17 @@ export function createBeforeUnloadHandler(
new Blob([], { type: 'application/json' })
);
}
if (
currentItem?.sourceType === 'plex' &&
currentItem.plexRatingKey &&
progress > 30 &&
isPlexScrobbleEnabled()
) {
navigator.sendBeacon(
plexScrobbleUrl(currentItem.plexRatingKey),
new Blob([], { type: 'application/json' })
);
}
};
}
@@ -119,7 +119,8 @@ export function updateItemByPlaylistTrackId(
currentIndex: number,
newSourceType: SourceType,
newTrackSourceId: string,
newFormat?: string
newFormat?: string,
plexRatingKey?: string
): QueueItem[] | null {
const index = queue.findIndex((item) => item.playlistTrackId === playlistTrackId);
if (index < 0 || index === currentIndex) return null;
@@ -132,7 +133,8 @@ export function updateItemByPlaylistTrackId(
trackSourceId: newTrackSourceId,
streamUrl,
format: newFormat,
playSessionId: undefined
playSessionId: undefined,
plexRatingKey: newSourceType === 'plex' ? plexRatingKey : undefined
};
return newQueue;
}
@@ -11,6 +11,8 @@ export function resolveSourceUrl(item: QueueItem): string | undefined {
return item.streamUrl ?? API.stream.navidrome(item.trackSourceId);
case 'jellyfin':
return API.stream.jellyfin(item.trackSourceId);
case 'plex':
return item.streamUrl ?? API.stream.plex(item.trackSourceId);
}
}
@@ -22,6 +24,8 @@ export function buildPrefetchUrl(item: QueueItem): string | null {
return API.stream.jellyfin(item.trackSourceId);
case 'navidrome':
return API.stream.navidrome(item.trackSourceId);
case 'plex':
return API.stream.plex(item.trackSourceId);
case 'local':
return API.stream.local(item.trackSourceId);
default:
@@ -40,6 +44,8 @@ export function buildStreamUrlForSource(
return API.stream.navidrome(trackSourceId);
case 'jellyfin':
return API.stream.jellyfin(trackSourceId);
case 'plex':
return API.stream.plex(trackSourceId);
default:
return undefined;
}