mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
m5, subsonic/jellyfin support
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user