m5, subsonic/jellyfin support

This commit is contained in:
Boof2015
2026-06-27 22:03:37 -04:00
parent 0e3e46293c
commit 3ec3f5ecf2
37 changed files with 3322 additions and 35 deletions
+4 -3
View File
@@ -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 (
<View style={[styles.albumArt, { width: size, height: size }]}>
{album.artwork_hash ? (
{artUri ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
source={{ uri: artUri }}
style={styles.image}
contentFit="cover"
transition={120}
+3 -3
View File
@@ -14,7 +14,7 @@ import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { albumArtworkSource } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import type { DbTrack } from '@/types/library';
@@ -51,9 +51,9 @@ export default function AlbumScreen() {
<View style={styles.header}>
<View style={styles.art}>
{album?.artwork_hash ? (
{album && albumArtworkSource(album) ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
source={{ uri: albumArtworkSource(album)! }}
style={styles.artImage}
contentFit="cover"
transition={120}
+25
View File
@@ -1,11 +1,13 @@
import { View, Pressable, ScrollView, StyleSheet, Switch } 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 { EQSlider } from '@/components/eq/EQSlider';
import { colors, radius, spacing } from '@/theme';
import { useSettingsStore } from '@/stores/settingsStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
@@ -58,6 +60,9 @@ function ToggleRow({
}
export default function SettingsScreen() {
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
@@ -166,6 +171,26 @@ export default function SettingsScreen() {
);
})}
</View>
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
REMOTE SOURCES
</Text>
<Pressable
style={styles.option}
onPress={() => router.push('/sources')}
accessibilityRole="button"
>
<Ionicons name="server-outline" size={20} color={colors.textSecondary} />
<View style={styles.optionText}>
<Text variant="body">Subsonic / Jellyfin servers</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{remoteSources.length === 0
? 'Stream and browse your self-hosted library.'
: `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'} connected.`}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
</ScrollView>
</Screen>
);
+7
View File
@@ -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;
+5
View File
@@ -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() {
<View style={styles.subRow}>
<View style={styles.subBadges}>
<RemoteSourceBadge sourceType={track.sourceType} />
<FormatBadges track={track} wrap={false} />
</View>
<View style={styles.subActions}>
@@ -864,6 +866,9 @@ const styles = StyleSheet.create({
flex: 1,
minWidth: 0,
overflow: 'hidden',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
subActions: {
flexDirection: 'row',
+402
View File
@@ -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 (
<View style={styles.field}>
<Text variant="label" color={colors.textTertiary} style={styles.fieldLabel}>
{label}
</Text>
<TextInput
style={styles.input}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.textTertiary}
secureTextEntry={secureTextEntry}
autoCapitalize={autoCapitalize}
autoCorrect={false}
keyboardType={keyboardType}
/>
</View>
);
}
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<RemoteSourceType>(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 (
<Screen>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.header}>
<Pressable style={styles.back} onPress={goBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
{backLabel}
</Text>
</Pressable>
</View>
{step === 'type' ? (
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<Text variant="title" style={styles.heading}>
Add server
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.subheading}>
Choose your server type to get started.
</Text>
<View style={styles.typeCards}>
{TYPE_OPTIONS.map((option) => (
<Pressable
key={option.type}
style={styles.typeCard}
onPress={() => chooseType(option.type)}
accessibilityRole="button"
>
<View style={styles.typeCardIcon}>
<Ionicons name={option.icon} size={24} color={colors.accent} />
</View>
<View style={styles.typeCardText}>
<Text variant="body">{option.label}</Text>
<Text
variant="caption"
color={colors.textSecondary}
style={styles.typeCardDesc}
>
{option.description}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
))}
</View>
</ScrollView>
) : (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<Text variant="title" style={styles.heading}>
{editing ? 'Edit server' : `${typeLabel} server`}
</Text>
<Field
label="NAME"
value={name}
onChangeText={setName}
placeholder={`My ${typeLabel}`}
autoCapitalize="sentences"
/>
<Field
label="SERVER URL"
value={baseUrl}
onChangeText={setBaseUrl}
placeholder="http://192.168.1.50:4533"
keyboardType="url"
/>
<Field label="USERNAME" value={username} onChangeText={setUsername} placeholder="username" />
<Field
label="PASSWORD"
value={password}
onChangeText={setPassword}
placeholder={editing ? 'Unchanged' : 'password'}
secureTextEntry
/>
{message ? (
<Text
variant="caption"
color={message.ok ? colors.accent : colors.warning}
style={styles.message}
>
{message.text}
</Text>
) : null}
<View style={styles.actions}>
<Pressable
style={[styles.button, styles.secondaryButton, busy ? styles.buttonDisabled : null]}
onPress={() => void onTest()}
disabled={!!busy}
>
{busy === 'test' ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Text variant="body" color={colors.textSecondary}>
Test connection
</Text>
)}
</Pressable>
<Pressable
style={[
styles.button,
styles.primaryButton,
!canSubmit || busy ? styles.buttonDisabled : null,
]}
onPress={() => void onSave()}
disabled={!canSubmit || !!busy}
>
{busy === 'save' ? (
<ActivityIndicator size="small" color={colors.accentTextStrong} />
) : (
<Text variant="body" color={colors.accentTextStrong}>
{editing ? 'Save' : 'Add server'}
</Text>
)}
</Pressable>
</View>
</ScrollView>
)}
</KeyboardAvoidingView>
</Screen>
);
}
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,
},
});
+267
View File
@@ -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<RemoteSourceRow | null>(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 (
<Screen>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Settings
</Text>
</Pressable>
<View style={styles.headerActions}>
{sources.length > 0 ? (
<Pressable onPress={() => void syncAll()} hitSlop={8} accessibilityLabel="Sync all">
<Ionicons name="sync" size={20} color={colors.textSecondary} />
</Pressable>
) : null}
<Pressable
onPress={() => router.push('/sources/edit')}
hitSlop={8}
accessibilityLabel="Add server"
>
<Ionicons name="add" size={26} color={colors.accent} />
</Pressable>
</View>
</View>
<Text variant="title" style={styles.heading}>
Remote sources
</Text>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
{sources.length === 0 ? (
<View style={styles.empty}>
<Ionicons name="server-outline" size={28} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
No servers yet. Add a Subsonic or Jellyfin server to stream and browse your
self-hosted library.
</Text>
<Pressable style={styles.addButton} onPress={() => router.push('/sources/edit')}>
<Ionicons name="add" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Add server
</Text>
</Pressable>
</View>
) : (
sources.map((source) => {
const status = statusLine(source, progressById[source.id] ?? null);
return (
<Pressable
key={source.id}
style={styles.row}
onPress={() => setActionFor(source)}
accessibilityRole="button"
>
<View style={styles.rowIcon}>
<Ionicons
name={source.type === 'subsonic' ? 'cloud-outline' : 'tv-outline'}
size={20}
color={colors.accent}
/>
</View>
<View style={styles.rowMeta}>
<View style={styles.rowTitleLine}>
<Text variant="body" numberOfLines={1} style={styles.rowName}>
{source.name}
</Text>
<Text variant="label" color={colors.textTertiary}>
{source.type.toUpperCase()}
</Text>
</View>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{source.base_url}
</Text>
<Text
variant="caption"
color={status.tone === 'error' ? colors.warning : colors.textSecondary}
numberOfLines={1}
>
{status.text}
</Text>
</View>
<Ionicons name="ellipsis-horizontal" size={18} color={colors.textTertiary} />
</Pressable>
);
})
)}
</ScrollView>
<ActionSheet
visible={actionFor !== null}
title={actionFor?.name}
items={actionItems}
onClose={() => setActionFor(null)}
/>
</Screen>
);
}
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,
},
});
+18 -2
View File
@@ -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,
};
}
+14 -2
View File
@@ -37,7 +37,8 @@ export function useNormalizationSync(): void {
let cancelled = false;
async function recompute(): Promise<void> {
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;
+4 -2
View File
@@ -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]);
}
+29
View File
@@ -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 (
<Ionicons
name="cloud"
size={size}
color={color}
accessibilityLabel={`Streaming from ${sourceType}`}
/>
);
}
export default RemoteSourceBadge;
+4 -3
View File
@@ -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 (
<Pressable style={styles.item} onPress={onPress} accessibilityRole="button">
<View style={styles.art}>
{album.artwork_hash ? (
{artUri ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
source={{ uri: artUri }}
style={styles.artImage}
contentFit="cover"
transition={120}
+4 -3
View File
@@ -3,17 +3,18 @@ import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { artworkUri } from '@/library/artwork';
import { albumArtworkSource } from '@/library/artwork';
import type { Album } from '@/types/library';
/** Compact album list row (search results) — the grid uses AlbumGridItem. */
export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void }) {
const artUri = albumArtworkSource(album);
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.art}>
{album.artwork_hash ? (
{artUri ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
source={{ uri: artUri }}
style={styles.artImage}
contentFit="cover"
/>
+17 -3
View File
@@ -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({
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1}>
{name}
</Text>
<View style={styles.titleRow}>
<Text variant="body" numberOfLines={1} style={styles.title}>
{name}
</Text>
{remote ? <Ionicons name="cloud" size={12} color={colors.accent} /> : null}
</View>
<Text variant="label" numberOfLines={1}>
{`${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,
},
});
+1
View File
@@ -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)}
/>
+13 -7
View File
@@ -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<string | null>(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<string | null>(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)}
/>
) : (
<AstraLogo size={18} />
@@ -87,6 +89,7 @@ export function TrackRow({
</Text>
) : null}
<View style={styles.badges}>
<RemoteSourceBadge sourceType={track.source_type} />
<FormatBadges
track={{
format: track.format,
@@ -171,6 +174,9 @@ const styles = StyleSheet.create({
color: colors.accent,
},
badges: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: 2,
},
duration: {
+101 -1
View File
@@ -3,10 +3,11 @@
import type { DbTrack } from '@/types/library';
import type { Playlist, PlaylistTrackEntry } from '@/types/playlist';
import type { RemotePlaylist } from '@/types/remote';
import type { LibraryDatabase } from './database';
const PLAYLIST_SELECT = `
SELECT p.id, p.name, p.created_at, p.updated_at, p.last_played_at,
SELECT p.id, p.name, p.created_at, p.updated_at, p.last_played_at, p.remote_source_id,
(SELECT t.artwork_hash
FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path
WHERE pt.playlist_id = p.id AND t.artwork_hash IS NOT NULL
@@ -245,3 +246,102 @@ export async function addFavorite(db: LibraryDatabase, trackPath: string): Promi
export async function removeFavorite(db: LibraryDatabase, trackPath: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, number>();
for (const row of existing) {
if (row.remote_playlist_id) existingByRemoteId.set(row.remote_playlist_id, row.id);
}
const seen = new Set<string>();
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<string>();
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]);
}
});
}
+125 -1
View File
@@ -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<void> {
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<number> {
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<Album[]> {
@@ -106,7 +227,10 @@ export function getAlbums(db: LibraryDatabase): Promise<Album[]> {
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
+122
View File
@@ -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<RemoteSourceRow[]> {
return db.all<RemoteSourceRow>('SELECT * FROM remote_sources ORDER BY created_at');
}
export function getRemoteSource(
db: LibraryDatabase,
id: number
): Promise<RemoteSourceRow | undefined> {
return db.get<RemoteSourceRow>('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<RemoteSourceRow> {
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<void> {
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<void> {
await db.run('DELETE FROM remote_sources WHERE id = ?', [id]);
}
export async function setRemoteSourceStatus(
db: LibraryDatabase,
id: number,
status: RemoteSourceStatus,
error: string | null
): Promise<void> {
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<void> {
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<void> {
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]
);
}
+102 -2
View File
@@ -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<void> {
+28
View File
@@ -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;
}
+36
View File
@@ -2,6 +2,8 @@
// md5-named files (desktop convention) and tracks store the file name.
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
import { artworkUrlForTrack } from '@/services/remoteUrls';
import type { Album, DbTrack } from '@/types/library';
let artworkDir: string | null = null;
let artworkThumbDir: string | null = null;
@@ -45,6 +47,40 @@ export function artworkThumbUri(hash: string): string {
return `file://${getArtworkThumbDir()}/${artworkThumbFileName(hash)}`;
}
type TrackArtworkFields = Pick<
DbTrack,
'source_type' | 'source_id' | 'artwork_source_id' | 'artwork_hash'
>;
/** Thumbnail source for a track row: a cached file for local, a server URL for remote. */
export function trackArtworkThumbSource(track: TrackArtworkFields): string | null {
if (track.source_type !== 'local') {
return artworkUrlForTrack({
sourceType: track.source_type,
sourceId: track.source_id ?? undefined,
artworkSourceId: track.artwork_source_id ?? undefined,
});
}
return track.artwork_hash ? artworkThumbUri(track.artwork_hash) : null;
}
type AlbumArtworkFields = Pick<
Album,
'source_type' | 'source_id' | 'artwork_source_id' | 'artwork_hash'
>;
/** Full-size album-art source: a cached file for local, a server URL for remote. */
export function albumArtworkSource(album: AlbumArtworkFields): string | null {
if (album.source_type && album.source_type !== 'local') {
return artworkUrlForTrack({
sourceType: album.source_type,
sourceId: album.source_id ?? undefined,
artworkSourceId: album.artwork_source_id ?? undefined,
});
}
return album.artwork_hash ? artworkUri(album.artwork_hash) : null;
}
export async function ensureArtworkThumbnails(
hashes: readonly (string | null | undefined)[]
): Promise<number> {
+151
View File
@@ -0,0 +1,151 @@
// Remote catalog sync orchestration (M5). Mirrors src/library/scanner.ts for local
// folders: fetch the server catalog -> upsert into `tracks` -> prune removed tracks.
// The caller (remoteSourcesStore) owns status/progress writes and libraryStore.refresh.
import type { LibraryDatabase } from '@/db/database';
import {
deleteTracksByPaths,
getRemoteSourcePaths,
upsertRemoteTracks,
type RemoteTrackUpsert,
} from '@/db/queries';
import { addFavoritePaths, syncRemotePlaylists } from '@/db/playlistQueries';
import { buildAlbumIdentityKey } from '@/library/trackAdapter';
import {
buildSubsonicTrackPath,
fetchSubsonicStarredTrackIds,
syncSubsonicCatalog,
syncSubsonicPlaylists,
} from '@/services/subsonic';
import { syncJellyfinCatalog, type JellyfinAuthContext } from '@/services/jellyfin';
import type {
RemoteCatalogTrack,
RemoteConnectionConfig,
RemoteSourceRow,
RemoteSyncProgress,
} from '@/types/remote';
const UPSERT_BATCH = 500;
export interface SyncRemoteResult {
tracksScanned: number;
removed: number;
}
export interface SyncRemoteOptions {
onProgress?: (progress: RemoteSyncProgress) => void;
/** Reuse an already-obtained Jellyfin auth (avoids a second AuthenticateByName). */
authContext?: JellyfinAuthContext;
signal?: AbortSignal;
}
function toUpsertRow(source: RemoteSourceRow, track: RemoteCatalogTrack): RemoteTrackUpsert {
return {
path: track.path,
source_type: source.type,
source_id: source.id,
source_track_id: track.source_track_id,
source_path: track.source_path,
artwork_source_id: track.artwork_source_id,
title: track.title,
artist: track.artist,
album: track.album,
album_artist: track.album_artist,
// Same album identity rule as local tracks so remote/local albums group consistently.
album_identity_key: buildAlbumIdentityKey(track.album_artist, track.artist, track.album),
duration: track.duration,
track_number: track.track_number,
disc_number: track.disc_number,
year: track.year,
genre: track.genre,
format: track.format,
sample_rate: track.sample_rate,
bit_depth: track.bit_depth,
bitrate: track.bitrate,
channels: track.channels,
codec: track.codec,
};
}
export async function syncRemoteSource(
db: LibraryDatabase,
source: RemoteSourceRow,
config: RemoteConnectionConfig,
options: SyncRemoteOptions = {}
): Promise<SyncRemoteResult> {
options.onProgress?.({ phase: 'connecting', current: 0, total: 0, detail: null });
let catalogTracks: RemoteCatalogTrack[];
if (source.type === 'subsonic') {
const result = await syncSubsonicCatalog(source.id, config, {
onProgress: options.onProgress,
signal: options.signal,
});
catalogTracks = result.tracks;
} else {
const result = await syncJellyfinCatalog(source.id, config, {
onProgress: options.onProgress,
authContext: options.authContext,
signal: options.signal,
});
catalogTracks = result.tracks;
}
options.onProgress?.({ phase: 'saving', current: 0, total: catalogTracks.length, detail: null });
const rows = catalogTracks.map((track) => toUpsertRow(source, track));
for (let i = 0; i < rows.length; i += UPSERT_BATCH) {
await upsertRemoteTracks(db, rows.slice(i, i + UPSERT_BATCH));
options.onProgress?.({
phase: 'saving',
current: Math.min(i + UPSERT_BATCH, rows.length),
total: rows.length,
detail: null,
});
}
// Prune tracks that vanished upstream (favorites/playlists keep their path-keyed
// entries; they just resolve as missing until re-added — same as local removal).
const currentPaths = new Set(rows.map((row) => row.path));
const existing = await getRemoteSourcePaths(db, source.type, source.id);
const toDelete = existing.map((row) => row.path).filter((path) => !currentPaths.has(path));
const removed = toDelete.length > 0 ? await deleteTracksByPaths(db, toDelete) : 0;
// Subsonic also exposes server favorites + playlists; mirror them into the local
// favorites/playlists tables (must run after the track upsert so paths resolve).
if (source.type === 'subsonic') {
await syncSubsonicFavoritesAndPlaylists(db, source.id, config, options);
}
return { tracksScanned: rows.length, removed };
}
async function syncSubsonicFavoritesAndPlaylists(
db: LibraryDatabase,
sourceId: number,
config: RemoteConnectionConfig,
options: SyncRemoteOptions
): Promise<void> {
const [starred, playlists] = await Promise.allSettled([
fetchSubsonicStarredTrackIds(config, { signal: options.signal }),
syncSubsonicPlaylists(sourceId, config, {
onProgress: options.onProgress,
signal: options.signal,
}),
]);
if (starred.status === 'fulfilled') {
// Starred ids -> deterministic identity paths; insert-or-ignore (additive, like
// desktop — un-starring on the server doesn't drop a local favorite).
const paths = starred.value.map((id) => buildSubsonicTrackPath(sourceId, id));
await addFavoritePaths(db, paths);
} else {
console.warn('[remoteSync] subsonic starred fetch failed', starred.reason);
}
if (playlists.status === 'fulfilled') {
await syncRemotePlaylists(db, sourceId, playlists.value);
} else {
console.warn('[remoteSync] subsonic playlist sync failed', playlists.reason);
}
}
+19 -1
View File
@@ -6,6 +6,7 @@ import type { DbTrack } from '@/types/library';
import type { TrackUpsert } from '@/db/queries';
import type { ExtractedMetadata, ScannedFile } from '../../modules/astra-library-scanner';
import { artworkUri } from './artwork';
import { artworkUrlForTrack } from '@/services/remoteUrls';
import { repairMojibakeTag } from './tagEncoding';
const UNKNOWN_ARTIST = 'Unknown Artist';
@@ -102,6 +103,19 @@ export function metadataToUpsertRow(
}
export function dbTrackToTrack(track: DbTrack): Track {
const isRemote = track.source_type !== 'local';
// Local artwork is a cached file (artworkUri); remote artwork is a server URL
// resolved on the fly from the source config + the stored cover-art id.
const artworkData = isRemote
? (artworkUrlForTrack({
sourceType: track.source_type,
sourceId: track.source_id ?? undefined,
artworkSourceId: track.artwork_source_id ?? undefined,
}) ?? undefined)
: track.artwork_hash
? artworkUri(track.artwork_hash)
: undefined;
return {
id: String(track.id),
path: track.path,
@@ -116,7 +130,7 @@ export function dbTrackToTrack(track: DbTrack): Track {
discNumber: track.disc_number ?? undefined,
year: track.year ?? undefined,
genre: track.genre ?? undefined,
artworkData: track.artwork_hash ? artworkUri(track.artwork_hash) : undefined,
artworkData,
artworkHash: track.artwork_hash ?? undefined,
format: track.format,
sampleRate: track.sample_rate ?? undefined,
@@ -125,5 +139,9 @@ export function dbTrackToTrack(track: DbTrack): Track {
channels: track.channels ?? undefined,
codec: track.codec ?? undefined,
sourceType: track.source_type,
sourceId: track.source_id ?? undefined,
sourceTrackId: track.source_track_id ?? undefined,
sourcePath: track.source_path ?? undefined,
artworkSourceId: track.artwork_source_id ?? undefined,
};
}
+575
View File
@@ -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<T>(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<string, string | number | boolean | null | undefined>
): 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<string, string | number | boolean | null | undefined>,
options: JellyfinRequestOptions = {}
): Promise<Record<string, unknown>> {
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<string, unknown>;
} 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<JellyfinAuthContext> {
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<void> {
await authenticateJellyfin(config, options);
}
function resolveJellyfinArtist(item: JellyfinAudioItem): string {
const artists = asArray<string>(item.Artists)
.map((value) => toTrimmedText(value))
.filter((value): value is string => Boolean(value));
if (artists.length > 0) return artists[0];
const artistItems = asArray<Record<string, unknown>>(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<string>(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<string, unknown>).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<string>(item.Genres)
.map((value) => toTrimmedText(value))
.find((value): value is string => Boolean(value)) ?? null;
const sourcePath = toTrimmedText(item.Path);
const mediaStreams = asArray<JellyfinAudioStream>(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<JellyfinCatalogSyncResult> {
const authContext = options.authContext ?? (await authenticateJellyfin(config, options));
const byTrackId = new Map<string, RemoteCatalogTrack>();
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<JellyfinAudioItem>(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();
}
+45
View File
@@ -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<number, ResolvedRemoteConfig>();
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);
}
+22
View File
@@ -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<string | null> {
return SecureStore.getItemAsync(secretKey(sourceId));
}
export async function setRemoteSecret(sourceId: number, password: string): Promise<void> {
await SecureStore.setItemAsync(secretKey(sourceId), password);
}
export async function deleteRemoteSecret(sourceId: number): Promise<void> {
await SecureStore.deleteItemAsync(secretKey(sourceId));
}
+53
View File
@@ -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<Track, 'sourceType' | 'sourceId' | 'artworkSourceId'>
): 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;
}
+632
View File
@@ -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<T>(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<string, string> {
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<string, string> = {
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<string, string | number | boolean | null | undefined>,
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<string, string | number | boolean | null | undefined>,
options: SubsonicRequestOptions = {}
): Promise<Record<string, unknown>> {
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<string, unknown>;
} 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<T, R>(
values: T[],
concurrency: number,
worker: (value: T, index: number) => Promise<R>
): Promise<R[]> {
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<void> => {
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<void> {
await requestSubsonicJson(config, 'ping', {}, options);
}
function toArtistRefs(indexResponse: Record<string, unknown>): SubsonicArtistRef[] {
const artistsContainer = indexResponse.artists as Record<string, unknown> | undefined;
if (!artistsContainer || typeof artistsContainer !== 'object') return [];
const indexEntries = asArray<Record<string, unknown>>(artistsContainer.index);
const artistIds: SubsonicArtistRef[] = [];
for (const indexEntry of indexEntries) {
for (const artist of asArray<Record<string, unknown>>(indexEntry.artist)) {
const id = toTrimmedText(artist.id);
if (!id) continue;
artistIds.push({ id });
}
}
return artistIds;
}
function toAlbumRefs(artistResponse: Record<string, unknown>): SubsonicAlbumRef[] {
const artistContainer = artistResponse.artist as Record<string, unknown> | undefined;
if (!artistContainer || typeof artistContainer !== 'object') return [];
const albums = asArray<Record<string, unknown>>(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<SubsonicCatalogSyncResult> {
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<SubsonicAlbumSongs> => {
const albumResponse = await requestSubsonicJson(config, 'getAlbum', { id: albumId }, options);
const albumContainer = albumResponse.album as Record<string, unknown> | 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<SubsonicSong>(albumContainer.song),
};
}
);
const byTrackId = new Map<string, RemoteCatalogTrack>();
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, unknown>): string[] {
const starredContainer = starredResponse.starred as Record<string, unknown> | undefined;
if (!starredContainer || typeof starredContainer !== 'object') return [];
const ids = new Set<string>();
for (const song of asArray<SubsonicSong>(starredContainer.song)) {
const id = toTrimmedText(song.id);
if (id) ids.add(id);
}
return Array.from(ids);
}
function toPlaylistRefs(playlistsResponse: Record<string, unknown>): SubsonicPlaylistRef[] {
const playlistsContainer = playlistsResponse.playlists as Record<string, unknown> | undefined;
if (!playlistsContainer || typeof playlistsContainer !== 'object') return [];
return asArray<Record<string, unknown>>(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<string, unknown>
): RemotePlaylistTrack[] {
const playlistContainer = playlistResponse.playlist as Record<string, unknown> | undefined;
if (!playlistContainer || typeof playlistContainer !== 'object') return [];
const entries = asArray<SubsonicSong>(playlistContainer.entry ?? playlistContainer.song);
const tracks: RemotePlaylistTrack[] = [];
const seen = new Set<string>();
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<string[]> {
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<RemotePlaylist[]> {
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<RemotePlaylist> => {
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),
};
}
);
}
+288
View File
@@ -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<RemoteConnectionConfig | null> {
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<JellyfinAuthContext> {
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<number, RemoteSyncProgress | null>;
init: () => Promise<void>;
refresh: () => Promise<void>;
testSource: (input: RemoteSourceTestInput) => Promise<RemoteSourceTestResult>;
createSource: (input: RemoteSourceCreateInput) => Promise<RemoteSourceRow>;
updateSource: (id: number, input: RemoteSourceUpdateInput) => Promise<void>;
deleteSource: (id: number, purgeTracks: boolean) => Promise<void>;
syncSource: (id: number) => Promise<void>;
syncAll: () => Promise<void>;
}
export const useRemoteSourcesStore = create<RemoteSourcesStore>((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);
}
},
}));
+2
View File
@@ -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;
}
+14 -2
View File
@@ -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 {
+2
View File
@@ -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 {
+125
View File
@@ -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;
}