diff --git a/app/api/jobs/[id]/review/[itemId]/route.ts b/app/api/jobs/[id]/review/[itemId]/route.ts index d9493c4..ec1707d 100644 --- a/app/api/jobs/[id]/review/[itemId]/route.ts +++ b/app/api/jobs/[id]/review/[itemId]/route.ts @@ -5,7 +5,8 @@ import { getReviewItemById, resolveReviewItem, skipReviewItem, type ReviewQueueR import { setMatchCacheEntry } from "@/lib/db/repositories/match-cache"; import { getAdapter } from "@/lib/adapters/registry"; import { getFreshTokens } from "@/lib/auth/token-manager"; -import type { CanonicalAlbum, CanonicalArtist, CanonicalTrack } from "@/lib/types/model"; +import { computeCanonicalPlaylistId } from "@/lib/matching/identity"; +import type { CanonicalAlbum, CanonicalArtist, CanonicalPlaylist, CanonicalTrack } from "@/lib/types/model"; interface ResolveBody { action: "confirm" | "pick" | "skip" | "search"; @@ -13,6 +14,17 @@ interface ResolveBody { query?: string; } +// CanonicalPlaylist has no canonicalId/platformIds (playlists are tracked by +// sourcePlaylistId, not a cross-platform dedup key) — this derives an +// equivalent cache key so "dismiss" still suppresses the note on reruns. +function reviewItemCanonicalId(item: ReviewQueueRow, sourceAccountId: string): string { + if (item.kind === "playlist") { + const playlist = item.sourceEntity as CanonicalPlaylist; + return computeCanonicalPlaylistId(sourceAccountId, playlist.sourcePlaylistId); + } + return (item.sourceEntity as CanonicalTrack | CanonicalArtist | CanonicalAlbum).canonicalId; +} + async function performAction( item: ReviewQueueRow, destAdapter: ReturnType, @@ -28,6 +40,8 @@ async function performAction( return destAdapter.followArtist(destTokens, destId); case "saveAlbum": return destAdapter.saveAlbum(destTokens, destId); + case "followPlaylistNote": + throw new Error("followPlaylistNote is informational only and cannot be resolved"); } } @@ -57,7 +71,7 @@ export async function POST( if (body.action === "skip") { skipReviewItem(itemId); setMatchCacheEntry({ - canonicalId: item.sourceEntity.canonicalId, + canonicalId: reviewItemCanonicalId(item, job.sourceAccountId), destAccountId: job.destAccountId, destPlatformTrackId: null, confidence: "manual", @@ -65,6 +79,10 @@ export async function POST( return NextResponse.json({ ok: true }); } + if (item.kind === "playlist") { + return NextResponse.json({ error: "playlist_notes_are_dismiss_only" }, { status: 400 }); + } + const destTokens = await getFreshTokens(destAccount.id, destAdapter); if (body.action === "search") { @@ -84,14 +102,18 @@ export async function POST( } const destId = - body.action === "confirm" ? item.candidates[0]?.platformIds[destAdapter.platform] : body.destId; + body.action === "confirm" + ? (item.candidates[0] as CanonicalTrack | CanonicalArtist | CanonicalAlbum | undefined)?.platformIds[ + destAdapter.platform + ] + : body.destId; if (!destId) return NextResponse.json({ error: "missing_dest_id" }, { status: 400 }); await performAction(item, destAdapter, destTokens, destId); resolveReviewItem(itemId, destId); setMatchCacheEntry({ - canonicalId: item.sourceEntity.canonicalId, + canonicalId: reviewItemCanonicalId(item, job.sourceAccountId), destAccountId: job.destAccountId, destPlatformTrackId: destId, confidence: "manual", diff --git a/app/migrate/[jobId]/review/page.tsx b/app/migrate/[jobId]/review/page.tsx index d31337a..1e45269 100644 --- a/app/migrate/[jobId]/review/page.tsx +++ b/app/migrate/[jobId]/review/page.tsx @@ -3,11 +3,11 @@ import { use, useEffect, useState } from "react"; import Link from "next/link"; -type Kind = "track" | "artist" | "album"; +type Kind = "track" | "artist" | "album" | "playlist"; interface Entity { - canonicalId: string; - platformIds: Record; + canonicalId?: string; + platformIds?: Record; // track title?: string; artists?: string[]; @@ -29,7 +29,7 @@ function entityLabel(kind: Kind, entity: Entity): string { const artists = entity.artists?.join(", ") ?? ""; return `${entity.title ?? "?"} — ${artists}`; } - if (kind === "artist") return entity.name ?? "?"; + if (kind === "artist" || kind === "playlist") return entity.name ?? "?"; return `${entity.title ?? "?"} — ${entity.artists?.join(", ") ?? ""}`; } @@ -95,6 +95,25 @@ function ReviewCard({ const candidates = searchResults ?? item.candidates; + if (item.kind === "playlist") { + return ( +
+
+ followed playlist — no cross-platform match + {entityLabel(item.kind, item.sourceEntity)} +
+

+ This playlist is followed, not owned, so it can't be recreated automatically across + platforms. Find and follow the equivalent yourself on the destination, then dismiss this note. +

+
+ +
+ ); + } + return (
@@ -110,7 +129,7 @@ function ReviewCard({
{candidates.map((candidate, i) => { - const destId = candidate.platformIds.spotify ?? candidate.platformIds.tidal; + const destId = candidate.platformIds?.spotify ?? candidate.platformIds?.tidal; if (!destId) return null; return (
diff --git a/lib/adapters/http-retry.ts b/lib/adapters/http-retry.ts index cc92ec2..a179b12 100644 --- a/lib/adapters/http-retry.ts +++ b/lib/adapters/http-retry.ts @@ -11,6 +11,35 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Formats a millisecond duration as a human-readable string, e.g. "23.7h" or + * "850ms". Rounds each candidate unit before deciding whether it's the right + * one to display, so e.g. 23.96h doesn't render as the misleading "24.0h" — + * it promotes to "1.0d" instead. + */ +export function formatDurationMs(ms: number): string { + if (ms < 1000) return `${ms}ms`; + + const units: [string, number][] = [ + ["s", 1000], + ["m", 1000 * 60], + ["h", 1000 * 60 * 60], + ["d", 1000 * 60 * 60 * 24], + ]; + + for (let i = 0; i < units.length; i++) { + const [suffix, unitMs] = units[i]; + const nextUnitMs = units[i + 1]?.[1]; + const rounded = Math.round((ms / unitMs) * 10) / 10; + const rolledOverToNext = nextUnitMs != null && rounded * unitMs >= nextUnitMs; + if (!rolledOverToNext && (nextUnitMs == null || ms < nextUnitMs)) { + return `${rounded.toFixed(1)}${suffix}`; + } + } + + return `${ms}ms`; +} + /** * Runs doFetch, retrying on 429 responses using the Retry-After header * (falling back to exponential backoff if absent, and capped at @@ -32,7 +61,7 @@ export async function fetchWithRetry(doFetch: () => Promise): Promise< if (requestedDelay > MAX_RETRY_DELAY_MS) { console.warn( - `[rate-limit] 429 on ${res.url} — Retry-After (${requestedDelay}ms) exceeds the ${MAX_RETRY_DELAY_MS}ms cap, giving up rather than hanging` + `[rate-limit] 429 on ${res.url} — Retry-After (${formatDurationMs(requestedDelay)}) exceeds the ${formatDurationMs(MAX_RETRY_DELAY_MS)} cap, giving up rather than hanging` ); return res; } @@ -42,7 +71,9 @@ export async function fetchWithRetry(doFetch: () => Promise): Promise< return res; } - console.warn(`[rate-limit] 429 on ${res.url} — attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${requestedDelay}ms`); + console.warn( + `[rate-limit] 429 on ${res.url} — attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${formatDurationMs(requestedDelay)}` + ); await sleep(requestedDelay); } } diff --git a/lib/adapters/spotify.ts b/lib/adapters/spotify.ts index f4c6796..a8190a9 100644 --- a/lib/adapters/spotify.ts +++ b/lib/adapters/spotify.ts @@ -1,6 +1,6 @@ import { buildSpotifyAuthUrl, exchangeSpotifyCode, refreshSpotifyToken } from "@/lib/auth/spotify-auth"; import { type MusicPlatformAdapter } from "@/lib/adapters/types"; -import { fetchWithRetry } from "@/lib/adapters/http-retry"; +import { fetchWithRetry, formatDurationMs } from "@/lib/adapters/http-retry"; import { computeCanonicalAlbumId, computeCanonicalArtistId, computeCanonicalTrackId } from "@/lib/matching/identity"; import type { AccountProfile, @@ -49,6 +49,7 @@ interface SpotifyPlaylist { id: string; name: string; description: string | null; + owner: { id: string }; } interface SpotifyPlaylistsPage { @@ -115,7 +116,7 @@ async function spotifyFetch(path: string, tokens: TokenSet, init?: RequestIni export class SpotifyRateLimitError extends Error { constructor(public retryAfterMs: number) { - super(`Spotify rate limited, retry after ${retryAfterMs}ms`); + super(`Spotify rate limited, retry after ${formatDurationMs(retryAfterMs)}`); } } @@ -164,6 +165,14 @@ async function addToLibrary(tokens: TokenSet, uris: string[]): Promise { } } +/** Removes Spotify URIs from the user's library via the unified DELETE /me/library endpoint. */ +async function removeFromLibrary(tokens: TokenSet, uris: string[]): Promise { + for (let i = 0; i < uris.length; i += ADD_TO_LIBRARY_BATCH_SIZE) { + const batch = uris.slice(i, i + ADD_TO_LIBRARY_BATCH_SIZE); + await spotifyFetch(`/me/library?uris=${encodeURIComponent(batch.join(","))}`, tokens, { method: "DELETE" }); + } +} + export const spotifyAdapter: MusicPlatformAdapter = { platform: "spotify", @@ -196,6 +205,10 @@ export const spotifyAdapter: MusicPlatformAdapter = { }, async *fetchPlaylists(tokens: TokenSet): AsyncGenerator { + // GET /me/playlists returns playlists the user OWNS OR FOLLOWS. A + // followed (not owned) playlist can't have tracks written to it (403) — + // the job runner branches on `owned` to follow rather than recreate it. + const me = await spotifyFetch("/me", tokens); let path: string | null = "/me/playlists?limit=50"; while (path) { const page: SpotifyPlaylistsPage = await spotifyFetch(path, tokens); @@ -203,6 +216,7 @@ export const spotifyAdapter: MusicPlatformAdapter = { sourcePlaylistId: playlist.id, name: playlist.name, description: playlist.description ?? undefined, + owned: playlist.owner.id === me.id, trackRefs: [], })); path = page.next ? page.next.replace(API_BASE, "") : null; @@ -314,4 +328,12 @@ export const spotifyAdapter: MusicPlatformAdapter = { async saveAlbum(tokens, platformAlbumId): Promise { await addToLibrary(tokens, [`spotify:album:${platformAlbumId}`]); }, + + async followPlaylist(tokens, platformPlaylistId): Promise { + await addToLibrary(tokens, [`spotify:playlist:${platformPlaylistId}`]); + }, + + async unfollowPlaylist(tokens, platformPlaylistId): Promise { + await removeFromLibrary(tokens, [`spotify:playlist:${platformPlaylistId}`]); + }, }; diff --git a/lib/adapters/tidal.ts b/lib/adapters/tidal.ts index 00be3ab..3bd522f 100644 --- a/lib/adapters/tidal.ts +++ b/lib/adapters/tidal.ts @@ -1,6 +1,6 @@ import { buildTidalAuthUrl, exchangeTidalCode, refreshTidalToken } from "@/lib/auth/tidal-auth"; import { type MusicPlatformAdapter } from "@/lib/adapters/types"; -import { fetchWithRetry } from "@/lib/adapters/http-retry"; +import { fetchWithRetry, formatDurationMs } from "@/lib/adapters/http-retry"; import { indexIncluded, parseIsoDurationMs, @@ -81,7 +81,7 @@ async function tidalFetch(path: string, tokens: TokenSet, init?: RequestInit) export class TidalRateLimitError extends Error { constructor(public retryAfterMs: number) { - super(`Tidal rate limited, retry after ${retryAfterMs}ms`); + super(`Tidal rate limited, retry after ${formatDurationMs(retryAfterMs)}`); } } @@ -245,6 +245,7 @@ export const tidalAdapter: MusicPlatformAdapter = { sourcePlaylistId: playlist.id, name: playlist.attributes?.name ?? "", description: playlist.attributes?.description, + owned: true, // filter[owners.id]=me above already restricts to owned playlists trackRefs: [], })); path = doc.links?.next ?? null; diff --git a/lib/adapters/types.ts b/lib/adapters/types.ts index 0f4a6b1..ca94f60 100644 --- a/lib/adapters/types.ts +++ b/lib/adapters/types.ts @@ -40,4 +40,11 @@ export interface MusicPlatformAdapter { addTracksToPlaylist(tokens: TokenSet, playlistId: string, platformTrackIds: string[]): Promise; followArtist(tokens: TokenSet, platformArtistId: string): Promise; saveAlbum(tokens: TokenSet, platformAlbumId: string): Promise; + + // Only meaningful same-platform (a followed playlist's ID is shared across + // accounts on the same platform) — omitted rather than cross-platform + // "matched", since curated/editorial playlists rarely have a reliable + // equivalent to search for on a different platform. + followPlaylist?(tokens: TokenSet, platformPlaylistId: string): Promise; + unfollowPlaylist?(tokens: TokenSet, platformPlaylistId: string): Promise; } diff --git a/lib/db/repositories/review-queue.ts b/lib/db/repositories/review-queue.ts index cb99be0..f3100c8 100644 --- a/lib/db/repositories/review-queue.ts +++ b/lib/db/repositories/review-queue.ts @@ -1,17 +1,21 @@ import { randomUUID } from "node:crypto"; import { db } from "@/lib/db/client"; -import type { CanonicalAlbum, CanonicalArtist, CanonicalTrack } from "@/lib/types/model"; +import type { CanonicalAlbum, CanonicalArtist, CanonicalPlaylist, CanonicalTrack } from "@/lib/types/model"; -export type ReviewKind = "track" | "artist" | "album"; +export type ReviewKind = "track" | "artist" | "album" | "playlist"; export type ReviewStatus = "pending" | "resolved" | "skipped"; export type ActionContext = | { type: "addLikedSongs" } | { type: "addToPlaylist"; playlistId: string } | { type: "followArtist" } - | { type: "saveAlbum" }; + | { type: "saveAlbum" } + // Informational only — a followed playlist that couldn't be auto-migrated + // across platforms (no reliable cross-platform playlist identity to search + // for). Not resolvable via confirm/pick, only dismissible. + | { type: "followPlaylistNote" }; -export interface ReviewQueueRow { +export interface ReviewQueueRow { id: string; jobId: string; kind: ReviewKind; diff --git a/lib/jobs/runner.ts b/lib/jobs/runner.ts index 008e2c2..aedf42f 100644 --- a/lib/jobs/runner.ts +++ b/lib/jobs/runner.ts @@ -12,7 +12,7 @@ import { computeCanonicalPlaylistId } from "@/lib/matching/identity"; import { emitDone, emitProgress } from "@/lib/jobs/progress-emitter"; import { registerActiveJob, shouldJobStop, unregisterActiveJob } from "@/lib/jobs/queue"; import { jobLog } from "@/lib/jobs/log"; -import type { CanonicalAlbum, CanonicalArtist, CanonicalTrack, TokenSet } from "@/lib/types/model"; +import type { CanonicalAlbum, CanonicalArtist, CanonicalPlaylist, CanonicalTrack, TokenSet } from "@/lib/types/model"; // Conservative shared batch size: Spotify's PUT /me/library caps at 40 URIs per request // (Tidal's relationship-add payloads cap at 50, so 40 is safe for both). @@ -52,9 +52,9 @@ function queueTrackForReview( function queueEntityForReview( ctx: ScopeContext, - kind: "artist" | "album", - sourceEntity: CanonicalArtist | CanonicalAlbum, - candidates: (CanonicalArtist | CanonicalAlbum)[], + kind: "artist" | "album" | "playlist", + sourceEntity: CanonicalArtist | CanonicalAlbum | CanonicalPlaylist, + candidates: (CanonicalArtist | CanonicalAlbum | CanonicalPlaylist)[], actionContext: ActionContext ): void { createReviewItem({ jobId: ctx.jobId, kind, sourceEntity, candidates, actionContext }); @@ -135,12 +135,15 @@ async function runLikedScope(ctx: ScopeContext): Promise { async function runPlaylistsScope(ctx: ScopeContext): Promise { const { sourceAdapter, destAdapter, sourceTokens, destTokens } = ctx; - // Existing destination playlists by name — reused for a playlist that was - // created in a prior interrupted run but never got its tracks written, - // rather than creating a second duplicate playlist. - const destExistingByName = new Map(); + // Existing destination playlists by name — reused for an owned playlist that + // was created in a prior interrupted run but never got its tracks written + // (rather than creating a duplicate), and to find/clean up a stray + // followed/owned entry that needs replacing with a proper follow. + const destExistingByName = new Map(); for await (const batch of destAdapter.fetchPlaylists(destTokens)) { - for (const playlist of batch) destExistingByName.set(playlist.name, playlist.sourcePlaylistId); + for (const playlist of batch) { + destExistingByName.set(playlist.name, { id: playlist.sourcePlaylistId, owned: playlist.owned }); + } } jobLog(ctx.jobId, `playlists: destination already has ${destExistingByName.size} playlists`); @@ -154,24 +157,101 @@ async function runPlaylistsScope(ctx: ScopeContext): Promise { // Authoritative "fully migrated" check — distinct from destExistingByName, // which only tells us a playlist with this name exists (possibly still - // empty from an interrupted prior run). + // empty from an interrupted prior run, or a stray followed duplicate). const playlistCanonicalId = computeCanonicalPlaylistId(ctx.sourceAccountId, playlist.sourcePlaylistId); const alreadyDone = getMatchCacheEntry(playlistCanonicalId, ctx.destAccountId); - if (alreadyDone?.destPlatformTrackId) { + // A followed (same-platform) playlist's correct cached value is always + // its own source ID — anything else is stale, e.g. from before this + // owned/followed distinction existed, when a followed playlist could + // get wrongly recorded as "done" pointing at a wrongly-created owned + // duplicate. Stale entries are reprocessed rather than trusted. + const cacheIsStale = + !playlist.owned && + sourceAdapter.platform === destAdapter.platform && + alreadyDone?.destPlatformTrackId != null && + alreadyDone.destPlatformTrackId !== playlist.sourcePlaylistId; + + if (alreadyDone?.destPlatformTrackId && !cacheIsStale) { jobLog(ctx.jobId, `playlists: "${playlist.name}" already fully migrated, skipping`); ctx.counters.alreadyPresent++; reportProgress(ctx, "playlists", processed); continue; } - const existingId = destExistingByName.get(playlist.name); + if (cacheIsStale) { + jobLog( + ctx.jobId, + `playlists: "${playlist.name}" has a stale cached match (${alreadyDone!.destPlatformTrackId}), reprocessing` + ); + } + + if (!playlist.owned) { + const sourceDestSamePlatform = sourceAdapter.platform === destAdapter.platform; + + if (sourceDestSamePlatform && destAdapter.followPlaylist) { + const existing = destExistingByName.get(playlist.name); + // Same platform means IDs are directly comparable — only remove the + // existing entry if it's a genuinely different playlist that merely + // shares a name (e.g. a stray duplicate from a bug in an earlier + // run); if it's already the correct one, there's nothing to do. + const isAlreadyCorrect = existing?.id === playlist.sourcePlaylistId; + + if (isAlreadyCorrect) { + jobLog(ctx.jobId, `playlists: "${playlist.name}" is already correctly followed on destination`); + } else { + if (existing && destAdapter.unfollowPlaylist) { + jobLog( + ctx.jobId, + `playlists: "${playlist.name}" on destination is a different playlist (id ${existing.id} != ${playlist.sourcePlaylistId}), removing before re-following` + ); + await destAdapter.unfollowPlaylist(destTokens, existing.id); + } + + jobLog(ctx.jobId, `playlists: following "${playlist.name}" on destination (same platform, same ID)`); + await destAdapter.followPlaylist(destTokens, playlist.sourcePlaylistId); + } + + ctx.counters.matched++; + setMatchCacheEntry({ + canonicalId: playlistCanonicalId, + destAccountId: ctx.destAccountId, + destPlatformTrackId: playlist.sourcePlaylistId, + confidence: "exact", + }); + } else { + jobLog(ctx.jobId, `playlists: "${playlist.name}" is followed, not owned — no reliable cross-platform match, queuing note`); + queueEntityForReview(ctx, "playlist", playlist, [], { type: "followPlaylistNote" }); + } + + reportProgress(ctx, "playlists", processed); + continue; + } + + const existing = destExistingByName.get(playlist.name); + const existingId = existing?.owned ? existing.id : undefined; const destPlaylist = existingId ? { id: existingId } : await (async () => { jobLog(ctx.jobId, `playlists: creating "${playlist.name}" on destination`); return destAdapter.createPlaylist(destTokens, playlist.name, playlist.description); })(); - if (existingId) jobLog(ctx.jobId, `playlists: resuming previously-created "${playlist.name}"`); + + // Reusing an existing (possibly non-empty — e.g. an unrelated playlist + // that happens to share the name, or a partially-completed prior run) + // playlist means its current contents must be checked before writing, + // otherwise already-present tracks get duplicated in the list rather + // than deduplicated (Spotify/Tidal playlists allow duplicate entries). + const existingDestTrackIds = new Set(); + if (existingId) { + jobLog(ctx.jobId, `playlists: resuming previously-created "${playlist.name}"`); + for await (const batch of destAdapter.fetchPlaylistTracks(destTokens, existingId)) { + for (const track of batch) { + const id = track.platformIds[destAdapter.platform]; + if (id) existingDestTrackIds.add(id); + } + } + jobLog(ctx.jobId, `playlists: "${playlist.name}" on destination already has ${existingDestTrackIds.size} tracks`); + } const destTrackIds: string[] = []; let stoppedMidPlaylist = false; @@ -196,7 +276,10 @@ async function runPlaylistsScope(ctx: ScopeContext): Promise { if (outcome.kind === "auto") { const destId = outcome.track.platformIds[destAdapter.platform]; - if (destId) { + if (destId && existingDestTrackIds.has(destId)) { + jobLog(ctx.jobId, `playlists: [${playlist.name}] already present ${trackLabel(track)}`); + ctx.counters.alreadyPresent++; + } else if (destId) { jobLog(ctx.jobId, `playlists: [${playlist.name}] matched (${outcome.confidence}) ${trackLabel(track)}`); destTrackIds.push(destId); ctx.counters.matched++; diff --git a/lib/types/model.ts b/lib/types/model.ts index dffebf7..e70af30 100644 --- a/lib/types/model.ts +++ b/lib/types/model.ts @@ -38,6 +38,7 @@ export interface CanonicalPlaylist { name: string; description?: string; trackRefs: string[]; // CanonicalTrack.canonicalId, order preserved + owned: boolean; // false = followed, not created by this account — can't have tracks written to it } export interface TrackSearchQuery {