desktop sync support

This commit is contained in:
Boof2015
2026-07-06 14:14:41 -04:00
parent 006e55422b
commit 2b19f51a49
26 changed files with 4183 additions and 208 deletions
+87 -3
View File
@@ -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<void> {
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<DesktopRemoteQueueSnapshot> {
return fetchJson<DesktopRemoteQueueSnapshot>(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<DesktopSyncState> {
return fetchJson<DesktopSyncState>(baseUrl, '/v1/sync/state', { token, timeoutMs: 30_000 });
}
export async function postDesktopSyncApply(
baseUrl: string,
token: string,
payload: DesktopSyncApplyPayload
): Promise<DesktopSyncApplyResult> {
return fetchJson<DesktopSyncApplyResult>(baseUrl, '/v1/sync/apply', {
method: 'POST',
token,
body: payload,
timeoutMs: 60_000,
});
}
export async function postDesktopSyncConflicts(
baseUrl: string,
token: string,
payload: DesktopSyncConflictReportPayload
): Promise<void> {
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;
+678
View File
@@ -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<DesktopRemoteConnection, 'id' | 'endpointUuid'>): 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<string, unknown>;
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<DesktopSyncSummary> {
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<string>([
...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<string>();
const localContentsFor = async (row: LocalSyncPlaylist): Promise<SyncPlaylistEntry[]> =>
row.kind === 'normal' ? getSyncPlaylistEntries(tx, row.id) : [];
const contentsMatch = async (
localRow: LocalSyncPlaylist,
remoteRow: SyncPlaylist,
localEntries: SyncPlaylistEntry[]
): Promise<boolean> => {
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<string>([
...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<void> {
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();
}
@@ -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> = {}): 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']
);
});
+50
View File
@@ -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<SyncPlaylistEntry, 'title' | 'artist' | 'album'>
): 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<string>();
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;
}