From 3ec3f5ecf2a1724235b5c4dccad61e4232f3fea6 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:03:37 -0400 Subject: [PATCH] m5, subsonic/jellyfin support --- app.json | 8 + package-lock.json | 51 ++ package.json | 4 + src/app/(tabs)/index.tsx | 7 +- src/app/(tabs)/library/album/[key].tsx | 6 +- src/app/(tabs)/settings.tsx | 25 + src/app/_layout.tsx | 7 + src/app/now-playing.tsx | 5 + src/app/sources/edit.tsx | 402 ++++++++++++++ src/app/sources/index.tsx | 267 ++++++++++ src/audio/sampleTracks.ts | 20 +- src/audio/useNormalizationSync.ts | 16 +- src/audio/usePlaybackSync.ts | 6 +- src/components/RemoteSourceBadge.tsx | 29 ++ src/components/library/AlbumGridItem.tsx | 7 +- src/components/library/AlbumRow.tsx | 7 +- src/components/library/PlaylistRow.tsx | 20 +- src/components/library/PlaylistsView.tsx | 1 + src/components/library/TrackRow.tsx | 20 +- src/db/playlistQueries.ts | 102 +++- src/db/queries.ts | 126 ++++- src/db/remoteSourceQueries.ts | 122 +++++ src/db/schema.ts | 104 +++- src/lib/hash.ts | 28 + src/library/artwork.ts | 36 ++ src/library/remoteSync.ts | 151 ++++++ src/library/trackAdapter.ts | 20 +- src/services/jellyfin.ts | 575 +++++++++++++++++++++ src/services/remoteConfig.ts | 45 ++ src/services/remoteCredentials.ts | 22 + src/services/remoteUrls.ts | 53 ++ src/services/subsonic.ts | 632 +++++++++++++++++++++++ src/stores/remoteSourcesStore.ts | 288 +++++++++++ src/types/audio.ts | 2 + src/types/library.ts | 16 +- src/types/playlist.ts | 2 + src/types/remote.ts | 125 +++++ 37 files changed, 3322 insertions(+), 35 deletions(-) create mode 100644 src/app/sources/edit.tsx create mode 100644 src/app/sources/index.tsx create mode 100644 src/components/RemoteSourceBadge.tsx create mode 100644 src/db/remoteSourceQueries.ts create mode 100644 src/lib/hash.ts create mode 100644 src/library/remoteSync.ts create mode 100644 src/services/jellyfin.ts create mode 100644 src/services/remoteConfig.ts create mode 100644 src/services/remoteCredentials.ts create mode 100644 src/services/remoteUrls.ts create mode 100644 src/services/subsonic.ts create mode 100644 src/stores/remoteSourcesStore.ts create mode 100644 src/types/remote.ts diff --git a/app.json b/app.json index 54b2c12..2f19f65 100644 --- a/app.json +++ b/app.json @@ -39,6 +39,14 @@ "backgroundColor": "#000000" } } + ], + [ + "expo-build-properties", + { + "android": { + "usesCleartextTraffic": true + } + } ] ], "experiments": { diff --git a/package-lock.json b/package-lock.json index 7ce309d..916608e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "encoding-japanese": "^2.2.0", "expo": "~56.0.4", "expo-asset": "~56.0.14", + "expo-build-properties": "^56.0.19", "expo-constants": "~56.0.15", "expo-device": "~56.0.4", "expo-document-picker": "~56.0.4", @@ -30,11 +31,14 @@ "expo-image": "~56.0.9", "expo-linking": "~56.0.11", "expo-router": "~56.2.6", + "expo-secure-store": "^56.0.4", "expo-splash-screen": "~56.0.10", "expo-status-bar": "~56.0.4", "expo-symbols": "~56.0.5", "expo-system-ui": "~56.0.5", "expo-web-browser": "~56.0.5", + "js-md5": "^0.8.3", + "js-sha1": "^0.7.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", @@ -6209,6 +6213,32 @@ "react-native": "*" } }, + "node_modules/expo-build-properties": { + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.19.tgz", + "integrity": "sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^56.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-build-properties/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-constants": { "version": "56.0.15", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.15.tgz", @@ -6449,6 +6479,15 @@ } } }, + "node_modules/expo-secure-store": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-56.0.4.tgz", + "integrity": "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "56.0.4", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.4.tgz", @@ -8177,6 +8216,18 @@ "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", "license": "MIT" }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, + "node_modules/js-sha1": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/js-sha1/-/js-sha1-0.7.0.tgz", + "integrity": "sha512-oQZ1Mo7440BfLSv9TX87VNEyU52pXPVG19F9PL3gTgNt0tVxlZ8F4O6yze3CLuLx28TxotxvlyepCNaaV0ZjMw==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 8f92696..caff3b7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "encoding-japanese": "^2.2.0", "expo": "~56.0.4", "expo-asset": "~56.0.14", + "expo-build-properties": "^56.0.19", "expo-constants": "~56.0.15", "expo-device": "~56.0.4", "expo-document-picker": "~56.0.4", @@ -24,11 +25,14 @@ "expo-image": "~56.0.9", "expo-linking": "~56.0.11", "expo-router": "~56.2.6", + "expo-secure-store": "^56.0.4", "expo-splash-screen": "~56.0.10", "expo-status-bar": "~56.0.4", "expo-symbols": "~56.0.5", "expo-system-ui": "~56.0.5", "expo-web-browser": "~56.0.5", + "js-md5": "^0.8.3", + "js-sha1": "^0.7.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index fa3217b..18a5ae0 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -22,7 +22,7 @@ import { togglePlay, } from '@/audio/playbackController'; import { dbTrackToTrack } from '@/library/trackAdapter'; -import { artworkUri } from '@/library/artwork'; +import { albumArtworkSource } from '@/library/artwork'; import { formatDuration } from '@/lib/format'; import { useScopeActive } from '@/scope/scopeStore'; import type { PlaybackState, Track } from '@/types/audio'; @@ -96,11 +96,12 @@ function SectionHeader({ } function AlbumCover({ album, size }: { album: Album; size: number }) { + const artUri = albumArtworkSource(album); return ( - {album.artwork_hash ? ( + {artUri ? ( - {album?.artwork_hash ? ( + {album && albumArtworkSource(album) ? ( s.sources); + const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode); @@ -166,6 +171,26 @@ export default function SettingsScreen() { ); })} + + + REMOTE SOURCES + + router.push('/sources')} + accessibilityRole="button" + > + + + Subsonic / Jellyfin servers + + {remoteSources.length === 0 + ? 'Stream and browse your self-hosted library.' + : `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'} connected.`} + + + + ); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 51514cc..009825e 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -20,6 +20,7 @@ import { useScopeLifecycle } from '@/scope/useScopeLifecycle'; import { useLibraryStore } from '@/stores/libraryStore'; import { useEQStore } from '@/stores/eqStore'; import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; +import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useNormalizationSync } from '@/audio/useNormalizationSync'; import { colors } from '@/theme'; @@ -75,6 +76,12 @@ export default function RootLayout() { .getState() .load() .catch((err) => console.error('[audioSettings] load failed', err)); + // Remote sources: load server rows + hydrate the URL registry from cached + // config/token (no network on launch). Runs after library init reads first. + useRemoteSourcesStore + .getState() + .init() + .catch((err) => console.error('[remoteSources] init failed', err)); }, []); if (!fontsLoaded) return null; diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index d5e8737..4b07501 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -16,6 +16,7 @@ import Animated, { import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { FormatBadges } from '@/components/FormatBadge'; +import { RemoteSourceBadge } from '@/components/RemoteSourceBadge'; import { MarqueeText } from '@/components/MarqueeText'; import { WaveformSeekBar } from '@/components/WaveformSeekBar'; import { Visualizer } from '@/components/Visualizer'; @@ -575,6 +576,7 @@ export default function NowPlayingScreen() { + @@ -864,6 +866,9 @@ const styles = StyleSheet.create({ flex: 1, minWidth: 0, overflow: 'hidden', + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, }, subActions: { flexDirection: 'row', diff --git a/src/app/sources/edit.tsx b/src/app/sources/edit.tsx new file mode 100644 index 0000000..3c2b2ac --- /dev/null +++ b/src/app/sources/edit.tsx @@ -0,0 +1,402 @@ +import { useMemo, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + TextInput, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Screen } from '@/components/Screen'; +import { Text } from '@/components/Text'; +import { colors, radius, spacing } from '@/theme'; +import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; +import type { RemoteSourceType } from '@/types/remote'; + +const TYPE_OPTIONS: { + type: RemoteSourceType; + label: string; + description: string; + icon: keyof typeof Ionicons.glyphMap; +}[] = [ + { + type: 'subsonic', + label: 'Subsonic', + description: 'Navidrome, Airsonic, Gonic, and other Subsonic-compatible servers.', + icon: 'cloud-outline', + }, + { + type: 'jellyfin', + label: 'Jellyfin', + description: 'A Jellyfin media server.', + icon: 'tv-outline', + }, +]; + +interface FieldProps { + label: string; + value: string; + onChangeText: (v: string) => void; + placeholder?: string; + secureTextEntry?: boolean; + autoCapitalize?: 'none' | 'sentences'; + keyboardType?: 'default' | 'url'; +} + +function Field({ + label, + value, + onChangeText, + placeholder, + secureTextEntry, + autoCapitalize = 'none', + keyboardType = 'default', +}: FieldProps) { + return ( + + + {label} + + + + ); +} + +export default function SourceEditScreen() { + const router = useRouter(); + const { id } = useLocalSearchParams<{ id?: string }>(); + const sources = useRemoteSourcesStore((s) => s.sources); + const createSource = useRemoteSourcesStore((s) => s.createSource); + const updateSource = useRemoteSourcesStore((s) => s.updateSource); + const testSource = useRemoteSourcesStore((s) => s.testSource); + + const editing = useMemo( + () => (id ? sources.find((s) => s.id === Number(id)) : undefined), + [id, sources] + ); + + // Wizard: pick a server type first (new only), then enter connection details. + const [step, setStep] = useState<'type' | 'details'>(editing ? 'details' : 'type'); + const [type, setType] = useState(editing?.type ?? 'subsonic'); + const [name, setName] = useState(editing?.name ?? ''); + const [baseUrl, setBaseUrl] = useState(editing?.base_url ?? ''); + const [username, setUsername] = useState(editing?.username ?? ''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState<'test' | 'save' | null>(null); + const [message, setMessage] = useState<{ text: string; ok: boolean } | null>(null); + + const typeLabel = type === 'subsonic' ? 'Subsonic' : 'Jellyfin'; + + const canSubmit = + name.trim().length > 0 && + baseUrl.trim().length > 0 && + username.trim().length > 0 && + (editing ? true : password.length > 0); + + const chooseType = (next: RemoteSourceType) => { + setType(next); + setMessage(null); + setStep('details'); + }; + + const goBack = () => { + // From details on a NEW server, step back to type selection; otherwise leave. + if (step === 'details' && !editing) { + setMessage(null); + setStep('type'); + return; + } + router.back(); + }; + + const onTest = async () => { + if (busy) return; + if (!baseUrl.trim() || !username.trim() || !password) { + setMessage({ text: 'Enter server URL, username, and password to test.', ok: false }); + return; + } + setBusy('test'); + setMessage(null); + const result = await testSource({ + type, + baseUrl: baseUrl.trim(), + username: username.trim(), + password, + }); + setMessage({ text: result.message, ok: result.ok }); + setBusy(null); + }; + + const onSave = async () => { + if (busy || !canSubmit) return; + setBusy('save'); + setMessage(null); + try { + if (editing) { + await updateSource(editing.id, { + name: name.trim(), + baseUrl: baseUrl.trim(), + username: username.trim(), + password: password || undefined, + }); + } else { + await createSource({ + type, + name: name.trim(), + baseUrl: baseUrl.trim(), + username: username.trim(), + password, + enabled: true, + }); + } + router.back(); + } catch (error) { + setMessage({ text: error instanceof Error ? error.message : String(error), ok: false }); + setBusy(null); + } + }; + + const backLabel = step === 'details' && !editing ? 'Type' : 'Servers'; + + return ( + + + + + + + {backLabel} + + + + + {step === 'type' ? ( + + + Add server + + + Choose your server type to get started. + + + + {TYPE_OPTIONS.map((option) => ( + chooseType(option.type)} + accessibilityRole="button" + > + + + + + {option.label} + + {option.description} + + + + + ))} + + + ) : ( + + + {editing ? 'Edit server' : `${typeLabel} server`} + + + + + + + + {message ? ( + + {message.text} + + ) : null} + + + void onTest()} + disabled={!!busy} + > + {busy === 'test' ? ( + + ) : ( + + Test connection + + )} + + void onSave()} + disabled={!canSubmit || !!busy} + > + {busy === 'save' ? ( + + ) : ( + + {editing ? 'Save' : 'Add server'} + + )} + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1 }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.md, + }, + back: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + content: { + paddingBottom: spacing.xxl, + }, + heading: { + marginTop: spacing.lg, + marginBottom: spacing.sm, + }, + subheading: { + marginBottom: spacing.xl, + }, + typeCards: { + gap: spacing.md, + }, + typeCard: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + padding: spacing.lg, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + typeCardIcon: { + width: 44, + height: 44, + borderRadius: radius.sm, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgTertiary, + }, + typeCardText: { + flex: 1, + gap: 2, + }, + typeCardDesc: { + lineHeight: 16, + }, + field: { + marginTop: spacing.lg, + }, + fieldLabel: { + letterSpacing: 1, + marginBottom: spacing.sm, + }, + input: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + color: colors.textPrimary, + fontSize: 15, + }, + message: { + marginTop: spacing.lg, + lineHeight: 18, + }, + actions: { + flexDirection: 'row', + gap: spacing.md, + marginTop: spacing.xl, + }, + button: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md + 2, + borderRadius: radius.md, + minHeight: 48, + }, + secondaryButton: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + primaryButton: { + backgroundColor: colors.accent, + }, + buttonDisabled: { + opacity: 0.5, + }, +}); diff --git a/src/app/sources/index.tsx b/src/app/sources/index.tsx new file mode 100644 index 0000000..88ec852 --- /dev/null +++ b/src/app/sources/index.tsx @@ -0,0 +1,267 @@ +import { useState } from 'react'; +import { Alert, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { Screen } from '@/components/Screen'; +import { Text } from '@/components/Text'; +import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet'; +import { colors, radius, spacing } from '@/theme'; +import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; +import type { RemoteSourceRow, RemoteSyncProgress } from '@/types/remote'; + +function statusLine( + source: RemoteSourceRow, + progress: RemoteSyncProgress | null +): { text: string; tone: 'normal' | 'error' } { + if (!source.enabled) return { text: 'Disabled', tone: 'normal' }; + if (progress) { + const pct = progress.total > 0 ? ` ${progress.current}/${progress.total}` : ''; + return { text: `Syncing… ${progress.phase}${pct}`, tone: 'normal' }; + } + if (source.last_status === 'error') { + return { text: source.last_error ?? 'Sync failed', tone: 'error' }; + } + if (source.last_sync_at) { + return { text: `Last synced ${new Date(source.last_sync_at).toLocaleString()}`, tone: 'normal' }; + } + return { text: 'Not synced yet', tone: 'normal' }; +} + +export default function SourcesScreen() { + const router = useRouter(); + const sources = useRemoteSourcesStore((s) => s.sources); + const progressById = useRemoteSourcesStore((s) => s.progressById); + const syncSource = useRemoteSourcesStore((s) => s.syncSource); + const syncAll = useRemoteSourcesStore((s) => s.syncAll); + const deleteSource = useRemoteSourcesStore((s) => s.deleteSource); + + const [actionFor, setActionFor] = useState(null); + + const confirmRemove = (source: RemoteSourceRow) => { + Alert.alert( + `Remove ${source.name}?`, + 'This removes the server and all of its tracks from your library. Favorites and playlist entries are kept but will show as missing.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: () => void deleteSource(source.id, true), + }, + ] + ); + }; + + const actionItems: ActionSheetItem[] = actionFor + ? [ + { + key: 'sync', + label: 'Sync now', + icon: 'sync', + onPress: () => { + const id = actionFor.id; + setActionFor(null); + void syncSource(id); + }, + }, + { + key: 'edit', + label: 'Edit server', + icon: 'create-outline', + onPress: () => { + const id = actionFor.id; + setActionFor(null); + router.push({ pathname: '/sources/edit', params: { id: String(id) } }); + }, + }, + { + key: 'remove', + label: 'Remove server', + icon: 'trash-outline', + destructive: true, + onPress: () => { + const source = actionFor; + setActionFor(null); + confirmRemove(source); + }, + }, + ] + : []; + + return ( + + + router.back()} hitSlop={8}> + + + Settings + + + + {sources.length > 0 ? ( + void syncAll()} hitSlop={8} accessibilityLabel="Sync all"> + + + ) : null} + router.push('/sources/edit')} + hitSlop={8} + accessibilityLabel="Add server" + > + + + + + + + Remote sources + + + + {sources.length === 0 ? ( + + + + No servers yet. Add a Subsonic or Jellyfin server to stream and browse your + self-hosted library. + + router.push('/sources/edit')}> + + + Add server + + + + ) : ( + sources.map((source) => { + const status = statusLine(source, progressById[source.id] ?? null); + return ( + setActionFor(source)} + accessibilityRole="button" + > + + + + + + + {source.name} + + + {source.type.toUpperCase()} + + + + {source.base_url} + + + {status.text} + + + + + ); + }) + )} + + + setActionFor(null)} + /> + + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: spacing.md, + }, + back: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.lg, + }, + heading: { + marginTop: spacing.lg, + marginBottom: spacing.lg, + }, + content: { + paddingBottom: spacing.xxl, + gap: spacing.sm, + }, + empty: { + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.xxl, + paddingHorizontal: spacing.lg, + }, + emptyText: { + textAlign: 'center', + lineHeight: 20, + }, + addButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + backgroundColor: colors.accentGlow, + marginTop: spacing.sm, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + padding: spacing.lg, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + rowIcon: { + width: 36, + height: 36, + borderRadius: radius.sm, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgTertiary, + }, + rowMeta: { + flex: 1, + gap: 2, + }, + rowTitleLine: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + }, + rowName: { + flex: 1, + }, +}); diff --git a/src/audio/sampleTracks.ts b/src/audio/sampleTracks.ts index 33c7f6a..b277bdb 100644 --- a/src/audio/sampleTracks.ts +++ b/src/audio/sampleTracks.ts @@ -1,5 +1,6 @@ import type { Track as RntpTrack } from 'react-native-track-player'; import type { Track } from '@/types/audio'; +import { streamUrlForTrack } from '@/services/remoteUrls'; /** * M0 verification tracks. Streamed from a public royalty-free source so playback @@ -37,9 +38,14 @@ export const SAMPLE_TRACKS: Track[] = [ /** Map an Astra Track to an RNTP track, carrying audiophile metadata as custom fields. */ export function toRntpTrack(track: Track): RntpTrack { + // Remote tracks play from a resolved HTTP stream URL; the stable identity path + // (subsonic://|jellyfin://) rides along as `astraPath` so history/favorites/now- + // playing match the `tracks` row. Local tracks already play from their path. + const isRemote = !!track.sourceType && track.sourceType !== 'local'; + const url = isRemote ? (streamUrlForTrack(track) ?? track.path) : track.path; return { id: track.id, - url: track.path, + url, title: track.title, artist: track.artist, album: track.album, @@ -50,14 +56,20 @@ export function toRntpTrack(track: Track): RntpTrack { sampleRate: track.sampleRate, bitDepth: track.bitDepth, bitrate: track.bitrate, + astraPath: track.path, + sourceType: track.sourceType, + sourceId: track.sourceId, + sourceTrackId: track.sourceTrackId, + artworkSourceId: track.artworkSourceId, }; } /** Reconstruct an Astra Track from the active RNTP track (for the player store). */ export function rntpToTrack(rt: RntpTrack): Track { + const astraPath = typeof rt.astraPath === 'string' ? rt.astraPath : null; return { id: String(rt.id ?? rt.url), - path: String(rt.url), + path: astraPath ?? String(rt.url), title: rt.title ?? 'Unknown title', artist: rt.artist ?? 'Unknown artist', album: rt.album ?? '', @@ -67,5 +79,9 @@ export function rntpToTrack(rt: RntpTrack): Track { sampleRate: rt.sampleRate as number | undefined, bitDepth: rt.bitDepth as number | undefined, bitrate: rt.bitrate as number | undefined, + sourceType: (rt.sourceType as Track['sourceType']) ?? undefined, + sourceId: rt.sourceId as number | undefined, + sourceTrackId: rt.sourceTrackId as string | undefined, + artworkSourceId: rt.artworkSourceId as string | undefined, }; } diff --git a/src/audio/useNormalizationSync.ts b/src/audio/useNormalizationSync.ts index bd1abcf..f1eead5 100644 --- a/src/audio/useNormalizationSync.ts +++ b/src/audio/useNormalizationSync.ts @@ -37,7 +37,8 @@ export function useNormalizationSync(): void { let cancelled = false; async function recompute(): Promise { - const path = usePlayerStore.getState().currentTrack?.path ?? null; + const current = usePlayerStore.getState().currentTrack; + const path = current?.path ?? null; const settings = useAudioSettingsStore.getState().asNormalizationSettings(); if (!path) { setNormalizationGainNative(1); @@ -45,6 +46,14 @@ export function useNormalizationSync(): void { return; } + // Remote tracks have no local file to decode and no synced loudness/RG facts, + // so normalization is unity. Skip analysis (it would try to download the stream). + if (current?.sourceType && current.sourceType !== 'local') { + setNormalizationGainNative(1); + useScopeStore.getState().setOscGain(DEFAULT_OSC_GAIN); + return; + } + // ensureTrackLoudness is cheap when already analyzed (single DB read) and // decodes+stores on a miss (lazy backfill for pre-scan tracks). let facts = EMPTY_FACTS; @@ -83,8 +92,11 @@ export function useNormalizationSync(): void { if (activeIndex < 0) return; const settings = useAudioSettingsStore.getState().asNormalizationSettings(); for (let i = 1; i <= PREFETCH_AHEAD; i++) { - const url = tracks[activeIndex + i]?.url; + const queued = tracks[activeIndex + i]; + const url = queued?.url; if (typeof url !== 'string' || url.length === 0) continue; + // Remote tracks: unity gain, and decoding the stream URL would download it. + if (queued?.sourceType && queued.sourceType !== 'local') continue; void ensureTrackLoudness(url) .then((facts) => { if (cancelled) return; diff --git a/src/audio/usePlaybackSync.ts b/src/audio/usePlaybackSync.ts index a52aee9..4622b0d 100644 --- a/src/audio/usePlaybackSync.ts +++ b/src/audio/usePlaybackSync.ts @@ -79,7 +79,9 @@ export function usePlaybackSync(): void { }, [activeTrack, mappedPlaybackState, recentlyPlayedTracks]); useEffect(() => { - const path = activeTrack?.url ? String(activeTrack.url) : null; + // Use the identity path (subsonic://|jellyfin:// for remote; the file URI for + // local) so history matches `tracks.path` — activeTrack.url is the stream URL. + const path = activeTrack ? rntpToTrack(activeTrack).path : null; const now = Date.now(); const candidate = recentPlayCandidate.current; @@ -119,5 +121,5 @@ export function usePlaybackSync(): void { void recordTrackPlayed(path).catch((err) => { console.warn('[library] playback history update failed', err); }); - }, [activeTrack?.url, mappedPlaybackState, progress.position, recordTrackPlayed]); + }, [activeTrack, mappedPlaybackState, progress.position, recordTrackPlayed]); } diff --git a/src/components/RemoteSourceBadge.tsx b/src/components/RemoteSourceBadge.tsx new file mode 100644 index 0000000..e53b2cc --- /dev/null +++ b/src/components/RemoteSourceBadge.tsx @@ -0,0 +1,29 @@ +import { Ionicons } from '@expo/vector-icons'; +import { colors } from '@/theme'; +import type { TrackSourceType } from '@/types/library'; + +/** + * Small "this track streams from a server" marker. Renders nothing for local files. + * A cloud icon reads as remote/streamed regardless of provider (Subsonic/Jellyfin). + */ +export function RemoteSourceBadge({ + sourceType, + size = 12, + color = colors.accent, +}: { + sourceType?: TrackSourceType | null; + size?: number; + color?: string; +}) { + if (!sourceType || sourceType === 'local') return null; + return ( + + ); +} + +export default RemoteSourceBadge; diff --git a/src/components/library/AlbumGridItem.tsx b/src/components/library/AlbumGridItem.tsx index 2f6b7e5..8db6be8 100644 --- a/src/components/library/AlbumGridItem.tsx +++ b/src/components/library/AlbumGridItem.tsx @@ -3,16 +3,17 @@ import { Image } from 'expo-image'; import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { colors, radius, spacing } from '@/theme'; -import { artworkUri } from '@/library/artwork'; +import { albumArtworkSource } from '@/library/artwork'; import type { Album } from '@/types/library'; export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () => void }) { + const artUri = albumArtworkSource(album); return ( - {album.artwork_hash ? ( + {artUri ? ( void }) { + const artUri = albumArtworkSource(album); return ( - {album.artwork_hash ? ( + {artUri ? ( diff --git a/src/components/library/PlaylistRow.tsx b/src/components/library/PlaylistRow.tsx index 7df3e34..addda15 100644 --- a/src/components/library/PlaylistRow.tsx +++ b/src/components/library/PlaylistRow.tsx @@ -11,6 +11,7 @@ export function PlaylistRow({ missingCount = 0, coverHash, pinned = false, + remote = false, onPress, onLongPress, }: { @@ -20,6 +21,8 @@ export function PlaylistRow({ coverHash: string | null; /** Favorites pseudo-playlist: heart cover instead of artwork/logo. */ pinned?: boolean; + /** Synced from a remote server — shows a cloud marker. */ + remote?: boolean; onPress: () => void; onLongPress?: () => void; }) { @@ -47,9 +50,12 @@ export function PlaylistRow({ )} - - {name} - + + + {name} + + {remote ? : null} + {`${trackCount} ${trackCount === 1 ? 'track' : 'tracks'}`} {missingCount > 0 ? ( @@ -92,4 +98,12 @@ const styles = StyleSheet.create({ flex: 1, gap: 2, }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + title: { + flexShrink: 1, + }, }); diff --git a/src/components/library/PlaylistsView.tsx b/src/components/library/PlaylistsView.tsx index e88cdd8..5378bdb 100644 --- a/src/components/library/PlaylistsView.tsx +++ b/src/components/library/PlaylistsView.tsx @@ -141,6 +141,7 @@ export function PlaylistsView() { trackCount={item.track_count} missingCount={item.missing_track_count} coverHash={item.auto_cover_hash} + remote={item.remote_source_id != null} onPress={() => router.push(`/library/playlist/${item.id}`)} onLongPress={() => setMenuFor(item)} /> diff --git a/src/components/library/TrackRow.tsx b/src/components/library/TrackRow.tsx index f81c8fe..587f719 100644 --- a/src/components/library/TrackRow.tsx +++ b/src/components/library/TrackRow.tsx @@ -4,10 +4,11 @@ import { Image } from 'expo-image'; import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { FormatBadges } from '@/components/FormatBadge'; +import { RemoteSourceBadge } from '@/components/RemoteSourceBadge'; import { SwipeableRow } from '@/components/SwipeableRow'; import { colors, radius, spacing } from '@/theme'; import { formatDuration } from '@/lib/format'; -import { artworkThumbUri } from '@/library/artwork'; +import { trackArtworkThumbSource } from '@/library/artwork'; import { dbTrackToTrack } from '@/library/trackAdapter'; import { enqueueEnd, enqueueTop } from '@/audio/playbackController'; import type { DbTrack } from '@/types/library'; @@ -36,11 +37,12 @@ export function TrackRow({ /** Swipe right → play next, swipe left → add to queue. Off in queue-like lists. */ swipeToQueue?: boolean; }) { - const artworkHash = track.artwork_hash; - const [failedArtworkHash, setFailedArtworkHash] = useState(null); + // Key the artwork by hash (local) or identity path (remote) so the error fallback + // and FlashList recycling work for both. + const artKey = track.source_type !== 'local' ? track.path : track.artwork_hash; + const [failedArtKey, setFailedArtKey] = useState(null); - const thumbUri = - artworkHash && failedArtworkHash !== artworkHash ? artworkThumbUri(artworkHash) : null; + const thumbUri = failedArtKey !== artKey ? trackArtworkThumbSource(track) : null; const secondaryText = subtitle ?? (showArtist ? track.artist : null); const row = ( @@ -57,10 +59,10 @@ export function TrackRow({ style={styles.artImage} contentFit="cover" cachePolicy="memory-disk" - recyclingKey={artworkHash} + recyclingKey={artKey ?? undefined} transition={null} allowDownscaling - onError={() => setFailedArtworkHash(artworkHash)} + onError={() => setFailedArtKey(artKey)} /> ) : ( @@ -87,6 +89,7 @@ export function TrackRow({ ) : null} + { await db.run('DELETE FROM favorites WHERE track_path = ?', [trackPath]); } + +/** Add many favorites at once (insert-or-ignore). Used by remote starred sync. */ +export async function addFavoritePaths(db: LibraryDatabase, paths: string[]): Promise { + if (paths.length === 0) return; + const now = Date.now(); + await db.transaction(async (tx) => { + for (const path of paths) { + await tx.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [ + path, + now, + ]); + } + }); +} + +// --- Remote sync (Subsonic playlists/favorites) ------------------------------ + +/** Remove favorites whose path belongs to a given remote source (on source delete). */ +export async function deleteFavoritesByPathPrefix( + db: LibraryDatabase, + prefix: string +): Promise { + await db.run('DELETE FROM favorites WHERE track_path LIKE ?', [`${prefix}%`]); +} + +/** Remove all synced playlists (and their entries via CASCADE) for a remote source. */ +export async function deleteRemotePlaylistsBySource( + db: LibraryDatabase, + sourceId: number +): Promise { + await db.run('DELETE FROM playlists WHERE remote_source_id = ?', [sourceId]); +} + +/** + * Upsert a source's server playlists by (remote_source_id, remote_playlist_id): + * create/update each + replace its entries, then delete remote playlists for this + * source that vanished upstream. Ports desktop `syncSubsonicRemotePlaylists`. + */ +export async function syncRemotePlaylists( + db: LibraryDatabase, + sourceId: number, + playlists: RemotePlaylist[] +): Promise { + await db.transaction(async (tx) => { + const existing = await tx.all<{ id: number; remote_playlist_id: string }>( + 'SELECT id, remote_playlist_id FROM playlists WHERE remote_source_id = ?', + [sourceId] + ); + const existingByRemoteId = new Map(); + for (const row of existing) { + if (row.remote_playlist_id) existingByRemoteId.set(row.remote_playlist_id, row.id); + } + + const seen = new Set(); + const now = Date.now(); + for (const playlist of playlists) { + const remotePlaylistId = playlist.source_playlist_id.trim(); + if (!remotePlaylistId) continue; + seen.add(remotePlaylistId); + const name = playlist.name.trim() || `Playlist ${remotePlaylistId}`; + + let playlistId = existingByRemoteId.get(remotePlaylistId); + if (playlistId == null) { + const result = await tx.run( + `INSERT INTO playlists (name, created_at, updated_at, remote_source_id, remote_playlist_id) + VALUES (?, ?, ?, ?, ?)`, + [name, now, now, sourceId, remotePlaylistId] + ); + playlistId = result.lastInsertRowid; + } else { + await tx.run('UPDATE playlists SET name = ?, updated_at = ? WHERE id = ?', [ + name, + now, + playlistId, + ]); + await tx.run('DELETE FROM playlist_tracks WHERE playlist_id = ?', [playlistId]); + } + + let position = 0; + const seenPaths = new Set(); + for (const track of playlist.tracks) { + if (seenPaths.has(track.path)) continue; + seenPaths.add(track.path); + await tx.run( + `INSERT OR IGNORE INTO playlist_tracks + (playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [playlistId, track.path, position++, now, track.title, track.artist, track.album] + ); + } + } + + // Reconcile: drop synced playlists that no longer exist upstream. + for (const [remoteId, playlistId] of existingByRemoteId) { + if (seen.has(remoteId)) continue; + await tx.run('DELETE FROM playlists WHERE id = ?', [playlistId]); + } + }); +} diff --git a/src/db/queries.ts b/src/db/queries.ts index 43ed12b..ec7f768 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -96,6 +96,127 @@ export async function upsertTracks(db: LibraryDatabase, rows: TrackUpsert[]): Pr }); } +// --- Remote tracks (Subsonic / Jellyfin) ------------------------------------- + +/** Row shape for a synced remote track. folder_id is NULL; file_name/size/mtime unused. */ +export interface RemoteTrackUpsert { + path: string; // subsonic://|jellyfin:// identity URI + source_type: 'subsonic' | 'jellyfin'; + source_id: number; + source_track_id: string; + source_path: string | null; + artwork_source_id: string | null; + title: string; + artist: string; + album: string; + album_artist: string | null; + album_identity_key: string; + duration: number; + track_number: number | null; + disc_number: number | null; + year: number | null; + genre: string | null; + format: string; + sample_rate: number | null; + bit_depth: number | null; + bitrate: number | null; + channels: number | null; + codec: string | null; +} + +const UPSERT_REMOTE_TRACK_SQL = ` + INSERT INTO tracks ( + path, folder_id, title, artist, album, album_artist, album_identity_key, + duration, track_number, disc_number, year, genre, artwork_hash, format, + sample_rate, bit_depth, bitrate, channels, codec, source_type, source_id, + source_track_id, source_path, artwork_source_id, file_name, size, mtime, + added_at, modified_at + ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', NULL, 0, ?, ?) + ON CONFLICT(path) DO UPDATE SET + title = excluded.title, + artist = excluded.artist, + album = excluded.album, + album_artist = excluded.album_artist, + album_identity_key = excluded.album_identity_key, + duration = excluded.duration, + track_number = excluded.track_number, + disc_number = excluded.disc_number, + year = excluded.year, + genre = excluded.genre, + format = excluded.format, + sample_rate = excluded.sample_rate, + bit_depth = excluded.bit_depth, + bitrate = excluded.bitrate, + channels = excluded.channels, + codec = excluded.codec, + source_track_id = excluded.source_track_id, + source_path = excluded.source_path, + artwork_source_id = excluded.artwork_source_id, + modified_at = excluded.modified_at +`; + +export async function upsertRemoteTracks( + db: LibraryDatabase, + rows: RemoteTrackUpsert[] +): Promise { + if (rows.length === 0) return; + const now = Date.now(); + await db.transaction(async (tx) => { + for (const row of rows) { + await tx.run(UPSERT_REMOTE_TRACK_SQL, [ + row.path, + row.title, + row.artist, + row.album, + row.album_artist, + row.album_identity_key, + row.duration, + row.track_number, + row.disc_number, + row.year, + row.genre, + row.format, + row.sample_rate, + row.bit_depth, + row.bitrate, + row.channels, + row.codec, + row.source_type, + row.source_id, + row.source_track_id, + row.source_path, + row.artwork_source_id, + now, + now, + ]); + } + }); +} + +/** Existing remote-track paths for a source, used to diff/prune removed tracks. */ +export function getRemoteSourcePaths( + db: LibraryDatabase, + sourceType: string, + sourceId: number +): Promise<{ path: string }[]> { + return db.all<{ path: string }>( + 'SELECT path FROM tracks WHERE source_type = ? AND source_id = ?', + [sourceType, sourceId] + ); +} + +export async function deleteRemoteTracksBySource( + db: LibraryDatabase, + sourceType: string, + sourceId: number +): Promise { + const result = await db.run('DELETE FROM tracks WHERE source_type = ? AND source_id = ?', [ + sourceType, + sourceId, + ]); + return result.changes; +} + const TRACK_ORDER = 'COALESCE(disc_number, 9999), COALESCE(track_number, 9999), title COLLATE NOCASE'; export function getAlbums(db: LibraryDatabase): Promise { @@ -106,7 +227,10 @@ export function getAlbums(db: LibraryDatabase): Promise { MAX(year) AS year, MAX(artwork_hash) AS artwork_hash, COUNT(*) AS track_count, - MAX(added_at) AS latest_added_at + MAX(added_at) AS latest_added_at, + MAX(source_type) AS source_type, + MAX(source_id) AS source_id, + MAX(artwork_source_id) AS artwork_source_id FROM tracks GROUP BY album_identity_key ORDER BY 3 COLLATE NOCASE, 2 COLLATE NOCASE diff --git a/src/db/remoteSourceQueries.ts b/src/db/remoteSourceQueries.ts new file mode 100644 index 0000000..d711924 --- /dev/null +++ b/src/db/remoteSourceQueries.ts @@ -0,0 +1,122 @@ +// CRUD for the `remote_sources` table (Subsonic/Jellyfin server config). Passwords +// are NOT stored here — see src/services/remoteCredentials.ts (expo-secure-store). + +import type { LibraryDatabase } from './database'; +import type { + RemoteSourceRow, + RemoteSourceStatus, + RemoteSourceType, +} from '@/types/remote'; + +export function getRemoteSources(db: LibraryDatabase): Promise { + return db.all('SELECT * FROM remote_sources ORDER BY created_at'); +} + +export function getRemoteSource( + db: LibraryDatabase, + id: number +): Promise { + return db.get('SELECT * FROM remote_sources WHERE id = ?', [id]); +} + +export interface InsertRemoteSourceInput { + type: RemoteSourceType; + name: string; + baseUrl: string; + username: string; + enabled: boolean; +} + +export async function insertRemoteSource( + db: LibraryDatabase, + input: InsertRemoteSourceInput +): Promise { + const now = Date.now(); + const result = await db.run( + `INSERT INTO remote_sources (type, name, base_url, username, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [input.type, input.name, input.baseUrl, input.username, input.enabled ? 1 : 0, now, now] + ); + const row = await getRemoteSource(db, result.lastInsertRowid); + if (!row) throw new Error('Remote source insert failed'); + return row; +} + +export interface UpdateRemoteSourceFields { + name?: string; + base_url?: string; + username?: string; + enabled?: boolean; +} + +export async function updateRemoteSource( + db: LibraryDatabase, + id: number, + fields: UpdateRemoteSourceFields +): Promise { + const sets: string[] = []; + const params: (string | number)[] = []; + if (fields.name !== undefined) { + sets.push('name = ?'); + params.push(fields.name); + } + if (fields.base_url !== undefined) { + sets.push('base_url = ?'); + params.push(fields.base_url); + } + if (fields.username !== undefined) { + sets.push('username = ?'); + params.push(fields.username); + } + if (fields.enabled !== undefined) { + sets.push('enabled = ?'); + params.push(fields.enabled ? 1 : 0); + } + if (sets.length === 0) return; + sets.push('updated_at = ?'); + params.push(Date.now()); + params.push(id); + await db.run(`UPDATE remote_sources SET ${sets.join(', ')} WHERE id = ?`, params); +} + +export async function deleteRemoteSource(db: LibraryDatabase, id: number): Promise { + await db.run('DELETE FROM remote_sources WHERE id = ?', [id]); +} + +export async function setRemoteSourceStatus( + db: LibraryDatabase, + id: number, + status: RemoteSourceStatus, + error: string | null +): Promise { + await db.run( + `UPDATE remote_sources + SET last_status = ?, last_error = ?, last_checked_at = ?, updated_at = ? + WHERE id = ?`, + [status, error, Date.now(), Date.now(), id] + ); +} + +export async function setRemoteSourceSynced(db: LibraryDatabase, id: number): Promise { + const now = Date.now(); + await db.run( + `UPDATE remote_sources + SET last_status = 'ok', last_error = NULL, last_sync_at = ?, last_checked_at = ?, updated_at = ? + WHERE id = ?`, + [now, now, now, id] + ); +} + +/** Cache Jellyfin auth (Subsonic derives a salted token per request, so it stays NULL). */ +export async function setRemoteSourceAuth( + db: LibraryDatabase, + id: number, + auth: { accessToken: string | null; userId: string | null; deviceId: string | null } +): Promise { + await db.run( + `UPDATE remote_sources + SET access_token = ?, user_id = ?, device_id = ?, updated_at = ? + WHERE id = ?`, + [auth.accessToken, auth.userId, auth.deviceId, Date.now(), id] + ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 7b37d6d..fdf350d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -10,11 +10,15 @@ // ungated whole-file method so it re-measures with the fast gated subset method; // v9 adds ReplayGain peak columns + an `rg_scanned` sentinel so tag reading runs // once per track (and is retried if it ever failed), independent of loudness; -// v10 adds lightweight local playback history for Home. +// v10 adds lightweight local playback history for Home; v11 (M5) adds remote +// sources (Subsonic/Jellyfin): a `remote_sources` table + remote-linkage columns on +// `tracks`, and makes `folder_id` nullable (remote tracks have no SAF folder); v12 +// marks playlists that mirror a server playlist (remote_source_id/remote_playlist_id) +// so remote playlist sync can upsert + reconcile them. import type { LibraryDatabase } from './database'; -export const SCHEMA_VERSION = 10; +export const SCHEMA_VERSION = 12; // One statement per entry — op-sqlite executes single statements. const MIGRATIONS: readonly (readonly string[])[] = [ @@ -152,6 +156,102 @@ const MIGRATIONS: readonly (readonly string[])[] = [ )`, 'CREATE INDEX IF NOT EXISTS idx_playback_history_last_played ON playback_history(last_played_at DESC)', ], + // v10 -> v11 — remote sources (M5: Subsonic/Jellyfin). One `remote_sources` table + // (type-discriminated) holds server config + cached Jellyfin auth (the password + // lives in expo-secure-store, never here). The `tracks` table gains remote-linkage + // columns and `folder_id` becomes nullable — SQLite can't drop NOT NULL in place, + // so we rebuild `tracks` (the only FK into it is its own folder_id; favorites / + // playlists / waveform_peaks / playback_history key on `path` with no FK, so the + // rebuild is safe). Existing (local) rows copy across; the 4 new columns default NULL. + [ + `CREATE TABLE IF NOT EXISTS remote_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + base_url TEXT NOT NULL, + username TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + last_status TEXT NOT NULL DEFAULT 'unknown', + last_error TEXT, + last_sync_at INTEGER, + last_checked_at INTEGER, + access_token TEXT, + user_id TEXT, + device_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE tracks_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT UNIQUE NOT NULL, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + title TEXT NOT NULL, + artist TEXT NOT NULL, + album TEXT NOT NULL, + album_artist TEXT, + album_identity_key TEXT NOT NULL, + duration REAL NOT NULL DEFAULT 0, + track_number INTEGER, + disc_number INTEGER, + year INTEGER, + genre TEXT, + artwork_hash TEXT, + format TEXT NOT NULL, + sample_rate INTEGER, + bit_depth INTEGER, + bitrate INTEGER, + channels INTEGER, + codec TEXT, + source_type TEXT NOT NULL DEFAULT 'local', + file_name TEXT NOT NULL, + size INTEGER, + mtime INTEGER NOT NULL DEFAULT 0, + added_at INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + loudness_lufs REAL, + sample_peak REAL, + replay_gain_track_db REAL, + replay_gain_album_db REAL, + replay_gain_track_peak REAL, + replay_gain_album_peak REAL, + rg_scanned INTEGER NOT NULL DEFAULT 0, + source_id INTEGER, + source_track_id TEXT, + source_path TEXT, + artwork_source_id TEXT + )`, + `INSERT INTO tracks_new ( + id, path, folder_id, title, artist, album, album_artist, album_identity_key, + duration, track_number, disc_number, year, genre, artwork_hash, format, + sample_rate, bit_depth, bitrate, channels, codec, source_type, file_name, size, + mtime, added_at, modified_at, loudness_lufs, sample_peak, replay_gain_track_db, + replay_gain_album_db, replay_gain_track_peak, replay_gain_album_peak, rg_scanned + ) + SELECT + id, path, folder_id, title, artist, album, album_artist, album_identity_key, + duration, track_number, disc_number, year, genre, artwork_hash, format, + sample_rate, bit_depth, bitrate, channels, codec, source_type, file_name, size, + mtime, added_at, modified_at, loudness_lufs, sample_peak, replay_gain_track_db, + replay_gain_album_db, replay_gain_track_peak, replay_gain_album_peak, rg_scanned + FROM tracks`, + `DROP TABLE tracks`, + `ALTER TABLE tracks_new RENAME TO tracks`, + 'CREATE INDEX IF NOT EXISTS idx_tracks_album_identity ON tracks(album_identity_key)', + 'CREATE INDEX IF NOT EXISTS idx_tracks_artist ON tracks(artist)', + 'CREATE INDEX IF NOT EXISTS idx_tracks_folder ON tracks(folder_id)', + 'CREATE INDEX IF NOT EXISTS idx_tracks_source ON tracks(source_type, source_id)', + ], + // v11 -> v12 — mark playlists that mirror a remote server playlist. A non-null + // remote_source_id (-> remote_sources.id) + remote_playlist_id make the row a synced + // remote playlist; the unique index lets sync upsert by that pair. Local playlists + // leave both NULL and are untouched. + [ + `ALTER TABLE playlists ADD COLUMN remote_source_id INTEGER`, + `ALTER TABLE playlists ADD COLUMN remote_playlist_id TEXT`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_remote + ON playlists(remote_source_id, remote_playlist_id) + WHERE remote_source_id IS NOT NULL AND remote_playlist_id IS NOT NULL`, + ], ]; export async function migrate(db: LibraryDatabase): Promise { diff --git a/src/lib/hash.ts b/src/lib/hash.ts new file mode 100644 index 0000000..5dcc6db --- /dev/null +++ b/src/lib/hash.ts @@ -0,0 +1,28 @@ +// Hashing for remote-source auth. RN/Hermes has no Node `crypto`, so the desktop +// `createHash('md5'|'sha1')` / `randomBytes` are swapped for tiny pure-JS hashers. + +import { md5 } from 'js-md5'; +import { sha1 } from 'js-sha1'; + +export function md5Hex(input: string): string { + return md5(input); +} + +export function sha1Hex(input: string): string { + return sha1(input); +} + +/** + * Random hex salt (default 6 bytes), ported from desktop `randomBytes(6).toString('hex')`. + * The Subsonic salt only needs to be unpredictable, not cryptographically strong, so + * Math.random is sufficient and avoids an async crypto round-trip. + */ +export function randomSaltHex(bytes = 6): string { + let out = ''; + for (let i = 0; i < bytes; i += 1) { + out += Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, '0'); + } + return out; +} diff --git a/src/library/artwork.ts b/src/library/artwork.ts index c801189..8027d31 100644 --- a/src/library/artwork.ts +++ b/src/library/artwork.ts @@ -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 { diff --git a/src/library/remoteSync.ts b/src/library/remoteSync.ts new file mode 100644 index 0000000..2092501 --- /dev/null +++ b/src/library/remoteSync.ts @@ -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 { + 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 { + 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); + } +} diff --git a/src/library/trackAdapter.ts b/src/library/trackAdapter.ts index 40b477c..5a3b55d 100644 --- a/src/library/trackAdapter.ts +++ b/src/library/trackAdapter.ts @@ -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, }; } diff --git a/src/services/jellyfin.ts b/src/services/jellyfin.ts new file mode 100644 index 0000000..9b58dc3 --- /dev/null +++ b/src/services/jellyfin.ts @@ -0,0 +1,575 @@ +// Jellyfin client — ported from desktop Astra (src/main/services/jellyfin.ts). +// Changes vs desktop: Node `crypto` (sha1 device id) -> src/lib/hash; the +// ArrayBuffer stream/cover fetchers are dropped (ExoPlayer + expo-image fetch the +// URLs directly). Stream/transcode/cover URLs already embed `api_key`, so they are +// self-contained — no per-track auth headers needed at playback time. + +import { sha1Hex } from '@/lib/hash'; +import type { + RemoteCatalogTrack, + RemoteConnectionConfig, + RemoteSyncProgress, +} from '@/types/remote'; + +const DEFAULT_TIMEOUT_MS = 12_000; +const DEFAULT_RETRIES = 1; +const DEFAULT_PAGE_SIZE = 500; +const CLIENT_NAME = 'Astra'; +const CLIENT_VERSION = '0.4.0'; +const DEVICE_NAME = 'Astra Mobile'; +const TRANSCODE_AUDIO_CODEC = 'mp3'; +const TRANSCODE_CONTAINER = 'mp3'; + +export interface JellyfinAuthContext { + accessToken: string; + userId: string; +} + +export interface JellyfinRequestOptions { + timeoutMs?: number; + retries?: number; + signal?: AbortSignal; +} + +export interface JellyfinCatalogSyncOptions extends JellyfinRequestOptions { + authContext?: JellyfinAuthContext; + onProgress?: (progress: RemoteSyncProgress) => void; +} + +export interface JellyfinCatalogSyncResult { + itemsScanned: number; + tracksScanned: number; + tracks: RemoteCatalogTrack[]; +} + +interface JellyfinAuthenticateResponse { + AccessToken?: unknown; + User?: { Id?: unknown }; +} + +interface JellyfinItemsResponse { + Items?: unknown; + TotalRecordCount?: unknown; +} + +interface JellyfinAudioStream { + Type?: unknown; + Codec?: unknown; + Profile?: unknown; + Channels?: unknown; + BitRate?: unknown; + SampleRate?: unknown; + BitDepth?: unknown; +} + +interface JellyfinAudioItem { + Id?: unknown; + Name?: unknown; + Path?: unknown; + Artists?: unknown; + ArtistItems?: unknown; + Album?: unknown; + AlbumArtist?: unknown; + AlbumArtists?: unknown; + AlbumId?: unknown; + AlbumPrimaryImageTag?: unknown; + ImageTags?: unknown; + RunTimeTicks?: unknown; + IndexNumber?: unknown; + ParentIndexNumber?: unknown; + ProductionYear?: unknown; + Genres?: unknown; + Container?: unknown; + Bitrate?: unknown; + MediaStreams?: unknown; +} + +function asArray(value: unknown): T[] { + if (!value) return []; + return Array.isArray(value) ? (value as T[]) : [value as T]; +} + +function toTrimmedText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function toFiniteInteger(value: unknown): number | null { + const parsed = toFiniteNumber(value); + if (parsed == null) return null; + const intValue = Math.trunc(parsed); + return Number.isFinite(intValue) ? intValue : null; +} + +function normalizeBooleanQueryValue(value: boolean): string { + return value ? 'true' : 'false'; +} + +export function normalizeJellyfinBaseUrl(rawBaseUrl: string): string { + const trimmed = rawBaseUrl.trim(); + if (!trimmed) { + throw new Error('Server URL is required.'); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmed); + } catch { + throw new Error('Server URL is invalid.'); + } + + const protocol = parsedUrl.protocol.toLowerCase(); + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error('Server URL must use http:// or https://'); + } + + parsedUrl.hash = ''; + parsedUrl.search = ''; + parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, ''); + return parsedUrl.toString().replace(/\/+$/, ''); +} + +export function buildJellyfinDeviceId(config: RemoteConnectionConfig): string { + const base = normalizeJellyfinBaseUrl(config.baseUrl); + const username = config.username.trim().toLowerCase(); + return sha1Hex(`${base}|${username}|${CLIENT_NAME}`); +} + +function escapeHeaderTokenValue(value: string): string { + return value.replace(/"/g, '\\"'); +} + +function buildJellyfinAuthorizationHeader( + config: RemoteConnectionConfig, + options: { token?: string } = {} +): string { + const parts = [ + `Client="${escapeHeaderTokenValue(CLIENT_NAME)}"`, + `Device="${escapeHeaderTokenValue(DEVICE_NAME)}"`, + `DeviceId="${escapeHeaderTokenValue(buildJellyfinDeviceId(config))}"`, + `Version="${escapeHeaderTokenValue(CLIENT_VERSION)}"`, + ]; + if (options.token) { + parts.push(`Token="${escapeHeaderTokenValue(options.token)}"`); + } + return `MediaBrowser ${parts.join(', ')}`; +} + +function buildJellyfinUrl( + config: RemoteConnectionConfig, + endpoint: string, + params: Record +): URL { + const baseUrl = normalizeJellyfinBaseUrl(config.baseUrl); + const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + const url = new URL(`${baseUrl}${normalizedEndpoint}`); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (typeof value === 'boolean') { + url.searchParams.set(key, normalizeBooleanQueryValue(value)); + } else { + url.searchParams.set(key, String(value)); + } + } + return url; +} + +function mergeAbortSignals( + signal: AbortSignal | undefined, + timeoutMs: number +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const onAbort = () => controller.abort(); + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener('abort', onAbort, { once: true }); + } + } + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeoutId); + if (signal) signal.removeEventListener('abort', onAbort); + }, + }; +} + +async function requestJellyfinJson( + config: RemoteConnectionConfig, + authContext: JellyfinAuthContext, + endpoint: string, + params: Record, + options: JellyfinRequestOptions = {} +): Promise> { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES); + const url = buildJellyfinUrl(config, endpoint, params); + + let lastError: unknown = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + const merged = mergeAbortSignals(options.signal, timeoutMs); + try { + const response = await fetch(url, { + method: 'GET', + signal: merged.signal, + headers: { + Accept: 'application/json', + 'X-Emby-Authorization': buildJellyfinAuthorizationHeader(config, { + token: authContext.accessToken, + }), + 'X-Emby-Token': authContext.accessToken, + }, + }); + + if (!response.ok) { + throw new Error(`Jellyfin request failed (${response.status})`); + } + + return (await response.json()) as Record; + } catch (error) { + lastError = error; + if (attempt >= retries) throw error; + } finally { + merged.cleanup(); + } + } + + throw lastError instanceof Error ? lastError : new Error('Jellyfin request failed.'); +} + +export async function authenticateJellyfin( + config: RemoteConnectionConfig, + options: JellyfinRequestOptions = {} +): Promise { + const baseUrl = normalizeJellyfinBaseUrl(config.baseUrl); + const username = config.username.trim(); + const password = config.password; + + if (!username) { + throw new Error('Username is required.'); + } + if (!password) { + throw new Error('Password is required.'); + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES); + const url = new URL(`${baseUrl}/Users/AuthenticateByName`); + + let lastError: unknown = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + const merged = mergeAbortSignals(options.signal, timeoutMs); + try { + const response = await fetch(url, { + method: 'POST', + signal: merged.signal, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Emby-Authorization': buildJellyfinAuthorizationHeader(config), + }, + body: JSON.stringify({ Username: username, Pw: password }), + }); + + if (!response.ok) { + throw new Error(`Jellyfin authentication failed (${response.status})`); + } + + const payload = (await response.json()) as JellyfinAuthenticateResponse; + const accessToken = toTrimmedText(payload.AccessToken); + const userId = toTrimmedText(payload.User?.Id); + + if (!accessToken || !userId) { + throw new Error('Invalid Jellyfin authentication response.'); + } + + return { accessToken, userId }; + } catch (error) { + lastError = error; + if (attempt >= retries) throw error; + } finally { + merged.cleanup(); + } + } + + throw lastError instanceof Error ? lastError : new Error('Jellyfin authentication failed.'); +} + +export async function testJellyfinConnection( + config: RemoteConnectionConfig, + options: JellyfinRequestOptions = {} +): Promise { + await authenticateJellyfin(config, options); +} + +function resolveJellyfinArtist(item: JellyfinAudioItem): string { + const artists = asArray(item.Artists) + .map((value) => toTrimmedText(value)) + .filter((value): value is string => Boolean(value)); + if (artists.length > 0) return artists[0]; + + const artistItems = asArray>(item.ArtistItems); + for (const artistItem of artistItems) { + const candidate = toTrimmedText(artistItem.Name); + if (candidate) return candidate; + } + + return 'Unknown Artist'; +} + +function resolveJellyfinAlbumArtist(item: JellyfinAudioItem): string | null { + const direct = toTrimmedText(item.AlbumArtist); + if (direct) return direct; + + const albumArtists = asArray(item.AlbumArtists) + .map((value) => toTrimmedText(value)) + .filter((value): value is string => Boolean(value)); + + if (albumArtists.length > 0) return albumArtists[0]; + return null; +} + +function resolveJellyfinArtworkSourceId( + item: JellyfinAudioItem, + sourceTrackId: string +): string | null { + const albumId = toTrimmedText(item.AlbumId); + const albumPrimaryImageTag = toTrimmedText(item.AlbumPrimaryImageTag); + if (albumId && albumPrimaryImageTag) { + return albumId; + } + + const imageTags = item.ImageTags; + if (imageTags && typeof imageTags === 'object') { + const primary = toTrimmedText((imageTags as Record).Primary); + if (primary) { + return sourceTrackId; + } + } + + return null; +} + +function normalizeJellyfinFormat(item: JellyfinAudioItem): string { + const container = toTrimmedText(item.Container); + if (container) return container.toLowerCase(); + + const pathValue = toTrimmedText(item.Path); + if (pathValue && pathValue.includes('.')) { + const ext = pathValue.split('.').pop()?.trim().toLowerCase(); + if (ext) return ext; + } + + return 'unknown'; +} + +function isAtmosJoc(codec: string | null, profile: string | null): boolean { + const combined = `${codec ?? ''} ${profile ?? ''}`.toLowerCase(); + return combined.includes('atmos') || combined.includes('joc'); +} + +function mapJellyfinItemToCatalogTrack( + sourceId: number, + item: JellyfinAudioItem +): RemoteCatalogTrack | null { + const sourceTrackId = toTrimmedText(item.Id); + if (!sourceTrackId) return null; + + const title = toTrimmedText(item.Name) ?? `Track ${sourceTrackId}`; + const artist = resolveJellyfinArtist(item); + const album = toTrimmedText(item.Album) ?? 'Unknown Album'; + const albumArtist = resolveJellyfinAlbumArtist(item); + const durationTicks = toFiniteNumber(item.RunTimeTicks); + const duration = durationTicks && durationTicks > 0 ? durationTicks / 10_000_000 : 0; + const trackNumber = toFiniteInteger(item.IndexNumber); + const discNumber = toFiniteInteger(item.ParentIndexNumber); + const year = toFiniteInteger(item.ProductionYear); + const genre = + asArray(item.Genres) + .map((value) => toTrimmedText(value)) + .find((value): value is string => Boolean(value)) ?? null; + + const sourcePath = toTrimmedText(item.Path); + const mediaStreams = asArray(item.MediaStreams); + const audioStream = mediaStreams.find( + (stream) => toTrimmedText(stream.Type)?.toLowerCase() === 'audio' + ); + const codec = toTrimmedText(audioStream?.Codec); + const codecProfile = toTrimmedText(audioStream?.Profile); + const channels = toFiniteInteger(audioStream?.Channels); + const sampleRate = toFiniteInteger(audioStream?.SampleRate); + const bitDepth = toFiniteInteger(audioStream?.BitDepth); + const streamBitrate = toFiniteInteger(audioStream?.BitRate); + const itemBitrate = toFiniteInteger(item.Bitrate); + const artworkSourceId = resolveJellyfinArtworkSourceId(item, sourceTrackId); + + return { + path: buildJellyfinTrackPath(sourceId, sourceTrackId), + source_track_id: sourceTrackId, + source_path: sourcePath, + artwork_source_id: artworkSourceId, + title, + artist, + album, + album_artist: albumArtist, + duration, + track_number: trackNumber, + disc_number: discNumber, + year, + genre, + artwork_hash: null, + format: normalizeJellyfinFormat(item), + sample_rate: sampleRate, + bit_depth: bitDepth, + bitrate: streamBitrate ?? itemBitrate, + channels, + codec, + codec_profile: codecProfile, + is_atmos_joc: isAtmosJoc(codec, codecProfile) ? 1 : null, + replaygain_track_gain_db: null, + replaygain_album_gain_db: null, + bpm: null, + musical_key: null, + }; +} + +export async function syncJellyfinCatalog( + sourceId: number, + config: RemoteConnectionConfig, + options: JellyfinCatalogSyncOptions = {} +): Promise { + const authContext = options.authContext ?? (await authenticateJellyfin(config, options)); + const byTrackId = new Map(); + let startIndex = 0; + let totalRecordCount: number | null = null; + + while (true) { + const response = (await requestJellyfinJson( + config, + authContext, + `/Users/${encodeURIComponent(authContext.userId)}/Items`, + { + Recursive: true, + IncludeItemTypes: 'Audio', + Fields: + 'Path,Genres,Container,Bitrate,RunTimeTicks,ProductionYear,IndexNumber,ParentIndexNumber,Album,AlbumArtist,AlbumArtists,AlbumId,AlbumPrimaryImageTag,ImageTags,MediaStreams', + SortBy: 'SortName', + SortOrder: 'Ascending', + StartIndex: startIndex, + Limit: DEFAULT_PAGE_SIZE, + }, + options + )) as JellyfinItemsResponse; + + const total = toFiniteInteger(response.TotalRecordCount); + if (totalRecordCount === null && total !== null && total >= 0) { + totalRecordCount = total; + } + + const items = asArray(response.Items); + for (const item of items) { + const mapped = mapJellyfinItemToCatalogTrack(sourceId, item); + if (!mapped) continue; + byTrackId.set(mapped.source_track_id, mapped); + } + + options.onProgress?.({ + phase: 'items', + current: startIndex + items.length, + total: totalRecordCount ?? startIndex + items.length, + detail: null, + }); + + if (items.length === 0) break; + startIndex += items.length; + if (totalRecordCount !== null && startIndex >= totalRecordCount) break; + if (items.length < DEFAULT_PAGE_SIZE) break; + } + + const tracks = Array.from(byTrackId.values()); + return { + itemsScanned: totalRecordCount ?? tracks.length, + tracksScanned: tracks.length, + tracks, + }; +} + +export function buildJellyfinTrackPath(sourceId: number, sourceTrackId: string): string { + return `jellyfin://${sourceId}/track/${encodeURIComponent(sourceTrackId)}`; +} + +export function parseJellyfinTrackPath( + path: string +): { sourceId: number; sourceTrackId: string } | null { + const match = /^jellyfin:\/\/(\d+)\/track\/(.+)$/.exec(path); + if (!match) return null; + + const sourceId = Number.parseInt(match[1], 10); + if (!Number.isInteger(sourceId) || sourceId <= 0) return null; + + const sourceTrackIdRaw = match[2]; + if (!sourceTrackIdRaw) return null; + try { + const sourceTrackId = decodeURIComponent(sourceTrackIdRaw); + if (!sourceTrackId) return null; + return { sourceId, sourceTrackId }; + } catch { + return null; + } +} + +export function buildJellyfinStreamUrl( + config: RemoteConnectionConfig, + sourceTrackId: string, + accessToken: string +): string { + return buildJellyfinUrl(config, `/Items/${encodeURIComponent(sourceTrackId)}/Download`, { + api_key: accessToken, + }).toString(); +} + +export function buildJellyfinTranscodeStreamUrl( + config: RemoteConnectionConfig, + sourceTrackId: string, + authContext: JellyfinAuthContext, + maxBitRateKbps: number +): string { + const normalizedMaxBitrate = Math.max(16, Math.trunc(maxBitRateKbps)) * 1000; + return buildJellyfinUrl(config, `/Audio/${encodeURIComponent(sourceTrackId)}/universal`, { + UserId: authContext.userId, + DeviceId: buildJellyfinDeviceId(config), + api_key: authContext.accessToken, + AudioCodec: TRANSCODE_AUDIO_CODEC, + Container: TRANSCODE_CONTAINER, + TranscodingContainer: TRANSCODE_CONTAINER, + MaxStreamingBitrate: normalizedMaxBitrate, + }).toString(); +} + +export function buildJellyfinCoverArtUrl( + config: RemoteConnectionConfig, + itemId: string, + accessToken: string, + options: { quality?: number; maxWidth?: number } = {} +): string { + return buildJellyfinUrl(config, `/Items/${encodeURIComponent(itemId)}/Images/Primary`, { + api_key: accessToken, + quality: options.quality ?? 90, + maxWidth: options.maxWidth, + }).toString(); +} diff --git a/src/services/remoteConfig.ts b/src/services/remoteConfig.ts new file mode 100644 index 0000000..6edb5a9 --- /dev/null +++ b/src/services/remoteConfig.ts @@ -0,0 +1,45 @@ +// In-memory registry of resolved remote-source configs (incl. the decrypted password +// and cached Jellyfin token), keyed by remote_sources.id. Populated by the remote +// sources store on init / create / update; read synchronously by remoteUrls.ts so the +// library UI and playback can build stream/cover URLs without an async hop. +// +// This is a plain module Map (not zustand) on purpose — secrets never enter store +// state / devtools, and lookups are synchronous. + +import type { RemoteSourceType } from '@/types/remote'; + +export interface ResolvedRemoteConfig { + id: number; + type: RemoteSourceType; + baseUrl: string; + username: string; + password: string; + /** Jellyfin only. */ + accessToken?: string; + userId?: string; +} + +const registry = new Map(); + +export function setResolvedRemoteConfig(config: ResolvedRemoteConfig): void { + registry.set(config.id, config); +} + +export function getResolvedRemoteConfig(id: number): ResolvedRemoteConfig | undefined { + return registry.get(id); +} + +export function updateResolvedRemoteAuth( + id: number, + auth: { accessToken: string; userId: string } +): void { + const existing = registry.get(id); + if (existing) { + existing.accessToken = auth.accessToken; + existing.userId = auth.userId; + } +} + +export function clearResolvedRemoteConfig(id: number): void { + registry.delete(id); +} diff --git a/src/services/remoteCredentials.ts b/src/services/remoteCredentials.ts new file mode 100644 index 0000000..aedf2ff --- /dev/null +++ b/src/services/remoteCredentials.ts @@ -0,0 +1,22 @@ +// Server passwords live in the Android Keystore (expo-secure-store), keyed by the +// remote_sources row id. Subsonic needs the plaintext password at request time to +// compute the per-request salted token; Jellyfin needs it to (re)authenticate. + +import * as SecureStore from 'expo-secure-store'; + +function secretKey(sourceId: number): string { + // SecureStore keys must be alphanumeric + ".-_" — this satisfies that. + return `remote_secret_${sourceId}`; +} + +export async function getRemoteSecret(sourceId: number): Promise { + return SecureStore.getItemAsync(secretKey(sourceId)); +} + +export async function setRemoteSecret(sourceId: number, password: string): Promise { + await SecureStore.setItemAsync(secretKey(sourceId), password); +} + +export async function deleteRemoteSecret(sourceId: number): Promise { + await SecureStore.deleteItemAsync(secretKey(sourceId)); +} diff --git a/src/services/remoteUrls.ts b/src/services/remoteUrls.ts new file mode 100644 index 0000000..11c1288 --- /dev/null +++ b/src/services/remoteUrls.ts @@ -0,0 +1,53 @@ +// Synchronous stream/cover URL resolution for remote tracks. Reads the decrypted +// config from the in-memory registry (remoteConfig) and delegates to the client URL +// builders. Returns null when the source isn't loaded yet (e.g. Jellyfin not +// authenticated) — callers fall back to no-art / the identity path. +// +// Audiophile default: original-quality streams (no Subsonic maxBitRate, Jellyfin +// Download endpoint rather than transcode). + +import type { Track } from '@/types/audio'; +import type { RemoteConnectionConfig } from '@/types/remote'; +import { getResolvedRemoteConfig, type ResolvedRemoteConfig } from './remoteConfig'; +import { buildSubsonicCoverArtUrl, buildSubsonicStreamUrl } from './subsonic'; +import { buildJellyfinCoverArtUrl, buildJellyfinStreamUrl } from './jellyfin'; + +function connection(cfg: ResolvedRemoteConfig): RemoteConnectionConfig { + return { baseUrl: cfg.baseUrl, username: cfg.username, password: cfg.password }; +} + +/** Build the playable HTTP stream URL for a remote track, or null if unavailable. */ +export function streamUrlForTrack(track: Track): string | null { + if (!track.sourceType || track.sourceType === 'local') return null; + if (track.sourceId == null || !track.sourceTrackId) return null; + const cfg = getResolvedRemoteConfig(track.sourceId); + if (!cfg) return null; + + if (cfg.type === 'subsonic') { + return buildSubsonicStreamUrl(connection(cfg), track.sourceTrackId); + } + if (cfg.type === 'jellyfin') { + if (!cfg.accessToken) return null; + return buildJellyfinStreamUrl(connection(cfg), track.sourceTrackId, cfg.accessToken); + } + return null; +} + +/** Build the cover-art URL for a remote track, or null if unavailable. */ +export function artworkUrlForTrack( + track: Pick +): string | null { + if (!track.sourceType || track.sourceType === 'local') return null; + if (track.sourceId == null || !track.artworkSourceId) return null; + const cfg = getResolvedRemoteConfig(track.sourceId); + if (!cfg) return null; + + if (cfg.type === 'subsonic') { + return buildSubsonicCoverArtUrl(connection(cfg), track.artworkSourceId); + } + if (cfg.type === 'jellyfin') { + if (!cfg.accessToken) return null; + return buildJellyfinCoverArtUrl(connection(cfg), track.artworkSourceId, cfg.accessToken); + } + return null; +} diff --git a/src/services/subsonic.ts b/src/services/subsonic.ts new file mode 100644 index 0000000..0e546f6 --- /dev/null +++ b/src/services/subsonic.ts @@ -0,0 +1,632 @@ +// Subsonic client — ported from desktop Astra (src/main/services/subsonic.ts). +// Changes vs desktop: Node `crypto` (md5 token + random salt) -> src/lib/hash; +// the ArrayBuffer stream/cover fetchers are dropped (ExoPlayer + expo-image fetch +// the URLs directly), and a sync `buildSubsonicCoverArtUrl` is added. + +import { md5Hex, randomSaltHex } from '@/lib/hash'; +import type { + RemoteCatalogTrack, + RemoteConnectionConfig, + RemotePlaylist, + RemotePlaylistTrack, + RemoteSyncProgress, +} from '@/types/remote'; + +const SUBSONIC_API_VERSION = '1.16.1'; +const SUBSONIC_CLIENT_ID = 'astra'; +const DEFAULT_TIMEOUT_MS = 12_000; +const DEFAULT_RETRIES = 1; +const MAX_SYNC_CONCURRENCY = 4; + +export interface SubsonicRequestOptions { + timeoutMs?: number; + retries?: number; + signal?: AbortSignal; +} + +export interface SubsonicCatalogSyncOptions extends SubsonicRequestOptions { + onProgress?: (progress: RemoteSyncProgress) => void; +} + +export interface SubsonicCatalogSyncResult { + artistsScanned: number; + albumsScanned: number; + tracksScanned: number; + tracks: RemoteCatalogTrack[]; +} + +interface SubsonicResponseEnvelope { + 'subsonic-response'?: { + status?: string; + error?: { code?: number; message?: string }; + [key: string]: unknown; + }; +} + +interface SubsonicArtistRef { + id: string; +} + +interface SubsonicAlbumRef { + id: string; +} + +interface SubsonicPlaylistRef { + id: string; + name: string; +} + +interface SubsonicAlbumSongs { + coverArtId: string | null; + songs: SubsonicSong[]; +} + +interface SubsonicSong { + id?: unknown; + title?: unknown; + artist?: unknown; + album?: unknown; + albumArtist?: unknown; + coverArt?: unknown; + duration?: unknown; + track?: unknown; + discNumber?: unknown; + year?: unknown; + genre?: unknown; + suffix?: unknown; + contentType?: unknown; + bitRate?: unknown; + sampleRate?: unknown; + bitDepth?: unknown; + channelCount?: unknown; + path?: unknown; +} + +function asArray(value: unknown): T[] { + if (!value) return []; + return Array.isArray(value) ? (value as T[]) : [value as T]; +} + +function toTrimmedText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function toFiniteInteger(value: unknown): number | null { + const parsed = toFiniteNumber(value); + if (parsed == null) return null; + const integer = Math.trunc(parsed); + return Number.isFinite(integer) ? integer : null; +} + +function normalizeSubsonicFormat(song: SubsonicSong): string { + const suffix = toTrimmedText(song.suffix); + if (suffix) return suffix.toLowerCase(); + + const pathValue = toTrimmedText(song.path); + if (pathValue && pathValue.includes('.')) { + const ext = pathValue.split('.').pop()?.trim().toLowerCase(); + if (ext) return ext; + } + + const contentType = toTrimmedText(song.contentType); + if (contentType?.includes('/')) { + const subtype = contentType.split('/')[1]?.trim().toLowerCase(); + if (subtype) return subtype; + } + + return 'unknown'; +} + +export function normalizeSubsonicBaseUrl(rawBaseUrl: string): string { + const trimmed = rawBaseUrl.trim(); + if (!trimmed) { + throw new Error('Server URL is required.'); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmed); + } catch { + throw new Error('Server URL is invalid.'); + } + + const protocol = parsedUrl.protocol.toLowerCase(); + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error('Server URL must use http:// or https://'); + } + + parsedUrl.hash = ''; + parsedUrl.search = ''; + parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, ''); + return parsedUrl.toString().replace(/\/+$/, ''); +} + +function buildAuthQuery( + config: RemoteConnectionConfig, + options: { includeFormat?: boolean } = {} +): Record { + const username = config.username.trim(); + const password = config.password; + if (!username) { + throw new Error('Username is required.'); + } + if (!password) { + throw new Error('Password is required.'); + } + + const salt = randomSaltHex(6); + const token = md5Hex(`${password}${salt}`); + + const query: Record = { + u: username, + t: token, + s: salt, + v: SUBSONIC_API_VERSION, + c: SUBSONIC_CLIENT_ID, + }; + if (options.includeFormat !== false) { + query.f = 'json'; + } + return query; +} + +function buildSubsonicEndpointUrl( + config: RemoteConnectionConfig, + endpoint: string, + params: Record, + options: { includeFormat?: boolean } = {} +): URL { + const baseUrl = normalizeSubsonicBaseUrl(config.baseUrl); + const url = new URL(`${baseUrl}/rest/${endpoint}.view`); + const auth = buildAuthQuery(config, options); + for (const [key, value] of Object.entries(auth)) { + url.searchParams.set(key, value); + } + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + url.searchParams.set(key, String(value)); + } + return url; +} + +function mergeAbortSignals( + signal: AbortSignal | undefined, + timeoutMs: number +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const onAbort = () => controller.abort(); + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener('abort', onAbort, { once: true }); + } + } + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeoutId); + if (signal) signal.removeEventListener('abort', onAbort); + }, + }; +} + +async function requestSubsonicJson( + config: RemoteConnectionConfig, + endpoint: string, + params: Record, + options: SubsonicRequestOptions = {} +): Promise> { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES); + const url = buildSubsonicEndpointUrl(config, endpoint, params, { includeFormat: true }); + + let lastError: unknown = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + const merged = mergeAbortSignals(options.signal, timeoutMs); + try { + const response = await fetch(url, { method: 'GET', signal: merged.signal }); + if (!response.ok) { + throw new Error(`Subsonic request failed (${response.status})`); + } + + const json = (await response.json()) as SubsonicResponseEnvelope; + const envelope = json['subsonic-response']; + if (!envelope || typeof envelope !== 'object') { + throw new Error('Invalid Subsonic response payload.'); + } + if (envelope.status !== 'ok') { + const message = toTrimmedText(envelope.error?.message) ?? 'Subsonic request failed.'; + throw new Error(message); + } + + return envelope as Record; + } catch (error) { + lastError = error; + if (attempt >= retries) throw error; + } finally { + merged.cleanup(); + } + } + + throw lastError instanceof Error ? lastError : new Error('Subsonic request failed.'); +} + +async function runWithConcurrency( + values: T[], + concurrency: number, + worker: (value: T, index: number) => Promise +): Promise { + if (values.length === 0) return []; + + const maxWorkers = Math.max(1, Math.min(concurrency, values.length)); + const results: R[] = new Array(values.length); + let nextIndex = 0; + + const runWorker = async (): Promise => { + while (nextIndex < values.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await worker(values[currentIndex], currentIndex); + } + }; + + await Promise.all(Array.from({ length: maxWorkers }, () => runWorker())); + return results; +} + +export async function testSubsonicConnection( + config: RemoteConnectionConfig, + options: SubsonicRequestOptions = {} +): Promise { + await requestSubsonicJson(config, 'ping', {}, options); +} + +function toArtistRefs(indexResponse: Record): SubsonicArtistRef[] { + const artistsContainer = indexResponse.artists as Record | undefined; + if (!artistsContainer || typeof artistsContainer !== 'object') return []; + + const indexEntries = asArray>(artistsContainer.index); + const artistIds: SubsonicArtistRef[] = []; + for (const indexEntry of indexEntries) { + for (const artist of asArray>(indexEntry.artist)) { + const id = toTrimmedText(artist.id); + if (!id) continue; + artistIds.push({ id }); + } + } + + return artistIds; +} + +function toAlbumRefs(artistResponse: Record): SubsonicAlbumRef[] { + const artistContainer = artistResponse.artist as Record | undefined; + if (!artistContainer || typeof artistContainer !== 'object') return []; + + const albums = asArray>(artistContainer.album); + return albums + .map((album) => toTrimmedText(album.id)) + .filter((id): id is string => Boolean(id)) + .map((id) => ({ id })); +} + +function mapSongToCatalogTrack( + sourceId: number, + song: SubsonicSong, + fallbackCoverArtId: string | null +): RemoteCatalogTrack | null { + const sourceTrackId = toTrimmedText(song.id); + if (!sourceTrackId) return null; + + const title = toTrimmedText(song.title) ?? `Track ${sourceTrackId}`; + const artist = toTrimmedText(song.artist) ?? 'Unknown Artist'; + const album = toTrimmedText(song.album) ?? 'Unknown Album'; + const albumArtist = toTrimmedText(song.albumArtist); + const duration = toFiniteNumber(song.duration); + const bitrate = toFiniteInteger(song.bitRate); + const sampleRate = toFiniteInteger(song.sampleRate); + const bitDepth = toFiniteInteger(song.bitDepth); + const channels = toFiniteInteger(song.channelCount); + const trackNumber = toFiniteInteger(song.track); + const discNumber = toFiniteInteger(song.discNumber); + const year = toFiniteInteger(song.year); + const genre = toTrimmedText(song.genre); + const sourcePath = toTrimmedText(song.path); + const contentType = toTrimmedText(song.contentType); + const artworkSourceId = toTrimmedText(song.coverArt) ?? fallbackCoverArtId; + + return { + path: buildSubsonicTrackPath(sourceId, sourceTrackId), + source_track_id: sourceTrackId, + source_path: sourcePath, + artwork_source_id: artworkSourceId, + title, + artist, + album, + album_artist: albumArtist, + duration: duration && duration > 0 ? duration : 0, + track_number: trackNumber, + disc_number: discNumber, + year, + genre, + artwork_hash: null, + format: normalizeSubsonicFormat(song), + sample_rate: sampleRate, + bit_depth: bitDepth, + bitrate, + channels, + codec: contentType, + codec_profile: null, + is_atmos_joc: null, + replaygain_track_gain_db: null, + replaygain_album_gain_db: null, + bpm: null, + musical_key: null, + }; +} + +export async function syncSubsonicCatalog( + sourceId: number, + config: RemoteConnectionConfig, + options: SubsonicCatalogSyncOptions = {} +): Promise { + const indexResponse = await requestSubsonicJson(config, 'getArtists', {}, options); + const artistRefs = toArtistRefs(indexResponse); + options.onProgress?.({ phase: 'artists', current: 0, total: artistRefs.length, detail: null }); + if (artistRefs.length === 0) { + return { artistsScanned: 0, albumsScanned: 0, tracksScanned: 0, tracks: [] }; + } + + let artistsProcessed = 0; + const albumRefsByArtist = await runWithConcurrency( + artistRefs, + MAX_SYNC_CONCURRENCY, + async (artistRef) => { + const artistResponse = await requestSubsonicJson( + config, + 'getArtist', + { id: artistRef.id }, + options + ); + const albums = toAlbumRefs(artistResponse); + artistsProcessed += 1; + options.onProgress?.({ + phase: 'artists', + current: artistsProcessed, + total: artistRefs.length, + detail: artistRef.id, + }); + return albums; + } + ); + + const allAlbumRefs = albumRefsByArtist.flat(); + const uniqueAlbumIds = Array.from(new Set(allAlbumRefs.map((album) => album.id))); + options.onProgress?.({ phase: 'albums', current: 0, total: uniqueAlbumIds.length, detail: null }); + + let albumsProcessed = 0; + const albumSongLists = await runWithConcurrency( + uniqueAlbumIds, + MAX_SYNC_CONCURRENCY, + async (albumId): Promise => { + const albumResponse = await requestSubsonicJson(config, 'getAlbum', { id: albumId }, options); + const albumContainer = albumResponse.album as Record | undefined; + if (!albumContainer || typeof albumContainer !== 'object') { + albumsProcessed += 1; + options.onProgress?.({ + phase: 'albums', + current: albumsProcessed, + total: uniqueAlbumIds.length, + detail: albumId, + }); + return { coverArtId: null, songs: [] }; + } + albumsProcessed += 1; + options.onProgress?.({ + phase: 'albums', + current: albumsProcessed, + total: uniqueAlbumIds.length, + detail: albumId, + }); + return { + coverArtId: toTrimmedText(albumContainer.coverArt), + songs: asArray(albumContainer.song), + }; + } + ); + + const byTrackId = new Map(); + options.onProgress?.({ phase: 'tracks', current: 0, total: albumSongLists.length, detail: null }); + let trackAlbumProcessed = 0; + for (const albumSongs of albumSongLists) { + for (const song of albumSongs.songs) { + const mapped = mapSongToCatalogTrack(sourceId, song, albumSongs.coverArtId); + if (!mapped) continue; + byTrackId.set(mapped.source_track_id, mapped); + } + trackAlbumProcessed += 1; + options.onProgress?.({ + phase: 'tracks', + current: trackAlbumProcessed, + total: albumSongLists.length, + detail: null, + }); + } + + const tracks = Array.from(byTrackId.values()); + return { + artistsScanned: artistRefs.length, + albumsScanned: uniqueAlbumIds.length, + tracksScanned: tracks.length, + tracks, + }; +} + +export function buildSubsonicTrackPath(sourceId: number, sourceTrackId: string): string { + return `subsonic://${sourceId}/track/${encodeURIComponent(sourceTrackId)}`; +} + +export function parseSubsonicTrackPath( + path: string +): { sourceId: number; sourceTrackId: string } | null { + const match = /^subsonic:\/\/(\d+)\/track\/(.+)$/.exec(path); + if (!match) return null; + + const sourceId = Number.parseInt(match[1], 10); + if (!Number.isInteger(sourceId) || sourceId <= 0) return null; + + const sourceTrackIdRaw = match[2]; + if (!sourceTrackIdRaw) return null; + try { + const sourceTrackId = decodeURIComponent(sourceTrackIdRaw); + if (!sourceTrackId) return null; + return { sourceId, sourceTrackId }; + } catch { + return null; + } +} + +export function buildSubsonicStreamUrl( + config: RemoteConnectionConfig, + sourceTrackId: string, + options: { maxBitRateKbps?: number } = {} +): string { + const maxBitRateKbps = options.maxBitRateKbps; + return buildSubsonicEndpointUrl( + config, + 'stream', + { + id: sourceTrackId, + maxBitRate: + typeof maxBitRateKbps === 'number' && Number.isFinite(maxBitRateKbps) && maxBitRateKbps > 0 + ? Math.trunc(maxBitRateKbps) + : undefined, + }, + { includeFormat: false } + ).toString(); +} + +export function buildSubsonicCoverArtUrl( + config: RemoteConnectionConfig, + coverArtId: string, + options: { size?: number } = {} +): string { + return buildSubsonicEndpointUrl( + config, + 'getCoverArt', + { id: coverArtId, size: options.size }, + { includeFormat: false } + ).toString(); +} + +// --- Favorites (starred) + playlists ----------------------------------------- + +function toStarredTrackIds(starredResponse: Record): string[] { + const starredContainer = starredResponse.starred as Record | undefined; + if (!starredContainer || typeof starredContainer !== 'object') return []; + + const ids = new Set(); + for (const song of asArray(starredContainer.song)) { + const id = toTrimmedText(song.id); + if (id) ids.add(id); + } + return Array.from(ids); +} + +function toPlaylistRefs(playlistsResponse: Record): SubsonicPlaylistRef[] { + const playlistsContainer = playlistsResponse.playlists as Record | undefined; + if (!playlistsContainer || typeof playlistsContainer !== 'object') return []; + + return asArray>(playlistsContainer.playlist) + .map((playlist) => { + const id = toTrimmedText(playlist.id); + if (!id) return null; + return { id, name: toTrimmedText(playlist.name) ?? `Playlist ${id}` }; + }) + .filter((playlist): playlist is SubsonicPlaylistRef => playlist !== null); +} + +function toPlaylistTracks( + sourceId: number, + playlistResponse: Record +): RemotePlaylistTrack[] { + const playlistContainer = playlistResponse.playlist as Record | undefined; + if (!playlistContainer || typeof playlistContainer !== 'object') return []; + + const entries = asArray(playlistContainer.entry ?? playlistContainer.song); + const tracks: RemotePlaylistTrack[] = []; + const seen = new Set(); + for (const entry of entries) { + const sourceTrackId = toTrimmedText(entry.id); + if (!sourceTrackId || seen.has(sourceTrackId)) continue; + seen.add(sourceTrackId); + tracks.push({ + path: buildSubsonicTrackPath(sourceId, sourceTrackId), + source_track_id: sourceTrackId, + title: toTrimmedText(entry.title), + artist: toTrimmedText(entry.artist), + album: toTrimmedText(entry.album), + }); + } + return tracks; +} + +/** Server-side starred (favorite) track ids. */ +export async function fetchSubsonicStarredTrackIds( + config: RemoteConnectionConfig, + options: SubsonicRequestOptions = {} +): Promise { + const starredResponse = await requestSubsonicJson(config, 'getStarred', {}, options); + return toStarredTrackIds(starredResponse); +} + +/** Server playlists with their track lists (getPlaylists -> getPlaylist per playlist). */ +export async function syncSubsonicPlaylists( + sourceId: number, + config: RemoteConnectionConfig, + options: SubsonicCatalogSyncOptions = {} +): Promise { + const playlistsResponse = await requestSubsonicJson(config, 'getPlaylists', {}, options); + const playlistRefs = toPlaylistRefs(playlistsResponse); + options.onProgress?.({ phase: 'playlists', current: 0, total: playlistRefs.length, detail: null }); + + let processed = 0; + return runWithConcurrency( + playlistRefs, + MAX_SYNC_CONCURRENCY, + async (playlistRef): Promise => { + const playlistResponse = await requestSubsonicJson( + config, + 'getPlaylist', + { id: playlistRef.id }, + options + ); + processed += 1; + options.onProgress?.({ + phase: 'playlists', + current: processed, + total: playlistRefs.length, + detail: playlistRef.id, + }); + return { + source_playlist_id: playlistRef.id, + name: playlistRef.name, + tracks: toPlaylistTracks(sourceId, playlistResponse), + }; + } + ); +} diff --git a/src/stores/remoteSourcesStore.ts b/src/stores/remoteSourcesStore.ts new file mode 100644 index 0000000..852b095 --- /dev/null +++ b/src/stores/remoteSourcesStore.ts @@ -0,0 +1,288 @@ +// Remote sources (Subsonic/Jellyfin) — config, connection test, and catalog sync. +// SQLite (remote_sources) + expo-secure-store (passwords) are the source of truth; +// this store mirrors the rows in memory and tracks per-source sync progress. +// +// The decrypted config + cached Jellyfin token are pushed into the synchronous +// registry (services/remoteConfig) so the library UI and playback can build URLs. + +import { create } from 'zustand'; +import { openLibraryDb } from '@/db/database'; +import { deleteRemoteTracksBySource } from '@/db/queries'; +import { + deleteFavoritesByPathPrefix, + deleteRemotePlaylistsBySource, +} from '@/db/playlistQueries'; +import { + deleteRemoteSource, + getRemoteSource, + getRemoteSources, + insertRemoteSource, + setRemoteSourceAuth, + setRemoteSourceStatus, + setRemoteSourceSynced, + updateRemoteSource, +} from '@/db/remoteSourceQueries'; +import { + deleteRemoteSecret, + getRemoteSecret, + setRemoteSecret, +} from '@/services/remoteCredentials'; +import { + clearResolvedRemoteConfig, + setResolvedRemoteConfig, + updateResolvedRemoteAuth, +} from '@/services/remoteConfig'; +import { + authenticateJellyfin, + buildJellyfinDeviceId, + testJellyfinConnection, + type JellyfinAuthContext, +} from '@/services/jellyfin'; +import { testSubsonicConnection } from '@/services/subsonic'; +import { syncRemoteSource } from '@/library/remoteSync'; +import { useLibraryStore } from '@/stores/libraryStore'; +import type { + RemoteConnectionConfig, + RemoteSourceCreateInput, + RemoteSourceRow, + RemoteSourceTestInput, + RemoteSourceTestResult, + RemoteSourceUpdateInput, + RemoteSyncProgress, +} from '@/types/remote'; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Hydrate the synchronous URL-building registry for one source (loads its secret). */ +async function hydrateRegistry(source: RemoteSourceRow): Promise { + const password = await getRemoteSecret(source.id); + if (password == null) return null; + setResolvedRemoteConfig({ + id: source.id, + type: source.type, + baseUrl: source.base_url, + username: source.username, + password, + accessToken: source.access_token ?? undefined, + userId: source.user_id ?? undefined, + }); + return { baseUrl: source.base_url, username: source.username, password }; +} + +/** Ensure a usable Jellyfin token, authenticating + persisting it if missing. */ +async function ensureJellyfinAuth( + source: RemoteSourceRow, + config: RemoteConnectionConfig +): Promise { + if (source.access_token && source.user_id) { + return { accessToken: source.access_token, userId: source.user_id }; + } + const auth = await authenticateJellyfin(config); + const db = await openLibraryDb(); + await setRemoteSourceAuth(db, source.id, { + accessToken: auth.accessToken, + userId: auth.userId, + deviceId: buildJellyfinDeviceId(config), + }); + updateResolvedRemoteAuth(source.id, auth); + return auth; +} + +interface RemoteSourcesStore { + sources: RemoteSourceRow[]; + initialized: boolean; + /** Per-source live sync progress (null = not syncing). */ + progressById: Record; + + init: () => Promise; + refresh: () => Promise; + testSource: (input: RemoteSourceTestInput) => Promise; + createSource: (input: RemoteSourceCreateInput) => Promise; + updateSource: (id: number, input: RemoteSourceUpdateInput) => Promise; + deleteSource: (id: number, purgeTracks: boolean) => Promise; + syncSource: (id: number) => Promise; + syncAll: () => Promise; +} + +export const useRemoteSourcesStore = create((set, get) => ({ + sources: [], + initialized: false, + progressById: {}, + + init: async () => { + if (get().initialized) return; + const db = await openLibraryDb(); + const sources = await getRemoteSources(db); + // Populate the URL registry from cached config/token (no network on launch). + await Promise.all(sources.filter((s) => s.enabled).map((s) => hydrateRegistry(s))); + set({ sources, initialized: true }); + // The library's initial refresh may have run before the registry was hydrated, + // leaving remote artwork URLs unresolved — refresh once more now that it's ready. + if (sources.length > 0) { + await useLibraryStore.getState().refresh(); + } + }, + + refresh: async () => { + const db = await openLibraryDb(); + set({ sources: await getRemoteSources(db) }); + }, + + testSource: async (input) => { + try { + const config: RemoteConnectionConfig = { + baseUrl: input.baseUrl, + username: input.username, + password: input.password, + }; + if (input.type === 'subsonic') { + await testSubsonicConnection(config); + } else { + await testJellyfinConnection(config); + } + return { ok: true, message: 'Connection successful.' }; + } catch (error) { + return { ok: false, message: errorMessage(error) }; + } + }, + + createSource: async (input) => { + const db = await openLibraryDb(); + const config: RemoteConnectionConfig = { + baseUrl: input.baseUrl, + username: input.username, + password: input.password, + }; + + // Validate before persisting anything. + let auth: JellyfinAuthContext | null = null; + if (input.type === 'subsonic') { + await testSubsonicConnection(config); + } else { + auth = await authenticateJellyfin(config); + } + + const row = await insertRemoteSource(db, { + type: input.type, + name: input.name, + baseUrl: input.baseUrl, + username: input.username, + enabled: input.enabled, + }); + await setRemoteSecret(row.id, input.password); + + if (auth) { + await setRemoteSourceAuth(db, row.id, { + accessToken: auth.accessToken, + userId: auth.userId, + deviceId: buildJellyfinDeviceId(config), + }); + } + + setResolvedRemoteConfig({ + id: row.id, + type: row.type, + baseUrl: row.base_url, + username: row.username, + password: input.password, + accessToken: auth?.accessToken, + userId: auth?.userId, + }); + + await get().refresh(); + // Kick off the first sync in the background (don't block the add flow). + void get().syncSource(row.id); + return row; + }, + + updateSource: async (id, input) => { + const db = await openLibraryDb(); + const existing = await getRemoteSource(db, id); + if (!existing) return; + + await updateRemoteSource(db, id, { + name: input.name, + base_url: input.baseUrl, + username: input.username, + enabled: input.enabled, + }); + if (input.password) { + await setRemoteSecret(id, input.password); + } + + const updated = await getRemoteSource(db, id); + if (updated) { + // Connection details may have changed → drop cached token, re-hydrate registry. + if (input.baseUrl || input.username || input.password) { + await setRemoteSourceAuth(db, id, { accessToken: null, userId: null, deviceId: null }); + } + const fresh = (await getRemoteSource(db, id)) ?? updated; + await hydrateRegistry(fresh); + } + await get().refresh(); + }, + + deleteSource: async (id, purgeTracks) => { + const db = await openLibraryDb(); + const source = await getRemoteSource(db, id); + if (purgeTracks && source) { + await deleteRemoteTracksBySource(db, source.type, id); + // Drop this source's synced playlists + favorites (favorites key on the + // `${type}://${id}/` path prefix). + await deleteRemotePlaylistsBySource(db, id); + await deleteFavoritesByPathPrefix(db, `${source.type}://${id}/`); + } + await deleteRemoteSource(db, id); + await deleteRemoteSecret(id); + clearResolvedRemoteConfig(id); + await get().refresh(); + if (purgeTracks) { + await useLibraryStore.getState().refresh(); + } + }, + + syncSource: async (id) => { + const db = await openLibraryDb(); + const source = await getRemoteSource(db, id); + if (!source) return; + if (get().progressById[id]) return; // already syncing + + const config = await hydrateRegistry(source); + if (!config) { + await setRemoteSourceStatus(db, id, 'error', 'Missing stored password.'); + await get().refresh(); + return; + } + + const onProgress = (progress: RemoteSyncProgress) => { + set((state) => ({ progressById: { ...state.progressById, [id]: progress } })); + }; + set((state) => ({ + progressById: { ...state.progressById, [id]: { phase: 'connecting', current: 0, total: 0, detail: null } }, + })); + + try { + let authContext: JellyfinAuthContext | undefined; + if (source.type === 'jellyfin') { + authContext = await ensureJellyfinAuth(source, config); + } + await syncRemoteSource(db, source, config, { onProgress, authContext }); + await setRemoteSourceSynced(db, id); + await useLibraryStore.getState().refresh(); + } catch (error) { + await setRemoteSourceStatus(db, id, 'error', errorMessage(error)); + } finally { + set((state) => ({ progressById: { ...state.progressById, [id]: null } })); + await get().refresh(); + } + }, + + syncAll: async () => { + const enabled = get().sources.filter((source) => source.enabled); + for (const source of enabled) { + await get().syncSource(source.id); + } + }, +})); diff --git a/src/types/audio.ts b/src/types/audio.ts index 9cecbe6..d7f2018 100644 --- a/src/types/audio.ts +++ b/src/types/audio.ts @@ -34,6 +34,8 @@ export interface Track { sourceId?: number; sourceTrackId?: string; sourcePath?: string; + /** Server cover-art id for remote tracks (resolved to a URL via remoteUrls). */ + artworkSourceId?: string; isAvailable?: boolean; availabilityReason?: string; } diff --git a/src/types/library.ts b/src/types/library.ts index ed584df..426b9b5 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -5,8 +5,8 @@ export type TrackSourceType = 'local' | 'subsonic' | 'jellyfin'; export interface DbTrack { id: number; - path: string; // SAF document content:// URI for local tracks - folder_id: number; + path: string; // SAF content:// URI (local), or subsonic://|jellyfin:// identity URI (remote) + folder_id: number | null; // NULL for remote tracks (no SAF folder) title: string; artist: string; album: string; @@ -25,6 +25,12 @@ export interface DbTrack { channels: number | null; codec: string | null; source_type: TrackSourceType; + // Remote-source linkage (NULL for local tracks). source_id -> remote_sources.id; + // source_track_id is the server's track id; artwork_source_id is its cover-art id. + source_id: number | null; + source_track_id: string | null; + source_path: string | null; + artwork_source_id: string | null; file_name: string; size: number | null; mtime: number; @@ -57,6 +63,12 @@ export interface Album { track_count: number; /** Newest track import timestamp in this album, used by Home recently-added. */ latest_added_at: number; + // Representative remote-source linkage (absent/NULL for local albums) so the album + // grid / detail can resolve a server cover-art URL when there's no cached artwork_hash. + // Optional so locally-derived album shapes (e.g. artist-detail aggregates) still fit. + source_type?: TrackSourceType; + source_id?: number | null; + artwork_source_id?: string | null; } export interface Artist { diff --git a/src/types/playlist.ts b/src/types/playlist.ts index d8b388d..dd7d1cf 100644 --- a/src/types/playlist.ts +++ b/src/types/playlist.ts @@ -15,6 +15,8 @@ export interface Playlist { track_count: number; /** Entries whose track is gone (folder removed / file deleted). */ missing_track_count: number; + /** Set when this playlist mirrors a remote server playlist (-> remote_sources.id). */ + remote_source_id: number | null; } export interface PlaylistTrackEntry { diff --git a/src/types/remote.ts b/src/types/remote.ts new file mode 100644 index 0000000..98d7777 --- /dev/null +++ b/src/types/remote.ts @@ -0,0 +1,125 @@ +// Shared types for remote music sources (Subsonic / Jellyfin). The catalog-track +// and connection-config shapes are ported verbatim from desktop Astra +// (src/main/services/{subsonic,jellyfin}.ts) — they were already identical across +// both providers, so they unify here. + +export type RemoteSourceType = 'subsonic' | 'jellyfin'; + +export type RemoteSourceStatus = 'unknown' | 'ok' | 'error' | 'disabled' | 'syncing'; + +/** Credentials needed to talk to a server. */ +export interface RemoteConnectionConfig { + baseUrl: string; + username: string; + password: string; +} + +/** + * One catalog track as produced by a client sync. Maps 1:1 onto the `tracks` table + * (plus remote-only id/path fields). Mirrors desktop `SubsonicCatalogTrack` / + * `JellyfinCatalogTrack`. + */ +export interface RemoteCatalogTrack { + /** Stable identity URI: `subsonic://{sourceId}/track/{id}` (stored as tracks.path). */ + path: string; + source_track_id: string; + source_path: string | null; + artwork_source_id: string | null; + title: string; + artist: string; + album: string; + album_artist: string | null; + duration: number; + track_number: number | null; + disc_number: number | null; + year: number | null; + genre: string | null; + artwork_hash: string | null; + format: string; + sample_rate: number | null; + bit_depth: number | null; + bitrate: number | null; + channels: number | null; + codec: string | null; + codec_profile: string | null; + is_atmos_joc: number | null; + replaygain_track_gain_db: number | null; + replaygain_album_gain_db: number | null; + bpm: number | null; + musical_key: string | null; +} + +/** Unified sync-progress event across providers. */ +export interface RemoteSyncProgress { + phase: 'connecting' | 'artists' | 'albums' | 'tracks' | 'items' | 'playlists' | 'saving'; + current: number; + total: number; + detail: string | null; +} + +/** A track entry within a synced remote playlist. */ +export interface RemotePlaylistTrack { + /** subsonic://|jellyfin:// identity URI (matches a tracks.path). */ + path: string; + source_track_id: string; + title: string | null; + artist: string | null; + album: string | null; +} + +/** A playlist as defined on the remote server. */ +export interface RemotePlaylist { + source_playlist_id: string; + name: string; + tracks: RemotePlaylistTrack[]; +} + +/** A configured server, as stored in the `remote_sources` table (no secret). */ +export interface RemoteSourceRow { + id: number; + type: RemoteSourceType; + name: string; + base_url: string; + username: string; + enabled: number; // 0 | 1 + last_status: RemoteSourceStatus; + last_error: string | null; + last_sync_at: number | null; + last_checked_at: number | null; + /** Jellyfin-only cached auth (Subsonic re-derives a salted token per request). */ + access_token: string | null; + user_id: string | null; + device_id: string | null; + created_at: number; + updated_at: number; +} + +export interface RemoteSourceCreateInput { + type: RemoteSourceType; + name: string; + baseUrl: string; + username: string; + password: string; + enabled: boolean; +} + +export interface RemoteSourceUpdateInput { + name?: string; + baseUrl?: string; + username?: string; + /** Only set when the user enters a new password. */ + password?: string; + enabled?: boolean; +} + +export interface RemoteSourceTestInput { + type: RemoteSourceType; + baseUrl: string; + username: string; + password: string; +} + +export interface RemoteSourceTestResult { + ok: boolean; + message: string; +}