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