From 2b19f51a49fbb69bba1515a042b978976303c482 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:14:41 -0400 Subject: [PATCH] desktop sync support --- package.json | 1 + src/app/(tabs)/settings.tsx | 32 + src/app/_layout.tsx | 117 +++ src/app/desktop-remote.tsx | 219 ++++-- src/app/desktop-sync.tsx | 408 +++++++++++ src/components/queue/RemoteQueueSheet.tsx | 173 +++++ src/components/sync/SyncConflictDetails.tsx | 347 +++++++++ src/components/sync/SyncConflictPrompt.tsx | 314 ++++++++ src/db/desktopSyncQueries.ts | 454 ++++++++++++ src/db/playlistQueries.ts | 55 +- src/db/schema.ts | 46 +- src/lib/format.ts | 12 + src/library/importMatching.test.mts | 159 ++++ src/library/importMatching.ts | 238 ++++++ src/library/playlistFiles.ts | 164 +---- src/services/desktopRemoteClient.ts | 90 ++- src/services/desktopSync.ts | 678 ++++++++++++++++++ .../desktopSyncPlaylistMerge.test.mts | 67 ++ src/services/desktopSyncPlaylistMerge.ts | 50 ++ src/shared/sync/conflictPreview.test.mts | 93 +++ src/shared/sync/conflictPreview.ts | 194 +++++ src/shared/sync/identity.ts | 21 + src/stores/desktopRemoteStore.ts | 61 +- src/stores/desktopSyncStore.ts | 199 +++++ src/types/desktopRemote.ts | 24 +- src/types/desktopSync.ts | 175 +++++ 26 files changed, 4183 insertions(+), 208 deletions(-) create mode 100644 src/app/desktop-sync.tsx create mode 100644 src/components/queue/RemoteQueueSheet.tsx create mode 100644 src/components/sync/SyncConflictDetails.tsx create mode 100644 src/components/sync/SyncConflictPrompt.tsx create mode 100644 src/db/desktopSyncQueries.ts create mode 100644 src/library/importMatching.test.mts create mode 100644 src/library/importMatching.ts create mode 100644 src/services/desktopSync.ts create mode 100644 src/services/desktopSyncPlaylistMerge.test.mts create mode 100644 src/services/desktopSyncPlaylistMerge.ts create mode 100644 src/shared/sync/conflictPreview.test.mts create mode 100644 src/shared/sync/conflictPreview.ts create mode 100644 src/shared/sync/identity.ts create mode 100644 src/stores/desktopSyncStore.ts create mode 100644 src/types/desktopSync.ts diff --git a/package.json b/package.json index 26cd53e..97d2f2e 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts", "test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts", "test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts", + "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts", "typecheck": "tsc --noEmit", "postinstall": "patch-package" }, diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index 13542c0..2339448 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -21,6 +21,8 @@ import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; +import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; +import { formatRelativeTime } from '@/lib/format'; import type { ReplayGainMode } from '@/audio/normalization'; import type { ArtistGroupingMode } from '@/library/artistGrouping'; import type { LastFmStatus } from '@/types/lastFm'; @@ -253,6 +255,18 @@ export default function SettingsScreen() { return () => task.cancel(); }, [initDesktopRemote]); + const desktopSyncStatus = useDesktopSyncStore((s) => s.status); + const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt); + const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length); + const desktopSyncSubtitle = !desktopRemoteConnection + ? 'Sync favorites and playlists with Astra Desktop.' + : desktopSyncConflictCount > 0 + ? `${desktopSyncConflictCount} conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve` + : desktopSyncStatus === 'syncing' + ? 'Syncing…' + : desktopLastSyncAt !== null + ? `Synced ${formatRelativeTime(desktopLastSyncAt)}` + : `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · not synced yet`; const desktopRemoteSubtitle = desktopRemoteConnection ? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}` : 'Pair with Astra Desktop to control playback from this phone.'; @@ -403,6 +417,24 @@ export default function SettingsScreen() { + router.push('/desktop-sync' as never)} + accessibilityRole="button" + > + + + Desktop Sync + 0 ? colors.warning : colors.textSecondary} + style={styles.optionDescription} + > + {desktopSyncSubtitle} + + + + s.connectionState); + const discovered = useDesktopRemoteStore((s) => s.discovered); + + useEffect(() => { + void useDesktopSyncStore.getState().hydrate(); + }, []); + + // Cold start + each return to foreground: attempt a (probe-guarded) sync and + // run a short mDNS burst so a desktop that changed LAN address is found. + useEffect(() => { + let burstTimer: ReturnType | null = null; + let startupRetryTimer: ReturnType | null = null; + const onActive = () => { + void (async () => { + const connection = await getDesktopRemoteConnection(); + if (!connection) return; + useDesktopSyncStore.getState().maybeAutoSync('foreground'); + const remote = useDesktopRemoteStore.getState(); + if (remote.discoveryAvailable && !remote.discoveryRunning) { + void remote.startDiscovery(); + burstTimer = setTimeout(() => { + burstTimer = null; + void useDesktopRemoteStore.getState().stopDiscovery(); + }, DESKTOP_DISCOVERY_BURST_MS); + } + })(); + }; + if (AppState.currentState === 'active') { + onActive(); + } else { + startupRetryTimer = setTimeout(() => { + startupRetryTimer = null; + if (AppState.currentState === 'active') onActive(); + }, DESKTOP_SYNC_STARTUP_RETRY_MS); + } + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') onActive(); + }); + return () => { + subscription.remove(); + if (burstTimer !== null) clearTimeout(burstTimer); + if (startupRetryTimer !== null) clearTimeout(startupRetryTimer); + }; + }, []); + + // Desktop-initiated "Sync now" pickup: a cheap identity poll while + // foregrounded (the SSE nudge only reaches us while the remote screen's + // stream happens to be connected). fetchDesktopRemoteIdentity swallows + // errors, so a powered-off desktop costs one timed-out request per minute. + useEffect(() => { + const timer = setInterval(() => { + if (AppState.currentState !== 'active') return; + void (async () => { + const connection = await getDesktopRemoteConnection(); + if (!connection) return; + const identity = await fetchDesktopRemoteIdentity(connection.baseUrl); + if (identity?.syncRequestedAt) { + useDesktopSyncStore.getState().handleSyncRequest(); + } + })(); + }, DESKTOP_SYNC_REQUEST_POLL_MS); + return () => clearInterval(timer); + }, []); + + // Paired desktop spotted on the LAN: refresh a stale baseUrl (DHCP moves) + // and trigger a sync. + useEffect(() => { + if (discovered.length === 0) return; + void (async () => { + const connection = await getDesktopRemoteConnection(); + if (!connection?.endpointUuid) return; + const match = discovered.find((desktop) => desktop.endpointUuid === connection.endpointUuid); + if (!match) return; + if (match.baseUrl && match.baseUrl !== connection.baseUrl) { + const updated = { ...connection, baseUrl: match.baseUrl }; + await setDesktopRemoteConnection(updated); + if (useDesktopRemoteStore.getState().connection) { + useDesktopRemoteStore.setState({ connection: updated }); + } + } + useDesktopSyncStore.getState().maybeAutoSync('discovery'); + })(); + }, [discovered]); + + // The remote screen connected — the desktop is definitely reachable. + useEffect(() => { + if (connectionState === 'connected') { + useDesktopSyncStore.getState().maybeAutoSync('connected'); + } + }, [connectionState]); + + return null; +} + export default function RootLayout() { const [fontsLoaded] = useFonts({ Inter_400Regular, @@ -176,6 +291,7 @@ export default function RootLayout() { + + ); diff --git a/src/app/desktop-remote.tsx b/src/app/desktop-remote.tsx index a7719a0..1a9cbce 100644 --- a/src/app/desktop-remote.tsx +++ b/src/app/desktop-remote.tsx @@ -16,7 +16,7 @@ import { View } from 'react-native'; import { Image } from 'expo-image'; -import { Ionicons } from '@expo/vector-icons'; +import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AstraLogo } from '@/components/AstraLogo'; @@ -24,21 +24,33 @@ import { MarqueeText } from '@/components/MarqueeText'; import { Screen } from '@/components/Screen'; import { SeekBar } from '@/components/SeekBar'; import { Text } from '@/components/Text'; +import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet'; import { colors, radius, spacing } from '@/theme'; +import { isWideWindow, WIDE_MIN_WIDTH } from '@/theme/adaptive'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote'; +// Layout mirrors now-playing's adaptive pattern (minus the scope stage): a +// wide two-pane branch, and a portrait branch where the controls own ALL real +// leftover space (flex + space-between) so height-estimate error spreads +// between the control rows instead of pooling as one dead gap. const MAX_CONTENT_WIDTH = 408; +const TABLET_MAX_CONTENT_WIDTH = 520; +const TABLET_ART_SIZE_MAX = 440; const CONTENT_SIDE_PADDING = spacing.lg; const NARROW_CONTENT_SIDE_PADDING = spacing.md; +const WIDE_MAX_CONTENT_WIDTH = 960; +const WIDE_PANE_GAP = spacing.xxl; +const WIDE_RIGHT_PANE_MIN = 300; +const WIDE_RIGHT_PANE_MAX = MAX_CONTENT_WIDTH; +const WIDE_ART_SIZE_MAX = 400; +const WIDE_ART_SIZE_MIN = 160; +const WIDE_COMPACT_HEIGHT = 480; const MEDIA_AREA_MIN = 220; -const COMPACT_MEDIA_AREA_MIN = 128; -const MEDIA_AREA_MAX = 360; -const ART_SIZE_MAX = 340; const HEADER_HEIGHT = 32; const CONTENT_TOP_PADDING = spacing.sm; const CONTENT_BOTTOM_PADDING = spacing.lg; @@ -56,9 +68,14 @@ const SUB_TOP_MARGIN = spacing.lg; const MIN_FLOATING_SPACE = spacing.sm; interface RemoteLayout { + isWide: boolean; contentPadding: number; contentWidth: number; + leftPaneWidth: number; + rightPaneWidth: number; + controlsGap: number; artSize: number; + mediaStackHeight: number; mediaTopMargin: number; mediaBottomGap: number; } @@ -67,21 +84,51 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -function getRemoteLayout(windowWidth: number, availableHeight: number): RemoteLayout { +function getRemoteLayout(availableWidth: number, availableHeight: number): RemoteLayout { + if (isWideWindow(availableWidth, availableHeight)) { + const contentPadding = CONTENT_SIDE_PADDING; + const contentWidth = Math.max( + 0, + Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH) + ); + const rightPaneWidth = Math.round( + clamp(contentWidth * 0.46, WIDE_RIGHT_PANE_MIN, WIDE_RIGHT_PANE_MAX) + ); + const leftPaneWidth = Math.max(0, contentWidth - WIDE_PANE_GAP - rightPaneWidth); + const verticalBudget = + availableHeight - CONTENT_TOP_PADDING - CONTENT_BOTTOM_PADDING - HEADER_HEIGHT - spacing.md; + const artSize = Math.round( + clamp(Math.min(leftPaneWidth, verticalBudget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX) + ); + return { + isWide: true, + contentPadding, + contentWidth, + leftPaneWidth, + rightPaneWidth, + controlsGap: availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg, + artSize, + mediaStackHeight: artSize, + mediaTopMargin: 0, + mediaBottomGap: 0, + }; + } + + // Tall windows: single column. Tablet-width ones get a larger column/art cap. + const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH; const contentPadding = - windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING; - const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH)); - const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX); - const mediaFloor = availableHeight < 620 ? COMPACT_MEDIA_AREA_MIN : MEDIA_AREA_MIN; - const mediaMin = Math.min(mediaMax, mediaFloor); + availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING; + const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH; + const contentWidth = Math.max(0, Math.min(availableWidth - contentPadding * 2, maxContentWidth)); + const mediaMax = Math.min(contentWidth, isTabletColumn ? TABLET_ART_SIZE_MAX : contentWidth); + const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN); const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN; const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP; - const fixedHeight = + const fixedHeightBase = CONTENT_TOP_PADDING + CONTENT_BOTTOM_PADDING + HEADER_HEIGHT + mediaTopMargin + - mediaBottomGap + TRACK_INFO_ESTIMATE + SEEK_BLOCK_ESTIMATE + TRANSPORT_TOP_MARGIN + @@ -89,16 +136,19 @@ function getRemoteLayout(windowWidth: number, availableHeight: number): RemoteLa SUB_TOP_MARGIN + SUB_BUTTON_SIZE + MIN_FLOATING_SPACE; - const heightBoundMedia = availableHeight - fixedHeight; - const fitAwareMediaMin = Math.min(mediaMin, Math.max(96, heightBoundMedia)); - const artSize = Math.min( - Math.round(clamp(heightBoundMedia, fitAwareMediaMin, mediaMax)), - ART_SIZE_MAX - ); + // The Math.max(96, ...) floor lets art shrink below MEDIA_AREA_MIN in squat + // windows (split-screen halves) instead of pushing the controls off-screen. + const bound = availableHeight - fixedHeightBase - mediaBottomGap; + const artSize = Math.round(clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax)); return { + isWide: false, contentPadding, contentWidth, + leftPaneWidth: contentWidth, + rightPaneWidth: contentWidth, + controlsGap: TRANSPORT_TOP_MARGIN, artSize, + mediaStackHeight: artSize, mediaTopMargin, mediaBottomGap, }; @@ -197,12 +247,14 @@ export default function DesktopRemoteScreen() { const reconnect = useDesktopRemoteStore((s) => s.reconnect); const forget = useDesktopRemoteStore((s) => s.forget); const sendControl = useDesktopRemoteStore((s) => s.sendControl); + const queue = useDesktopRemoteStore((s) => s.queue); const [pairingLink, setPairingLink] = useState(''); const [pinInput, setPinInput] = useState(''); const [pinClock, setPinClock] = useState(() => Date.now()); const [manualBaseUrl, setManualBaseUrl] = useState(''); const [manualTicket, setManualTicket] = useState(''); + const [queueOpen, setQueueOpen] = useState(false); useEffect(() => { void init(); @@ -234,9 +286,16 @@ export default function DesktopRemoteScreen() { const art = currentTrack?.artworkDataUrl ?? null; const accent = snapshot?.visualizerLineColor || colors.accent; const availableHeight = windowHeight - insets.top - insets.bottom; - const remoteLayout = getRemoteLayout(windowWidth, availableHeight); + const effectiveWidth = windowWidth - insets.left - insets.right; + const remoteLayout = getRemoteLayout(effectiveWidth, availableHeight); const remoteSource = connection?.desktopName ?? 'Astra Desktop'; const remoteDetail = snapshot?.outputDeviceLabel?.trim() || (connection ? hostFromBaseUrl(connection.baseUrl) : ''); + // Live protocol gate: protocol-1 desktops omit shuffle/repeat from the + // snapshot and 404 the queue endpoint (queue stays null). + const supportsShuffleRepeat = snapshot?.shuffle !== undefined; + const shuffleOn = snapshot?.shuffle === true; + const repeatMode = snapshot?.repeat ?? 'none'; + const queueAvailable = queue !== null; const countdown = pairing ? formatPairingCountdown(pairing.expiresAt) : ''; const pinPairingActive = Boolean(pinPairing && pinPairing.expiresAt > pinClock); const pinCountdown = pinPairing ? formatPairingCountdown(pinPairing.expiresAt, pinClock) : ''; @@ -499,14 +558,17 @@ export default function DesktopRemoteScreen() { {currentTrack ? ( - + - - - + + void sendControl('toggle-favorite')} + accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'} + accessibilityState={{ selected: currentTrack.isFavorite }} + > + + void sendControl('seek', seconds)} /> - + void reconnect()} - accessibilityLabel="Reconnect" + style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]} + disabled={!supportsShuffleRepeat} + onPress={() => void sendControl('toggle-shuffle')} + accessibilityLabel="Shuffle" + accessibilityState={{ selected: shuffleOn }} > - + void sendControl('previous')} @@ -595,20 +681,29 @@ export default function DesktopRemoteScreen() { void sendControl('toggle-favorite')} - accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'} - accessibilityState={{ selected: currentTrack.isFavorite }} + style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]} + disabled={!supportsShuffleRepeat} + onPress={() => void sendControl('toggle-repeat')} + accessibilityLabel="Repeat" + accessibilityState={{ selected: repeatMode !== 'none' }} > - + {repeatMode === 'one' ? ( + + ) : ( + + )} - + {remoteDetail} + {queueAvailable ? ( + setQueueOpen(true)} + accessibilityLabel="Desktop queue" + > + + + ) : null} )} + {queueOpen && setQueueOpen(false)} />} ); } @@ -882,6 +988,12 @@ const styles = StyleSheet.create({ remotePlayer: { flex: 1, }, + remotePlayerWide: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + columnGap: WIDE_PANE_GAP, + }, middleStack: { width: '100%', alignItems: 'center', @@ -897,13 +1009,15 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', }, - spacer: { - flex: 1, - minHeight: MIN_FLOATING_SPACE, - }, playerControls: { width: '100%', }, + // Portrait: the controls own all real leftover space; spare pixels spread + // evenly between the rows instead of pooling above the track title. + playerControlsFill: { + flex: 1, + justifyContent: 'space-between', + }, trackInfo: { alignSelf: 'stretch', flexDirection: 'row', @@ -929,7 +1043,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - marginTop: TRANSPORT_TOP_MARGIN, }, transportMainBtn: { width: 48, @@ -943,6 +1056,15 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + transportSideBtnDisabled: { + opacity: 0.35, + }, + inlineActionBtn: { + width: SUB_BUTTON_SIZE, + height: SUB_BUTTON_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, playButton: { width: PLAY_BUTTON_SIZE, height: PLAY_BUTTON_SIZE, @@ -955,8 +1077,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - gap: spacing.md, - marginTop: SUB_TOP_MARGIN, + gap: spacing.sm, paddingHorizontal: spacing.sm, }, subBtn: { diff --git a/src/app/desktop-sync.tsx b/src/app/desktop-sync.tsx new file mode 100644 index 0000000..d3fa1bf --- /dev/null +++ b/src/app/desktop-sync.tsx @@ -0,0 +1,408 @@ +// Desktop Sync — favorites/playlists library sync with the paired desktop. +// Separate surface from the Desktop Remote controller: it shares the pairing +// (one paired desktop serves both features) but nothing else. Conflict +// resolution (Steam-Cloud style) lives inline here; the desktop mirrors the +// same conflicts in its own settings and either side may resolve them. + +import { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Switch, + 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 { SyncConflictDetails } from '@/components/sync/SyncConflictDetails'; +import { colors, radius, spacing } from '@/theme'; +import { formatRelativeTime } from '@/lib/format'; +import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials'; +import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; +import { + buildSyncConflictResolutionPreview, + syncPlaylistToSnapshot, +} from '@/shared/sync/conflictPreview'; +import type { DesktopRemoteConnection } from '@/types/desktopRemote'; +import type { + DesktopSyncConflictResolution, + DesktopSyncPlaylistConflict, +} from '@/types/desktopSync'; + +const RESOLUTION_LABELS: Record = { + desktop: 'Use desktop', + phone: 'Use phone', + both: 'Keep both', + merge: 'Combine songs', +}; + +function resolutionOptions(conflict: DesktopSyncPlaylistConflict): DesktopSyncConflictResolution[] { + return conflict.playlistKind === 'dynamic' + ? ['desktop', 'phone', 'both'] + : ['desktop', 'phone', 'both', 'merge']; +} + +function conflictDescription(conflict: DesktopSyncPlaylistConflict): string { + if (conflict.kind === 'first-pairing') { + return conflict.playlistKind === 'dynamic' + ? 'Exists on both devices with different rules.' + : 'Exists on both devices with different songs.'; + } + return 'Edited on both devices since the last sync.'; +} + +function ConflictCard({ + conflict, + desktopName, + busy, + onResolve, +}: { + conflict: DesktopSyncPlaylistConflict; + desktopName: string; + busy: boolean; + onResolve: (resolution: DesktopSyncConflictResolution) => void; +}) { + const [selectedResolution, setSelectedResolution] = useState(null); + const desktopSnapshot = syncPlaylistToSnapshot(conflict.remote); + const phoneSnapshot = syncPlaylistToSnapshot(conflict.local); + const options = resolutionOptions(conflict); + const preview = selectedResolution + ? buildSyncConflictResolutionPreview(selectedResolution, desktopSnapshot, phoneSnapshot) + : null; + return ( + + + {conflict.localName} + + + {conflictDescription(conflict)} + + + {options.map((resolution) => ( + setSelectedResolution(resolution)} + > + {RESOLUTION_LABELS[resolution]} + + ))} + + + + {preview ? preview.title : 'Choose an option to preview it'} + + + {preview ? preview.detail : 'Nothing changes until you confirm.'} + + + + + selectedResolution ? onResolve(selectedResolution) : undefined} + > + + Confirm + + + + + ); +} + +export default function DesktopSyncScreen() { + const router = useRouter(); + const status = useDesktopSyncStore((s) => s.status); + const lastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt); + const lastSummary = useDesktopSyncStore((s) => s.lastSummary); + const conflicts = useDesktopSyncStore((s) => s.conflicts); + const errorMessage = useDesktopSyncStore((s) => s.errorMessage); + const autoSyncEnabled = useDesktopSyncStore((s) => s.autoSyncEnabled); + const syncNow = useDesktopSyncStore((s) => s.syncNow); + const setAutoSyncEnabled = useDesktopSyncStore((s) => s.setAutoSyncEnabled); + const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict); + + const [connection, setConnection] = useState(null); + const [connectionLoaded, setConnectionLoaded] = useState(false); + + useEffect(() => { + void useDesktopSyncStore.getState().hydrate(); + void getDesktopRemoteConnection().then((stored) => { + setConnection(stored); + setConnectionLoaded(true); + }); + }, []); + + const desktopName = connection?.desktopName ?? 'Astra Desktop'; + const syncing = status === 'syncing'; + const summaryLine = lastSummary + ? [ + lastSummary.favoritesAdded > 0 ? `${lastSummary.favoritesAdded} favorites added` : null, + lastSummary.favoritesRemoved > 0 ? `${lastSummary.favoritesRemoved} removed` : null, + lastSummary.playlistsCreated + lastSummary.playlistsReplaced > 0 + ? `${lastSummary.playlistsCreated + lastSummary.playlistsReplaced} playlists updated` + : null, + lastSummary.playlistsDeleted > 0 ? `${lastSummary.playlistsDeleted} playlists removed` : null, + lastSummary.favoritesPending > 0 ? `${lastSummary.favoritesPending} pending a library match` : null, + ] + .filter((part): part is string => part !== null) + .join(' · ') + : ''; + + return ( + + + router.back()} hitSlop={8}> + + + Settings + + + + + + + + + Desktop Sync + + + Keep favorites and playlists in step with Astra Desktop over your LAN. + + + + + {!connectionLoaded ? ( + + ) : !connection ? ( + + No desktop paired + + Sync uses the same pairing as the Desktop Remote. Pair this phone with Astra Desktop + once and both features work. + + router.push('/desktop-remote' as never)} + > + + + Pair with a desktop + + + + ) : ( + <> + + + + {desktopName} + + {syncing + ? 'Syncing…' + : lastSyncAt !== null + ? `Synced ${formatRelativeTime(lastSyncAt)}` + : 'Not synced yet'} + + + void syncNow()} + accessibilityLabel="Sync favorites and playlists now" + > + {syncing ? ( + + ) : ( + + )} + + Sync now + + + + {summaryLine ? ( + + Last sync: {summaryLine} + + ) : null} + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + + + Sync automatically + + Sync when this desktop appears on the network or the app returns to the + foreground. Manual and desktop-requested syncs always work. + + + void setAutoSyncEnabled(value)} + trackColor={{ false: colors.glassBorder, true: colors.accent }} + thumbColor={colors.textPrimary} + /> + + + + {conflicts.length > 0 ? ( + + + {conflicts.length === 1 ? '1 conflict' : `${conflicts.length} conflicts`} to + resolve + + + These playlists differ between devices. Nothing changes until you choose — + everything else already synced. You can also resolve these from the desktop + settings. + + {conflicts.map((conflict) => ( + void resolveConflict(conflict, resolution)} + /> + ))} + + ) : null} + + )} + + + ); +} + +const styles = StyleSheet.create({ + topBar: { + marginTop: spacing.md, + marginBottom: spacing.sm, + }, + back: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + content: { + paddingBottom: spacing.xxl, + gap: spacing.md, + }, + hero: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginTop: spacing.lg, + marginBottom: spacing.sm, + }, + heroText: { + flex: 1, + }, + heading: { + marginBottom: spacing.xs, + }, + card: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + padding: spacing.lg, + gap: spacing.md, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md, + }, + cardHeaderText: { + flex: 1, + minWidth: 0, + }, + cardCopy: { + lineHeight: 19, + }, + primaryButton: { + minHeight: 44, + borderRadius: radius.sm, + backgroundColor: colors.accent, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: spacing.sm, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + toggleText: { + flex: 1, + }, + conflictCard: { + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgSecondary, + padding: spacing.md, + gap: spacing.xs, + }, + conflictActions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginTop: spacing.sm, + }, + conflictBtn: { + minHeight: 36, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + paddingHorizontal: spacing.md, + alignItems: 'center', + justifyContent: 'center', + }, + conflictBtnSelected: { + borderColor: colors.accent, + backgroundColor: colors.glassHighlight, + }, + previewBox: { + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + padding: spacing.md, + gap: spacing.xs, + marginTop: spacing.xs, + }, + conflictConfirmRow: { + alignItems: 'flex-end', + marginTop: spacing.xs, + }, + disabled: { + opacity: 0.5, + }, +}); diff --git a/src/components/queue/RemoteQueueSheet.tsx b/src/components/queue/RemoteQueueSheet.tsx new file mode 100644 index 0000000..9db580d --- /dev/null +++ b/src/components/queue/RemoteQueueSheet.tsx @@ -0,0 +1,173 @@ +// Read-only queue sheet for the Desktop Remote: the desktop's current + +// upcoming tracks, tap-to-play. Deliberately NOT QueueTray — that component is +// welded to the local RNTP queue store (drag-reorder, swipe-remove, +// multi-select), none of which applies to a remote snapshot. Uses an INLINE +// BottomSheet like QueueTray does — BottomSheetModal's portal does not work in +// this app's screen setups (see queue-tray-sheet gotcha). + +import { useCallback, useEffect, useMemo } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import BottomSheet, { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + useBottomSheetScrollableCreator, +} from '@gorhom/bottom-sheet'; +import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list'; +import { Text } from '@/components/Text'; +import { colors, radius, spacing } from '@/theme'; +import { formatDuration } from '@/lib/format'; +import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; +import type { DesktopRemoteQueueItem } from '@/types/desktopRemote'; + +interface RemoteQueueSheetProps { + onClose: () => void; +} + +export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) { + const queue = useDesktopRemoteStore((s) => s.queue); + const snapPoints = useMemo(() => ['58%', '100%'], []); + const renderFlashListScrollComponent = useBottomSheetScrollableCreator(); + + // The SSE stream keeps the queue fresh while connected; refresh once on open + // in case the stream fell back to snapshot polling (which has no queue). + useEffect(() => { + void useDesktopRemoteStore.getState().refreshQueue(); + }, []); + + const items = queue?.items ?? []; + const upcomingCount = items.filter((item) => !item.isCurrent).length; + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [] + ); + + const playItem = useCallback( + (item: DesktopRemoteQueueItem) => { + if (item.isCurrent) return; + void useDesktopRemoteStore.getState().playQueueItem(item.queueId); + onClose(); + }, + [onClose] + ); + + const renderItem = useCallback( + ({ item }: ListRenderItemInfo) => ( + [styles.row, pressed && !item.isCurrent && styles.rowPressed]} + onPress={() => playItem(item)} + disabled={item.isCurrent} + accessibilityRole="button" + accessibilityLabel={ + item.isCurrent ? `Now playing: ${item.title}` : `Play ${item.title} on desktop` + } + > + + + {item.title || 'Unknown title'} + + + {item.artist || 'Unknown artist'} + + + {item.isCurrent ? ( + + ) : item.durationSeconds !== null ? ( + + {formatDuration(item.durationSeconds)} + + ) : null} + + ), + [playItem] + ); + + return ( + + + Desktop queue + + {upcomingCount === 1 ? '1 song up next' : `${upcomingCount} songs up next`} + + + item.queueId} + renderScrollComponent={renderFlashListScrollComponent} + renderItem={renderItem} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + The desktop queue is empty. + + + } + /> + + ); +} + +const styles = StyleSheet.create({ + sheetBg: { + backgroundColor: colors.bgSecondary, + borderRadius: radius.lg, + }, + handle: { + backgroundColor: colors.textTertiary, + }, + headerRow: { + flexDirection: 'row', + alignItems: 'baseline', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + }, + listContent: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xl, + }, + row: { + minHeight: 56, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + rowPressed: { + opacity: 0.6, + }, + rowText: { + flex: 1, + minWidth: 0, + }, + titleActive: { + color: colors.accent, + }, + empty: { + paddingVertical: spacing.xl, + alignItems: 'center', + }, +}); diff --git a/src/components/sync/SyncConflictDetails.tsx b/src/components/sync/SyncConflictDetails.tsx new file mode 100644 index 0000000..77afbe9 --- /dev/null +++ b/src/components/sync/SyncConflictDetails.tsx @@ -0,0 +1,347 @@ +import { StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { colors, radius, spacing } from '@/theme'; +import { formatRelativeTime } from '@/lib/format'; +import { + buildSyncPlaylistEntryDiff, + syncPlaylistToSnapshot, + type SyncPlaylistEntryDiff, +} from '@/shared/sync/conflictPreview'; +import type { + DesktopSyncConflictResolution, + DesktopSyncPlaylistConflict, + SyncPlaylistSnapshot, +} from '@/types/desktopSync'; + +function playlistKindLabel(snapshot: SyncPlaylistSnapshot): string { + return snapshot.kind === 'dynamic' + ? 'Dynamic playlist' + : `${snapshot.trackCount} song${snapshot.trackCount === 1 ? '' : 's'}`; +} + +function sideSummary(snapshot: SyncPlaylistSnapshot): string { + return `${playlistKindLabel(snapshot)} · edited ${formatRelativeTime(snapshot.updatedAt)}`; +} + +function diffSummary(conflict: DesktopSyncPlaylistConflict): string { + const desktop = syncPlaylistToSnapshot(conflict.remote); + const phone = syncPlaylistToSnapshot(conflict.local); + if (desktop.kind !== 'normal' || phone.kind !== 'normal') { + return desktop.dynamicRules === phone.dynamicRules + ? 'Names or dynamic playlist metadata differ.' + : 'Dynamic playlist rules differ.'; + } + + const diff = buildSyncPlaylistEntryDiff(desktop.entries, phone.entries); + const parts = [ + diff.desktopOnlyCount > 0 ? `${diff.desktopOnlyCount} only on desktop` : null, + diff.phoneOnlyCount > 0 ? `${diff.phoneOnlyCount} only on phone` : null, + diff.movedCount > 0 ? `${diff.movedCount} in a different order` : null, + ].filter((part): part is string => part !== null); + + if (parts.length > 0) return parts.join(' · '); + if (desktop.name.trim() !== phone.name.trim()) return 'Playlist names differ.'; + return 'Same songs; playlist metadata differs.'; +} + +function previewStatusLabel( + row: SyncPlaylistEntryDiff, + side: 'desktop' | 'phone', + resolution: DesktopSyncConflictResolution | null +): string | null { + if (!resolution) return null; + if (resolution === 'both') return 'Stays separate'; + if (resolution === 'merge') return row.status === 'moved' ? 'Order chosen' : 'Added'; + + const keptSide = resolution; + if (side === keptSide) return row.status === 'moved' ? 'Order kept' : 'Kept'; + return row.status === 'moved' ? 'Order changes' : 'Removed'; +} + +function moveStatusLabel(row: SyncPlaylistEntryDiff, side: 'desktop' | 'phone'): string { + if (row.status === 'moved') { + const from = side === 'desktop' ? row.desktopIndex : row.phoneIndex; + const to = side === 'desktop' ? row.phoneIndex : row.desktopIndex; + return from !== null && to !== null ? `${from + 1} to ${to + 1}` : 'Different order'; + } + return ''; +} + +function TrackDiffRow({ + row, + side, + previewResolution, +}: { + row: SyncPlaylistEntryDiff; + side: 'desktop' | 'phone'; + previewResolution: DesktopSyncConflictResolution | null; +}) { + const subtitle = [row.artist, row.album].filter((part) => part.trim().length > 0).join(' · '); + const previewLabel = previewStatusLabel(row, side, previewResolution); + const moveLabel = row.status === 'moved' && !previewLabel ? moveStatusLabel(row, side) : null; + return ( + + + + {row.title || 'Untitled track'} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {previewLabel || moveLabel ? ( + + {previewLabel ?? moveLabel} + + ) : null} + + ); +} + +function SideTrackList({ + side, + sideOnlyRows, + movedRows, + previewResolution, + maxRows, +}: { + side: 'desktop' | 'phone'; + sideOnlyRows: SyncPlaylistEntryDiff[]; + movedRows: SyncPlaylistEntryDiff[]; + previewResolution: DesktopSyncConflictResolution | null; + maxRows: number; +}) { + const rows = [...sideOnlyRows, ...movedRows].slice(0, maxRows); + const hiddenCount = Math.max(0, sideOnlyRows.length + movedRows.length - rows.length); + const sideName = side === 'desktop' ? 'desktop' : 'phone'; + + if (rows.length === 0) { + return ( + + No songs only on {sideName}. + + ); + } + + return ( + + {sideOnlyRows.length > 0 ? ( + + Only on {sideName} + + ) : null} + {rows.map((row, index) => { + const startsMovedSection = row.status === 'moved' && rows[index - 1]?.status !== 'moved'; + return ( + + {startsMovedSection ? ( + + Different order + + ) : null} + + + ); + })} + {hiddenCount > 0 ? ( + + +{hiddenCount} more + + ) : null} + + ); +} + +export function SyncConflictDetails({ + conflict, + desktopName, + maxRows = 4, + previewResolution = null, +}: { + conflict: DesktopSyncPlaylistConflict; + desktopName: string; + maxRows?: number; + previewResolution?: DesktopSyncConflictResolution | null; +}) { + const desktop = syncPlaylistToSnapshot(conflict.remote); + const phone = syncPlaylistToSnapshot(conflict.local); + const isNormal = desktop.kind === 'normal' && phone.kind === 'normal'; + const diff = isNormal ? buildSyncPlaylistEntryDiff(desktop.entries, phone.entries) : null; + const desktopOnlyRows = diff?.rows.filter((row) => row.status === 'desktop-only') ?? []; + const phoneOnlyRows = diff?.rows.filter((row) => row.status === 'phone-only') ?? []; + const movedRows = diff?.rows.filter((row) => row.status === 'moved') ?? []; + const desktopDimmed = previewResolution === 'phone'; + const phoneDimmed = previewResolution === 'desktop'; + const desktopActive = previewResolution === 'desktop' || previewResolution === 'both' || previewResolution === 'merge'; + const phoneActive = previewResolution === 'phone' || previewResolution === 'both' || previewResolution === 'merge'; + + return ( + + + + + + + {desktopName} + + + + {desktop.name} + + + {sideSummary(desktop)} + + {isNormal ? ( + + ) : ( + + {desktop.dynamicRules ?? 'No rules'} + + )} + + + + + + + This phone + + + + {phone.name} + + + {sideSummary(phone)} + + {isNormal ? ( + + ) : ( + + {phone.dynamicRules ?? 'No rules'} + + )} + + + + + + {diffSummary(conflict)} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: spacing.sm, + }, + compareGrid: { + flexDirection: 'row', + gap: spacing.sm, + }, + sideCard: { + flex: 1, + minWidth: 0, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + padding: spacing.sm, + gap: spacing.xs, + }, + sideCardActive: { + borderColor: colors.accent, + }, + sideCardDimmed: { + opacity: 0.48, + }, + sideHead: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + sideTitle: { + flex: 1, + minWidth: 0, + }, + trackRows: { + gap: spacing.xs, + marginTop: spacing.xs, + }, + trackGroup: { + gap: spacing.xs, + }, + sectionLabel: { + textTransform: 'uppercase', + letterSpacing: 0.4, + }, + trackRow: { + minHeight: 42, + borderRadius: radius.sm, + backgroundColor: colors.bgSecondary, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + trackRowAdded: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + }, + trackRowSeparate: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + trackRowRemoved: { + opacity: 0.46, + }, + trackText: { + flex: 1, + minWidth: 0, + }, + trackBadge: { + maxWidth: 104, + }, + summaryBlock: { + borderRadius: radius.sm, + backgroundColor: colors.bgTertiary, + padding: spacing.sm, + gap: spacing.xs, + }, +}); diff --git a/src/components/sync/SyncConflictPrompt.tsx b/src/components/sync/SyncConflictPrompt.tsx new file mode 100644 index 0000000..a9c53b8 --- /dev/null +++ b/src/components/sync/SyncConflictPrompt.tsx @@ -0,0 +1,314 @@ +// Root-mounted popup for desktop-sync conflicts: fires the moment a sync run +// detects NEW conflicts (auto or manual) instead of waiting for the user to +// wander into Settings. The once-per-session bookkeeping lives in +// desktopSyncStore (conflictPromptVisible) so this component is a pure +// derivation of store state — no set-state-in-effect (React Compiler rule). +// Suppressed while the user is already on the sync screen; if they leave it +// without resolving, the one pending reminder still shows. + +import { useState } from 'react'; +import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router, usePathname } from 'expo-router'; +import { Text } from '@/components/Text'; +import { colors, radius, spacing } from '@/theme'; +import { formatRelativeTime } from '@/lib/format'; +import { + buildSyncConflictResolutionPreview, + buildSyncPlaylistEntryDiff, + syncPlaylistToSnapshot, +} from '@/shared/sync/conflictPreview'; +import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; +import type { + DesktopSyncConflictResolution, + DesktopSyncPlaylistConflict, + SyncPlaylistSnapshot, +} from '@/types/desktopSync'; + +const RESOLUTION_LABELS: Record = { + desktop: 'Use desktop version', + phone: 'Use phone version', + both: 'Keep both playlists', + merge: 'Combine songs', +}; + +function resolutionOptions(conflict: DesktopSyncPlaylistConflict): DesktopSyncConflictResolution[] { + return conflict.playlistKind === 'dynamic' + ? ['desktop', 'phone', 'both'] + : ['desktop', 'phone', 'both', 'merge']; +} + +function sideSubtitle(snapshot: SyncPlaylistSnapshot): string { + const count = snapshot.kind === 'dynamic' + ? 'Dynamic playlist' + : `${snapshot.trackCount} song${snapshot.trackCount === 1 ? '' : 's'}`; + return `${count} · edited ${formatRelativeTime(snapshot.updatedAt)}`; +} + +function diffLine(desktop: SyncPlaylistSnapshot, phone: SyncPlaylistSnapshot): string { + if (desktop.kind !== 'normal' || phone.kind !== 'normal') { + return desktop.dynamicRules === phone.dynamicRules + ? 'The playlist details do not match.' + : 'The playlist rules do not match.'; + } + + const diff = buildSyncPlaylistEntryDiff(desktop.entries, phone.entries); + const parts = [ + diff.desktopOnlyCount > 0 ? `${diff.desktopOnlyCount} only on desktop` : null, + diff.phoneOnlyCount > 0 ? `${diff.phoneOnlyCount} only on phone` : null, + diff.movedCount > 0 ? `${diff.movedCount} in a different order` : null, + ].filter((part): part is string => part !== null); + return parts.length > 0 ? parts.join(' · ') : 'The playlists have the same songs.'; +} + +export function SyncConflictPrompt() { + const conflicts = useDesktopSyncStore((s) => s.conflicts); + const status = useDesktopSyncStore((s) => s.status); + const promptVisible = useDesktopSyncStore((s) => s.conflictPromptVisible); + const dismissConflictPrompt = useDesktopSyncStore((s) => s.dismissConflictPrompt); + const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict); + const pathname = usePathname(); + const [choice, setChoice] = useState<{ + syncUid: string; + resolution: DesktopSyncConflictResolution; + } | null>(null); + + const visible = promptVisible && conflicts.length > 0 && pathname !== '/desktop-sync'; + if (!visible) return null; + + const count = conflicts.length; + const firstConflict = conflicts[0]; + const busy = status === 'syncing'; + const desktopSnapshot = syncPlaylistToSnapshot(firstConflict.remote); + const phoneSnapshot = syncPlaylistToSnapshot(firstConflict.local); + const options = resolutionOptions(firstConflict); + const selectedResolution = choice?.syncUid === firstConflict.syncUid && options.includes(choice.resolution) + ? choice.resolution + : null; + const preview = selectedResolution + ? buildSyncConflictResolutionPreview(selectedResolution, desktopSnapshot, phoneSnapshot) + : null; + + const review = () => { + dismissConflictPrompt(); + router.push('/desktop-sync' as never); + }; + + const confirm = () => { + if (!selectedResolution || busy) return; + dismissConflictPrompt(); + void resolveConflict(firstConflict, selectedResolution); + }; + + return ( + + + + + + + + + + Sync conflict{count === 1 ? '' : 's'} + + {count > 1 ? ( + + 1 of {count} + + ) : null} + + + + “{firstConflict.localName}” is different on desktop and this phone. + + + + + Desktop + + + {desktopSnapshot.name} + + + {sideSubtitle(desktopSnapshot)} + + + + + This phone + + + {phoneSnapshot.name} + + + {sideSubtitle(phoneSnapshot)} + + + + + {diffLine(desktopSnapshot, phoneSnapshot)} + + + {options.map((resolution) => ( + setChoice({ syncUid: firstConflict.syncUid, resolution })} + > + {RESOLUTION_LABELS[resolution]} + + ))} + + + + {preview ? preview.title : 'Choose what should happen'} + + + {preview + ? preview.detail + : 'Nothing changes until you confirm.'} + + + {count > 1 ? ( + + + Review all {count} conflicts + + + ) : null} + + + + + Not now + + + + + Confirm + + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.55)', + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + }, + card: { + width: '100%', + maxWidth: 400, + maxHeight: '88%', + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgSecondary, + padding: spacing.lg, + gap: spacing.sm, + }, + scrollContent: { + gap: spacing.md, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + titleBlock: { + flex: 1, + }, + title: { + flex: 1, + }, + body: { + lineHeight: 21, + }, + sideSummaryGrid: { + flexDirection: 'row', + gap: spacing.sm, + }, + sideSummaryCard: { + flex: 1, + minWidth: 0, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + padding: spacing.sm, + gap: 2, + }, + choiceList: { + gap: spacing.sm, + }, + choiceRow: { + minHeight: 42, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + paddingHorizontal: spacing.md, + alignItems: 'flex-start', + justifyContent: 'center', + }, + choiceRowSelected: { + borderColor: colors.accent, + backgroundColor: colors.glassHighlight, + }, + previewBox: { + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + padding: spacing.md, + gap: spacing.xs, + }, + reviewLink: { + alignSelf: 'flex-start', + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.xs, + }, + secondaryButton: { + minHeight: 44, + borderRadius: radius.sm, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButton: { + minHeight: 44, + borderRadius: radius.sm, + backgroundColor: colors.accent, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', + }, + disabled: { + opacity: 0.5, + }, +}); diff --git a/src/db/desktopSyncQueries.ts b/src/db/desktopSyncQueries.ts new file mode 100644 index 0000000..d8efbf9 --- /dev/null +++ b/src/db/desktopSyncQueries.ts @@ -0,0 +1,454 @@ +// Mobile-side DB surface for the desktop LAN sync (src/services/desktopSync.ts). +// Serializes local favorites/playlists into the shared wire vocabulary and +// applies merged results. Apply-variants deliberately use the caller-supplied +// (source) timestamps and never write tombstones for the rows they touch — +// otherwise an applied change would look like a fresh local edit on the next +// sync and ping-pong between devices. + +import { buildTrackSyncKey, normalizeSyncKeyPart } from '@/shared/sync/identity'; +import { normalizeDynamicPlaylistRules } from '@/shared/playlists/dynamicPlaylist'; +import { randomSaltHex } from '@/lib/hash'; +import type { + SyncFavorite, + SyncPlaylist, + SyncPlaylistEntry, + SyncPlaylistKind, +} from '@/types/desktopSync'; +import type { LibraryDatabase } from './database'; +import { + decodedDocPath, + matchSyncEntry, + type ImportMatchIndex, +} from '@/library/playlistFiles'; + +export interface LocalSyncFavorite extends SyncFavorite { + /** Local favorite rows carrying this identity (empty for pending rows). */ + trackPaths: string[]; + pending: boolean; +} + +export interface LocalSyncPlaylist { + id: number; + syncUid: string; + name: string; + kind: SyncPlaylistKind; + dynamicRules: string | null; + createdAt: number; + updatedAt: number; +} + +export interface LocalSyncState { + favorites: Map; + favoriteTombstones: Map; + playlists: LocalSyncPlaylist[]; + playlistTombstones: Map; +} + +/** Assign a sync identity to every sync-eligible playlist that lacks one. + * Assigning identity is not an edit: updated_at stays untouched. */ +export async function ensurePlaylistSyncUids(db: LibraryDatabase): Promise { + const rows = await db.all<{ id: number }>( + 'SELECT id FROM playlists WHERE sync_uid IS NULL AND remote_source_id IS NULL' + ); + for (const row of rows) { + await db.run('UPDATE playlists SET sync_uid = ? WHERE id = ?', [randomSaltHex(16), row.id]); + } +} + +export async function getLocalSyncState(db: LibraryDatabase): Promise { + const favorites = new Map(); + const favoriteRows = await db.all<{ + track_path: string; + added_at: number; + title: string | null; + artist: string | null; + album: string | null; + }>(` + SELECT f.track_path, f.added_at, t.title, t.artist, t.album + FROM favorites f + LEFT JOIN tracks t ON t.path = f.track_path + `); + for (const row of favoriteRows) { + // Orphaned favorites (no track row) have no metadata identity — skip. + if (row.title == null || !normalizeSyncKeyPart(row.title)) continue; + const key = buildTrackSyncKey(row.title, row.artist ?? '', row.album ?? ''); + const existing = favorites.get(key); + if (existing) { + existing.trackPaths.push(row.track_path); + if (row.added_at > existing.addedAt) existing.addedAt = row.added_at; + } else { + favorites.set(key, { + key, + title: row.title, + artist: row.artist ?? '', + album: row.album ?? '', + addedAt: row.added_at, + trackPaths: [row.track_path], + pending: false, + }); + } + } + + // Pending favorites re-enter sync state so they keep propagating even while + // unresolved locally. + const pendingRows = await db.all<{ + sync_key: string; + title: string; + artist: string; + album: string; + added_at: number; + }>('SELECT sync_key, title, artist, album, added_at FROM favorite_sync_pending'); + for (const row of pendingRows) { + if (favorites.has(row.sync_key)) continue; + favorites.set(row.sync_key, { + key: row.sync_key, + title: row.title, + artist: row.artist, + album: row.album, + addedAt: row.added_at, + trackPaths: [], + pending: true, + }); + } + + const favoriteTombstones = new Map(); + for (const row of await db.all<{ sync_key: string; deleted_at: number }>( + 'SELECT sync_key, deleted_at FROM favorite_tombstones' + )) { + favoriteTombstones.set(row.sync_key, row.deleted_at); + } + + const playlists: LocalSyncPlaylist[] = []; + for (const row of await db.all<{ + id: number; + sync_uid: string; + name: string; + kind: string | null; + dynamic_rules_json: string | null; + created_at: number; + updated_at: number; + }>(` + SELECT id, sync_uid, name, kind, dynamic_rules_json, created_at, updated_at + FROM playlists + WHERE sync_uid IS NOT NULL AND remote_source_id IS NULL + `)) { + const kind: SyncPlaylistKind = row.kind === 'dynamic' ? 'dynamic' : 'normal'; + playlists.push({ + id: row.id, + syncUid: row.sync_uid, + name: row.name, + kind, + dynamicRules: kind === 'dynamic' ? row.dynamic_rules_json : null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + } + + const playlistTombstones = new Map(); + for (const row of await db.all<{ sync_uid: string; deleted_at: number }>( + 'SELECT sync_uid, deleted_at FROM playlist_tombstones' + )) { + playlistTombstones.set(row.sync_uid, row.deleted_at); + } + + return { favorites, favoriteTombstones, playlists, playlistTombstones }; +} + +/** Serialize a normal playlist's entries for a push to the desktop. */ +export async function getSyncPlaylistEntries( + db: LibraryDatabase, + playlistId: number +): Promise { + const rows = await db.all<{ + track_path: string; + position: number; + added_at: number; + fallback_title: string | null; + fallback_artist: string | null; + fallback_album: string | null; + title: string | null; + artist: string | null; + album: string | null; + duration: number | null; + file_name: string | null; + }>(` + SELECT pt.track_path, pt.position, pt.added_at, + pt.fallback_title, pt.fallback_artist, pt.fallback_album, + t.title, t.artist, t.album, t.duration, t.file_name + FROM playlist_tracks pt + LEFT JOIN tracks t ON t.path = pt.track_path + WHERE pt.playlist_id = ? + ORDER BY pt.position, pt.id + `, [playlistId]); + + return rows.map((row) => ({ + title: row.title ?? row.fallback_title ?? '', + artist: row.artist ?? row.fallback_artist ?? '', + album: row.album ?? row.fallback_album ?? '', + durationSeconds: typeof row.duration === 'number' && row.duration > 0 ? row.duration : null, + position: row.position, + addedAt: row.added_at, + // The peer can only use trailing path segments; send the decoded SAF path + // when the track exists locally, else pass the stored (foreign) path along. + sourcePath: row.title != null + ? (decodedDocPath(row.track_path) ?? row.file_name ?? null) + : row.track_path || null, + })); +} + +export async function upsertPendingFavorite(db: LibraryDatabase, item: SyncFavorite): Promise { + await db.run( + 'INSERT OR REPLACE INTO favorite_sync_pending (sync_key, title, artist, album, added_at) VALUES (?, ?, ?, ?, ?)', + [item.key, item.title, item.artist, item.album, item.addedAt] + ); +} + +/** Retry pending favorites against the matching ladder; promoted rows keep + * their original added_at. Returns the number promoted. */ +export async function resolvePendingFavorites( + db: LibraryDatabase, + index: ImportMatchIndex +): Promise { + const rows = await db.all<{ + sync_key: string; + title: string; + artist: string; + album: string; + added_at: number; + }>('SELECT sync_key, title, artist, album, added_at FROM favorite_sync_pending'); + let resolved = 0; + for (const row of rows) { + const match = matchSyncEntry({ title: row.title, artist: row.artist, album: row.album }, index); + if (match.kind !== 'matched') continue; + await db.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [ + match.track.path, + row.added_at, + ]); + await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [row.sync_key]); + resolved += 1; + } + return resolved; +} + +export async function applySyncedFavoriteAdd( + db: LibraryDatabase, + trackPath: string, + syncKey: string, + addedAt: number +): Promise { + await db.run('INSERT OR REPLACE INTO favorites (track_path, added_at) VALUES (?, ?)', [ + trackPath, + addedAt, + ]); + await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]); + await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); +} + +export async function applySyncedFavoriteRemove( + db: LibraryDatabase, + trackPaths: readonly string[], + syncKey: string, + deletedAt: number +): Promise { + for (const trackPath of trackPaths) { + await db.run('DELETE FROM favorites WHERE track_path = ?', [trackPath]); + } + await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); + await db.run('INSERT OR REPLACE INTO favorite_tombstones (sync_key, deleted_at) VALUES (?, ?)', [ + syncKey, + deletedAt, + ]); +} + +export async function removeFavoriteTombstone(db: LibraryDatabase, syncKey: string): Promise { + await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]); +} + +/** Link a local playlist to the desktop's identity (first-sync name pairing). + * Must NOT bump updated_at — adopting identity is not an edit. */ +export async function adoptPlaylistSyncUid( + db: LibraryDatabase, + playlistId: number, + syncUid: string +): Promise { + await db.run('UPDATE playlists SET sync_uid = ? WHERE id = ?', [syncUid, playlistId]); +} + +export async function removePlaylistTombstone(db: LibraryDatabase, syncUid: string): Promise { + await db.run('DELETE FROM playlist_tombstones WHERE sync_uid = ?', [syncUid]); +} + +/** Create-or-replace a playlist by sync_uid from desktop state (whole-playlist + * last-writer-wins). Returns per-entry match counts for the sync summary. */ +export async function replaceSyncedPlaylist( + db: LibraryDatabase, + input: SyncPlaylist, + index: ImportMatchIndex +): Promise<{ status: 'created' | 'replaced' | 'skipped-incompatible'; entriesMatched: number; entriesFallback: number }> { + const kind: SyncPlaylistKind = input.kind === 'dynamic' ? 'dynamic' : 'normal'; + let rulesJson: string | null = null; + if (kind === 'dynamic') { + try { + rulesJson = JSON.stringify(normalizeDynamicPlaylistRules(JSON.parse(input.dynamicRules ?? ''))); + } catch { + return { status: 'skipped-incompatible', entriesMatched: 0, entriesFallback: 0 }; + } + } + + const existing = await db.get<{ id: number }>('SELECT id FROM playlists WHERE sync_uid = ?', [ + input.syncUid, + ]); + let playlistId: number; + let created = false; + if (existing) { + playlistId = existing.id; + await db.run( + 'UPDATE playlists SET name = ?, kind = ?, dynamic_rules_json = ?, updated_at = ? WHERE id = ?', + [input.name, kind, rulesJson, input.updatedAt, playlistId] + ); + await db.run('DELETE FROM playlist_tracks WHERE playlist_id = ?', [playlistId]); + } else { + const result = await db.run( + `INSERT INTO playlists (name, kind, dynamic_rules_json, created_at, updated_at, sync_uid) + VALUES (?, ?, ?, ?, ?, ?)`, + [input.name, kind, rulesJson, input.createdAt, input.updatedAt, input.syncUid] + ); + playlistId = result.lastInsertRowid; + created = true; + } + await db.run('DELETE FROM playlist_tombstones WHERE sync_uid = ?', [input.syncUid]); + + let entriesMatched = 0; + let entriesFallback = 0; + if (kind === 'normal' && Array.isArray(input.entries)) { + const orderedEntries = [...input.entries].sort((a, b) => a.position - b.position); + const seenTrackPaths = new Set(); + let position = 0; + for (const entry of orderedEntries) { + const match = matchSyncEntry( + { title: entry.title, artist: entry.artist, album: entry.album, sourcePath: entry.sourcePath }, + index + ); + let trackPath: string; + let matched = false; + if (match.kind === 'matched') { + trackPath = match.track.path; + matched = true; + } else { + const sourcePath = entry.sourcePath?.trim(); + trackPath = sourcePath || `astra-sync://unmatched/${buildTrackSyncKey(entry.title, entry.artist, entry.album)}`; + } + if (seenTrackPaths.has(trackPath)) continue; + seenTrackPaths.add(trackPath); + await db.run( + `INSERT INTO playlist_tracks + (playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + playlistId, + trackPath, + position++, + entry.addedAt > 0 ? entry.addedAt : input.updatedAt, + matched ? null : entry.title || null, + matched ? null : entry.artist || null, + matched ? null : entry.album || null, + ] + ); + if (matched) { + entriesMatched += 1; + } else { + entriesFallback += 1; + } + } + } + + return { status: created ? 'created' : 'replaced', entriesMatched, entriesFallback }; +} + +export async function applySyncedPlaylistDelete( + db: LibraryDatabase, + syncUid: string, + deletedAt: number +): Promise { + // ON DELETE CASCADE removes the entries. + await db.run('DELETE FROM playlists WHERE sync_uid = ?', [syncUid]); + await db.run('INSERT OR REPLACE INTO playlist_tombstones (sync_uid, deleted_at) VALUES (?, ?)', [ + syncUid, + deletedAt, + ]); + await db.run('DELETE FROM playlist_sync_state WHERE sync_uid = ?', [syncUid]); +} + +// --- Conflict-detection baseline (playlist_sync_state) ----------------------- +// The (local, remote) updated_at pair from the last successful sync per +// playlist. With a baseline, sync direction comes from which side changed — +// not the clock — and both-changed becomes a user-facing conflict. + +export interface PlaylistSyncBaseline { + localUpdatedAt: number; + remoteUpdatedAt: number; +} + +export async function getPlaylistSyncBaselines( + db: LibraryDatabase +): Promise> { + const result = new Map(); + for (const row of await db.all<{ + sync_uid: string; + local_updated_at: number; + remote_updated_at: number; + }>('SELECT sync_uid, local_updated_at, remote_updated_at FROM playlist_sync_state')) { + result.set(row.sync_uid, { + localUpdatedAt: row.local_updated_at, + remoteUpdatedAt: row.remote_updated_at, + }); + } + return result; +} + +export async function upsertPlaylistSyncBaseline( + db: LibraryDatabase, + syncUid: string, + localUpdatedAt: number, + remoteUpdatedAt: number +): Promise { + await db.run( + 'INSERT OR REPLACE INTO playlist_sync_state (sync_uid, local_updated_at, remote_updated_at) VALUES (?, ?, ?)', + [syncUid, localUpdatedAt, remoteUpdatedAt] + ); +} + +export async function deletePlaylistSyncBaseline(db: LibraryDatabase, syncUid: string): Promise { + await db.run('DELETE FROM playlist_sync_state WHERE sync_uid = ?', [syncUid]); +} + +/** Baselines are meaningless against a different desktop — cleared on forget. */ +export async function clearPlaylistSyncBaselines(db: LibraryDatabase): Promise { + await db.run('DELETE FROM playlist_sync_state'); +} + +/** "Keep both" for a concurrent edit: duplicate the local playlist (entries + * included) under a new name + fresh sync identity so both versions survive. */ +export async function clonePlaylistAsLocalCopy( + db: LibraryDatabase, + playlistId: number, + newName: string +): Promise { + const source = await db.get<{ kind: string | null; dynamic_rules_json: string | null }>( + 'SELECT kind, dynamic_rules_json FROM playlists WHERE id = ?', + [playlistId] + ); + if (!source) return; + const now = Date.now(); + const result = await db.run( + `INSERT INTO playlists (name, kind, dynamic_rules_json, created_at, updated_at, sync_uid) + VALUES (?, ?, ?, ?, ?, ?)`, + [newName, source.kind === 'dynamic' ? 'dynamic' : 'normal', source.dynamic_rules_json, now, now, randomSaltHex(16)] + ); + await db.run( + `INSERT INTO playlist_tracks (playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album) + SELECT ?, track_path, position, added_at, fallback_title, fallback_artist, fallback_album + FROM playlist_tracks WHERE playlist_id = ?`, + [result.lastInsertRowid, playlistId] + ); +} diff --git a/src/db/playlistQueries.ts b/src/db/playlistQueries.ts index 176417b..36eee73 100644 --- a/src/db/playlistQueries.ts +++ b/src/db/playlistQueries.ts @@ -16,6 +16,7 @@ import { buildDynamicPlaylistOrderByClause, buildDynamicPlaylistWhereClause, } from './dynamicPlaylistSql'; +import { buildTrackSyncKey, normalizeSyncKeyPart } from '@/shared/sync/identity'; const PLAYLIST_SELECT = ` SELECT p.id, p.name, p.kind, p.dynamic_rules_json, @@ -274,6 +275,19 @@ export async function renamePlaylist(db: LibraryDatabase, id: number, name: stri } export async function deletePlaylist(db: LibraryDatabase, id: number): Promise { + // Tombstone sync-eligible playlists so the desktop LAN sync propagates the + // deletion (server-mirrored playlists are excluded from that sync and are + // deleted via raw SQL elsewhere, never through here). + const row = await db.get<{ sync_uid: string | null; remote_source_id: number | null }>( + 'SELECT sync_uid, remote_source_id FROM playlists WHERE id = ?', + [id] + ); + if (row?.sync_uid && row.remote_source_id == null) { + await db.run('INSERT OR REPLACE INTO playlist_tombstones (sync_uid, deleted_at) VALUES (?, ?)', [ + row.sync_uid, + Date.now(), + ]); + } // ON DELETE CASCADE removes the entries (foreign_keys is ON per connection). await db.run('DELETE FROM playlists WHERE id = ?', [id]); } @@ -469,14 +483,41 @@ export async function getFavoritePaths(db: LibraryDatabase): Promise { return rows.map((row) => row.track_path); } +/** Metadata identity key for the desktop LAN sync; null when the track is + * unknown or has no usable title. */ +async function trackSyncKeyForPath(db: LibraryDatabase, trackPath: string): Promise { + const row = await db.get<{ title: string; artist: string; album: string }>( + 'SELECT title, artist, album FROM tracks WHERE path = ?', + [trackPath] + ); + if (!row || !normalizeSyncKeyPart(row.title)) return null; + return buildTrackSyncKey(row.title, row.artist, row.album); +} + export async function addFavorite(db: LibraryDatabase, trackPath: string): Promise { await db.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [ trackPath, Date.now(), ]); + // Re-favoriting must clear any sync deletion tombstone for the same identity. + const syncKey = await trackSyncKeyForPath(db, trackPath); + if (syncKey) { + await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]); + await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); + } } export async function removeFavorite(db: LibraryDatabase, trackPath: string): Promise { + // Record a deletion tombstone so the desktop LAN sync propagates the + // unfavorite instead of resurrecting it from the desktop's copy. + const syncKey = await trackSyncKeyForPath(db, trackPath); + if (syncKey) { + await db.run('INSERT OR REPLACE INTO favorite_tombstones (sync_key, deleted_at) VALUES (?, ?)', [ + syncKey, + Date.now(), + ]); + await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); + } await db.run('DELETE FROM favorites WHERE track_path = ?', [trackPath]); } @@ -484,14 +525,26 @@ export async function removeFavorite(db: LibraryDatabase, trackPath: string): Pr export async function addFavoritePaths(db: LibraryDatabase, paths: string[]): Promise { if (paths.length === 0) return; const now = Date.now(); + const insertedPaths: string[] = []; await db.transaction(async (tx) => { for (const path of paths) { - await tx.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [ + const result = await tx.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [ path, now, ]); + if (result.changes > 0) insertedPaths.push(path); } }); + // Only genuinely new favorites clear tombstones — the remote starred sync + // re-runs its inserts every pass and must not keep resurrecting identities + // the user unfavorited elsewhere. + for (const path of insertedPaths) { + const syncKey = await trackSyncKeyForPath(db, path); + if (syncKey) { + await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]); + await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); + } + } } // --- Remote sync (Subsonic playlists/favorites) ------------------------------ diff --git a/src/db/schema.ts b/src/db/schema.ts index 71802f5..fd4c8c2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -21,11 +21,18 @@ // v15 adds `album_display_artist` — the settled group artist ("Various Artists" for // shared-artwork compilations) written by the album-identity recompute pass // (src/library/albumIdentity.ts); the backfill itself runs from libraryStore.initialize -// via the `album_grouping_version` settings sentinel (v3 precedent: SQL marks, store acts). +// via the `album_grouping_version` settings sentinel (v3 precedent: SQL marks, store acts); +// v16 adds desktop LAN sync state (src/services/desktopSync.ts): a `sync_uid` playlist +// identity shared with the paired desktop, deletion tombstones for favorites/playlists, +// and a pending table for incoming favorites that haven't matched a local track yet; +// v17 adds `playlist_sync_state` — the per-playlist (local, remote) updated_at baseline +// from the last successful sync, which turns blind last-writer-wins into 3-way change +// detection: only-one-side-changed syncs silently, both-changed surfaces a conflict +// prompt (Steam-Cloud style) instead of silently dropping an edit. import type { LibraryDatabase } from './database'; -export const SCHEMA_VERSION = 15; +export const SCHEMA_VERSION = 17; // One statement per entry — op-sqlite executes single statements. const MIGRATIONS: readonly (readonly string[])[] = [ @@ -283,6 +290,41 @@ const MIGRATIONS: readonly (readonly string[])[] = [ // v14 -> v15 — settled album display artist (see header). NULL until the // startup recompute pass backfills it; readers fall back to album_artist/artist. [`ALTER TABLE tracks ADD COLUMN album_display_artist TEXT`], + // v15 -> v16 — desktop LAN sync (see header). Tombstones record deletions so a + // two-way merge propagates them instead of resurrecting the row from the peer; + // favorite_sync_pending holds incoming favorites with no local track match yet + // (retried against the metadata ladder at each sync). Table shapes mirror the + // desktop's (astra src/main/services/library.ts). + [ + `ALTER TABLE playlists ADD COLUMN sync_uid TEXT`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_sync_uid + ON playlists(sync_uid) + WHERE sync_uid IS NOT NULL`, + `CREATE TABLE IF NOT EXISTS favorite_tombstones ( + sync_key TEXT PRIMARY KEY NOT NULL, + deleted_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS favorite_sync_pending ( + sync_key TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + album TEXT NOT NULL, + added_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS playlist_tombstones ( + sync_uid TEXT PRIMARY KEY NOT NULL, + deleted_at INTEGER NOT NULL + )`, + ], + // v16 -> v17 — desktop sync conflict detection baseline (see header). Rows are + // written only for playlists that ended a sync run in-sync on both devices. + [ + `CREATE TABLE IF NOT EXISTS playlist_sync_state ( + sync_uid TEXT PRIMARY KEY NOT NULL, + local_updated_at INTEGER NOT NULL, + remote_updated_at INTEGER NOT NULL + )`, + ], ]; export async function migrate(db: LibraryDatabase): Promise { diff --git a/src/lib/format.ts b/src/lib/format.ts index 9f68817..049be93 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -6,3 +6,15 @@ export function formatDuration(seconds: number): string { const s = total % 60; return `${m}:${s.toString().padStart(2, '0')}`; } + +/** Coarse "how long ago" for status lines ("just now", "5 min ago", "2 h ago"). */ +export function formatRelativeTime(timestampMs: number): string { + const elapsed = Date.now() - timestampMs; + if (!Number.isFinite(elapsed) || elapsed < 60_000) return 'just now'; + const minutes = Math.floor(elapsed / 60_000); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} h ago`; + const days = Math.floor(hours / 24); + return `${days} d ago`; +} diff --git a/src/library/importMatching.test.mts b/src/library/importMatching.test.mts new file mode 100644 index 0000000..a2d036c --- /dev/null +++ b/src/library/importMatching.test.mts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildImportIndex, matchSyncEntry } from './importMatching.ts'; +import type { DbTrack } from '../types/library.ts'; + +function track(overrides: Partial & Pick): DbTrack { + return { + id: 1, + folder_id: 1, + album_artist: null, + album_identity_key: 'k', + album_display_artist: null, + duration: 200, + track_number: null, + disc_number: null, + year: null, + genre: null, + artwork_hash: null, + format: 'flac', + sample_rate: null, + bit_depth: null, + bitrate: null, + channels: null, + codec: null, + bpm: null, + musical_key: null, + source_type: 'local', + source_id: null, + source_track_id: null, + source_path: null, + artwork_source_id: null, + size: null, + mtime: 0, + added_at: 0, + modified_at: 0, + play_count: 0, + last_played_at: null, + ...overrides, + } as DbTrack; +} + +function safUri(docPath: string): string { + return `content://com.android.externalstorage.documents/document/${encodeURIComponent(`primary:${docPath}`)}`; +} + +const NEBULA = track({ + path: safUri('Music/Nova/Drift/nebula.flac'), + title: 'Nebula', + artist: 'Nova', + album: 'Drift', + file_name: 'nebula.flac', +}); +const NEBULA_LIVE = track({ + path: safUri('Music/Nova/Live/nebula.flac'), + title: 'Nebula', + artist: 'Nova', + album: 'Live at Dawn', + file_name: 'nebula.flac', +}); +const EMBER = track({ + path: safUri('Music/Cinder/Ash/ember.flac'), + title: 'Ember', + artist: 'Cinder', + album: 'Ash', + file_name: 'ember.flac', +}); + +test('matches by title+artist+album with case and whitespace variance', () => { + const index = buildImportIndex([NEBULA, NEBULA_LIVE, EMBER]); + const match = matchSyncEntry( + { title: ' NEBULA ', artist: 'nova', album: 'Drift ' }, + index + ); + assert.equal(match.kind, 'matched'); + assert.equal(match.kind === 'matched' && match.track.path, NEBULA.path); +}); + +test('unique source-path file name wins before metadata', () => { + const index = buildImportIndex([EMBER, NEBULA]); + const match = matchSyncEntry( + { title: 'Wrong Title', artist: 'Wrong', album: 'Wrong', sourcePath: 'D:/Music/Cinder/Ash/ember.flac' }, + index + ); + assert.equal(match.kind, 'matched'); + assert.equal(match.kind === 'matched' && match.track.path, EMBER.path); +}); + +test('duplicate file names resolve by trailing path-segment overlap', () => { + const index = buildImportIndex([NEBULA, NEBULA_LIVE, EMBER]); + const match = matchSyncEntry( + { title: 'Nebula', artist: 'Nova', album: 'Nonexistent', sourcePath: 'D:/Library/Nova/Live/nebula.flac' }, + index + ); + assert.equal(match.kind, 'matched'); + assert.equal(match.kind === 'matched' && match.track.path, NEBULA_LIVE.path); +}); + +test('ambiguous file-name rung falls through to metadata instead of giving up', () => { + // Same file name, same trailing segment depth -> tied overlap; the + // title+artist+album rung must still resolve it. + const a = track({ + path: safUri('One/song.flac'), + title: 'Song', + artist: 'A', + album: 'First', + file_name: 'song.flac', + }); + const b = track({ + path: safUri('Two/song.flac'), + title: 'Song', + artist: 'A', + album: 'Second', + file_name: 'song.flac', + }); + const index = buildImportIndex([a, b]); + const match = matchSyncEntry( + { title: 'Song', artist: 'A', album: 'Second', sourcePath: 'Z:/Elsewhere/song.flac' }, + index + ); + assert.equal(match.kind, 'matched'); + assert.equal(match.kind === 'matched' && match.track.path, b.path); +}); + +test('duplicate metadata identity reports ambiguous, not a guess', () => { + const a = track({ + path: safUri('One/x.flac'), + title: 'Twin', + artist: 'Dup', + album: 'Same', + file_name: 'x.flac', + }); + const b = track({ + path: safUri('Two/y.flac'), + title: 'Twin', + artist: 'Dup', + album: 'Same', + file_name: 'y.flac', + }); + const index = buildImportIndex([a, b]); + const match = matchSyncEntry({ title: 'Twin', artist: 'Dup', album: 'Same' }, index); + assert.equal(match.kind, 'ambiguous'); +}); + +test('falls back to title+artist then title-only rungs', () => { + const index = buildImportIndex([NEBULA, EMBER]); + // Album mismatch -> title+artist rung; but 'Nebula' by 'Nova' exists twice + // in the library? No — only NEBULA here, so title+artist matches. + const byTitleArtist = matchSyncEntry({ title: 'Nebula', artist: 'Nova', album: 'Renamed Album' }, index); + assert.equal(byTitleArtist.kind, 'matched'); + + const byTitle = matchSyncEntry({ title: 'Ember', artist: 'Different Artist', album: '' }, index); + assert.equal(byTitle.kind, 'matched'); + assert.equal(byTitle.kind === 'matched' && byTitle.track.path, EMBER.path); +}); + +test('no title means no identity', () => { + const index = buildImportIndex([NEBULA]); + assert.equal(matchSyncEntry({ title: ' ', artist: 'Nova', album: 'Drift' }, index).kind, 'none'); +}); diff --git a/src/library/importMatching.ts b/src/library/importMatching.ts new file mode 100644 index 0000000..1caaf68 --- /dev/null +++ b/src/library/importMatching.ts @@ -0,0 +1,238 @@ +// Track matching ladders (pure — node-testable, no expo imports): +// - M3U import: exact content URI -> decoded SAF path -> file name (+ +// path-suffix overlap) -> metadata (title+artist -> title, unique-or-null). +// - Desktop sync: source-path file name -> title+artist+album (shared +// whitespace-collapsing normalization) -> the M3U metadata rungs. +// Ported in spirit from the desktop playlist importer (library.ts). +// Runtime imports are relative (not '@/') so this module runs under plain +// `node --test`; type-only '@/' imports are erased by strip-types. + +import type { DbTrack } from '@/types/library'; +import type { M3uEntry } from '@/lib/m3u'; +import { decodedSafDocumentPath } from './folderTree.ts'; +import { normalizeSyncKeyPart } from '../shared/sync/identity.ts'; + +export const decodedDocPath = decodedSafDocumentPath; + +interface IndexedTrack { + track: DbTrack; + /** Lowercased decoded SAF path, for suffix-overlap scoring. */ + decodedPath: string | null; +} + +export interface ImportMatchIndex { + byContentUri: Map; + byDecodedPath: Map; + byFileName: Map; + /** Metadata maps are unique-or-null: null marks a collision (ambiguous). */ + byTitleArtist: Map; + byTitle: Map; + /** Desktop-sync rung, keyed with the shared whitespace-collapsing + * normalization (normalizeSyncKeyPart) unlike the M3U maps above. */ + byTitleArtistAlbum: Map; +} + +function upsertUnique(map: Map, key: string, track: DbTrack): void { + if (!key) return; + map.set(key, map.has(key) ? null : track); +} + +export function buildImportIndex(tracks: DbTrack[]): ImportMatchIndex { + const index: ImportMatchIndex = { + byContentUri: new Map(), + byDecodedPath: new Map(), + byFileName: new Map(), + byTitleArtist: new Map(), + byTitle: new Map(), + byTitleArtistAlbum: new Map(), + }; + for (const track of tracks) { + index.byContentUri.set(track.path, track); + + const decoded = decodedDocPath(track.path)?.toLocaleLowerCase() ?? null; + if (decoded && !index.byDecodedPath.has(decoded)) { + index.byDecodedPath.set(decoded, track); + } + + const fileName = track.file_name.toLocaleLowerCase(); + const bucket = index.byFileName.get(fileName); + if (bucket) { + bucket.push({ track, decodedPath: decoded }); + } else { + index.byFileName.set(fileName, [{ track, decodedPath: decoded }]); + } + + const title = track.title.trim().toLocaleLowerCase(); + const artist = track.artist.trim().toLocaleLowerCase(); + upsertUnique(index.byTitleArtist, `${title}\n${artist}`, track); + upsertUnique(index.byTitle, title, track); + + const syncTitle = normalizeSyncKeyPart(track.title); + if (syncTitle) { + upsertUnique( + index.byTitleArtistAlbum, + `${syncTitle}\n${normalizeSyncKeyPart(track.artist)}\n${normalizeSyncKeyPart(track.album)}`, + track + ); + } + } + return index; +} + +/** Foreign playlist paths: strip file://, unify slashes, percent-decode. */ +export function normalizeEntryPath(path: string): string { + let value = path.trim().replace(/\\/g, '/'); + if (/^file:\/\//i.test(value)) value = value.slice('file://'.length); + if (value.includes('%')) { + try { + value = decodeURIComponent(value); + } catch { + // keep the raw value + } + } + return value; +} + +export type ImportMatch = + | { kind: 'matched'; track: DbTrack; via: 'path' | 'metadata' } + | { kind: 'ambiguous' } + | { kind: 'none' }; + +/** Trailing path-segment overlap between an entry path and a track's decoded path. */ +function suffixOverlap(entrySegments: string[], decodedPath: string | null): number { + if (!decodedPath) return 1; // file name matched, nothing more to compare + const trackSegments = decodedPath.split('/'); + let overlap = 0; + while ( + overlap < entrySegments.length && + overlap < trackSegments.length && + entrySegments[entrySegments.length - 1 - overlap] === + trackSegments[trackSegments.length - 1 - overlap] + ) { + overlap += 1; + } + return overlap; +} + +export function matchImportEntry(entry: M3uEntry, index: ImportMatchIndex): ImportMatch { + const raw = entry.path.trim(); + + // 1. Exact SAF content URI (our own exports never write these, but be safe). + const exact = index.byContentUri.get(raw); + if (exact) return { kind: 'matched', track: exact, via: 'path' }; + + const normalized = normalizeEntryPath(raw).toLocaleLowerCase(); + + // 2. Decoded SAF path ("Music/Artist/Album/file.flac" — our export format). + const byPath = index.byDecodedPath.get(normalized); + if (byPath) return { kind: 'matched', track: byPath, via: 'path' }; + + // 3. File name bucket, disambiguated by longest trailing-segment overlap. + const entrySegments = normalized.split('/'); + const fileName = entrySegments[entrySegments.length - 1]; + const candidates = index.byFileName.get(fileName) ?? []; + if (candidates.length === 1) { + return { kind: 'matched', track: candidates[0].track, via: 'path' }; + } + if (candidates.length > 1) { + let best: IndexedTrack | null = null; + let bestScore = 0; + let tied = false; + for (const candidate of candidates) { + const score = suffixOverlap(entrySegments, candidate.decodedPath); + if (score > bestScore) { + best = candidate; + bestScore = score; + tied = false; + } else if (score === bestScore) { + tied = true; + } + } + if (best && !tied) return { kind: 'matched', track: best.track, via: 'path' }; + return { kind: 'ambiguous' }; + } + + // 4. Metadata from EXTINF: title+artist, then title (unique-or-null). + const title = entry.title?.trim().toLocaleLowerCase(); + if (title) { + const artist = entry.artist?.trim().toLocaleLowerCase(); + if (artist) { + const hit = index.byTitleArtist.get(`${title}\n${artist}`); + if (hit) return { kind: 'matched', track: hit, via: 'metadata' }; + if (hit === null) return { kind: 'ambiguous' }; + } + const hit = index.byTitle.get(title); + if (hit) return { kind: 'matched', track: hit, via: 'metadata' }; + if (hit === null) return { kind: 'ambiguous' }; + } + + return { kind: 'none' }; +} + +export interface SyncEntryQuery { + title: string; + artist: string; + album: string; + /** The peer's file path, useful only for its file name / trailing segments. */ + sourcePath?: string | null; +} + +/** + * Desktop-sync matching ladder: source-path file name (unique or best + * suffix-overlap) -> title+artist+album -> title+artist -> title. Unlike M3U + * import, sync entries always carry metadata, so an ambiguous file-name rung + * falls through to metadata instead of giving up. + */ +export function matchSyncEntry(query: SyncEntryQuery, index: ImportMatchIndex): ImportMatch { + const sourcePath = query.sourcePath?.trim(); + if (sourcePath) { + const normalized = normalizeEntryPath(sourcePath).toLocaleLowerCase(); + const entrySegments = normalized.split('/'); + const fileName = entrySegments[entrySegments.length - 1]; + const candidates = index.byFileName.get(fileName) ?? []; + if (candidates.length === 1) { + return { kind: 'matched', track: candidates[0].track, via: 'path' }; + } + if (candidates.length > 1) { + let best: DbTrack | null = null; + let bestScore = 0; + let tied = false; + for (const candidate of candidates) { + const score = suffixOverlap(entrySegments, candidate.decodedPath); + if (score > bestScore) { + best = candidate.track; + bestScore = score; + tied = false; + } else if (score === bestScore) { + tied = true; + } + } + if (best && !tied) return { kind: 'matched', track: best, via: 'path' }; + } + } + + const title = normalizeSyncKeyPart(query.title); + if (!title) return { kind: 'none' }; + const artist = normalizeSyncKeyPart(query.artist); + const album = normalizeSyncKeyPart(query.album); + + if (artist && album) { + const hit = index.byTitleArtistAlbum.get(`${title}\n${artist}\n${album}`); + if (hit) return { kind: 'matched', track: hit, via: 'metadata' }; + if (hit === null) return { kind: 'ambiguous' }; + } + + // The M3U maps use plain trim+lowercase keys — query them the same way. + const m3uTitle = query.title.trim().toLocaleLowerCase(); + const m3uArtist = query.artist.trim().toLocaleLowerCase(); + if (m3uArtist) { + const hit = index.byTitleArtist.get(`${m3uTitle}\n${m3uArtist}`); + if (hit) return { kind: 'matched', track: hit, via: 'metadata' }; + if (hit === null) return { kind: 'ambiguous' }; + } + const hit = index.byTitle.get(m3uTitle); + if (hit) return { kind: 'matched', track: hit, via: 'metadata' }; + if (hit === null) return { kind: 'ambiguous' }; + + return { kind: 'none' }; +} diff --git a/src/library/playlistFiles.ts b/src/library/playlistFiles.ts index 42b325b..d30203f 100644 --- a/src/library/playlistFiles.ts +++ b/src/library/playlistFiles.ts @@ -1,7 +1,6 @@ -// M3U file IO (SAF export, document-picker import) + the import matching -// ladder: exact content URI -> decoded SAF path -> file name (+ path-suffix -// overlap) -> metadata (title+artist -> title, unique-or-null), ported in -// spirit from the desktop playlist importer. +// M3U file IO (SAF export, document-picker import). The matching ladders live +// in importMatching.ts (pure, node-testable) and are re-exported here for the +// existing import sites. import * as DocumentPicker from 'expo-document-picker'; import { @@ -9,155 +8,18 @@ import { readAsStringAsync, writeAsStringAsync, } from 'expo-file-system/legacy'; -import type { DbTrack } from '@/types/library'; -import { decodedSafDocumentPath } from './folderTree'; import { parseM3u, serializeM3u, type M3uEntry, type M3uExportEntry } from '@/lib/m3u'; -export const decodedDocPath = decodedSafDocumentPath; - -// --- Import matching --------------------------------------------------------- - -interface IndexedTrack { - track: DbTrack; - /** Lowercased decoded SAF path, for suffix-overlap scoring. */ - decodedPath: string | null; -} - -export interface ImportMatchIndex { - byContentUri: Map; - byDecodedPath: Map; - byFileName: Map; - /** Metadata maps are unique-or-null: null marks a collision (ambiguous). */ - byTitleArtist: Map; - byTitle: Map; -} - -function upsertUnique(map: Map, key: string, track: DbTrack): void { - if (!key) return; - map.set(key, map.has(key) ? null : track); -} - -export function buildImportIndex(tracks: DbTrack[]): ImportMatchIndex { - const index: ImportMatchIndex = { - byContentUri: new Map(), - byDecodedPath: new Map(), - byFileName: new Map(), - byTitleArtist: new Map(), - byTitle: new Map(), - }; - for (const track of tracks) { - index.byContentUri.set(track.path, track); - - const decoded = decodedDocPath(track.path)?.toLocaleLowerCase() ?? null; - if (decoded && !index.byDecodedPath.has(decoded)) { - index.byDecodedPath.set(decoded, track); - } - - const fileName = track.file_name.toLocaleLowerCase(); - const bucket = index.byFileName.get(fileName); - if (bucket) { - bucket.push({ track, decodedPath: decoded }); - } else { - index.byFileName.set(fileName, [{ track, decodedPath: decoded }]); - } - - const title = track.title.trim().toLocaleLowerCase(); - const artist = track.artist.trim().toLocaleLowerCase(); - upsertUnique(index.byTitleArtist, `${title}\n${artist}`, track); - upsertUnique(index.byTitle, title, track); - } - return index; -} - -/** Foreign playlist paths: strip file://, unify slashes, percent-decode. */ -export function normalizeEntryPath(path: string): string { - let value = path.trim().replace(/\\/g, '/'); - if (/^file:\/\//i.test(value)) value = value.slice('file://'.length); - if (value.includes('%')) { - try { - value = decodeURIComponent(value); - } catch { - // keep the raw value - } - } - return value; -} - -export type ImportMatch = - | { kind: 'matched'; track: DbTrack; via: 'path' | 'metadata' } - | { kind: 'ambiguous' } - | { kind: 'none' }; - -/** Trailing path-segment overlap between an entry path and a track's decoded path. */ -function suffixOverlap(entrySegments: string[], decodedPath: string | null): number { - if (!decodedPath) return 1; // file name matched, nothing more to compare - const trackSegments = decodedPath.split('/'); - let overlap = 0; - while ( - overlap < entrySegments.length && - overlap < trackSegments.length && - entrySegments[entrySegments.length - 1 - overlap] === - trackSegments[trackSegments.length - 1 - overlap] - ) { - overlap += 1; - } - return overlap; -} - -export function matchImportEntry(entry: M3uEntry, index: ImportMatchIndex): ImportMatch { - const raw = entry.path.trim(); - - // 1. Exact SAF content URI (our own exports never write these, but be safe). - const exact = index.byContentUri.get(raw); - if (exact) return { kind: 'matched', track: exact, via: 'path' }; - - const normalized = normalizeEntryPath(raw).toLocaleLowerCase(); - - // 2. Decoded SAF path ("Music/Artist/Album/file.flac" — our export format). - const byPath = index.byDecodedPath.get(normalized); - if (byPath) return { kind: 'matched', track: byPath, via: 'path' }; - - // 3. File name bucket, disambiguated by longest trailing-segment overlap. - const entrySegments = normalized.split('/'); - const fileName = entrySegments[entrySegments.length - 1]; - const candidates = index.byFileName.get(fileName) ?? []; - if (candidates.length === 1) { - return { kind: 'matched', track: candidates[0].track, via: 'path' }; - } - if (candidates.length > 1) { - let best: IndexedTrack | null = null; - let bestScore = 0; - let tied = false; - for (const candidate of candidates) { - const score = suffixOverlap(entrySegments, candidate.decodedPath); - if (score > bestScore) { - best = candidate; - bestScore = score; - tied = false; - } else if (score === bestScore) { - tied = true; - } - } - if (best && !tied) return { kind: 'matched', track: best.track, via: 'path' }; - return { kind: 'ambiguous' }; - } - - // 4. Metadata from EXTINF: title+artist, then title (unique-or-null). - const title = entry.title?.trim().toLocaleLowerCase(); - if (title) { - const artist = entry.artist?.trim().toLocaleLowerCase(); - if (artist) { - const hit = index.byTitleArtist.get(`${title}\n${artist}`); - if (hit) return { kind: 'matched', track: hit, via: 'metadata' }; - if (hit === null) return { kind: 'ambiguous' }; - } - const hit = index.byTitle.get(title); - if (hit) return { kind: 'matched', track: hit, via: 'metadata' }; - if (hit === null) return { kind: 'ambiguous' }; - } - - return { kind: 'none' }; -} +export { + buildImportIndex, + decodedDocPath, + matchImportEntry, + matchSyncEntry, + normalizeEntryPath, + type ImportMatch, + type ImportMatchIndex, + type SyncEntryQuery, +} from './importMatching'; // --- File IO ------------------------------------------------------------------- diff --git a/src/services/desktopRemoteClient.ts b/src/services/desktopRemoteClient.ts index df241c9..df04da6 100644 --- a/src/services/desktopRemoteClient.ts +++ b/src/services/desktopRemoteClient.ts @@ -6,7 +6,14 @@ import type { DesktopRemotePairingClaim, DesktopRemotePairingStatus, DesktopRemotePinPairingRequest, + DesktopRemoteQueueSnapshot, } from '@/types/desktopRemote'; +import type { + DesktopSyncApplyPayload, + DesktopSyncApplyResult, + DesktopSyncConflictReportPayload, + DesktopSyncState, +} from '@/types/desktopSync'; export { parseDesktopRemoteManualInput, parseDesktopRemotePairingInput, @@ -102,6 +109,10 @@ function normalizeIdentity(payload: unknown): DesktopRemoteIdentity | null { typeof candidate.protocolVersion === 'number' && Number.isFinite(candidate.protocolVersion) ? candidate.protocolVersion : 1, + syncRequestedAt: + typeof candidate.syncRequestedAt === 'number' && Number.isFinite(candidate.syncRequestedAt) + ? candidate.syncRequestedAt + : null, }; } @@ -234,8 +245,63 @@ export async function sendDesktopRemoteControl( }); } +export async function sendDesktopRemotePlayQueueItem( + baseUrl: string, + token: string, + queueId: string +): Promise { + await fetchJson<{ ok: true }>(baseUrl, '/v1/control', { + method: 'POST', + token, + body: { command: 'play-queue-item', queueId }, + }); +} + +export async function fetchDesktopRemoteQueue( + baseUrl: string, + token: string +): Promise { + return fetchJson(baseUrl, '/v1/queue', { token }); +} + +// ── Favorites/playlists LAN sync (protocolVersion >= 2) ────────────────────── +// Sync payloads can carry thousands of favorites/playlist entries, so both +// calls get generous timeouts compared to the 8 s control default. + +export async function fetchDesktopSyncState(baseUrl: string, token: string): Promise { + return fetchJson(baseUrl, '/v1/sync/state', { token, timeoutMs: 30_000 }); +} + +export async function postDesktopSyncApply( + baseUrl: string, + token: string, + payload: DesktopSyncApplyPayload +): Promise { + return fetchJson(baseUrl, '/v1/sync/apply', { + method: 'POST', + token, + body: payload, + timeoutMs: 60_000, + }); +} + +export async function postDesktopSyncConflicts( + baseUrl: string, + token: string, + payload: DesktopSyncConflictReportPayload +): Promise { + await fetchJson<{ ok: true }>(baseUrl, '/v1/sync/conflicts', { + method: 'POST', + token, + body: payload, + }); +} + export type DesktopRemoteSseHandlers = { onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void; + onQueue?: (queue: DesktopRemoteQueueSnapshot) => void; + /** Desktop-initiated library-sync nudge (user clicked Sync Now / resolved a conflict there). */ + onSyncRequest?: () => void; onUnauthorized: () => void; onDisconnect: () => void; onError?: (error: unknown) => void; @@ -244,7 +310,9 @@ export type DesktopRemoteSseHandlers = { function processSseChunk( buffer: { value: string }, chunk: string, - onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void + onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void, + onQueue?: (queue: DesktopRemoteQueueSnapshot) => void, + onSyncRequest?: () => void ): void { buffer.value += chunk.replace(/\r/g, ''); let boundary = buffer.value.indexOf('\n\n'); @@ -267,6 +335,14 @@ function processSseChunk( } catch { // Ignore a malformed event; polling/reconnect will correct the UI. } + } else if (eventName === 'queue' && data.length > 0 && onQueue) { + try { + onQueue(JSON.parse(data.join('\n')) as DesktopRemoteQueueSnapshot); + } catch { + // Ignore a malformed event; the on-demand queue fetch will correct it. + } + } else if (eventName === 'sync-request' && onSyncRequest) { + onSyncRequest(); } boundary = buffer.value.indexOf('\n\n'); } @@ -307,9 +383,17 @@ export function startDesktopRemoteEventStream( while (!closed) { const next = await reader.read(); if (next.done) break; - if (next.value) processSseChunk(buffer, decoder.decode(next.value, { stream: true }), handlers.onSnapshot); + if (next.value) { + processSseChunk( + buffer, + decoder.decode(next.value, { stream: true }), + handlers.onSnapshot, + handlers.onQueue, + handlers.onSyncRequest + ); + } } - processSseChunk(buffer, decoder.decode(), handlers.onSnapshot); + processSseChunk(buffer, decoder.decode(), handlers.onSnapshot, handlers.onQueue, handlers.onSyncRequest); if (!closed) handlers.onDisconnect(); } catch (error) { if (closed || controller.signal.aborted) return; diff --git a/src/services/desktopSync.ts b/src/services/desktopSync.ts new file mode 100644 index 0000000..062a3c9 --- /dev/null +++ b/src/services/desktopSync.ts @@ -0,0 +1,678 @@ +// Desktop LAN sync engine — the mobile side is the merge authority. One run: +// pull the desktop's full favorites/playlists state, merge against local state +// (two-way, deletion tombstones), apply the local half of the merge in one +// transaction, then push only the desktop-bound diff. +// Loop-prevention invariant: everything applied here uses the SOURCE +// timestamps via the desktopSyncQueries apply-variants (never Date.now(), and +// never the tombstone-writing user-mutation paths) so an applied change does +// not read as a fresh local edit on the next run — an immediate second sync +// must produce an empty diff. +// Conflict model: per-playlist baselines (playlist_sync_state) make direction +// come from WHICH side changed since the last sync, not the clock. Only-one- +// side-changed syncs silently; both-changed (or a first-pairing name collision +// with divergent contents) is left untouched and surfaced as a +// DesktopSyncPlaylistConflict for the user to resolve. Timestamp LWW remains +// the fallback for pairs without a baseline yet. + +import { openLibraryDb } from '@/db/database'; +import { getAllTracks, setSetting } from '@/db/queries'; +import { renamePlaylist } from '@/db/playlistQueries'; +import { + adoptPlaylistSyncUid, + applySyncedFavoriteAdd, + applySyncedFavoriteRemove, + applySyncedPlaylistDelete, + clonePlaylistAsLocalCopy, + deletePlaylistSyncBaseline, + ensurePlaylistSyncUids, + getLocalSyncState, + getPlaylistSyncBaselines, + getSyncPlaylistEntries, + removeFavoriteTombstone, + removePlaylistTombstone, + replaceSyncedPlaylist, + resolvePendingFavorites, + upsertPendingFavorite, + upsertPlaylistSyncBaseline, + type LocalSyncPlaylist, +} from '@/db/desktopSyncQueries'; +import { buildImportIndex, matchSyncEntry } from '@/library/playlistFiles'; +import { normalizeSyncKeyPart } from '@/shared/sync/identity'; +import { syncPlaylistToSnapshot } from '@/shared/sync/conflictPreview'; +import { normalizeDynamicPlaylistRules } from '@/shared/playlists/dynamicPlaylist'; +import { usePlaylistStore } from '@/stores/playlistStore'; +import type { DesktopRemoteConnection } from '@/types/desktopRemote'; +import { + DESKTOP_SYNC_FORMAT, + DESKTOP_SYNC_MIN_PROTOCOL_VERSION, + type DesktopSyncApplyPayload, + type DesktopSyncConflictResolution, + type DesktopSyncPendingResolution, + type DesktopSyncPlaylistConflict, + type DesktopSyncSummary, + type SyncFavorite, + type SyncPlaylist, + type SyncPlaylistEntry, +} from '@/types/desktopSync'; +import { mergePlaylistEntries, playlistEntriesEqual } from './desktopSyncPlaylistMerge'; +import { + fetchDesktopRemoteIdentity, + fetchDesktopSyncState, + postDesktopSyncApply, + postDesktopSyncConflicts, +} from './desktopRemoteClient'; +import { + getDesktopRemoteConnection, + getDesktopRemoteToken, + setDesktopRemoteConnection, +} from './desktopRemoteCredentials'; + +const CLOCK_SKEW_WARN_MS = 5 * 60_000; + +/** The paired desktop runs a protocol without /v1/sync/* — needs an update. */ +export class DesktopSyncUnsupportedError extends Error { + constructor() { + super('The desktop app needs an update before it can sync favorites and playlists.'); + } +} + +export function desktopSyncSettingKey(connection: Pick): string { + return `desktop_sync_last_${connection.endpointUuid ?? connection.id}`; +} + +function newestFavorite(a: SyncFavorite | null, b: SyncFavorite | null): SyncFavorite | null { + if (!a) return b; + if (!b) return a; + return b.addedAt > a.addedAt ? b : a; +} + +function maxTimestamp(a: number | null, b: number | null): number | null { + if (a === null) return b; + if (b === null) return a; + return Math.max(a, b); +} + +function dynamicRulesEqual(a: string | null, b: string | null): boolean { + if (!a || !b) return a === b; + try { + return ( + JSON.stringify(normalizeDynamicPlaylistRules(JSON.parse(a))) === + JSON.stringify(normalizeDynamicPlaylistRules(JSON.parse(b))) + ); + } catch { + return false; + } +} + +function sanitizePendingResolutions(raw: unknown): DesktopSyncPendingResolution[] { + if (!Array.isArray(raw)) return []; + const resolutions: DesktopSyncPendingResolution[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const candidate = item as Record; + const syncUid = typeof candidate.syncUid === 'string' ? candidate.syncUid : ''; + const resolution = candidate.resolution; + if (!syncUid) continue; + if (resolution !== 'desktop' && resolution !== 'phone' && resolution !== 'both' && resolution !== 'merge') { + continue; + } + const decidedAt = Number(candidate.decidedAt); + resolutions.push({ + syncUid, + resolution, + decidedAt: Number.isFinite(decidedAt) && decidedAt > 0 ? decidedAt : 0, + }); + } + return resolutions; +} + +/** + * One sync run + desktop-side signals: applies any conflict resolutions the + * desktop user chose (re-running once to settle them), then reports the + * remaining conflicts back so the desktop can display them. + */ +export async function runDesktopSync(): Promise { + const first = await runDesktopSyncOnce(); + let summary = first.summary; + + const consumedResolutions: string[] = []; + let appliedAny = false; + for (const pending of first.pendingResolutions) { + const conflict = summary.conflicts.find((entry) => entry.syncUid === pending.syncUid); + if (!conflict) { + // Already resolved on the phone (or gone) — acknowledge so it clears. + consumedResolutions.push(pending.syncUid); + continue; + } + try { + await applyDesktopSyncConflictResolution(conflict, pending.resolution); + appliedAny = true; + } catch (error) { + console.warn('Desktop-chosen sync resolution failed:', error); + } + // Consume either way — a bad choice (e.g. merge on a dynamic playlist) + // re-surfaces as a conflict for the desktop to re-decide, not a loop. + consumedResolutions.push(pending.syncUid); + } + if (appliedAny) { + summary = (await runDesktopSyncOnce()).summary; + } + + // Report remaining conflicts (best-effort — older desktops 404 here). + const connection = await getDesktopRemoteConnection(); + const token = await getDesktopRemoteToken(); + if (connection && token) { + try { + await postDesktopSyncConflicts(connection.baseUrl, token, { + syncFormat: DESKTOP_SYNC_FORMAT, + conflicts: summary.conflicts.map((conflict) => ({ + kind: conflict.kind, + syncUid: conflict.syncUid, + name: conflict.name, + playlistKind: conflict.playlistKind, + phoneName: conflict.localName, + desktopName: conflict.remoteName, + phoneUpdatedAt: conflict.localUpdatedAt, + desktopUpdatedAt: conflict.remoteUpdatedAt, + phoneTrackCount: conflict.localTrackCount, + desktopTrackCount: conflict.remoteTrackCount, + phoneSnapshot: syncPlaylistToSnapshot(conflict.local), + desktopSnapshot: syncPlaylistToSnapshot(conflict.remote), + })), + consumedResolutions, + }); + } catch { + // The desktop just misses the conflict mirror; sync itself succeeded. + } + } + + return summary; +} + +async function runDesktopSyncOnce(): Promise<{ + summary: DesktopSyncSummary; + pendingResolutions: DesktopSyncPendingResolution[]; +}> { + const startedAt = Date.now(); + const connection = await getDesktopRemoteConnection(); + const token = await getDesktopRemoteToken(); + if (!connection || !token) { + throw new Error('No paired desktop.'); + } + + // The stored protocolVersion predates any desktop upgrade — re-check live and + // persist the refreshed value before gating. + const identity = await fetchDesktopRemoteIdentity(connection.baseUrl); + if (!identity) { + throw new Error('Desktop is unreachable.'); + } + if (identity.protocolVersion !== connection.protocolVersion) { + await setDesktopRemoteConnection({ ...connection, protocolVersion: identity.protocolVersion }); + } + if (identity.protocolVersion < DESKTOP_SYNC_MIN_PROTOCOL_VERSION) { + throw new DesktopSyncUnsupportedError(); + } + + const db = await openLibraryDb(); + await ensurePlaylistSyncUids(db); + const index = buildImportIndex(await getAllTracks(db)); + await resolvePendingFavorites(db, index); + const local = await getLocalSyncState(db); + const remote = await fetchDesktopSyncState(connection.baseUrl, token); + if (remote.syncFormat !== DESKTOP_SYNC_FORMAT) { + throw new DesktopSyncUnsupportedError(); + } + if (Math.abs(remote.now - Date.now()) > CLOCK_SKEW_WARN_MS) { + console.warn( + `Desktop sync: clock skew of ${Math.round(Math.abs(remote.now - Date.now()) / 1000)}s detected; ` + + 'last-writer-wins conflict resolution may pick the wrong side.' + ); + } + + const payload: DesktopSyncApplyPayload = { + syncFormat: DESKTOP_SYNC_FORMAT, + favoriteAdds: [], + favoriteRemoves: [], + playlistUpserts: [], + playlistDeletes: [], + }; + const summary: DesktopSyncSummary = { + favoritesAdded: 0, + favoritesRemoved: 0, + favoritesPending: 0, + playlistsCreated: 0, + playlistsReplaced: 0, + playlistsDeleted: 0, + playlistsSkipped: 0, + entriesFallback: 0, + pushedToDesktop: false, + conflicts: [], + startedAt, + finishedAt: startedAt, + }; + const baselines = await getPlaylistSyncBaselines(db); + // Baselines are recorded only for playlists that END this run in sync; + // push-dependent ones wait for the desktop's per-playlist apply result. + const baselinePlans: { uid: string; localUpdatedAt: number; remoteUpdatedAt: number; afterPush: boolean }[] = []; + + const remoteFavByKey = new Map(remote.favorites.map((favorite) => [favorite.key, favorite])); + const remoteFavTombByKey = new Map(remote.favoriteTombstones.map((tomb) => [tomb.key, tomb.deletedAt])); + const remoteByUid = new Map(remote.playlists.map((playlist) => [playlist.syncUid, playlist])); + const remoteTombByUid = new Map(remote.playlistTombstones.map((tomb) => [tomb.syncUid, tomb.deletedAt])); + + await db.transaction(async (tx) => { + // ── Favorites ──────────────────────────────────────────────────────────── + const favoriteKeys = new Set([ + ...local.favorites.keys(), + ...local.favoriteTombstones.keys(), + ...remoteFavByKey.keys(), + ...remoteFavTombByKey.keys(), + ]); + for (const key of favoriteKeys) { + const localFav = local.favorites.get(key) ?? null; + const remoteFav = remoteFavByKey.get(key) ?? null; + const localTombAt = local.favoriteTombstones.get(key) ?? null; + const bestAdd = newestFavorite(localFav, remoteFav); + const bestDelAt = maxTimestamp(localTombAt, remoteFavTombByKey.get(key) ?? null); + // Tie between an add and a delete keeps the favorite (deterministic on + // both sides). + const present = bestAdd !== null && (bestDelAt === null || bestAdd.addedAt >= bestDelAt); + + if (present) { + if (localTombAt !== null) { + await removeFavoriteTombstone(tx, key); + } + if (!localFav || localFav.pending) { + const match = matchSyncEntry( + { title: bestAdd.title, artist: bestAdd.artist, album: bestAdd.album }, + index + ); + if (match.kind === 'matched') { + await applySyncedFavoriteAdd(tx, match.track.path, key, bestAdd.addedAt); + summary.favoritesAdded += 1; + } else if (!localFav || localFav.addedAt < bestAdd.addedAt) { + await upsertPendingFavorite(tx, { + key, + title: bestAdd.title, + artist: bestAdd.artist, + album: bestAdd.album, + addedAt: bestAdd.addedAt, + }); + if (!localFav) summary.favoritesPending += 1; + } + } + if (!remoteFav) { + payload.favoriteAdds.push({ + key, + title: bestAdd.title, + artist: bestAdd.artist, + album: bestAdd.album, + addedAt: bestAdd.addedAt, + }); + } + } else if (bestDelAt !== null) { + if (localFav) { + await applySyncedFavoriteRemove(tx, localFav.trackPaths, key, bestDelAt); + if (!localFav.pending) summary.favoritesRemoved += 1; + } else if (localTombAt === null || localTombAt < bestDelAt) { + // Record the peer's tombstone locally so the merge stays + // deterministic even if the desktop ever loses its copy. + await applySyncedFavoriteRemove(tx, [], key, bestDelAt); + } + if (remoteFav) { + payload.favoriteRemoves.push({ key, deletedAt: bestDelAt }); + } + } + } + + // ── Playlists ──────────────────────────────────────────────────────────── + const localByUid = new Map(local.playlists.map((playlist) => [playlist.syncUid, playlist])); + const skippedConflictUids = new Set(); + + const localContentsFor = async (row: LocalSyncPlaylist): Promise => + row.kind === 'normal' ? getSyncPlaylistEntries(tx, row.id) : []; + + const contentsMatch = async ( + localRow: LocalSyncPlaylist, + remoteRow: SyncPlaylist, + localEntries: SyncPlaylistEntry[] + ): Promise => { + if (localRow.kind !== remoteRow.kind) return false; + if (localRow.kind === 'dynamic') { + return dynamicRulesEqual(localRow.dynamicRules, remoteRow.dynamicRules); + } + return playlistEntriesEqual(localEntries, remoteRow.entries ?? []); + }; + + const buildConflict = ( + kind: DesktopSyncPlaylistConflict['kind'], + localRow: LocalSyncPlaylist, + remoteRow: SyncPlaylist, + localEntries: SyncPlaylistEntry[] + ): DesktopSyncPlaylistConflict => { + const localSnapshot: SyncPlaylist = { + syncUid: localRow.syncUid, + name: localRow.name, + kind: localRow.kind, + dynamicRules: localRow.kind === 'dynamic' ? localRow.dynamicRules : null, + createdAt: localRow.createdAt, + updatedAt: localRow.updatedAt, + entries: localRow.kind === 'normal' ? localEntries : null, + }; + return { + kind, + syncUid: remoteRow.syncUid, + localPlaylistId: localRow.id, + localSyncUid: localRow.syncUid, + name: localRow.name, + playlistKind: localRow.kind === 'normal' && remoteRow.kind === 'normal' ? 'normal' : 'dynamic', + localName: localRow.name, + remoteName: remoteRow.name, + localUpdatedAt: localRow.updatedAt, + remoteUpdatedAt: remoteRow.updatedAt, + localTrackCount: localSnapshot.entries?.length ?? 0, + remoteTrackCount: remoteRow.entries?.length ?? 0, + local: localSnapshot, + remote: remoteRow, + }; + }; + + // First sync of a playlist that exists on both sides under different uids: + // pair case-insensitively by name and adopt the DESKTOP uid (identity + // adoption is not an edit — updated_at stays put). Pairing only happens + // automatically when the contents already match; divergent same-named + // lists become a first-pairing conflict and BOTH copies are left alone. + for (const remotePlaylist of remote.playlists) { + if (localByUid.has(remotePlaylist.syncUid)) continue; + if (local.playlistTombstones.has(remotePlaylist.syncUid)) continue; + const nameKey = normalizeSyncKeyPart(remotePlaylist.name); + if (!nameKey) continue; + let paired: LocalSyncPlaylist | null = null; + for (const candidate of local.playlists) { + if (candidate.syncUid === remotePlaylist.syncUid) continue; + if (remoteByUid.has(candidate.syncUid) || remoteTombByUid.has(candidate.syncUid)) continue; + if (normalizeSyncKeyPart(candidate.name) !== nameKey) continue; + if (paired === null || candidate.id < paired.id) paired = candidate; + } + if (!paired) continue; + const pairedEntries = await localContentsFor(paired); + if (await contentsMatch(paired, remotePlaylist, pairedEntries)) { + await adoptPlaylistSyncUid(tx, paired.id, remotePlaylist.syncUid); + localByUid.delete(paired.syncUid); + paired.syncUid = remotePlaylist.syncUid; + localByUid.set(remotePlaylist.syncUid, paired); + } else { + summary.conflicts.push( + buildConflict('first-pairing', paired, remotePlaylist, pairedEntries) + ); + skippedConflictUids.add(remotePlaylist.syncUid); + skippedConflictUids.add(paired.syncUid); + } + } + + const playlistUids = new Set([ + ...localByUid.keys(), + ...remoteByUid.keys(), + ...local.playlistTombstones.keys(), + ...remoteTombByUid.keys(), + ]); + for (const uid of playlistUids) { + if (skippedConflictUids.has(uid)) continue; + const localRow = localByUid.get(uid) ?? null; + const remoteRow = remoteByUid.get(uid) ?? null; + const localTombAt = local.playlistTombstones.get(uid) ?? null; + const bestTombAt = maxTimestamp(localTombAt, remoteTombByUid.get(uid) ?? null); + const bestRowAt = maxTimestamp(localRow?.updatedAt ?? null, remoteRow?.updatedAt ?? null); + + // Deletion wins only when strictly newer than the newest edit. + if (bestTombAt !== null && (bestRowAt === null || bestTombAt > bestRowAt)) { + if (localRow) { + await applySyncedPlaylistDelete(tx, uid, bestTombAt); + summary.playlistsDeleted += 1; + } else if (localTombAt === null || localTombAt < bestTombAt) { + await applySyncedPlaylistDelete(tx, uid, bestTombAt); + } + await deletePlaylistSyncBaseline(tx, uid); + if (remoteRow) { + payload.playlistDeletes.push({ syncUid: uid, deletedAt: bestTombAt }); + } + continue; + } + + const pushLocal = async (row: LocalSyncPlaylist) => { + if (localTombAt !== null) { + await removePlaylistTombstone(tx, uid); + } + payload.playlistUpserts.push({ + syncUid: uid, + name: row.name, + kind: row.kind, + dynamicRules: row.dynamicRules, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + entries: row.kind === 'normal' ? await getSyncPlaylistEntries(tx, row.id) : null, + } satisfies SyncPlaylist); + baselinePlans.push({ + uid, + localUpdatedAt: row.updatedAt, + remoteUpdatedAt: row.updatedAt, + afterPush: true, + }); + }; + + const applyRemote = async (row: SyncPlaylist) => { + const result = await replaceSyncedPlaylist(tx, row, index); + if (result.status === 'created') summary.playlistsCreated += 1; + else if (result.status === 'replaced') summary.playlistsReplaced += 1; + else summary.playlistsSkipped += 1; + summary.entriesFallback += result.entriesFallback; + if (result.status !== 'skipped-incompatible') { + baselinePlans.push({ + uid, + localUpdatedAt: row.updatedAt, + remoteUpdatedAt: row.updatedAt, + afterPush: false, + }); + } + }; + + // With a baseline, sync direction comes from WHICH side changed since + // the last run — the clock only matters when both did (conflict). + const baseline = baselines.get(uid) ?? null; + if (baseline && localRow && remoteRow) { + const localChanged = localRow.updatedAt !== baseline.localUpdatedAt; + const remoteChanged = remoteRow.updatedAt !== baseline.remoteUpdatedAt; + if (localChanged && remoteChanged) { + const localEntries = await localContentsFor(localRow); + const trivial = + localRow.name.trim() === remoteRow.name.trim() && + (await contentsMatch(localRow, remoteRow, localEntries)); + if (trivial) { + // Both sides moved to the same result (e.g. identical edits) — + // just advance the baseline. + baselinePlans.push({ + uid, + localUpdatedAt: localRow.updatedAt, + remoteUpdatedAt: remoteRow.updatedAt, + afterPush: false, + }); + } else { + summary.conflicts.push( + buildConflict('concurrent-edit', localRow, remoteRow, localEntries) + ); + } + continue; + } + if (remoteChanged) { + await applyRemote(remoteRow); + continue; + } + if (localChanged) { + await pushLocal(localRow); + continue; + } + continue; // Neither side moved. + } + + // No baseline yet (first sync of this pair): timestamp last-writer-wins. + if (remoteRow && (localRow === null || localRow.updatedAt < remoteRow.updatedAt)) { + await applyRemote(remoteRow); + continue; + } + if (localRow && (remoteRow === null || remoteRow.updatedAt < localRow.updatedAt)) { + await pushLocal(localRow); + continue; + } + if (localRow && remoteRow) { + // Equal timestamps on both sides: in sync — record the first baseline. + baselinePlans.push({ + uid, + localUpdatedAt: localRow.updatedAt, + remoteUpdatedAt: remoteRow.updatedAt, + afterPush: false, + }); + } + } + }); + + // Baselines that don't depend on the push are valid as soon as the local + // transaction committed. + for (const plan of baselinePlans) { + if (plan.afterPush) continue; + const existing = baselines.get(plan.uid); + if ( + existing && + existing.localUpdatedAt === plan.localUpdatedAt && + existing.remoteUpdatedAt === plan.remoteUpdatedAt + ) { + continue; + } + await upsertPlaylistSyncBaseline(db, plan.uid, plan.localUpdatedAt, plan.remoteUpdatedAt); + } + + const hasDiff = + payload.favoriteAdds.length > 0 || + payload.favoriteRemoves.length > 0 || + payload.playlistUpserts.length > 0 || + payload.playlistDeletes.length > 0; + if (hasDiff) { + const result = await postDesktopSyncApply(connection.baseUrl, token, payload); + summary.pushedToDesktop = true; + summary.favoritesAdded += result.favorites.added; + summary.favoritesPending += result.favorites.pending; + summary.favoritesRemoved += result.favorites.removed; + const pushStatusByUid = new Map(result.playlists.map((entry) => [entry.syncUid, entry.status])); + for (const playlistResult of result.playlists) { + if (playlistResult.status === 'created') summary.playlistsCreated += 1; + else if (playlistResult.status === 'replaced') summary.playlistsReplaced += 1; + else if (playlistResult.status === 'deleted') summary.playlistsDeleted += 1; + else summary.playlistsSkipped += 1; + summary.entriesFallback += playlistResult.entriesFallback; + } + // Push-dependent baselines only count once the desktop confirmed the + // upsert; a failed/skipped push re-syncs naturally next run. + for (const plan of baselinePlans) { + if (!plan.afterPush) continue; + const status = pushStatusByUid.get(plan.uid); + if (status !== 'created' && status !== 'replaced') continue; + await upsertPlaylistSyncBaseline(db, plan.uid, plan.localUpdatedAt, plan.remoteUpdatedAt); + } + } + + await setSetting(db, desktopSyncSettingKey(connection), String(Date.now())); + await usePlaylistStore.getState().refresh(); + + summary.finishedAt = Date.now(); + return { summary, pendingResolutions: sanitizePendingResolutions(remote.pendingResolutions) }; +} + +/** + * Applies the user's choice for one sync conflict LOCALLY (no network): + * either adjusting the sync baseline so the next run pulls/pushes the chosen + * side, or restructuring the local copies for keep-both/merge. The caller + * should run a sync afterwards to settle both devices; if the desktop moved + * again in the meantime the conflict legitimately re-surfaces. + */ +export async function applyDesktopSyncConflictResolution( + conflict: DesktopSyncPlaylistConflict, + resolution: DesktopSyncConflictResolution +): Promise { + const db = await openLibraryDb(); + const currentRow = await db.get<{ updated_at: number; name: string }>( + 'SELECT updated_at, name FROM playlists WHERE id = ?', + [conflict.localPlaylistId] + ); + if (!currentRow) { + // The local copy vanished since detection — nothing to choose between; + // the next sync settles whatever remains. + return; + } + + switch (resolution) { + case 'desktop': { + if (conflict.kind === 'first-pairing') { + await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid); + } + // Local reads as unchanged, remote as changed → next run pulls desktop. + await upsertPlaylistSyncBaseline(db, conflict.syncUid, currentRow.updated_at, 0); + break; + } + case 'phone': { + if (conflict.kind === 'first-pairing') { + await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid); + } + // Remote reads as unchanged (as of detection), local as changed → next + // run pushes the phone copy. + await upsertPlaylistSyncBaseline(db, conflict.syncUid, 0, conflict.remoteUpdatedAt); + break; + } + case 'both': { + const copyName = `${currentRow.name} (Phone)`; + if (conflict.kind === 'first-pairing') { + // Rename the local copy out of the collision; both lists then sync as + // independent playlists. + await renamePlaylist(db, conflict.localPlaylistId, copyName); + } else { + // Duplicate the local version under a fresh identity, then let the + // shared uid take the desktop version. + await clonePlaylistAsLocalCopy(db, conflict.localPlaylistId, copyName); + await upsertPlaylistSyncBaseline(db, conflict.syncUid, currentRow.updated_at, 0); + } + break; + } + case 'merge': { + if (conflict.playlistKind !== 'normal') { + throw new Error('Dynamic playlists cannot be merged — keep one side instead.'); + } + const localEntries = await getSyncPlaylistEntries(db, conflict.localPlaylistId); + const remoteEntries = conflict.remote.entries ?? []; + const localIsNewer = conflict.localUpdatedAt >= conflict.remoteUpdatedAt; + const merged = mergePlaylistEntries( + localIsNewer ? localEntries : remoteEntries, + localIsNewer ? remoteEntries : localEntries + ); + if (conflict.kind === 'first-pairing') { + await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid); + } + const index = buildImportIndex(await getAllTracks(db)); + await replaceSyncedPlaylist( + db, + { + syncUid: conflict.syncUid, + name: localIsNewer ? currentRow.name : conflict.remote.name, + kind: 'normal', + dynamicRules: null, + createdAt: conflict.remote.createdAt, + updatedAt: Date.now(), + entries: merged, + }, + index + ); + // The merged list is a fresh local edit → next run pushes it. + await upsertPlaylistSyncBaseline(db, conflict.syncUid, 0, conflict.remoteUpdatedAt); + break; + } + } + + await usePlaylistStore.getState().refresh(); +} diff --git a/src/services/desktopSyncPlaylistMerge.test.mts b/src/services/desktopSyncPlaylistMerge.test.mts new file mode 100644 index 0000000..fe5ea32 --- /dev/null +++ b/src/services/desktopSyncPlaylistMerge.test.mts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + mergePlaylistEntries, + playlistEntriesEqual, +} from './desktopSyncPlaylistMerge.ts'; +import type { SyncPlaylistEntry } from '../types/desktopSync.ts'; + +function entry(title: string, position: number, overrides: Partial = {}): SyncPlaylistEntry { + return { + title, + artist: 'Artist', + album: 'Album', + durationSeconds: 200, + position, + addedAt: 1_000 + position, + sourcePath: null, + ...overrides, + }; +} + +test('equal contents in the same order compare equal despite case/whitespace', () => { + const a = [entry('One', 0), entry('Two', 1)]; + const b = [entry(' one ', 0), entry('TWO', 1)]; + assert.equal(playlistEntriesEqual(a, b), true); +}); + +test('same songs in a different order are NOT equal (order is content)', () => { + const a = [entry('One', 0), entry('Two', 1)]; + const b = [entry('Two', 0), entry('One', 1)]; + assert.equal(playlistEntriesEqual(a, b), false); +}); + +test('different lengths are not equal', () => { + assert.equal(playlistEntriesEqual([entry('One', 0)], []), false); +}); + +test('merge keeps the newer order first and appends the older side extras', () => { + const newer = [entry('A', 0), entry('B', 1), entry('C', 2)]; + const older = [entry('B', 0), entry('X', 1), entry('A', 2), entry('Y', 3)]; + const merged = mergePlaylistEntries(newer, older); + assert.deepEqual( + merged.map((e) => e.title), + ['A', 'B', 'C', 'X', 'Y'] + ); + assert.deepEqual( + merged.map((e) => e.position), + [0, 1, 2, 3, 4] + ); +}); + +test('merged entries keep their origin metadata', () => { + const newer = [entry('A', 0, { addedAt: 111 })]; + const older = [entry('Z', 0, { addedAt: 999, sourcePath: 'D:/z.flac' })]; + const merged = mergePlaylistEntries(newer, older); + assert.equal(merged[1].addedAt, 999); + assert.equal(merged[1].sourcePath, 'D:/z.flac'); +}); + +test('merge respects stored positions, not array order', () => { + const newer = [entry('B', 1), entry('A', 0)]; + const merged = mergePlaylistEntries(newer, []); + assert.deepEqual( + merged.map((e) => e.title), + ['A', 'B'] + ); +}); diff --git a/src/services/desktopSyncPlaylistMerge.ts b/src/services/desktopSyncPlaylistMerge.ts new file mode 100644 index 0000000..0d96497 --- /dev/null +++ b/src/services/desktopSyncPlaylistMerge.ts @@ -0,0 +1,50 @@ +// Pure playlist content comparison + union merge for desktop sync conflicts +// (node-testable, no RN imports). Entries are compared by the shared metadata +// identity key — the same identity favorites sync on. +// Runtime imports are relative with explicit .ts so this runs under +// `node --test`; type-only '@/' imports are erased by strip-types. + +import type { SyncPlaylistEntry } from '@/types/desktopSync'; +import { buildTrackSyncKey } from '../shared/sync/identity.ts'; + +export function playlistEntryKey( + entry: Pick +): string { + return buildTrackSyncKey(entry.title, entry.artist, entry.album); +} + +/** Same songs in the same order (by metadata identity). */ +export function playlistEntriesEqual(a: SyncPlaylistEntry[], b: SyncPlaylistEntry[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (playlistEntryKey(a[i]) !== playlistEntryKey(b[i])) return false; + } + return true; +} + +/** + * Union merge: the newer side's entries keep their order, then the older + * side's entries whose identity isn't already present are appended (in their + * own order). Positions are renumbered; each entry keeps its origin metadata + * (addedAt, sourcePath, duration). + */ +export function mergePlaylistEntries( + newer: SyncPlaylistEntry[], + older: SyncPlaylistEntry[] +): SyncPlaylistEntry[] { + const merged: SyncPlaylistEntry[] = []; + const seenKeys = new Set(); + for (const entry of [...newer].sort((a, b) => a.position - b.position)) { + const key = playlistEntryKey(entry); + if (seenKeys.has(key)) continue; + seenKeys.add(key); + merged.push({ ...entry, position: merged.length }); + } + for (const entry of [...older].sort((a, b) => a.position - b.position)) { + const key = playlistEntryKey(entry); + if (seenKeys.has(key)) continue; + seenKeys.add(key); + merged.push({ ...entry, position: merged.length }); + } + return merged; +} diff --git a/src/shared/sync/conflictPreview.test.mts b/src/shared/sync/conflictPreview.test.mts new file mode 100644 index 0000000..fca8b99 --- /dev/null +++ b/src/shared/sync/conflictPreview.test.mts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildSyncConflictResolutionPreview, + buildSyncPlaylistEntryDiff, + mergePlaylistEntriesForPreview, + syncPlaylistToSnapshot, +} from './conflictPreview.ts'; +import type { SyncPlaylist, SyncPlaylistEntry } from '../../types/desktopSync.ts'; + +function entry(title: string, position: number, overrides: Partial = {}): SyncPlaylistEntry { + return { + title, + artist: 'Artist', + album: 'Album', + durationSeconds: 180, + position, + addedAt: 1_000 + position, + sourcePath: null, + ...overrides, + }; +} + +function playlist(overrides: Partial = {}): SyncPlaylist { + return { + syncUid: 'uid', + name: 'Playlist', + kind: 'normal', + dynamicRules: null, + createdAt: 1, + updatedAt: 2, + entries: [], + ...overrides, + }; +} + +test('entry diff reports moved and side-only tracks', () => { + const diff = buildSyncPlaylistEntryDiff( + [entry('A', 0), entry('B', 1), entry('Desktop only', 2)], + [entry('B', 0), entry('A', 1), entry('Phone only', 2)] + ); + + assert.equal(diff.movedCount, 2); + assert.equal(diff.desktopOnlyCount, 1); + assert.equal(diff.phoneOnlyCount, 1); + assert.deepEqual( + diff.rows.map((row) => row.status), + ['moved', 'moved', 'desktop-only', 'phone-only'] + ); +}); + +test('entry diff treats duplicate occurrences independently', () => { + const diff = buildSyncPlaylistEntryDiff( + [entry('A', 0), entry('A', 1)], + [entry('A', 0)] + ); + + assert.equal(diff.sameCount, 1); + assert.equal(diff.desktopOnlyCount, 1); +}); + +test('merge preview keeps newer order and appends missing older tracks', () => { + const merged = mergePlaylistEntriesForPreview( + [entry('B', 1), entry('A', 0)], + [entry('A', 0), entry('C', 1)] + ); + + assert.deepEqual( + merged.map((item) => item.title), + ['A', 'B', 'C'] + ); + assert.deepEqual( + merged.map((item) => item.position), + [0, 1, 2] + ); +}); + +test('resolution preview uses newer side for merge result name', () => { + const desktop = syncPlaylistToSnapshot(playlist({ + name: 'Desktop Mix', + updatedAt: 20, + entries: [entry('Desktop Track', 0)], + })); + const phone = syncPlaylistToSnapshot(playlist({ + name: 'Phone Mix', + updatedAt: 10, + entries: [entry('Phone Track', 0)], + })); + + const preview = buildSyncConflictResolutionPreview('merge', desktop, phone); + assert.equal(preview.resultName, 'Desktop Mix'); + assert.equal(preview.resultTrackCount, 2); +}); diff --git a/src/shared/sync/conflictPreview.ts b/src/shared/sync/conflictPreview.ts new file mode 100644 index 0000000..3b7d145 --- /dev/null +++ b/src/shared/sync/conflictPreview.ts @@ -0,0 +1,194 @@ +import type { + DesktopSyncConflictResolution, + SyncPlaylist, + SyncPlaylistEntry, + SyncPlaylistSnapshot, +} from '../../types/desktopSync'; +import { buildTrackSyncKey } from './identity.ts'; + +export type SyncPlaylistEntryDiffStatus = 'same' | 'moved' | 'desktop-only' | 'phone-only'; + +export interface SyncPlaylistEntryDiff { + key: string; + title: string; + artist: string; + album: string; + desktopIndex: number | null; + phoneIndex: number | null; + status: SyncPlaylistEntryDiffStatus; +} + +export interface SyncPlaylistEntryDiffSummary { + rows: SyncPlaylistEntryDiff[]; + sameCount: number; + movedCount: number; + desktopOnlyCount: number; + phoneOnlyCount: number; +} + +export interface SyncConflictResolutionPreview { + resolution: DesktopSyncConflictResolution; + title: string; + detail: string; + resultName: string; + resultTrackCount: number | null; + mergedEntries: SyncPlaylistEntry[] | null; +} + +export function syncPlaylistToSnapshot(playlist: Pick): SyncPlaylistSnapshot { + const entries = playlist.kind === 'normal' ? playlist.entries ?? [] : null; + return { + name: playlist.name, + kind: playlist.kind, + dynamicRules: playlist.kind === 'dynamic' ? playlist.dynamicRules : null, + updatedAt: playlist.updatedAt, + trackCount: entries?.length ?? 0, + entries, + }; +} + +function orderedEntries(entries: readonly SyncPlaylistEntry[] | null | undefined): SyncPlaylistEntry[] { + return [...(entries ?? [])].sort((left, right) => left.position - right.position); +} + +function entryIdentity(entry: Pick): string { + return buildTrackSyncKey(entry.title, entry.artist, entry.album); +} + +function occurrenceRows(entries: readonly SyncPlaylistEntry[] | null | undefined) { + const counts = new Map(); + return orderedEntries(entries).map((entry, index) => { + const identity = entryIdentity(entry); + const occurrence = counts.get(identity) ?? 0; + counts.set(identity, occurrence + 1); + return { + key: `${identity}\u0000${occurrence}`, + index, + entry, + }; + }); +} + +export function buildSyncPlaylistEntryDiff( + desktopEntries: readonly SyncPlaylistEntry[] | null | undefined, + phoneEntries: readonly SyncPlaylistEntry[] | null | undefined +): SyncPlaylistEntryDiffSummary { + const desktop = occurrenceRows(desktopEntries); + const phone = occurrenceRows(phoneEntries); + const desktopByKey = new Map(desktop.map((row) => [row.key, row])); + const phoneByKey = new Map(phone.map((row) => [row.key, row])); + const keys = new Set([...desktopByKey.keys(), ...phoneByKey.keys()]); + const rows: SyncPlaylistEntryDiff[] = []; + + for (const key of keys) { + const desktopRow = desktopByKey.get(key) ?? null; + const phoneRow = phoneByKey.get(key) ?? null; + const source = desktopRow?.entry ?? phoneRow?.entry; + if (!source) continue; + const desktopIndex = desktopRow ? desktopRow.index : null; + const phoneIndex = phoneRow ? phoneRow.index : null; + let status: SyncPlaylistEntryDiffStatus = 'same'; + if (!desktopRow) status = 'phone-only'; + else if (!phoneRow) status = 'desktop-only'; + else if (desktopIndex !== phoneIndex) status = 'moved'; + rows.push({ + key, + title: source.title, + artist: source.artist, + album: source.album, + desktopIndex, + phoneIndex, + status, + }); + } + + rows.sort((left, right) => { + const leftIndex = Math.min(left.desktopIndex ?? Number.MAX_SAFE_INTEGER, left.phoneIndex ?? Number.MAX_SAFE_INTEGER); + const rightIndex = Math.min(right.desktopIndex ?? Number.MAX_SAFE_INTEGER, right.phoneIndex ?? Number.MAX_SAFE_INTEGER); + return leftIndex - rightIndex; + }); + + return { + rows, + sameCount: rows.filter((row) => row.status === 'same').length, + movedCount: rows.filter((row) => row.status === 'moved').length, + desktopOnlyCount: rows.filter((row) => row.status === 'desktop-only').length, + phoneOnlyCount: rows.filter((row) => row.status === 'phone-only').length, + }; +} + +export function mergePlaylistEntriesForPreview( + newer: readonly SyncPlaylistEntry[] | null | undefined, + older: readonly SyncPlaylistEntry[] | null | undefined +): SyncPlaylistEntry[] { + const merged: SyncPlaylistEntry[] = []; + const seenKeys = new Set(); + for (const entry of [...orderedEntries(newer), ...orderedEntries(older)]) { + const key = entryIdentity(entry); + if (seenKeys.has(key)) continue; + seenKeys.add(key); + merged.push({ ...entry, position: merged.length }); + } + return merged; +} + +export function buildSyncConflictResolutionPreview( + resolution: DesktopSyncConflictResolution, + desktop: SyncPlaylistSnapshot, + phone: SyncPlaylistSnapshot +): SyncConflictResolutionPreview { + switch (resolution) { + case 'desktop': + return { + resolution, + title: 'Keep desktop', + detail: 'The desktop version will replace the phone copy on the next sync.', + resultName: desktop.name, + resultTrackCount: desktop.kind === 'normal' ? desktop.trackCount : null, + mergedEntries: null, + }; + case 'phone': + return { + resolution, + title: 'Keep phone', + detail: 'The phone version will replace the desktop copy on the next sync.', + resultName: phone.name, + resultTrackCount: phone.kind === 'normal' ? phone.trackCount : null, + mergedEntries: null, + }; + case 'both': + return { + resolution, + title: 'Keep both', + detail: 'Both versions will remain as separate playlists after the next sync.', + resultName: phone.name, + resultTrackCount: phone.kind === 'normal' ? phone.trackCount + desktop.trackCount : null, + mergedEntries: null, + }; + case 'merge': { + if (desktop.kind !== 'normal' || phone.kind !== 'normal') { + return { + resolution, + title: 'Merge unavailable', + detail: 'Dynamic playlists cannot be merged. Keep one side or keep both instead.', + resultName: phone.name, + resultTrackCount: null, + mergedEntries: null, + }; + } + const phoneIsNewer = phone.updatedAt >= desktop.updatedAt; + const mergedEntries = mergePlaylistEntriesForPreview( + phoneIsNewer ? phone.entries : desktop.entries, + phoneIsNewer ? desktop.entries : phone.entries + ); + return { + resolution, + title: 'Merge', + detail: 'The newer order stays first, then missing songs from the other side are appended.', + resultName: phoneIsNewer ? phone.name : desktop.name, + resultTrackCount: mergedEntries.length, + mergedEntries, + }; + } + } +} diff --git a/src/shared/sync/identity.ts b/src/shared/sync/identity.ts new file mode 100644 index 0000000..1757ebd --- /dev/null +++ b/src/shared/sync/identity.ts @@ -0,0 +1,21 @@ +// Cross-device track identity for desktop<->mobile LAN sync. Track paths never +// match across devices (desktop filesystem vs Android SAF content:// URIs), so +// favorites and playlist entries travel as normalized metadata keys and each +// side resolves them against its own library. Normalization mirrors library.ts +// normalizeKey (whitespace-collapse + locale lowercase). +// This file is ported verbatim to astra-mobile/src/shared/sync/identity.ts — +// keep the two copies identical. + +export const TRACK_SYNC_KEY_SEPARATOR = '\u001f' + +export function normalizeSyncKeyPart(value: string): string { + return value.replace(/\s+/g, ' ').trim().toLocaleLowerCase() +} + +export function buildTrackSyncKey(title: string, artist: string, album: string): string { + return [ + normalizeSyncKeyPart(title), + normalizeSyncKeyPart(artist), + normalizeSyncKeyPart(album) + ].join(TRACK_SYNC_KEY_SEPARATOR) +} diff --git a/src/stores/desktopRemoteStore.ts b/src/stores/desktopRemoteStore.ts index 72789d2..c37320a 100644 --- a/src/stores/desktopRemoteStore.ts +++ b/src/stores/desktopRemoteStore.ts @@ -11,10 +11,12 @@ import { fetchDesktopRemoteIdentity, fetchDesktopRemoteNowPlaying, fetchDesktopRemotePairingStatus, + fetchDesktopRemoteQueue, parseDesktopRemoteManualInput, parseDesktopRemotePairingInput, requestDesktopRemotePinPairing, sendDesktopRemoteControl, + sendDesktopRemotePlayQueueItem, startDesktopRemoteEventStream, } from '@/services/desktopRemoteClient'; import { normalizeDesktopRemotePinInput } from '@/services/desktopRemotePairing'; @@ -25,12 +27,16 @@ import { setDesktopRemoteConnection, setDesktopRemoteToken, } from '@/services/desktopRemoteCredentials'; +import { openLibraryDb } from '@/db/database'; +import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries'; +import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import type { DesktopRemoteConnection, DesktopRemoteControlCommand, DesktopRemoteDiscoveredDesktop, DesktopRemoteIdentity, DesktopRemoteNowPlayingSnapshot, + DesktopRemoteQueueSnapshot, } from '@/types/desktopRemote'; const PAIR_POLL_INTERVAL_MS = 1500; @@ -66,6 +72,8 @@ interface DesktopRemoteStore { connection: DesktopRemoteConnection | null; token: string | null; snapshot: DesktopRemoteNowPlayingSnapshot | null; + /** Desktop queue (current + upcoming); null on protocol-1 desktops. */ + queue: DesktopRemoteQueueSnapshot | null; discovered: DesktopRemoteDiscoveredDesktop[]; discoveryAvailable: boolean; discoveryRunning: boolean; @@ -86,6 +94,8 @@ interface DesktopRemoteStore { disconnect: () => void; forget: () => Promise; sendControl: (command: DesktopRemoteControlCommand, time?: number) => Promise; + refreshQueue: () => Promise; + playQueueItem: (queueId: string) => Promise; } let pairingPollTimer: ReturnType | null = null; @@ -210,11 +220,15 @@ export const useDesktopRemoteStore = create((set, get) => { if (!connection || !token || connectionState === 'connecting') return; void fetchDesktopRemoteNowPlaying(connection.baseUrl, token).then( (snapshot) => { + const wasConnected = get().connectionState === 'connected'; set((state) => ({ snapshot: mergeSnapshotArtwork(state.snapshot, snapshot), connectionState: 'connected', errorMessage: '', })); + if (!wasConnected) { + useDesktopSyncStore.getState().maybeAutoSync('connected'); + } refreshInlineArtwork(); }, (error) => { @@ -434,6 +448,7 @@ export const useDesktopRemoteStore = create((set, get) => { connection: null, token: null, snapshot: null, + queue: null, discovered: [], discoveryAvailable: desktopRemoteDiscoveryAvailable, discoveryRunning: false, @@ -536,16 +551,27 @@ export const useDesktopRemoteStore = create((set, get) => { message: '', errorMessage: '', }); + useDesktopSyncStore.getState().maybeAutoSync('connected'); stopEventStream = startDesktopRemoteEventStream(connection.baseUrl, token, { onSnapshot: (nextSnapshot) => { + const wasConnected = get().connectionState === 'connected'; set((state) => ({ snapshot: mergeSnapshotArtwork(state.snapshot, nextSnapshot), connectionState: 'connected', message: '', errorMessage: '', })); + if (!wasConnected) { + useDesktopSyncStore.getState().maybeAutoSync('connected'); + } refreshInlineArtwork(); }, + onQueue: (queue) => { + set({ queue }); + }, + onSyncRequest: () => { + useDesktopSyncStore.getState().handleSyncRequest(); + }, onUnauthorized: () => { void get().forget(); set({ errorMessage: 'Desktop pairing was revoked.' }); @@ -556,6 +582,7 @@ export const useDesktopRemoteStore = create((set, get) => { }, }); scheduleSnapshotPoll(); + void get().refreshQueue(); return true; } catch (error) { if (error instanceof DesktopRemoteHttpError && error.status === 401) { @@ -580,18 +607,23 @@ export const useDesktopRemoteStore = create((set, get) => { disconnect: () => { stopRealtime(); clearPairingPoll(); - set({ connectionState: get().connection ? 'error' : 'unpaired', message: '', snapshot: null, pinPairing: null }); + set({ connectionState: get().connection ? 'error' : 'unpaired', message: '', snapshot: null, queue: null, pinPairing: null }); }, forget: async () => { stopRealtime(); clearPairingPoll(); await clearDesktopRemotePairing(); + // Sync baselines are meaningless against a different desktop. + void openLibraryDb() + .then((db) => clearPlaylistSyncBaselines(db)) + .catch(() => {}); set({ connectionState: 'unpaired', connection: null, token: null, snapshot: null, + queue: null, pairing: null, pinPairing: null, message: '', @@ -618,5 +650,32 @@ export const useDesktopRemoteStore = create((set, get) => { set({ errorMessage: errorMessage(error) }); } }, + + refreshQueue: async () => { + const { connection, token } = get(); + if (!connection || !token) return; + try { + const queue = await fetchDesktopRemoteQueue(connection.baseUrl, token); + set({ queue }); + } catch { + // Protocol-1 desktops 404 here; the queue UI simply stays hidden. + } + }, + + playQueueItem: async (queueId) => { + const { connection, token } = get(); + if (!connection || !token) return; + try { + await sendDesktopRemotePlayQueueItem(connection.baseUrl, token, queueId); + set({ errorMessage: '' }); + } catch (error) { + if (error instanceof DesktopRemoteHttpError && error.status === 401) { + await get().forget(); + set({ errorMessage: 'Desktop pairing was revoked.' }); + return; + } + set({ errorMessage: errorMessage(error) }); + } + }, }; }); diff --git a/src/stores/desktopSyncStore.ts b/src/stores/desktopSyncStore.ts new file mode 100644 index 0000000..e686f19 --- /dev/null +++ b/src/stores/desktopSyncStore.ts @@ -0,0 +1,199 @@ +// Desktop LAN sync status + triggers. The engine itself lives in +// src/services/desktopSync.ts; this store serializes runs (one at a time), +// debounces the automatic triggers, and exposes last-synced state for the UI. +// Module-scope timers (not component state) per the React Compiler rules. + +import { AppState } from 'react-native'; +import { create } from 'zustand'; +import { openLibraryDb } from '@/db/database'; +import { getSetting, setSetting } from '@/db/queries'; +import { + DesktopSyncUnsupportedError, + applyDesktopSyncConflictResolution, + desktopSyncSettingKey, + runDesktopSync, +} from '@/services/desktopSync'; +import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials'; +import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient'; +import type { + DesktopSyncConflictResolution, + DesktopSyncPlaylistConflict, + DesktopSyncSummary, +} from '@/types/desktopSync'; + +const AUTO_SYNC_DEBOUNCE_MS = 5_000; +const AUTO_SYNC_MIN_INTERVAL_MS = 15 * 60_000; +const AUTO_SYNC_SETTING_KEY = 'desktop_sync_auto'; + +export type DesktopSyncStatus = 'idle' | 'syncing' | 'error'; + +export type DesktopSyncAutoReason = 'discovery' | 'connected' | 'foreground'; + +interface DesktopSyncStore { + status: DesktopSyncStatus; + /** Wall-clock ms of the last successful sync with the paired desktop. */ + lastSyncAt: number | null; + lastSummary: DesktopSyncSummary | null; + /** Playlists the last run refused to resolve automatically. */ + conflicts: DesktopSyncPlaylistConflict[]; + /** True while a NEW conflict awaits its once-per-session popup + * (SyncConflictPrompt); set by syncNow, cleared on dismiss/resolve. */ + conflictPromptVisible: boolean; + errorMessage: string; + /** False once the paired desktop reported a pre-sync protocol version. */ + supported: boolean; + /** Automatic syncing (foreground/discovery). Manual + desktop-requested + * syncs run regardless. */ + autoSyncEnabled: boolean; + + hydrate: () => Promise; + syncNow: () => Promise; + dismissConflictPrompt: () => void; + setAutoSyncEnabled: (enabled: boolean) => Promise; + resolveConflict: ( + conflict: DesktopSyncPlaylistConflict, + resolution: DesktopSyncConflictResolution + ) => Promise; + maybeAutoSync: (reason: DesktopSyncAutoReason) => void; + /** Desktop clicked Sync Now (or resolved a conflict there) — sync promptly, + * bypassing the auto-sync interval limits. */ + handleSyncRequest: () => void; +} + +let autoSyncDebounceTimer: ReturnType | null = null; +// One popup per conflict per app session — dismissing must not re-nag on the +// next sync run re-detecting the same conflicts. +const promptedConflictUids = new Set(); + +export const useDesktopSyncStore = create((set, get) => ({ + status: 'idle', + lastSyncAt: null, + lastSummary: null, + conflicts: [], + conflictPromptVisible: false, + errorMessage: '', + supported: true, + autoSyncEnabled: true, + + hydrate: async () => { + try { + const db = await openLibraryDb(); + const autoSetting = await getSetting(db, AUTO_SYNC_SETTING_KEY); + if (autoSetting !== null) { + set({ autoSyncEnabled: autoSetting !== '0' }); + } + const connection = await getDesktopRemoteConnection(); + if (!connection) return; + const stored = await getSetting(db, desktopSyncSettingKey(connection)); + const lastSyncAt = stored ? Number(stored) : NaN; + if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) { + set({ lastSyncAt }); + } + } catch { + // Hydration is best-effort; the first sync will set lastSyncAt. + } + }, + + setAutoSyncEnabled: async (enabled) => { + set({ autoSyncEnabled: enabled }); + try { + const db = await openLibraryDb(); + await setSetting(db, AUTO_SYNC_SETTING_KEY, enabled ? '1' : '0'); + } catch { + // The in-memory value still applies for this session. + } + }, + + syncNow: async () => { + if (get().status === 'syncing') return; + set({ status: 'syncing', errorMessage: '' }); + try { + const summary = await runDesktopSync(); + let conflictPromptVisible = get().conflictPromptVisible; + if (summary.conflicts.length === 0) { + promptedConflictUids.clear(); + conflictPromptVisible = false; + } else if (summary.conflicts.some((conflict) => !promptedConflictUids.has(conflict.syncUid))) { + for (const conflict of summary.conflicts) { + promptedConflictUids.add(conflict.syncUid); + } + conflictPromptVisible = true; + } + set({ + status: 'idle', + lastSyncAt: summary.finishedAt, + lastSummary: summary, + conflicts: summary.conflicts, + conflictPromptVisible, + supported: true, + }); + } catch (error) { + set({ + status: 'error', + errorMessage: + error instanceof Error && error.message.trim() ? error.message : 'Desktop sync failed.', + supported: !(error instanceof DesktopSyncUnsupportedError), + }); + } + }, + + resolveConflict: async (conflict, resolution) => { + if (get().status === 'syncing') return; + try { + await applyDesktopSyncConflictResolution(conflict, resolution); + } catch (error) { + set({ + status: 'error', + errorMessage: + error instanceof Error && error.message.trim() + ? error.message + : 'Failed to resolve the sync conflict.', + }); + return; + } + // Drop it optimistically; the follow-up sync re-detects anything unsettled. + set((state) => ({ + conflicts: state.conflicts.filter((entry) => entry.syncUid !== conflict.syncUid), + })); + await get().syncNow(); + }, + + dismissConflictPrompt: () => { + set({ conflictPromptVisible: false }); + }, + + handleSyncRequest: () => { + const { status } = get(); + if (status === 'syncing') return; + void get().syncNow(); + }, + + maybeAutoSync: (reason) => { + const { status, lastSyncAt, supported, autoSyncEnabled } = get(); + if (!supported || !autoSyncEnabled) return; + if (status === 'syncing') return; + if (AppState.currentState !== 'active') return; + if (lastSyncAt !== null && Date.now() - lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return; + + // Discovery/connect events arrive in bursts — coalesce them. + if (autoSyncDebounceTimer !== null) clearTimeout(autoSyncDebounceTimer); + autoSyncDebounceTimer = setTimeout(() => { + autoSyncDebounceTimer = null; + void (async () => { + const state = useDesktopSyncStore.getState(); + if (state.status === 'syncing') return; + if (AppState.currentState !== 'active') return; + if (state.lastSyncAt !== null && Date.now() - state.lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return; + // Automatic triggers are speculative — probe reachability first so an + // off-LAN desktop doesn't surface an error banner from an attempt the + // user never asked for. + const connection = await getDesktopRemoteConnection(); + if (!connection) return; + const identity = await fetchDesktopRemoteIdentity(connection.baseUrl); + if (!identity) return; + void state.syncNow(); + })(); + }, AUTO_SYNC_DEBOUNCE_MS); + void reason; + }, +})); diff --git a/src/types/desktopRemote.ts b/src/types/desktopRemote.ts index 852d038..0914ee9 100644 --- a/src/types/desktopRemote.ts +++ b/src/types/desktopRemote.ts @@ -1,18 +1,24 @@ -export const DESKTOP_REMOTE_PROTOCOL_VERSION = 1; +export const DESKTOP_REMOTE_PROTOCOL_VERSION = 2; export type DesktopRemotePlaybackState = 'stopped' | 'playing' | 'paused' | 'loading'; +export type DesktopRemoteRepeatMode = 'none' | 'one' | 'all'; export type DesktopRemoteControlCommand = | 'play' | 'pause' | 'next' | 'previous' | 'toggle-favorite' + | 'toggle-shuffle' + | 'toggle-repeat' | 'seek'; export interface DesktopRemoteIdentity { endpointUuid: string | null; desktopName: string | null; protocolVersion: number; + /** Set while a desktop-initiated library-sync request awaits pickup; + * read by the phone's periodic foreground probe. */ + syncRequestedAt?: number | null; } export interface DesktopRemoteConnection extends DesktopRemoteIdentity { @@ -40,12 +46,28 @@ export interface DesktopRemoteNowPlayingSnapshot { currentTime: number; duration: number; queueLength: number; + /** Absent on protocol-1 desktops — the UI hides the shuffle/repeat controls. */ + shuffle?: boolean; + repeat?: DesktopRemoteRepeatMode; outputDeviceLabel: string | null; visualizerLineColor: string; currentTrack: DesktopRemoteTrackSnapshot | null; updatedAt: number; } +export interface DesktopRemoteQueueItem { + queueId: string; + title: string; + artist: string; + durationSeconds: number | null; + isCurrent: boolean; +} + +export interface DesktopRemoteQueueSnapshot { + items: DesktopRemoteQueueItem[]; + updatedAt: number; +} + export interface DesktopRemotePairingClaim { requestId: string; pollToken: string; diff --git a/src/types/desktopSync.ts b/src/types/desktopSync.ts new file mode 100644 index 0000000..72d2c0d --- /dev/null +++ b/src/types/desktopSync.ts @@ -0,0 +1,175 @@ +// Wire types for the desktop<->mobile favorites/playlists LAN sync +// (GET /v1/sync/state, POST /v1/sync/apply on the paired desktop's +// phone-remote server). Mirrors the desktop's src/types/phoneSync.ts. +// Track-level items are keyed by the metadata identity key from +// shared/sync/identity.ts; playlists by sync_uid. Timestamps are wall-clock ms +// and travel verbatim between devices — last-writer-wins merges must never mix +// in the receiving side's clock. + +export const DESKTOP_SYNC_FORMAT = 1; + +/** Desktop protocol version that introduced /v1/sync/* (and queue/shuffle). */ +export const DESKTOP_SYNC_MIN_PROTOCOL_VERSION = 2; + +export interface SyncFavorite { + key: string; + title: string; + artist: string; + album: string; + addedAt: number; +} + +export interface SyncKeyTombstone { + key: string; + deletedAt: number; +} + +export interface SyncPlaylistEntry { + title: string; + artist: string; + album: string; + durationSeconds: number | null; + position: number; + addedAt: number; + sourcePath: string | null; +} + +export type SyncPlaylistKind = 'normal' | 'dynamic'; + +export interface SyncPlaylist { + syncUid: string; + name: string; + kind: SyncPlaylistKind; + dynamicRules: string | null; + createdAt: number; + updatedAt: number; + entries: SyncPlaylistEntry[] | null; +} + +export interface SyncPlaylistSnapshot { + name: string; + kind: SyncPlaylistKind; + dynamicRules: string | null; + updatedAt: number; + trackCount: number; + entries: SyncPlaylistEntry[] | null; +} + +export interface SyncUidTombstone { + syncUid: string; + deletedAt: number; +} + +export interface DesktopSyncState { + syncFormat: number; + now: number; + favorites: SyncFavorite[]; + favoriteTombstones: SyncKeyTombstone[]; + playlists: SyncPlaylist[]; + playlistTombstones: SyncUidTombstone[]; + /** Conflict resolutions chosen on the desktop, awaiting this phone. */ + pendingResolutions?: DesktopSyncPendingResolution[]; +} + +export interface DesktopSyncPendingResolution { + syncUid: string; + resolution: DesktopSyncConflictResolution; + decidedAt: number; +} + +/** Phone→desktop conflict report (POST /v1/sync/conflicts) so the desktop can + * show conflicts and offer resolutions too. */ +export interface DesktopSyncReportedConflict { + kind: DesktopSyncConflictKind; + syncUid: string; + name: string; + playlistKind: SyncPlaylistKind; + phoneName: string; + desktopName: string; + phoneUpdatedAt: number; + desktopUpdatedAt: number; + phoneTrackCount: number; + desktopTrackCount: number; + phoneSnapshot?: SyncPlaylistSnapshot | null; + desktopSnapshot?: SyncPlaylistSnapshot | null; +} + +export interface DesktopSyncConflictReportPayload { + syncFormat: number; + conflicts: DesktopSyncReportedConflict[]; + /** Uids of desktop-chosen resolutions this phone just applied. */ + consumedResolutions: string[]; +} + +export interface DesktopSyncApplyPayload { + syncFormat: number; + favoriteAdds: SyncFavorite[]; + favoriteRemoves: SyncKeyTombstone[]; + playlistUpserts: SyncPlaylist[]; + playlistDeletes: SyncUidTombstone[]; +} + +export type DesktopSyncPlaylistApplyStatus = 'created' | 'replaced' | 'deleted' | 'skipped-incompatible'; + +export interface DesktopSyncPlaylistApplyResult { + syncUid: string; + status: DesktopSyncPlaylistApplyStatus; + entriesMatched: number; + entriesFallback: number; +} + +export interface DesktopSyncApplyResult { + ok: true; + favorites: { + added: number; + pending: number; + removed: number; + }; + playlists: DesktopSyncPlaylistApplyResult[]; +} + +export type DesktopSyncConflictKind = 'first-pairing' | 'concurrent-edit'; + +export type DesktopSyncConflictResolution = 'desktop' | 'phone' | 'both' | 'merge'; + +/** + * A playlist change sync refuses to resolve automatically: either a + * first-pairing name collision with divergent contents, or both devices + * edited the same playlist since the last sync. Sync completes everything + * else and leaves both copies untouched until the user picks a resolution. + */ +export interface DesktopSyncPlaylistConflict { + kind: DesktopSyncConflictKind; + /** The desktop playlist's sync uid (the identity the pair shares/would share). */ + syncUid: string; + localPlaylistId: number; + /** The local playlist's own uid at detection (differs from syncUid for first-pairing). */ + localSyncUid: string; + name: string; + playlistKind: SyncPlaylistKind; + localName: string; + remoteName: string; + localUpdatedAt: number; + remoteUpdatedAt: number; + localTrackCount: number; + remoteTrackCount: number; + /** Snapshot of this phone's version at detection (for previews/reporting). */ + local: SyncPlaylist; + /** Snapshot of the desktop version at detection (for keep-desktop / merge). */ + remote: SyncPlaylist; +} + +export interface DesktopSyncSummary { + favoritesAdded: number; + favoritesRemoved: number; + favoritesPending: number; + playlistsCreated: number; + playlistsReplaced: number; + playlistsDeleted: number; + playlistsSkipped: number; + entriesFallback: number; + pushedToDesktop: boolean; + conflicts: DesktopSyncPlaylistConflict[]; + startedAt: number; + finishedAt: number; +}