m5, subsonic/jellyfin support

This commit is contained in:
Boof2015
2026-06-27 22:03:37 -04:00
parent 0e3e46293c
commit 3ec3f5ecf2
37 changed files with 3322 additions and 35 deletions
+36
View File
@@ -2,6 +2,8 @@
// md5-named files (desktop convention) and tracks store the file name.
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
import { artworkUrlForTrack } from '@/services/remoteUrls';
import type { Album, DbTrack } from '@/types/library';
let artworkDir: string | null = null;
let artworkThumbDir: string | null = null;
@@ -45,6 +47,40 @@ export function artworkThumbUri(hash: string): string {
return `file://${getArtworkThumbDir()}/${artworkThumbFileName(hash)}`;
}
type TrackArtworkFields = Pick<
DbTrack,
'source_type' | 'source_id' | 'artwork_source_id' | 'artwork_hash'
>;
/** Thumbnail source for a track row: a cached file for local, a server URL for remote. */
export function trackArtworkThumbSource(track: TrackArtworkFields): string | null {
if (track.source_type !== 'local') {
return artworkUrlForTrack({
sourceType: track.source_type,
sourceId: track.source_id ?? undefined,
artworkSourceId: track.artwork_source_id ?? undefined,
});
}
return track.artwork_hash ? artworkThumbUri(track.artwork_hash) : null;
}
type AlbumArtworkFields = Pick<
Album,
'source_type' | 'source_id' | 'artwork_source_id' | 'artwork_hash'
>;
/** Full-size album-art source: a cached file for local, a server URL for remote. */
export function albumArtworkSource(album: AlbumArtworkFields): string | null {
if (album.source_type && album.source_type !== 'local') {
return artworkUrlForTrack({
sourceType: album.source_type,
sourceId: album.source_id ?? undefined,
artworkSourceId: album.artwork_source_id ?? undefined,
});
}
return album.artwork_hash ? artworkUri(album.artwork_hash) : null;
}
export async function ensureArtworkThumbnails(
hashes: readonly (string | null | undefined)[]
): Promise<number> {
+151
View File
@@ -0,0 +1,151 @@
// Remote catalog sync orchestration (M5). Mirrors src/library/scanner.ts for local
// folders: fetch the server catalog -> upsert into `tracks` -> prune removed tracks.
// The caller (remoteSourcesStore) owns status/progress writes and libraryStore.refresh.
import type { LibraryDatabase } from '@/db/database';
import {
deleteTracksByPaths,
getRemoteSourcePaths,
upsertRemoteTracks,
type RemoteTrackUpsert,
} from '@/db/queries';
import { addFavoritePaths, syncRemotePlaylists } from '@/db/playlistQueries';
import { buildAlbumIdentityKey } from '@/library/trackAdapter';
import {
buildSubsonicTrackPath,
fetchSubsonicStarredTrackIds,
syncSubsonicCatalog,
syncSubsonicPlaylists,
} from '@/services/subsonic';
import { syncJellyfinCatalog, type JellyfinAuthContext } from '@/services/jellyfin';
import type {
RemoteCatalogTrack,
RemoteConnectionConfig,
RemoteSourceRow,
RemoteSyncProgress,
} from '@/types/remote';
const UPSERT_BATCH = 500;
export interface SyncRemoteResult {
tracksScanned: number;
removed: number;
}
export interface SyncRemoteOptions {
onProgress?: (progress: RemoteSyncProgress) => void;
/** Reuse an already-obtained Jellyfin auth (avoids a second AuthenticateByName). */
authContext?: JellyfinAuthContext;
signal?: AbortSignal;
}
function toUpsertRow(source: RemoteSourceRow, track: RemoteCatalogTrack): RemoteTrackUpsert {
return {
path: track.path,
source_type: source.type,
source_id: source.id,
source_track_id: track.source_track_id,
source_path: track.source_path,
artwork_source_id: track.artwork_source_id,
title: track.title,
artist: track.artist,
album: track.album,
album_artist: track.album_artist,
// Same album identity rule as local tracks so remote/local albums group consistently.
album_identity_key: buildAlbumIdentityKey(track.album_artist, track.artist, track.album),
duration: track.duration,
track_number: track.track_number,
disc_number: track.disc_number,
year: track.year,
genre: track.genre,
format: track.format,
sample_rate: track.sample_rate,
bit_depth: track.bit_depth,
bitrate: track.bitrate,
channels: track.channels,
codec: track.codec,
};
}
export async function syncRemoteSource(
db: LibraryDatabase,
source: RemoteSourceRow,
config: RemoteConnectionConfig,
options: SyncRemoteOptions = {}
): Promise<SyncRemoteResult> {
options.onProgress?.({ phase: 'connecting', current: 0, total: 0, detail: null });
let catalogTracks: RemoteCatalogTrack[];
if (source.type === 'subsonic') {
const result = await syncSubsonicCatalog(source.id, config, {
onProgress: options.onProgress,
signal: options.signal,
});
catalogTracks = result.tracks;
} else {
const result = await syncJellyfinCatalog(source.id, config, {
onProgress: options.onProgress,
authContext: options.authContext,
signal: options.signal,
});
catalogTracks = result.tracks;
}
options.onProgress?.({ phase: 'saving', current: 0, total: catalogTracks.length, detail: null });
const rows = catalogTracks.map((track) => toUpsertRow(source, track));
for (let i = 0; i < rows.length; i += UPSERT_BATCH) {
await upsertRemoteTracks(db, rows.slice(i, i + UPSERT_BATCH));
options.onProgress?.({
phase: 'saving',
current: Math.min(i + UPSERT_BATCH, rows.length),
total: rows.length,
detail: null,
});
}
// Prune tracks that vanished upstream (favorites/playlists keep their path-keyed
// entries; they just resolve as missing until re-added — same as local removal).
const currentPaths = new Set(rows.map((row) => row.path));
const existing = await getRemoteSourcePaths(db, source.type, source.id);
const toDelete = existing.map((row) => row.path).filter((path) => !currentPaths.has(path));
const removed = toDelete.length > 0 ? await deleteTracksByPaths(db, toDelete) : 0;
// Subsonic also exposes server favorites + playlists; mirror them into the local
// favorites/playlists tables (must run after the track upsert so paths resolve).
if (source.type === 'subsonic') {
await syncSubsonicFavoritesAndPlaylists(db, source.id, config, options);
}
return { tracksScanned: rows.length, removed };
}
async function syncSubsonicFavoritesAndPlaylists(
db: LibraryDatabase,
sourceId: number,
config: RemoteConnectionConfig,
options: SyncRemoteOptions
): Promise<void> {
const [starred, playlists] = await Promise.allSettled([
fetchSubsonicStarredTrackIds(config, { signal: options.signal }),
syncSubsonicPlaylists(sourceId, config, {
onProgress: options.onProgress,
signal: options.signal,
}),
]);
if (starred.status === 'fulfilled') {
// Starred ids -> deterministic identity paths; insert-or-ignore (additive, like
// desktop — un-starring on the server doesn't drop a local favorite).
const paths = starred.value.map((id) => buildSubsonicTrackPath(sourceId, id));
await addFavoritePaths(db, paths);
} else {
console.warn('[remoteSync] subsonic starred fetch failed', starred.reason);
}
if (playlists.status === 'fulfilled') {
await syncRemotePlaylists(db, sourceId, playlists.value);
} else {
console.warn('[remoteSync] subsonic playlist sync failed', playlists.reason);
}
}
+19 -1
View File
@@ -6,6 +6,7 @@ import type { DbTrack } from '@/types/library';
import type { TrackUpsert } from '@/db/queries';
import type { ExtractedMetadata, ScannedFile } from '../../modules/astra-library-scanner';
import { artworkUri } from './artwork';
import { artworkUrlForTrack } from '@/services/remoteUrls';
import { repairMojibakeTag } from './tagEncoding';
const UNKNOWN_ARTIST = 'Unknown Artist';
@@ -102,6 +103,19 @@ export function metadataToUpsertRow(
}
export function dbTrackToTrack(track: DbTrack): Track {
const isRemote = track.source_type !== 'local';
// Local artwork is a cached file (artworkUri); remote artwork is a server URL
// resolved on the fly from the source config + the stored cover-art id.
const artworkData = isRemote
? (artworkUrlForTrack({
sourceType: track.source_type,
sourceId: track.source_id ?? undefined,
artworkSourceId: track.artwork_source_id ?? undefined,
}) ?? undefined)
: track.artwork_hash
? artworkUri(track.artwork_hash)
: undefined;
return {
id: String(track.id),
path: track.path,
@@ -116,7 +130,7 @@ export function dbTrackToTrack(track: DbTrack): Track {
discNumber: track.disc_number ?? undefined,
year: track.year ?? undefined,
genre: track.genre ?? undefined,
artworkData: track.artwork_hash ? artworkUri(track.artwork_hash) : undefined,
artworkData,
artworkHash: track.artwork_hash ?? undefined,
format: track.format,
sampleRate: track.sample_rate ?? undefined,
@@ -125,5 +139,9 @@ export function dbTrackToTrack(track: DbTrack): Track {
channels: track.channels ?? undefined,
codec: track.codec ?? undefined,
sourceType: track.source_type,
sourceId: track.source_id ?? undefined,
sourceTrackId: track.source_track_id ?? undefined,
sourcePath: track.source_path ?? undefined,
artworkSourceId: track.artwork_source_id ?? undefined,
};
}