From beb19eebe1eb743c205d9ac494806dfb16aa7045 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 04:35:30 +0000 Subject: [PATCH] App Store release candidate: local file import replaces YouTube ingestion Removes everything App Review would reject and the external dependency that powered it: - YouTubeKit dependency dropped; all network resolvers deleted (YouTube stream/search/playlist, Spotify, oEmbed), plus the ranged downloader, retry helper, playlist source-sync, and link import pipeline. - Share extension target, app-group entitlements, URL scheme, and clipboard link detection removed. - The + button now imports local audio files via the Files picker: security-scoped copy into the audio cache, embedded metadata (title/artist/duration/artwork) extracted, tracks land ready in a find-or-create My Music playlist, and the full BPM/key/loudness/ silence analysis pipeline runs on import. - Stems still work for local files: the stem cache key falls back to the track UUID when there is no video ID (Track.stemKey), and the on-device ONNX separation pipeline is unchanged (App Store safe). - SwiftData stored attributes untouched (additive artworkPath only) so existing installs migrate cleanly; old source-backed playlists degrade to plain local playlists. --- App/Continuity/Continuity.entitlements | 10 - App/Continuity/Info.plist | 11 - App/Continuity/Library/LinkImporter.swift | 140 ----------- App/Continuity/Views/AddMusicView.swift | 108 +++------ App/Continuity/Views/ContinuityApp.swift | 32 +-- App/Continuity/Views/LibrarySheetView.swift | 13 - App/Continuity/Views/LibraryView.swift | 4 +- App/Continuity/Views/PlaylistDetailView.swift | 51 +--- App/Continuity/Views/RootView.swift | 184 +------------- .../ContinuityShare.entitlements | 10 - App/ShareExtension/Info.plist | 36 --- App/ShareExtension/ShareViewController.swift | 130 ---------- Packages/ContinuityKit/Package.resolved | 9 - Packages/ContinuityKit/Package.swift | 6 +- .../ContinuityKit/Sources/Domain/Track.swift | 34 ++- .../Sources/Ingest/AudioDownloader.swift | 132 ---------- .../Sources/Ingest/IngestContracts.swift | 94 +------- .../Sources/Ingest/LibraryCleanup.swift | 63 +++-- .../Ingest/PreparationQueue+Import.swift | 99 -------- .../Ingest/PreparationQueue+LocalImport.swift | 113 +++++++++ .../Ingest/PreparationQueue+Stems.swift | 11 +- .../Ingest/PreparationQueue+Sync.swift | 188 --------------- .../Sources/Ingest/PreparationQueue.swift | 225 ++++-------------- .../ContinuityKit/Sources/Ingest/Retry.swift | 37 --- .../Ingest/SpotifyPlaylistResolver.swift | 74 ------ .../Ingest/YouTubeOEmbedResolver.swift | 57 ----- .../Ingest/YouTubePlaylistResolver.swift | 136 ----------- .../Ingest/YouTubeSearchResolver.swift | 63 ----- .../Ingest/YouTubeStreamResolver.swift | 84 ------- project.yml | 48 ---- 30 files changed, 283 insertions(+), 1919 deletions(-) delete mode 100644 App/Continuity/Continuity.entitlements delete mode 100644 App/Continuity/Library/LinkImporter.swift delete mode 100644 App/ShareExtension/ContinuityShare.entitlements delete mode 100644 App/ShareExtension/Info.plist delete mode 100644 App/ShareExtension/ShareViewController.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/AudioDownloader.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift create mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/Retry.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/SpotifyPlaylistResolver.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/YouTubeOEmbedResolver.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/YouTubePlaylistResolver.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/YouTubeSearchResolver.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/YouTubeStreamResolver.swift diff --git a/App/Continuity/Continuity.entitlements b/App/Continuity/Continuity.entitlements deleted file mode 100644 index 1f8d13d..0000000 --- a/App/Continuity/Continuity.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.sanylax.continuity - - - diff --git a/App/Continuity/Info.plist b/App/Continuity/Info.plist index 047e700..2cb81ab 100644 --- a/App/Continuity/Info.plist +++ b/App/Continuity/Info.plist @@ -18,17 +18,6 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) - CFBundleURLTypes - - - CFBundleURLName - com.sanylax.continuity - CFBundleURLSchemes - - continuity - - - CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption diff --git a/App/Continuity/Library/LinkImporter.swift b/App/Continuity/Library/LinkImporter.swift deleted file mode 100644 index 711662e..0000000 --- a/App/Continuity/Library/LinkImporter.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation -import SwiftData -import Ingest -import Domain -import ContinuityCore - -/// Single home for link → import routing, shared by AddMusicView and the URL-scheme / -/// clipboard handlers so YouTube/Spotify classification lives in exactly one place. -@MainActor -enum LinkImporter { - - /// What a raw link resolves to. - enum Link { - case spotify(SpotifyLink) - case youtubePlaylist(String) - case youtubeVideo(String) - - /// Source name for confirmation UI ("Import from YouTube?"). - var sourceName: String { - switch self { - case .spotify: return "Spotify" - case .youtubePlaylist, .youtubeVideo: return "YouTube" - } - } - - /// Whether this imports a whole playlist (vs. adding a single video). - var isPlaylistImport: Bool { - switch self { - case .spotify, .youtubePlaylist: return true - case .youtubeVideo: return false - } - } - - /// Noun used in error messages ("Couldn't import that …"). - var noun: String { - switch self { - case .spotify(let link): return "Spotify \(link.kind.rawValue)" - case .youtubePlaylist: return "playlist" - case .youtubeVideo: return "video" - } - } - } - - /// Pure classification of pasted/shared text. Nil if no importable link is found. - nonisolated static func classify(_ raw: String) -> Link? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if let link = SpotifyURL.parse(trimmed) { return .spotify(link) } - if let link = YouTubeURL.parse(trimmed) { - if let playlistID = link.playlistID { return .youtubePlaylist(playlistID) } - if let videoID = link.videoID { return .youtubeVideo(videoID) } - } - return nil - } - - /// Kicks off the import for a classified link. Playlist imports resolve before returning; - /// single videos enqueue instantly. Throws the raw resolver error — map it with - /// `errorMessage(_:noun:)` for display. - static func run( - _ link: Link, - sourceURL: String, - queue: PreparationQueue, - in modelContext: ModelContext - ) async throws { - switch link { - case .spotify(let spotifyLink): - _ = try await queue.importSpotifyPlaylist(spotifyLink, in: modelContext) - case .youtubePlaylist(let playlistID): - _ = try await queue.importPlaylist(playlistID: playlistID, in: modelContext) - case .youtubeVideo(let videoID): - addSingleVideo(videoID, sourceURL: sourceURL, queue: queue, in: modelContext) - } - } - - /// Maps a resolve failure to a message that names the actual cause. Retryable failures - /// (network/rate-limit) already retried inside the resolver, so reaching here means they - /// persisted — the message tells the user to try again rather than blaming their playlist. - nonisolated static func errorMessage(_ error: Error, noun: String) -> String { - switch error as? IngestError { - case .network: - return "Couldn't reach the server. Check your connection and try again." - case .rateLimited: - return "Too many requests right now — please try again in a minute." - case .sourceUnavailable: - return "That \(noun) looks private or empty. Make sure it's public and try again." - default: - return "Couldn't import that \(noun). Please try again." - } - } - - /// Builds a placeholder track in the shared "From YouTube" playlist and enqueues it. - private static func addSingleVideo( - _ videoID: String, - sourceURL: String, - queue: PreparationQueue, - in modelContext: ModelContext - ) { - let playlist = findOrCreateYouTubePlaylist(in: modelContext) - - // Title/artist/duration start as placeholders so the row appears instantly; the - // ingest pipeline replaces them with the real oEmbed title/channel + decoded duration. - let track = Track( - title: "YouTube Video (\(videoID.prefix(6)))", - artist: "YouTube", - durationSeconds: 0, - artworkSymbol: playlist.artworkSymbol, - // Vary the gradient per track so rows are visually distinct. - gradientSeed: playlist.gradientSeed * 100 + playlist.tracks.count, - sortIndex: playlist.tracks.count, - prepState: .pending, - youtubeVideoID: videoID, - sourceURLString: sourceURL - ) - - playlist.tracks.append(track) - modelContext.insert(track) - playlist.touch() // membership changed → resort the library - - queue.enqueue(track, in: modelContext) - } - - /// Returns the shared "From YouTube" playlist, creating and inserting it if missing. - private static func findOrCreateYouTubePlaylist(in modelContext: ModelContext) -> Playlist { - let title = "From YouTube" - var descriptor = FetchDescriptor(predicate: #Predicate { $0.title == title }) - descriptor.fetchLimit = 1 - if let existing = try? modelContext.fetch(descriptor).first { - return existing - } - - let playlist = Playlist( - title: title, - subtitle: "Added from YouTube", - artworkSymbol: "arrow.down.circle.fill", - gradientSeed: 11 - ) - modelContext.insert(playlist) - return playlist - } -} diff --git a/App/Continuity/Views/AddMusicView.swift b/App/Continuity/Views/AddMusicView.swift index 7ad4439..b34f77b 100644 --- a/App/Continuity/Views/AddMusicView.swift +++ b/App/Continuity/Views/AddMusicView.swift @@ -2,71 +2,43 @@ import SwiftUI import Ingest import Domain import SwiftData -import ContinuityCore +import UniformTypeIdentifiers -/// Sheet for pasting a music link and queueing it for ingestion. Detects the source locally and -/// routes it: -/// - **YouTube video** → added to the shared "From YouTube" playlist. -/// - **YouTube playlist** → imported as its own library playlist. -/// - **Spotify playlist/album** → its tracklist is imported as a new playlist, with each song's -/// audio re-sourced from YouTube (Spotify audio is DRM-protected and can't feed our engine). -/// -/// Classification + import routing live in `LinkImporter` (shared with the URL-scheme and -/// clipboard handlers); the `PreparationQueue` does the resolving/searching/downloading. +/// Sheet for importing audio files from the Files app into the library. Picked files are +/// copied into the app's audio cache, their embedded metadata (title/artist/artwork) is read, +/// and the `PreparationQueue` runs BPM/key/loudness analysis in the background. struct AddMusicView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @Environment(PreparationQueue.self) private var preparationQueue - @State private var text = "" - @State private var errorMessage: String? - /// True while a playlist is being resolved (page fetch + track creation). + @State private var showingFilePicker = false + /// True while picked files are being copied + their metadata read. @State private var isImporting = false - - private var trimmed: String { - text.trimmingCharacters(in: .whitespacesAndNewlines) - } - - /// What the pasted text resolves to, if anything. - private var detected: LinkImporter.Link? { - LinkImporter.classify(trimmed) - } - - /// Whether the detected input imports a whole playlist (vs. adding a single video). - private var isImportAction: Bool { - detected?.isPlaylistImport ?? false - } + /// Post-import confirmation ("Imported 3 songs"), or a failure note. + @State private var resultMessage: String? var body: some View { NavigationStack { Form { Section { - TextField("YouTube or Spotify link", text: $text, axis: .vertical) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .lineLimit(1...3) - .onChange(of: text) { errorMessage = nil } - } footer: { - footerContent - } - - Section { - Button(action: add) { + Button { + showingFilePicker = true + } label: { HStack { if isImporting { ProgressView() Text("Importing…") } else { - Label( - isImportAction ? "Import Playlist" : "Add", - systemImage: isImportAction ? "music.note.list" : "arrow.down.circle.fill" - ) + Label("Choose Files", systemImage: "folder.badge.plus") } } .frame(maxWidth: .infinity) } .buttonStyle(.glassProminent) - .disabled(trimmed.isEmpty || isImporting) + .disabled(isImporting) + } footer: { + footerContent } .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) @@ -74,51 +46,41 @@ struct AddMusicView: View { .navigationTitle("Add Music") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } .disabled(isImporting) } } + .fileImporter( + isPresented: $showingFilePicker, + allowedContentTypes: [.audio], + allowsMultipleSelection: true + ) { result in + handlePicked(result) + } } } @ViewBuilder private var footerContent: some View { - if let errorMessage { - Label(errorMessage, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.red) + if let resultMessage { + Label(resultMessage, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) .font(.footnote) } else { - switch detected { - case .spotify?: - Text("Spotify playlist — each song is matched to YouTube for audio and imported as a new playlist.") - case .youtubePlaylist?: - Text("This is a playlist link — every track will be imported as a new playlist.") - default: - Text("Paste a YouTube video or playlist link, or a Spotify playlist/album link.") - } + Text("Import songs from the Files app. Title, artist, and artwork are read from the file; tempo and key are analyzed automatically for seamless transitions.") } } - /// Routes the pasted link through the shared importer, or shows an inline error. Shows the - /// spinner while resolving, dismisses on success, and maps failures to a cause-specific - /// message (so a transient network blip doesn't read as "private"). - private func add() { - guard let link = detected else { - errorMessage = "Couldn't find a YouTube or Spotify link in that text." - return - } + /// Copies the picked files into the library, then shows how many made it. + private func handlePicked(_ result: Result<[URL], Error>) { + guard case .success(let urls) = result, !urls.isEmpty else { return } isImporting = true - errorMessage = nil - let source = trimmed + resultMessage = nil Task { - defer { isImporting = false } - do { - try await LinkImporter.run(link, sourceURL: source, queue: preparationQueue, in: modelContext) - dismiss() - } catch { - errorMessage = LinkImporter.errorMessage(error, noun: link.noun) - } + let count = await preparationQueue.importLocalFiles(urls, in: modelContext) + isImporting = false + resultMessage = count == 1 ? "Imported 1 song" : "Imported \(count) songs" } } } diff --git a/App/Continuity/Views/ContinuityApp.swift b/App/Continuity/Views/ContinuityApp.swift index 1be724e..33ef8bc 100644 --- a/App/Continuity/Views/ContinuityApp.swift +++ b/App/Continuity/Views/ContinuityApp.swift @@ -8,17 +8,13 @@ import SwiftData struct ContinuityApp: App { let container: ModelContainer @State private var player = Player() - /// Drives YouTube ingestion (resolve → download → ready) for newly added tracks. + /// Drives local-file ingestion (import → analyse → ready) for newly added tracks. @State private var prepQueue = PreparationQueue() init() { do { let schema = Schema([Playlist.self, Track.self]) - // Pin the store to the app container. `groupContainer` defaults to `.automatic`, - // which (with our share-extension app group entitlement) put SwiftData in the - // group container — unused by the extension, noisy on first launch, and desynced - // from the `UserDefaults.standard` seed flag. - Self.migrateStoreOutOfAppGroupIfNeeded() + // Pin the store to the app container explicitly. let configuration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: false, @@ -40,28 +36,4 @@ struct ContinuityApp: App { } .modelContainer(container) } - - /// One-time move of an existing app-group SwiftData store into Application Support so - /// libraries built before `groupContainer: .none` aren't orphaned beside an empty new store. - private static func migrateStoreOutOfAppGroupIfNeeded() { - let fm = FileManager.default - guard let groupRoot = fm.containerURL( - forSecurityApplicationGroupIdentifier: "group.com.sanylax.continuity" - ), - let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - else { return } - - let legacyDir = groupRoot.appendingPathComponent("Library/Application Support", isDirectory: true) - let legacyStore = legacyDir.appendingPathComponent("default.store") - let appStore = appSupport.appendingPathComponent("default.store") - guard fm.fileExists(atPath: legacyStore.path), - !fm.fileExists(atPath: appStore.path) else { return } - - try? fm.createDirectory(at: appSupport, withIntermediateDirectories: true) - for name in ["default.store", "default.store-wal", "default.store-shm"] { - let src = legacyDir.appendingPathComponent(name) - guard fm.fileExists(atPath: src.path) else { continue } - try? fm.moveItem(at: src, to: appSupport.appendingPathComponent(name)) - } - } } diff --git a/App/Continuity/Views/LibrarySheetView.swift b/App/Continuity/Views/LibrarySheetView.swift index b52b865..293d7ae 100644 --- a/App/Continuity/Views/LibrarySheetView.swift +++ b/App/Continuity/Views/LibrarySheetView.swift @@ -6,9 +6,7 @@ import Playback /// with a mini player that jumps back to the home page. struct LibrarySheetView: View { @Environment(Player.self) private var player - @Environment(PreparationQueue.self) private var prepQueue @Environment(MainPagerState.self) private var pagerState - @Environment(\.modelContext) private var modelContext @State private var showingAdd = false var body: some View { @@ -16,17 +14,6 @@ struct LibrarySheetView: View { LibraryView() .navigationTitle("Continuity") .toolbar { - // Manual whole-library sync (source-backed playlists only). - ToolbarItem(placement: .secondaryAction) { - Button { - prepQueue.syncAll(in: modelContext) - } label: { - Image(systemName: "arrow.triangle.2.circlepath") - .symbolEffect(.rotate, isActive: !prepQueue.syncingPlaylistIDs.isEmpty) - } - .disabled(!prepQueue.syncingPlaylistIDs.isEmpty) - .accessibilityLabel("Sync library") - } ToolbarItem(placement: .primaryAction) { Button { showingAdd = true diff --git a/App/Continuity/Views/LibraryView.swift b/App/Continuity/Views/LibraryView.swift index 15f97d4..5101f32 100644 --- a/App/Continuity/Views/LibraryView.swift +++ b/App/Continuity/Views/LibraryView.swift @@ -87,11 +87,11 @@ struct LibraryView: View { /// surviving track shares. private func delete(_ playlist: Playlist) { let trackIDs = Set(playlist.tracks.map(\.id)) - let videoIDs = playlist.tracks.compactMap(\.youtubeVideoID) + let keys = playlist.tracks.map(\.stemKey) player.handleDeleted(trackIDs: trackIDs) modelContext.delete(playlist) // cascade deletes its tracks try? modelContext.save() - LibraryCleanup.removeOrphanedFiles(videoIDs: videoIDs, in: modelContext) + LibraryCleanup.removeOrphanedFiles(keys: keys, in: modelContext) } } diff --git a/App/Continuity/Views/PlaylistDetailView.swift b/App/Continuity/Views/PlaylistDetailView.swift index ebfcbd4..dd1c963 100644 --- a/App/Continuity/Views/PlaylistDetailView.swift +++ b/App/Continuity/Views/PlaylistDetailView.swift @@ -7,12 +7,9 @@ import Domain struct PlaylistDetailView: View { @Bindable var playlist: Playlist @Environment(Player.self) private var player - @Environment(PreparationQueue.self) private var prepQueue @Environment(MainPagerState.self) private var pagerState @Environment(\.modelContext) private var modelContext - private var isSyncing: Bool { prepQueue.syncingPlaylistIDs.contains(playlist.id) } - var body: some View { List { // The header is a regular row — NOT a pinned section header, which in a plain list @@ -26,13 +23,10 @@ struct PlaylistDetailView: View { TrackRow(track: track, isCurrent: player.currentTrack?.id == track.id) .contentShape(Rectangle()) .onTapGesture { - // A failed ingest can't be played — tapping it retries instead. - if track.prepState == .failed { - prepQueue.enqueue(track, in: modelContext) - } else { - player.play(tracks: playlist.orderedTracks, startAt: index) - pagerState.goToNowPlaying() - } + // A failed track (its imported file went missing) can't be played. + guard track.prepState != .failed else { return } + player.play(tracks: playlist.orderedTracks, startAt: index) + pagerState.goToNowPlaying() } .contextMenu { Button { @@ -58,14 +52,12 @@ struct PlaylistDetailView: View { /// Removes a track: the player drops it first (so no deck/queue reference dangles), then the /// model goes, then any cached files no other track shares. private func delete(_ track: Track) { - let videoID = track.youtubeVideoID + let key = track.stemKey player.handleDeleted(trackIDs: [track.id]) modelContext.delete(track) playlist.touch() // membership changed → resort the library try? modelContext.save() - if let videoID { - LibraryCleanup.removeOrphanedFiles(videoIDs: [videoID], in: modelContext) - } + LibraryCleanup.removeOrphanedFiles(keys: [key], in: modelContext) } private var header: some View { @@ -84,33 +76,6 @@ struct PlaylistDetailView: View { } .buttonStyle(.glassProminent) .padding(.top, 4) - - // Source-backed playlists mirror a remote list: manual sync + the auto-sync opt-out. - if playlist.isSourceBacked { - HStack(spacing: 16) { - Button { - Task { await prepQueue.syncPlaylist(playlist, in: modelContext) } - } label: { - Label(isSyncing ? "Syncing…" : "Sync", systemImage: "arrow.triangle.2.circlepath") - .font(.subheadline) - } - .buttonStyle(.bordered) - .disabled(isSyncing) - - Toggle(isOn: $playlist.autoSyncEnabled) { - Text("Auto-sync") - .font(.subheadline) - } - .fixedSize() - } - .padding(.top, 2) - - if let synced = playlist.lastSyncedAt { - Text("Synced \(synced.formatted(.relative(presentation: .named)))") - .font(.caption2) - .foregroundStyle(.tertiary) - } - } } .frame(maxWidth: .infinity) .padding(.vertical, 16) @@ -166,8 +131,8 @@ private struct TrackRow: View { ProgressView() .controlSize(.mini) case .failed: - // Tapping the row retries a failed ingest — the retry glyph signals it's actionable. - Image(systemName: "arrow.clockwise") + // The imported file went missing — the track can't play until re-imported. + Image(systemName: "exclamationmark.triangle") .font(.caption.weight(.semibold)) .foregroundStyle(.orange) case .ready: diff --git a/App/Continuity/Views/RootView.swift b/App/Continuity/Views/RootView.swift index b144755..7a8d721 100644 --- a/App/Continuity/Views/RootView.swift +++ b/App/Continuity/Views/RootView.swift @@ -1,6 +1,5 @@ import SwiftUI import UIKit -import Combine import Ingest import Playback import Domain @@ -13,63 +12,16 @@ struct RootView: View { @Environment(Player.self) private var player @Environment(PreparationQueue.self) private var prepQueue @Environment(\.modelContext) private var modelContext - @Environment(\.scenePhase) private var scenePhase - - /// Link awaiting user confirmation (from the URL scheme, a shared https URL, or the clipboard). - @State private var pendingImport: PendingLinkImport? - @State private var importError: String? - /// Debounce: a clipboard URL is offered at most once, even across launches. - @AppStorage("lastOfferedClipboardURL") private var lastOfferedClipboardURL = "" - /// Last pasteboard generation we inspected — gates the banner-triggering reads below. - /// In-memory on purpose: UIPasteboard.changeCount restarts from a small number after a - /// device reboot, so a value persisted across launches can collide with a fresh generation - /// and silently skip a genuinely new link. Re-checking once per launch is the correct cost. - @State private var lastCheckedPasteboardChange = -1 - - /// One publisher for the view's identity — inline `Timer.publish` in `body` is rebuilt (and - /// its countdown reset) on every re-evaluation (scene transitions, alert state). - @State private var syncTick = Timer.publish(every: 60, on: .main, in: .common).autoconnect() var body: some View { MainPagerView() // On launch: drop cached files orphaned by deletions, resume unfinished ingestion, // then bring back the previous playback session (or stage the first-run track). .task { - // Sync-driven deletions must clear the live queue before models are destroyed. + // Deletions must clear the live queue before models are destroyed. prepQueue.onTracksDeleted = { [weak player] ids in player?.handleDeleted(trackIDs: ids) } - // A changed sync re-mirrors the live queue when the current track belongs to the - // synced playlist: what follows it matches the fresh remote order, earlier tracks - // loop after (wrap-around next() semantics). Remote truth wins for playlist-backed - // queues — manual Up Next edits / Flow ordering are overwritten. - prepQueue.onPlaylistSynced = { [weak player] _, orderedTracks in - Task { @MainActor in - // replaceUpcoming would cancel a live blend mid-fade — and `changed` - // fires once per remote edit, so a silent skip here would lose the new - // order until the NEXT edit. Wait the blend out instead: blends last - // seconds, syncs are a tick apart, so waiters never stack. Bounded in - // case a blend chain keeps isTransitioning hot. - var waited: TimeInterval = 0 - while player?.isTransitioning == true, waited < 30 { - try? await Task.sleep(nanoseconds: 500_000_000) - waited += 0.5 - } - guard let player, !player.isTransitioning, - let current = player.currentTrack else { return } - // Tracks can die during the wait (manual delete mid-blend) — a dead - // @Model in the queue crashes on next access. - let live = orderedTracks.filter { $0.modelContext != nil } - // Current track not in the synced playlist (or removed by the sync, - // already handled via onTracksDeleted) → not our queue, leave it alone. - guard let index = live.firstIndex(where: { $0.id == current.id }) - else { return } - let rotated = Array(live[(index + 1)...]) + Array(live[..` carries the real link; a directly-shared - /// http(s) URL *is* the link. Anything unclassifiable is silently ignored. - private func handleIncomingLink(_ url: URL) { - let raw: String - if url.scheme?.lowercased() == "continuity" { - guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), - let target = comps.queryItems?.first(where: { $0.name == "url" })?.value, - !target.isEmpty else { return } - raw = target - } else { - raw = url.absoluteString - } - guard let link = LinkImporter.classify(raw) else { return } - offer(link, rawURL: raw) - } - - /// Picks up a URL stashed by the share extension (written to group defaults because the - /// extension can't talk to the app directly). Read-and-clear so each share is offered once. - private func consumePendingSharedURL() { - guard pendingImport == nil else { return } - // Nil suite (missing app-group entitlement) degrades to a no-op rather than crashing. - guard let defaults = UserDefaults(suiteName: "group.com.sanylax.continuity") else { return } - guard let payload = defaults.dictionary(forKey: "pendingSharedURL.v1"), - let raw = payload["url"] as? String else { return } - defaults.removeObject(forKey: "pendingSharedURL.v1") - guard let link = LinkImporter.classify(raw) else { return } - offer(link, rawURL: raw) - } - - /// Offers to import a YouTube/Spotify link sitting on the clipboard. Pattern detection is - /// banner-free; the one `.string` read (only after detection says it's a URL) shows the - /// iOS paste notice, which is acceptable for a confirmed hit. - private func checkClipboardForImportableLink() { - guard pendingImport == nil else { return } // don't stomp a link-open confirmation - let pasteboard = UIPasteboard.general - // changeCount is banner-free: inspect each clipboard generation once, else the - // `.string` read below would flash the paste banner on every foreground. - guard pasteboard.changeCount != lastCheckedPasteboardChange else { return } - lastCheckedPasteboardChange = pasteboard.changeCount - guard pasteboard.hasStrings || pasteboard.hasURLs else { return } - Task { - guard let patterns = try? await pasteboard.detectedPatterns(for: [\.probableWebURL]), - patterns.contains(\.probableWebURL), - let raw = (pasteboard.string ?? pasteboard.url?.absoluteString)? - .trimmingCharacters(in: .whitespacesAndNewlines), - raw != lastOfferedClipboardURL, - let link = LinkImporter.classify(raw) else { return } - // Debounce only once the offer actually presents — if another confirmation is up, - // offer() drops the link and it must stay eligible for the next foreground pass. - if offer(link, rawURL: raw) { - lastOfferedClipboardURL = raw - } - } - } - - /// Returns whether the confirmation was actually presented (false when another one is up). - @discardableResult - private func offer(_ link: LinkImporter.Link, rawURL: String) -> Bool { - // First confirmation wins — replacing the item under a presented alert would leave it - // showing (and importing) stale captured data. Covers the async clipboard task racing - // a link-open, and a second link-open while the alert is up. - guard pendingImport == nil else { return false } - let host = URLComponents(string: rawURL.contains("://") ? rawURL : "https://" + rawURL)? - .host ?? link.sourceName - pendingImport = PendingLinkImport(link: link, rawURL: rawURL, host: host) - return true - } - - /// Same import path AddMusicView uses; failures surface in the "Import Failed" alert. - private func startImport(_ pending: PendingLinkImport) { - Task { - do { - try await LinkImporter.run( - pending.link, sourceURL: pending.rawURL, queue: prepQueue, in: modelContext - ) - } catch { - importError = LinkImporter.errorMessage(error, noun: pending.link.noun) - } - } } // MARK: - Session restore @@ -246,11 +72,3 @@ struct RootView: View { player.prepare(tracks: queue, startAt: index) } } - -/// A classified link waiting for the user's "Import" confirmation. -private struct PendingLinkImport: Identifiable { - let id = UUID() - let link: LinkImporter.Link - let rawURL: String - let host: String -} diff --git a/App/ShareExtension/ContinuityShare.entitlements b/App/ShareExtension/ContinuityShare.entitlements deleted file mode 100644 index 1f8d13d..0000000 --- a/App/ShareExtension/ContinuityShare.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.sanylax.continuity - - - diff --git a/App/ShareExtension/Info.plist b/App/ShareExtension/Info.plist deleted file mode 100644 index b318ea7..0000000 --- a/App/ShareExtension/Info.plist +++ /dev/null @@ -1,36 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Continuity - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - NSExtension - - NSExtensionAttributes - - NSExtensionActivationRule - SUBQUERY(extensionItems, $extensionItem, SUBQUERY($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text").@count >= 1).@count >= 1 - - NSExtensionPointIdentifier - com.apple.share-services - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).ShareViewController - - - diff --git a/App/ShareExtension/ShareViewController.swift b/App/ShareExtension/ShareViewController.swift deleted file mode 100644 index 2311f9f..0000000 --- a/App/ShareExtension/ShareViewController.swift +++ /dev/null @@ -1,130 +0,0 @@ -import UIKit -import UniformTypeIdentifiers -import ContinuityCore - -/// Sheet-less share extension: grabs the shared URL, stashes it in the app group for the main -/// app to import on next foreground, flashes a confirmation, and dismisses. -final class ShareViewController: UIViewController { - - /// Handoff channel — must match the app-side reader (RootView) and both entitlements files. - private static let appGroupID = "group.com.sanylax.continuity" - private static let pendingURLKey = "pendingSharedURL.v1" - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .clear - extractSharedURL() - } - - /// Walks attachments for a URL (or a plain-text string that parses as one). - private func extractSharedURL() { - let providers = (extensionContext?.inputItems as? [NSExtensionItem])? - .compactMap(\.attachments) - .flatMap { $0 } ?? [] - - if let provider = providers.first(where: { - $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) - }) { - provider.loadItem(forTypeIdentifier: UTType.url.identifier) { [weak self] item, _ in - DispatchQueue.main.async { - guard let self else { return } - // Some hosts hand the URL over as bookmark-style Data rather than NSURL. - let url = (item as? URL) - ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) } - guard let url else { return self.finishUnsupported() } - self.acceptIfImportable(url) - } - } - return - } - - // Spotify (and some browsers) share the link as plain text alongside/instead of public.url. - if let provider = providers.first(where: { - $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) - }) { - provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] item, _ in - DispatchQueue.main.async { - guard let self else { return } - let raw = (item as? String) - ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } - guard let raw, let url = Self.firstURL(in: raw) else { - return self.finishUnsupported() - } - self.acceptIfImportable(url) - } - } - return - } - - finishUnsupported() - } - - /// Only stash + cheer when ContinuityCore can classify the link — otherwise the main app - /// would silently drop it after we already told the user it worked. - private func acceptIfImportable(_ url: URL) { - let raw = url.absoluteString - let importable = SpotifyURL.parse(raw) != nil || YouTubeURL.parse(raw) != nil - guard importable else { return finishUnsupported() } - stash(url) - } - - /// Writes the URL + timestamp to group defaults; the app reads and clears it on foreground. - private func stash(_ url: URL) { - guard let defaults = UserDefaults(suiteName: Self.appGroupID) else { - return finish(message: "Couldn't hand off to Continuity", succeed: false) - } - defaults.set( - ["url": url.absoluteString, "sharedAt": Date().timeIntervalSince1970], - forKey: Self.pendingURLKey - ) - finish(message: "Added to Continuity", succeed: true) - } - - private func finishUnsupported() { - finish(message: "Can't import this link", succeed: false) - } - - /// Brief status pill, then complete or cancel the share request. - private func finish(message: String, succeed: Bool) { - let label = UILabel() - label.text = message - label.font = .preferredFont(forTextStyle: .headline) - label.textColor = .label - label.textAlignment = .center - label.backgroundColor = .secondarySystemBackground - label.layer.cornerRadius = 14 - label.layer.masksToBounds = true - label.translatesAutoresizingMaskIntoConstraints = false - label.isAccessibilityElement = true - label.accessibilityTraits = .staticText - view.addSubview(label) - NSLayoutConstraint.activate([ - label.centerXAnchor.constraint(equalTo: view.centerXAnchor), - label.centerYAnchor.constraint(equalTo: view.centerYAnchor), - label.widthAnchor.constraint(greaterThanOrEqualToConstant: 240), - label.widthAnchor.constraint(lessThanOrEqualToConstant: 300), - label.heightAnchor.constraint(equalToConstant: 56) - ]) - UIAccessibility.post(notification: .announcement, argument: message) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in - guard let self else { return } - if succeed { - self.extensionContext?.completeRequest(returningItems: nil) - } else { - self.extensionContext?.cancelRequest( - withError: NSError(domain: "com.sanylax.continuity.share", code: 1) - ) - } - } - } - - /// First http(s) URL token in a shared text blob, if any. - private static func firstURL(in raw: String) -> URL? { - let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - let range = NSRange(raw.startIndex.. URL { - let destination = AudioCache.fileURL(videoID: resolved.videoID, container: resolved.container) - - // Cache hit: already on disk. - if FileManager.default.fileExists(atPath: destination.path) { - return destination - } - - // Assemble into a unique temp file, guaranteed cleaned up on every exit path. - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent("continuity-\(resolved.videoID)-\(UUID().uuidString).\(resolved.container)") - defer { try? FileManager.default.removeItem(at: tempURL) } - - try await downloadRanged(from: resolved.url, to: tempURL) - - // Publish. Move (rename) is atomic on the same volume; if a concurrent download already - // won the race and the file now exists, treat that as success rather than corrupting it. - do { - try FileManager.default.moveItem(at: tempURL, to: destination) - } catch { - if FileManager.default.fileExists(atPath: destination.path) { - return destination - } - throw IngestError.downloadFailed(String(describing: error)) - } - return destination - } - - /// Streams `url` into `fileURL` using sequential `Range` requests until the whole file is fetched. - private func downloadRanged(from url: URL, to fileURL: URL) async throws { - FileManager.default.createFile(atPath: fileURL.path, contents: nil) - let handle = try FileHandle(forWritingTo: fileURL) - do { - var offset = 0 - var totalSize: Int? - repeat { - let upperBound = offset + chunkSize - 1 - let (data, reportedTotal, isWholeFile) = try await fetchChunk(url: url, from: offset, to: upperBound) - if let reportedTotal { totalSize = reportedTotal } - if data.isEmpty { break } // nothing more to read - if isWholeFile && offset > 0 { - // The server ignored Range mid-download and sent the entire file — appending - // it would duplicate every byte already written. Restart the file with this - // complete body instead. - try handle.truncate(atOffset: 0) - offset = 0 - } - try handle.write(contentsOf: data) - offset += data.count - } while totalSize == nil || offset < totalSize! - - try handle.close() - - if let totalSize, offset < totalSize { - throw IngestError.downloadFailed("incomplete download: \(offset)/\(totalSize) bytes") - } - if offset == 0 { - throw IngestError.downloadFailed("empty download") - } - } catch { - try? handle.close() - throw error - } - } - - /// Fetches one byte range. Returns the chunk, the total file size when the server reports it - /// (via `Content-Range` on a 206, or the body length on a 200 where the server ignored `Range`), - /// and whether the body is the WHOLE file (a 200) rather than the requested range — the caller - /// must not append a whole-file body at a non-zero offset. - private func fetchChunk(url: URL, from: Int, to: Int) async throws -> (Data, Int?, Bool) { - var lastError: Error? - for attempt in 0.. Int? { - guard let header, let slash = header.lastIndex(of: "/") else { return nil } - let totalString = header[header.index(after: slash)...].trimmingCharacters(in: .whitespaces) - return totalString == "*" ? nil : Int(totalString) - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift b/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift index 8ec58df..277de81 100644 --- a/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift +++ b/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift @@ -1,99 +1,7 @@ import Foundation import ContinuityCore -/// Shared contracts for the M1 ingestion pipeline (resolve → download → ready). -/// Concrete types implement these so each stage stays swappable and independently testable. - -/// A resolved, directly-downloadable audio stream for one YouTube video. -struct ResolvedAudio: Sendable, Equatable { - let videoID: String - let url: URL - let itag: Int - /// Container/extension, e.g. "m4a". - let container: String - /// True if `AVAudioFile` can decode it without transcoding (AAC/m4a). - let isNativelyPlayable: Bool - /// Best-effort average bitrate in bits/sec. - let approxBitrate: Int -} - +/// Errors surfaced by the local-file ingest pipeline (import → analyse → ready). public enum IngestError: Error, Sendable { - case invalidURL - case noVideoID - case noPlayableStream - case resolveFailed(String) - case downloadFailed(String) case decodeFailed(String) - /// Connectivity/timeout/5xx talking to the source — a retry may succeed. - case network(String) - /// HTTP 429 from the source — a retry after a short delay may succeed. - case rateLimited - /// The source was reached and understood, but has no usable content - /// (private, empty, region-locked, or deleted). Retrying won't help. - case sourceUnavailable - - /// Whether retrying the same request with backoff could plausibly succeed. Distinguishes a - /// transient blip (worth retrying, and not the user's fault) from a definitively empty source. - var isRetryable: Bool { - switch self { - case .network, .rateLimited: return true - default: return false - } - } -} - -/// A resolved YouTube playlist: its videos (in order) plus the playlist's own title. -struct ResolvedPlaylist: Sendable, Equatable { - let playlistID: String - let title: String? - let items: [YouTubePlaylistItem] -} - -/// A resolved Spotify playlist/album: its tracks (metadata only — audio comes from YouTube). -struct ResolvedSpotifyPlaylist: Sendable, Equatable { - let link: SpotifyLink - let name: String? - let tracks: [SpotifyTrack] -} - -/// Resolves a YouTube video ID to a downloadable audio stream. -/// Implemented by `YouTubeStreamResolver`. -protocol AudioStreamResolving: Sendable { - func resolveAudio(videoID: String) async throws -> ResolvedAudio -} - -/// Resolves a YouTube playlist ID to its constituent videos. -/// Implemented by `YouTubePlaylistResolver`. -protocol PlaylistResolving: Sendable { - func resolvePlaylist(playlistID: String) async throws -> ResolvedPlaylist -} - -/// Resolves a Spotify playlist/album to its tracklist (metadata only). -/// Implemented by `SpotifyPlaylistResolver`. -protocol SpotifyPlaylistResolving: Sendable { - func resolvePlaylist(_ link: SpotifyLink) async throws -> ResolvedSpotifyPlaylist -} - -/// Finds the best-matching YouTube video ID for a text query. -/// Implemented by `YouTubeSearchResolver`. -protocol YouTubeSearching: Sendable { - func firstVideoID(query: String) async throws -> String? -} - -/// Real display metadata for one YouTube video (title + channel/author). -struct VideoMetadata: Sendable, Equatable { - let title: String - let author: String? -} - -/// Resolves a video ID to its display metadata. -/// Implemented by `YouTubeOEmbedResolver`. -protocol VideoMetadataResolving: Sendable { - func metadata(videoID: String) async throws -> VideoMetadata -} - -/// Downloads a resolved stream to local storage and returns the on-disk file URL. -/// Implemented by `AudioDownloader`. -protocol AudioFileDownloading: Sendable { - func downloadAudio(_ resolved: ResolvedAudio) async throws -> URL } diff --git a/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift b/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift index ae0eb70..bf6ea13 100644 --- a/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift +++ b/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift @@ -2,43 +2,31 @@ import Foundation import Domain import SwiftData -/// Removes cached files (downloaded audio + separated stems) for deleted tracks — but only when -/// no surviving track still references the same source video (the same video can appear in -/// several playlists; caches are keyed by video ID and shared). +/// Removes cached files (imported audio + separated stems + extracted artwork) for deleted +/// tracks — but only when no surviving track still shares the same stem key (a legacy +/// YouTube-sourced video can appear in several playlists; caches are keyed by `Track.stemKey` +/// and shared). /// /// Call AFTER the track models have been deleted and saved, so the reference check sees the /// post-deletion library. public enum LibraryCleanup { @MainActor - public static func removeOrphanedFiles(videoIDs: [String], in context: ModelContext) { - for videoID in Set(videoIDs) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.youtubeVideoID == videoID } - ) - let stillReferenced = ((try? context.fetchCount(descriptor)) ?? 0) > 0 - guard !stillReferenced else { continue } - - // Audio: the container extension varies (m4a/webm/…), so match by basename. - if let files = try? FileManager.default.contentsOfDirectory( - at: AudioCache.directory, includingPropertiesForKeys: nil - ) { - for file in files where file.deletingPathExtension().lastPathComponent == videoID { - try? FileManager.default.removeItem(at: file) - } - } - - StemCache.removeStems(key: videoID) + public static func removeOrphanedFiles(keys: [String], in context: ModelContext) { + let tracks = (try? context.fetch(FetchDescriptor())) ?? [] + let referenced = Set(tracks.map(\.stemKey)) + for key in Set(keys) where !referenced.contains(key) { + removeCachedFiles(key: key) } } - /// Launch-time sweep: removes any cached file whose video ID has no surviving track. Catches - /// downloads/stems that were still in flight when their tracks were deleted and landed on - /// disk after the delete-time cleanup had already run. + /// Launch-time sweep: removes any cached file whose stem key has no surviving track. + /// Catches files that were still being written when their tracks were deleted and landed + /// on disk after the delete-time cleanup had already run. @MainActor public static func sweepOrphanedFiles(in context: ModelContext) { guard let tracks = try? context.fetch(FetchDescriptor()) else { return } - let referenced = Set(tracks.compactMap(\.youtubeVideoID)) + let referenced = Set(tracks.map(\.stemKey)) if let files = try? FileManager.default.contentsOfDirectory( at: AudioCache.directory, includingPropertiesForKeys: nil @@ -53,7 +41,7 @@ public enum LibraryCleanup { ) { for file in files { // Stem names are "-vocals.*" / "-accompaniment.*". Suffix-only parsing - // (a video ID can contain "-vocals" as a substring); files that don't match the + // (a key can contain "-vocals" as a substring); files that don't match the // stem naming scheme at all are junk in this directory and stay sweepable. let base = file.deletingPathExtension().lastPathComponent let key = StemCache.key(fromStemBaseName: base) ?? base @@ -62,5 +50,28 @@ public enum LibraryCleanup { } } } + + if let files = try? FileManager.default.contentsOfDirectory( + at: ArtworkStore.directory, includingPropertiesForKeys: nil + ) { + for file in files where !referenced.contains(file.deletingPathExtension().lastPathComponent) { + try? FileManager.default.removeItem(at: file) + } + } + } + + /// Deletes every cached artifact for one stem key: audio (extension varies, match by + /// basename), stems, and extracted artwork. + private static func removeCachedFiles(key: String) { + if let files = try? FileManager.default.contentsOfDirectory( + at: AudioCache.directory, includingPropertiesForKeys: nil + ) { + for file in files where file.deletingPathExtension().lastPathComponent == key { + try? FileManager.default.removeItem(at: file) + } + } + StemCache.removeStems(key: key) + try? FileManager.default.removeItem( + at: ArtworkStore.directory.appendingPathComponent("\(key).jpg")) } } diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift deleted file mode 100644 index 387f127..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift +++ /dev/null @@ -1,99 +0,0 @@ -import AVFoundation -import Domain -import Foundation -import SwiftData -import ContinuityCore -import os - -extension PreparationQueue { - /// Resolves a YouTube playlist, creates a matching library `Playlist` with one placeholder - /// `Track` per video, and enqueues every track for ingestion. The page fetch runs off the - /// main actor inside the awaited resolver; the model writes happen here on the main actor. - /// - /// Throws if the playlist can't be resolved (private/empty/unavailable or a YouTube change), - /// so the caller can surface an inline error. Returns the created playlist on success. - @discardableResult - public func importPlaylist(playlistID: String, fallbackTitle: String? = nil, in context: ModelContext) async throws -> Playlist { - let resolved = try await playlistResolver.resolvePlaylist(playlistID: playlistID) - - let title = resolved.title?.isEmpty == false ? resolved.title! : (fallbackTitle ?? "YouTube Playlist") - // Deterministic-ish gradient seed from the playlist ID so the card has a stable colour. - let seed = resolved.playlistID.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } % 90 + 10 - - let playlist = Playlist( - title: title, - subtitle: "From YouTube · \(resolved.items.count) tracks", - artworkSymbol: "music.note.list", - gradientSeed: seed - ) - playlist.sourceKind = .youtube - playlist.sourceID = resolved.playlistID - playlist.lastSyncedAt = Date() - context.insert(playlist) - - for (index, item) in resolved.items.enumerated() { - let track = Track( - title: item.title ?? "YouTube Video (\(item.videoID.prefix(6)))", - artist: item.author ?? "YouTube", - durationSeconds: Double(item.lengthSeconds ?? 0), - artworkSymbol: playlist.artworkSymbol, - gradientSeed: seed * 100 + index, - sortIndex: index, - prepState: .pending, - youtubeVideoID: item.videoID, - sourceURLString: "https://www.youtube.com/watch?v=\(item.videoID)" - ) - playlist.tracks.append(track) - context.insert(track) - enqueue(track, in: context) - } - playlist.touch() // creation + initial tracks count as a content change - try? context.save() - return playlist - } - - /// Imports a Spotify playlist/album: resolves its tracklist (metadata only — Spotify audio is - /// DRM-protected and unusable by our engine), creates a matching library `Playlist`, and - /// enqueues one `Track` per song. Each track carries a `searchQuery` instead of a video ID; - /// the ingest pipeline resolves that to real YouTube audio (see `process`). - /// - /// Throws if the playlist can't be resolved so the caller can surface an inline error. - @discardableResult - public func importSpotifyPlaylist(_ link: SpotifyLink, in context: ModelContext) async throws -> Playlist { - let resolved = try await spotifyResolver.resolvePlaylist(link) - - let title = resolved.name?.isEmpty == false ? resolved.name! : "Spotify \(link.kind.rawValue.capitalized)" - let seed = link.id.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } % 90 + 10 - - let playlist = Playlist( - title: title, - subtitle: "From Spotify · \(resolved.tracks.count) tracks", - artworkSymbol: "music.note.list", - gradientSeed: seed - ) - playlist.sourceKind = link.kind == .album ? .spotifyAlbum : .spotifyPlaylist - playlist.sourceID = link.id - playlist.lastSyncedAt = Date() - context.insert(playlist) - - for (index, spotifyTrack) in resolved.tracks.enumerated() { - let track = Track( - title: spotifyTrack.title, - artist: spotifyTrack.artist ?? "Unknown Artist", - durationSeconds: Double(spotifyTrack.durationSeconds ?? 0), - artworkSymbol: playlist.artworkSymbol, - gradientSeed: seed * 100 + index, - sortIndex: index, - prepState: .pending, - // No video ID yet — the pipeline finds the audio on YouTube from this query. - searchQuery: spotifyTrack.youtubeSearchQuery - ) - playlist.tracks.append(track) - context.insert(track) - enqueue(track, in: context) - } - playlist.touch() // creation + initial tracks count as a content change - try? context.save() - return playlist - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift new file mode 100644 index 0000000..feb7cc2 --- /dev/null +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift @@ -0,0 +1,113 @@ +import AVFoundation +import Domain +import Foundation +import SwiftData +import ContinuityCore +import os + +extension PreparationQueue { + /// Imports user-picked local audio files: copies each into the audio cache, reads its + /// embedded metadata (title/artist/duration/artwork), creates a ready `Track` in the + /// shared "My Music" playlist, and kicks off analysis. Returns how many files imported + /// successfully; failures are logged and skipped (partial imports succeed). + public func importLocalFiles(_ urls: [URL], in context: ModelContext) async -> Int { + var imported = 0 + for url in urls { + if await importOne(url, in: context) { imported += 1 } + } + return imported + } + + private func importOne(_ url: URL, in context: ModelContext) async -> Bool { + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + + // One fresh UUID keys everything for this track: the model row, the cached audio + // file's basename, and the stem-cache key (`Track.stemKey` == id for local imports) — + // so cleanup/sweep can match files to tracks by basename. + let trackID = UUID() + let ext = url.pathExtension.isEmpty ? "m4a" : url.pathExtension.lowercased() + let destination = AudioCache.fileURL(videoID: trackID.uuidString, container: ext) + do { + try FileManager.default.copyItem(at: url, to: destination) + } catch { + Logger.ingest.error("local import copy failed for \(url.lastPathComponent, privacy: .public): \(String(describing: error), privacy: .public)") + return false + } + + // Metadata load runs off-main (AVURLAsset touches the file). + let fallbackTitle = url.deletingPathExtension().lastPathComponent + let meta = await Task.detached(priority: .userInitiated) { () -> (title: String?, artist: String?, duration: Double, artwork: Data?) in + let asset = AVURLAsset(url: destination) + var title: String? + var artist: String? + var artwork: Data? + var duration: Double = 0 + if let seconds = try? await asset.load(.duration).seconds, seconds.isFinite { + duration = seconds + } + if let items = try? await asset.load(.commonMetadata) { + for item in items { + switch item.commonKey { + case .commonKeyTitle?: title = try? await item.load(.stringValue) + case .commonKeyArtist?: artist = try? await item.load(.stringValue) + case .commonKeyArtwork?: artwork = try? await item.load(.dataValue) + default: break + } + } + } + return (title, artist, duration, artwork) + }.value + + // Embedded artwork → Application Support/Artwork/.jpg (excluded from backup by + // the directory, mirroring StemCache). + var artworkPath: String? + if let data = meta.artwork { + let name = "\(trackID.uuidString).jpg" + let artworkURL = ArtworkStore.directory.appendingPathComponent(name) + if (try? data.write(to: artworkURL)) != nil { artworkPath = name } + } + + let playlist = Self.findOrCreateMyMusicPlaylist(in: context) + let track = Track( + id: trackID, + title: meta.title?.isEmpty == false ? meta.title! : fallbackTitle, + artist: meta.artist?.isEmpty == false ? meta.artist! : "Unknown Artist", + durationSeconds: meta.duration, + artworkSymbol: playlist.artworkSymbol, + // Vary the gradient per track so rows are visually distinct. + gradientSeed: playlist.gradientSeed * 100 + playlist.tracks.count, + sortIndex: playlist.tracks.count, + prepState: .ready, + localRelativePath: AudioCache.relativePath(for: destination) + ) + track.artworkPath = artworkPath + + playlist.tracks.append(track) + context.insert(track) + playlist.touch() // membership changed → resort the library + try? context.save() + + // BPM/key/loudness/silence analysis runs post-ready, limiter-gated. + backfillTrackDetails(track, in: context) + return true + } + + /// Returns the shared "My Music" playlist for local imports, creating it if missing. + private static func findOrCreateMyMusicPlaylist(in context: ModelContext) -> Playlist { + let title = "My Music" + let descriptor = FetchDescriptor(predicate: #Predicate { $0.title == title }) + // Skip a demo playlist that happens to share the name — imports go to a real one. + if let existing = (try? context.fetch(descriptor))?.first(where: { !$0.isDemo }) { + return existing + } + let playlist = Playlist( + title: title, + subtitle: "Imported from Files", + artworkSymbol: "music.note", + gradientSeed: 11 + ) + context.insert(playlist) + return playlist + } +} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift index b7abb40..9be684d 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift @@ -14,17 +14,17 @@ extension PreparationQueue { // Diagnostic kill switch: launch argument `-debug.disableStemSeparation YES` rules the // whole separation pipeline in/out of a memory repro in one run, no code edits. if UserDefaults.standard.bool(forKey: "debug.disableStemSeparation") { return } - protectedStemKeys = Set(tracks.compactMap(\.youtubeVideoID)) + protectedStemKeys = Set(tracks.map(\.stemKey)) // First stem demand of the session = playback just started. Hold separation for a // minute so its peak never stacks on engine/UI/download ramp-up. if separationAllowedAt == nil { separationAllowedAt = Date().addingTimeInterval(60) } let holdActive = Date() < (separationAllowedAt ?? .distantPast) for track in tracks { guard !track.isDemo, track.prepState == .ready, - let key = track.youtubeVideoID, track.localRelativePath != nil else { continue } + track.localRelativePath != nil else { continue } reconcileStemLinks(track, in: context) if track.hasStems { - StemCache.markUsed(key: key) // actively played material stays cache-resident + StemCache.markUsed(key: track.stemKey) // actively played material stays cache-resident } else if holdActive { scheduleDeferredStemsRetry(for: tracks, in: context) } else { @@ -65,7 +65,7 @@ extension PreparationQueue { /// (e.g. a re-added video), clears links whose files were evicted so `hasStems` tells the /// truth and the track becomes eligible for re-separation. func reconcileStemLinks(_ track: Track, in context: ModelContext) { - guard let key = track.youtubeVideoID else { return } + let key = track.stemKey if let v = track.vocalsRelativePath, let a = track.accompanimentRelativePath, FileManager.default.fileExists(atPath: StemCache.url(forRelativePath: v).path), FileManager.default.fileExists(atPath: StemCache.url(forRelativePath: a).path) { @@ -88,7 +88,8 @@ extension PreparationQueue { /// transition for this track. Serialised via `stemLimiter`; after each separation the cache's /// byte budget is enforced (LRU eviction, protecting the play-queue neighborhood). private func separateStems(_ track: Track, in context: ModelContext) { - guard let key = track.youtubeVideoID, let relativePath = track.localRelativePath, + let key = track.stemKey + guard let relativePath = track.localRelativePath, !stemsInFlight.contains(key) else { return } stemsInFlight.insert(key) diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift deleted file mode 100644 index e976baf..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift +++ /dev/null @@ -1,188 +0,0 @@ -import AVFoundation -import Domain -import Foundation -import SwiftData -import ContinuityCore -import os - -extension PreparationQueue { - /// Polling pass (launch + foreground minute tick): refreshes each source-backed playlist - /// that has auto-sync on (the opt-out) and hasn't synced recently. `lastSyncedAt` staleness - /// is the rate limiter — a tick right after a sync is a no-op per playlist. - public func autoSyncIfNeeded(in context: ModelContext) { - guard let playlists = try? context.fetch(FetchDescriptor()) else { return } - for playlist in playlists where playlist.isSourceBacked && playlist.autoSyncEnabled { - // Failing sources sit out their backoff — staleness alone would retry every tick, - // since only success advances `lastSyncedAt`. - if let notBefore = syncBackoff[playlist.id]?.notBefore, Date() < notBefore { continue } - let stale = playlist.lastSyncedAt.map { - Date().timeIntervalSince($0) > Self.autoSyncStaleness - } ?? true - if stale { - Task { await syncPlaylist(playlist, in: context) } - } - } - } - - /// Manual "sync everything now" — ignores staleness but still skips in-flight playlists. - public func syncAll(in context: ModelContext) { - guard let playlists = try? context.fetch(FetchDescriptor()) else { return } - for playlist in playlists where playlist.isSourceBacked { - Task { await syncPlaylist(playlist, in: context) } - } - } - - /// Mirrors one playlist against its remote source: tracks added remotely are created (and - /// ingested), tracks removed remotely are deleted locally (Player-coordinated, files cleaned - /// share-aware), and local ordering follows the remote. Best-effort: a resolve failure leaves - /// the local playlist untouched. - public func syncPlaylist(_ playlist: Playlist, in context: ModelContext) async { - guard playlist.isSourceBacked, let sourceID = playlist.sourceID, let kind = playlist.sourceKind, - !syncingPlaylistIDs.contains(playlist.id) else { return } - syncingPlaylistIDs.insert(playlist.id) - defer { syncingPlaylistIDs.remove(playlist.id) } - - do { - let changed: Bool - switch kind { - case .youtube: - let resolved = try await playlistResolver.resolvePlaylist(playlistID: sourceID) - guard playlist.modelContext != nil, !resolved.items.isEmpty else { return } - changed = applyYouTubeSync(resolved.items, to: playlist, in: context) - case .spotifyPlaylist, .spotifyAlbum: - let link = SpotifyLink(kind: kind == .spotifyAlbum ? .album : .playlist, id: sourceID) - let resolved = try await spotifyResolver.resolvePlaylist(link) - guard playlist.modelContext != nil, !resolved.tracks.isEmpty else { return } - changed = applySpotifySync(resolved.tracks, to: playlist, in: context) - } - playlist.lastSyncedAt = Date() - syncBackoff[playlist.id] = nil - try? context.save() - Logger.sync.info("synced \(playlist.title, privacy: .public)") - // Real content change only — a steady-state sync must not churn the live queue. - if changed { onPlaylistSynced?(playlist.id, playlist.orderedTracks) } - } catch { - // The local playlist is never modified on a failed fetch; auto-sync retries after - // an exponential backoff (2 min doubling to a 30 min cap) — each attempt already - // costs up to 3 requests via Retry, and hammering a rate limit only extends it. - let failures = (syncBackoff[playlist.id]?.failures ?? 0) + 1 - let delay = min(120 * pow(2, Double(failures - 1)), 1_800) - syncBackoff[playlist.id] = (Date().addingTimeInterval(delay), failures) - Logger.sync.error("sync failed for \(playlist.title, privacy: .public): \(String(describing: error), privacy: .public)") - } - } - - /// Applies a fresh remote YouTube tracklist: key = video ID. Returns whether membership - /// or order actually changed (drives `touch()` and `onPlaylistSynced`). - private func applyYouTubeSync(_ remote: [YouTubePlaylistItem], to playlist: Playlist, in context: ModelContext) -> Bool { - var localByKey: [String: Track] = [:] - for track in playlist.tracks { - if let id = track.youtubeVideoID { localByKey[id] = track } - } - - let remoteKeys = Set(remote.map(\.videoID)) - let removed = playlist.tracks.filter { track in - guard let id = track.youtubeVideoID else { return false } - return !remoteKeys.contains(id) - } - removeTracks(removed, in: context) - - // Bump `updatedAt` only when the sync actually changed content — a no-op auto-sync - // must not float untouched playlists to the top of the library. - var changed = !removed.isEmpty - // Duplicate remote entries share one local track; settle on the last occurrence's index - // up front so an unchanged remote reaches a steady state instead of touching every sync. - var targetIndexByKey: [String: Int] = [:] - for (index, item) in remote.enumerated() { targetIndexByKey[item.videoID] = index } - let seed = playlist.gradientSeed - for (index, item) in remote.enumerated() { - if let existing = localByKey[item.videoID] { - let target = targetIndexByKey[item.videoID] ?? index - if existing.sortIndex != target { - existing.sortIndex = target // follow remote ordering - changed = true - } - } else { - let track = Track( - title: item.title ?? "YouTube Video (\(item.videoID.prefix(6)))", - artist: item.author ?? "YouTube", - durationSeconds: Double(item.lengthSeconds ?? 0), - artworkSymbol: playlist.artworkSymbol, - gradientSeed: seed * 100 + index, - sortIndex: index, - prepState: .pending, - youtubeVideoID: item.videoID, - sourceURLString: "https://www.youtube.com/watch?v=\(item.videoID)" - ) - playlist.tracks.append(track) - context.insert(track) - enqueue(track, in: context) - changed = true - } - } - playlist.subtitle = "From YouTube · \(remote.count) tracks" - if changed { playlist.touch() } - return changed - } - - /// Applies a fresh remote Spotify tracklist: key = the YouTube search query (title + artist), - /// the identity Spotify-sourced tracks carry locally. Returns whether membership or order - /// actually changed (drives `touch()` and `onPlaylistSynced`). - private func applySpotifySync(_ remote: [SpotifyTrack], to playlist: Playlist, in context: ModelContext) -> Bool { - var localByKey: [String: Track] = [:] - for track in playlist.tracks { - if let query = track.searchQuery { localByKey[query] = track } - } - - let remoteKeys = Set(remote.map(\.youtubeSearchQuery)) - let removed = playlist.tracks.filter { track in - guard let query = track.searchQuery else { return false } - return !remoteKeys.contains(query) - } - removeTracks(removed, in: context) - - // Same rule as the YouTube path: only a real content change bumps `updatedAt`. - var changed = !removed.isEmpty - // As above: duplicate keys settle on one index so unchanged remotes stop touching. - var targetIndexByKey: [String: Int] = [:] - for (index, item) in remote.enumerated() { targetIndexByKey[item.youtubeSearchQuery] = index } - let seed = playlist.gradientSeed - for (index, item) in remote.enumerated() { - if let existing = localByKey[item.youtubeSearchQuery] { - let target = targetIndexByKey[item.youtubeSearchQuery] ?? index - if existing.sortIndex != target { - existing.sortIndex = target - changed = true - } - } else { - let track = Track( - title: item.title, - artist: item.artist ?? "Unknown Artist", - durationSeconds: Double(item.durationSeconds ?? 0), - artworkSymbol: playlist.artworkSymbol, - gradientSeed: seed * 100 + index, - sortIndex: index, - prepState: .pending, - searchQuery: item.youtubeSearchQuery - ) - playlist.tracks.append(track) - context.insert(track) - enqueue(track, in: context) - changed = true - } - } - playlist.subtitle = "From Spotify · \(remote.count) tracks" - if changed { playlist.touch() } - return changed - } - - /// Deletes tracks the same way the UI does: Player first (so the live queue never holds a - /// dead model), then the models, then share-aware file cleanup. - private func removeTracks(_ tracks: [Track], in context: ModelContext) { - guard !tracks.isEmpty else { return } - onTracksDeleted?(Set(tracks.map(\.id))) - let videoIDs = tracks.compactMap(\.youtubeVideoID) - for track in tracks { context.delete(track) } - LibraryCleanup.removeOrphanedFiles(videoIDs: videoIDs, in: context) - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift index 45001d2..86fb849 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift @@ -6,85 +6,55 @@ import ContinuityCore import os extension Logger { - /// Resolve → download → analyse failures (the path that lands tracks in `.failed`). + /// Import → analyse failures (the path that lands tracks in `.failed`). static let ingest = Logger(subsystem: "com.continuity.app", category: "ingest") /// Stem-separation pipeline logging (subsystem matches the bundle id for easy filtering). static let stems = Logger(subsystem: "com.continuity.app", category: "stems") - /// Playlist source-sync logging. - static let sync = Logger(subsystem: "com.continuity.app", category: "sync") } -/// Drives tracks through the M1 ingest pipeline (resolve → download → analyse → ready) and, -/// once playable, the optional M4 stem separation — writing the resulting `prepState` / -/// `localRelativePath` / analysis / stem paths back onto the SwiftData model. +/// Drives locally-imported tracks through the ingest pipeline (analyse → ready) and, +/// once playable, the optional stem separation — writing the resulting `prepState` / +/// analysis / stem paths back onto the SwiftData model. /// -/// One `Task` is spawned per `enqueue(_:in:)`, but the heavy network/CPU work is gated by -/// concurrency limiters so importing a whole playlist (`importPlaylist(...)`) doesn't fan out -/// into dozens of simultaneous downloads or stem separations. +/// One `Task` is spawned per `enqueue(_:in:)`, but the heavy CPU work is gated by +/// concurrency limiters so importing many files at once doesn't fan out into dozens of +/// simultaneous analyses or stem separations. /// /// Lives on the main actor because it mutates `@Model` objects bound to the UI's -/// `ModelContext`; the actual networking/DSP happens off-actor inside the awaited calls. +/// `ModelContext`; the actual DSP happens off-actor inside the awaited calls. @MainActor @Observable public final class PreparationQueue { - /// Resolves a YouTube video ID to a downloadable audio stream. - let resolver: AudioStreamResolving - /// Resolves a YouTube playlist ID to its constituent videos. - let playlistResolver: PlaylistResolving - /// Resolves a Spotify playlist/album to its tracklist (metadata only). - let spotifyResolver: SpotifyPlaylistResolving - /// Finds a YouTube video for a Spotify-sourced track (title + artist → video ID). - let searcher: YouTubeSearching - /// Resolves a video's real title/channel (replaces bare-ID placeholders). - let metadataResolver: VideoMetadataResolving - /// Downloads a resolved stream into the on-disk audio cache. - let downloader: AudioFileDownloading - - /// Caps simultaneous resolve+download+analyse work (network-bound). + /// Caps simultaneous analyse/backfill work (CPU + file-IO bound). private let ingestLimiter = ConcurrencyLimiter(limit: 3) /// Caps simultaneous stem separations to one — each is CPU/RAM-heavy, so they queue. let stemLimiter = ConcurrencyLimiter(limit: 1) - /// Production wiring — the app constructs the queue with no arguments. The parameterized - /// initializer stays internal for dependency-injected tests within the module. - public convenience init() { self.init(resolver: YouTubeStreamResolver()) } + public init() {} - init( - resolver: AudioStreamResolving = YouTubeStreamResolver(), - playlistResolver: PlaylistResolving = YouTubePlaylistResolver(), - spotifyResolver: SpotifyPlaylistResolving = SpotifyPlaylistResolver(), - searcher: YouTubeSearching = YouTubeSearchResolver(), - metadataResolver: VideoMetadataResolving = YouTubeOEmbedResolver(), - downloader: AudioFileDownloading = AudioDownloader() - ) { - self.resolver = resolver - self.playlistResolver = playlistResolver - self.spotifyResolver = spotifyResolver - self.searcher = searcher - self.metadataResolver = metadataResolver - self.downloader = downloader - } + /// Coordination hook: deletions must drop tracks from the live `Player` queue BEFORE the + /// models die. Wired to `Player.handleDeleted` at startup. + public var onTracksDeleted: ((Set) -> Void)? /// Marks `track` as `.pending` and kicks off its preparation in the background. /// /// Safe to call from the UI: it persists the pending state immediately so the row's - /// badge updates, then detaches the resolve/download work into its own `Task`. + /// badge updates, then detaches the analysis work into its own `Task`. public func enqueue(_ track: Track, in context: ModelContext) { track.prepState = .pending try? context.save() Task { await process(track, in: context) } } - /// Resumes preparation for a persisted library at launch: re-enqueues tracks that were - /// interrupted mid-ingest (e.g. the app was killed partway through a large import) or whose - /// downloaded audio went missing, and finishes stem separation for tracks that have audio but - /// no stems yet. `.failed` tracks are left as-is for an explicit retry. + /// Resumes preparation for a persisted library at launch: finishes analysis for tracks that + /// were interrupted mid-import, and marks ready tracks whose audio file went missing as + /// `.failed` (a local import has no remote source to re-download from). `.failed` tracks + /// are left as-is. public func resumePreparation(in context: ModelContext) { guard let tracks = try? context.fetch(FetchDescriptor()) else { return } for track in tracks { // Demo tracks have no source and play synthesized audio — there is nothing to ingest - // or resume. Without this guard they'd be re-enqueued (they have no audio file), fail - // for lack of a source, and show up as retry-able failures. Heal any that already did. + // or resume. Heal any that aren't marked ready. if track.isDemo { if track.prepState != .ready { track.prepState = .ready; try? context.save() } continue @@ -95,7 +65,9 @@ public final class PreparationQueue { FileManager.default.fileExists(atPath: AudioCache.url(forRelativePath: $0).path) } ?? false if !hasAudio { - enqueue(track, in: context) // file lost/evicted → re-fetch end to end + // The imported file was lost/evicted — nothing to re-fetch it from. + track.prepState = .failed + try? context.save() } else { // Stems are demand-driven from the play queue (`ensureStems`) — never // separated library-wide at launch. Just true-up links vs the disk. @@ -103,148 +75,54 @@ public final class PreparationQueue { backfillTrackDetails(track, in: context) } case .pending, .preparing: - enqueue(track, in: context) // interrupted before finishing → pick back up + enqueue(track, in: context) // interrupted before finishing → pick back up case .failed: break } } } - // MARK: - Source sync - - /// Playlists currently syncing (drives spinners and disables sync buttons). - public internal(set) var syncingPlaylistIDs: Set = [] - - /// In-memory failure backoff per playlist: `lastSyncedAt` only advances on success, so - /// without this a persistently-failing source (deleted remotely, sustained rate limit) - /// would re-fetch on every minute tick forever. Exponential, capped; cleared by the next - /// success. Manual sync deliberately bypasses it. - var syncBackoff: [UUID: (notBefore: Date, failures: Int)] = [:] - - /// Coordination hook: sync deletes tracks removed remotely, and the live `Player` must drop - /// them from its queue BEFORE the models die. Wired to `Player.handleDeleted` at startup. - public var onTracksDeleted: ((Set) -> Void)? - - /// Coordination hook: fires after a sync actually changed a playlist's membership/order, - /// with the playlist's id and its fresh play order — the app mirrors it into the live - /// queue. Never fires for no-op syncs. Wired in RootView at startup. - public var onPlaylistSynced: ((UUID, [Track]) -> Void)? - - /// How stale a playlist may get before an auto-sync pass refreshes it. Sync is **polling** - /// (a foreground minute tick + manual): push would need server infrastructure neither - /// YouTube nor Spotify offers a client-only app. Near-live mirroring, so one tick period — - /// minus fetch latency: `lastSyncedAt` stamps at fetch *completion*, and a full 60s - /// threshold would make every other tick read "fresh" (120s effective cadence). - static let autoSyncStaleness: TimeInterval = 55 - - /// Runs the resolve → download → analyse → ready pipeline for one track, updating `prepState` - /// at each stage. Any failure (missing video ID, resolve, or download error) lands the - /// track in `.failed`; the UI surfaces that as a retry-able badge rather than a crash. + /// Runs the analyse → ready pipeline for one already-on-disk track, updating `prepState`. + /// A missing audio file lands the track in `.failed`; the UI surfaces that as a badge. private func process(_ track: Track, in context: ModelContext) async { track.prepState = .preparing try? context.save() - // A track needs either a direct video ID (YouTube) or a search query (Spotify-sourced). - guard track.youtubeVideoID != nil || track.searchQuery != nil else { - track.prepState = .failed + let fileExists = track.localRelativePath.map { + FileManager.default.fileExists(atPath: AudioCache.url(forRelativePath: $0).path) + } ?? false + + guard fileExists, track.modelContext != nil else { + Logger.ingest.error("prep failed for \(track.title, privacy: .public): audio file missing") + if track.modelContext != nil { track.prepState = .failed } try? context.save() return } - // Gate the network/CPU-heavy stage so a playlist import doesn't run all tracks at once. - await ingestLimiter.acquire() - var prepared = false - do { - // Resolve the video ID: use the known one, or find it on YouTube from the search query. - let id: String - if let known = track.youtubeVideoID { - id = known - } else if let query = track.searchQuery, let found = try await searcher.firstVideoID(query: query) { - id = found - if track.modelContext != nil { track.youtubeVideoID = found } - } else { - throw IngestError.noPlayableStream - } - - let resolved = try await resolver.resolveAudio(videoID: id) - let fileURL = try await downloader.downloadAudio(resolved) - // The track could have been deleted while we were off the main actor; don't write to - // (or resurrect) a dead model. - if track.modelContext != nil { - track.localRelativePath = AudioCache.relativePath(for: fileURL) - - // Real duration for the row (bare-ID adds start at 0, which renders as "0:00"). - if track.durationSeconds <= 0, let file = try? AVAudioFile(forReading: fileURL) { - track.durationSeconds = Double(file.length) / file.processingFormat.sampleRate - } - - // NOTE: the real title/channel (oEmbed) is deliberately NOT fetched here — it - // would block readiness and hold an ingest slot on a slow endpoint even though - // the audio is already playable. It runs post-ready via backfillTrackDetails. - - // Analyse tempo + key off the main actor (full-track FFTs). Non-fatal: if analysis - // fails the track still plays, just without BPM/key metadata. - if let analysis = try? await Task.detached(priority: .utility, operation: { - try TrackAnalyzer.analyze(fileURL: fileURL) - }).value, track.modelContext != nil { - track.bpm = analysis.bpm > 0 ? analysis.bpm : nil - track.beatTimes = analysis.beatTimes - track.keyName = analysis.key?.displayName - track.camelotCode = analysis.camelot?.code - track.loudnessLUFS = analysis.lufs - track.analysisVersion = TrackAnalyzer.analysisVersion - } - prepared = true - } - } catch { - // Keep the Error — stems/sync already log failures; ingest was silent and left - // `.failed` badges with nothing to diagnose in Console. - let label = track.youtubeVideoID ?? track.searchQuery ?? track.title - Logger.ingest.error( - "prep failed for \(label, privacy: .public): \(String(describing: error), privacy: .public)" - ) - prepared = false - } - await ingestLimiter.release() - - // Don't touch a track that was deleted while we worked. - if track.modelContext != nil { - track.prepState = prepared ? .ready : .failed - } + track.prepState = .ready try? context.save() - // Display metadata is an optional enhancement — start it in the background AFTER the - // track is playable. Stems are NOT separated here: separation is demand-driven from the - // play queue (`ensureStems`) — eagerly separating whole imports burned CPU-hours and - // filled disks. Already-cached stems (re-adds) are linked instantly, though. - if track.modelContext != nil, track.prepState == .ready { - reconcileStemLinks(track, in: context) - backfillTrackDetails(track, in: context) - } - } - - /// Whether the track still shows the "YouTube Video (abc123)" placeholder from a bare-ID add. - private static func hasPlaceholderMetadata(_ track: Track) -> Bool { - track.youtubeVideoID != nil && track.title.hasPrefix("YouTube Video (") + // Already-cached stems (re-adds) are linked instantly; separation itself stays + // demand-driven from the play queue. Analysis/backfill runs post-ready. + reconcileStemLinks(track, in: context) + backfillTrackDetails(track, in: context) } - /// Best-effort, fire-and-forget healing of a ready track's display details: + /// Best-effort, fire-and-forget healing of a ready track's details: /// - `durationSeconds` from the local audio file when the model still says 0 ("0:00" rows), /// - audible bounds for gapless transitions, - /// - real title/channel via oEmbed when the bare-ID placeholder is still showing, /// - re-analysis when `TrackAnalyzer.analysisVersion` has moved. /// - /// Runs post-`.ready` (never blocks playability) from both the ingest pipeline and + /// Runs post-`.ready` (never blocks playability) from the import pipeline and /// `resumePreparation`. Every heavy step is gated by `ingestLimiter` so a large library's - /// launch backfill can't fan out into dozens of simultaneous file opens / PCM decodes / - /// oEmbed requests (which hitch launch and thrash memory). - private func backfillTrackDetails(_ track: Track, in context: ModelContext) { - let needsTitle = Self.hasPlaceholderMetadata(track) + /// launch backfill can't fan out into dozens of simultaneous file opens / PCM decodes + /// (which hitch launch and thrash memory). + func backfillTrackDetails(_ track: Track, in context: ModelContext) { let needsDuration = track.durationSeconds <= 0 && track.localRelativePath != nil let needsSilenceScan = track.audibleEndSeconds == nil && track.localRelativePath != nil let needsReanalysis = track.localRelativePath != nil && (track.analysisVersion ?? 0) < TrackAnalyzer.analysisVersion - guard needsTitle || needsDuration || needsSilenceScan || needsReanalysis else { return } + guard needsDuration || needsSilenceScan || needsReanalysis else { return } Task { guard track.modelContext != nil else { return } @@ -291,15 +169,6 @@ public final class PreparationQueue { track.analysisVersion = TrackAnalyzer.analysisVersion } } - if needsTitle, let id = track.youtubeVideoID { - await ingestLimiter.acquire() - let meta = try? await metadataResolver.metadata(videoID: id) - await ingestLimiter.release() - if let meta, track.modelContext != nil { - track.title = meta.title - if let author = meta.author, !author.isEmpty { track.artist = author } - } - } guard track.modelContext != nil else { return } try? context.save() } @@ -309,9 +178,9 @@ public final class PreparationQueue { /// Stem keys for the current play-queue neighborhood — protected from cache eviction. var protectedStemKeys: Set = [] - /// Stem keys (YouTube video IDs) whose separation is currently running. Keyed like the - /// cache — by video, not Track row — so the same video in two playlists can't run two - /// concurrent separations that rewrite the same output files. + /// Stem keys whose separation is currently running. Keyed like the cache — by the stable + /// track key (`Track.stemKey`), not the Track row — so the same source in two playlists + /// can't run two concurrent separations that rewrite the same output files. var stemsInFlight: Set = [] /// Separation is held until this moment — armed on the session's FIRST stem demand /// (i.e. when playback starts). Pressing play spins up the audio engine, UI, artwork, and @@ -320,6 +189,4 @@ public final class PreparationQueue { var separationAllowedAt: Date? /// At most one pending post-hold retry (ensureStems also re-fires on every track change). var stemsRetryScheduled = false - - } diff --git a/Packages/ContinuityKit/Sources/Ingest/Retry.swift b/Packages/ContinuityKit/Sources/Ingest/Retry.swift deleted file mode 100644 index 3412f45..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/Retry.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - -/// Retries a transient-failing async operation with exponential backoff + jitter. -/// -/// Only `IngestError`s that report themselves `isRetryable` (network blips, rate limits) are -/// retried; a definitively-empty source or any other error propagates immediately. The scraped -/// endpoints (Spotify embed, YouTube playlist page) fail transiently often enough that a single -/// blip shouldn't surface to the user as "playlist unavailable". -enum Retry { - static func run( - maxAttempts: Int = 3, - baseDelay: TimeInterval = 0.7, - _ operation: () async throws -> T - ) async throws -> T { - var attempt = 1 - while true { - do { - return try await operation() - } catch let error as IngestError where error.isRetryable && attempt < maxAttempts { - // Exponential backoff (0.7s, 1.4s, …) with up to 30% jitter so retries don't - // synchronize. Rate limits get a longer floor — they rarely clear in <1s. - let floor = (error.isRateLimited ? 1.5 : baseDelay) - let delay = floor * pow(2, Double(attempt - 1)) - let jitter = Double.random(in: 0...(delay * 0.3)) - try? await Task.sleep(nanoseconds: UInt64((delay + jitter) * 1_000_000_000)) - attempt += 1 - } - } - } -} - -private extension IngestError { - var isRateLimited: Bool { - if case .rateLimited = self { return true } - return false - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/SpotifyPlaylistResolver.swift b/Packages/ContinuityKit/Sources/Ingest/SpotifyPlaylistResolver.swift deleted file mode 100644 index 746adb6..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/SpotifyPlaylistResolver.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import ContinuityCore -import os - -private let spotifyLog = Logger(subsystem: "com.continuity.app", category: "spotify") - -/// Resolves a Spotify playlist/album ID to its tracklist by fetching Spotify's **embed** page -/// (`open.spotify.com/embed/{kind}/{id}`) and handing the HTML to `ContinuityCore.SpotifyPlaylist`. -/// -/// Spotify audio is DRM-protected and unusable by our engine, so this pulls **metadata only** -/// (title + artist per track); the caller re-sources each song's audio from YouTube. The embed -/// page needs no credentials but is **fragile** (Spotify can change the `__NEXT_DATA__` shape) — -/// parsing lives in unit-tested ContinuityCore; this type only does networking + error mapping. -/// -/// **Coverage note:** the embed page lists ~50 tracks; longer playlists would need pagination. -final class SpotifyPlaylistResolver: SpotifyPlaylistResolving { - - init() {} - - func resolvePlaylist(_ link: SpotifyLink) async throws -> ResolvedSpotifyPlaylist { - guard let url = URL(string: "https://open.spotify.com/embed/\(link.kind.rawValue)/\(link.id)") else { - throw IngestError.invalidURL - } - - // Retry the fetch+parse on transient failures (network blips, 429, 5xx) — those shouldn't - // read to the user as "playlist unavailable". A 200-with-no-tracks is NOT retried: it means - // the playlist really is private/empty. - do { - return try await Retry.run { - let html = try await self.fetchEmbedHTML(url) - let contents = SpotifyPlaylist.parse(html: html) - guard !contents.tracks.isEmpty else { - spotifyLog.error("reached \(link.id, privacy: .public) but parsed 0 tracks (private/empty or shape change)") - throw IngestError.sourceUnavailable - } - return ResolvedSpotifyPlaylist(link: link, name: contents.name, tracks: contents.tracks) - } - } catch { - spotifyLog.error("resolve \(link.id, privacy: .public) failed: \(String(describing: error), privacy: .public)") - throw error - } - } - - /// GETs the embed page, mapping the outcome to a retryability-aware `IngestError`. - private func fetchEmbedHTML(_ url: URL) async throws -> String { - var request = URLRequest(url: url) - request.setValue( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", - forHTTPHeaderField: "User-Agent" - ) - request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - throw IngestError.network(String(describing: error)) // connectivity/timeout → retryable - } - - if let http = response as? HTTPURLResponse { - switch http.statusCode { - case 200..<300: break - case 429: throw IngestError.rateLimited - case 500..<600: throw IngestError.network("Spotify embed HTTP \(http.statusCode)") - default: throw IngestError.resolveFailed("Spotify embed HTTP \(http.statusCode)") - } - } - guard let html = String(data: data, encoding: .utf8) else { - throw IngestError.decodeFailed("Spotify embed page was not valid UTF-8") - } - return html - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/YouTubeOEmbedResolver.swift b/Packages/ContinuityKit/Sources/Ingest/YouTubeOEmbedResolver.swift deleted file mode 100644 index 7ef9ddf..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/YouTubeOEmbedResolver.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation - -/// Fetches a video's real title + channel via YouTube's **oEmbed** endpoint — a stable, public, -/// documented JSON API (no key, no scraping), unlike the extraction paths used elsewhere. Used to -/// replace the "YouTube Video (abc123)" placeholder on tracks added by bare link/ID. -final class YouTubeOEmbedResolver: VideoMetadataResolving { - - private struct OEmbed: Decodable { - let title: String - let author_name: String? - } - - init() {} - - func metadata(videoID: String) async throws -> VideoMetadata { - guard var components = URLComponents(string: "https://www.youtube.com/oembed") else { - throw IngestError.invalidURL - } - components.queryItems = [ - URLQueryItem(name: "url", value: "https://www.youtube.com/watch?v=\(videoID)"), - URLQueryItem(name: "format", value: "json"), - ] - guard let url = components.url else { throw IngestError.invalidURL } - - // Title backfill is best-effort, but launch/import can burst many oEmbeds — retry - // transient failures so a blip doesn't leave the "YouTube Video (…)" placeholder stuck. - return try await Retry.run { - try await self.fetchMetadata(from: url) - } - } - - private func fetchMetadata(from url: URL) async throws -> VideoMetadata { - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(from: url) - } catch { - throw IngestError.network(String(describing: error)) - } - if let http = response as? HTTPURLResponse { - switch http.statusCode { - case 200..<300: break - case 429: throw IngestError.rateLimited - case 500..<600: throw IngestError.network("oEmbed HTTP \(http.statusCode)") - // 401/404 usually mean private/deleted — retrying won't help. - default: throw IngestError.resolveFailed("oEmbed HTTP \(http.statusCode)") - } - } - - do { - let embed = try JSONDecoder().decode(OEmbed.self, from: data) - return VideoMetadata(title: embed.title, author: embed.author_name) - } catch { - throw IngestError.decodeFailed("oEmbed: \(error)") - } - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/YouTubePlaylistResolver.swift b/Packages/ContinuityKit/Sources/Ingest/YouTubePlaylistResolver.swift deleted file mode 100644 index 29c1477..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/YouTubePlaylistResolver.swift +++ /dev/null @@ -1,136 +0,0 @@ -import Foundation -import ContinuityCore - -/// Resolves a YouTube playlist ID to its list of videos by fetching the public -/// `youtube.com/playlist?list=…` page and handing the HTML to `ContinuityCore.YouTubePlaylist` -/// for parsing. -/// -/// YouTubeKit only resolves single videos (no playlist support), so we scrape the playlist -/// page directly. As with the stream resolver, this is **fragile** — YouTube can change the -/// embedded `ytInitialData` shape at any time — so resolve failures are expected, recoverable -/// runtime errors. All parsing lives in (unit-tested) ContinuityCore; this type only does the -/// networking and translates errors into `IngestError`. -/// -/// Long playlists are followed page-by-page via InnerTube continuation tokens (the same -/// `youtubei/v1/browse` calls the web player makes), capped at `maxTracks`. A mid-pagination -/// failure returns the pages fetched so far — partial import beats none. -final class YouTubePlaylistResolver: PlaylistResolving { - - /// Upper bound on imported tracks: bounds memory/ingest work for pathological playlists - /// (each page is ~100 videos, so this is ~5 continuation calls at most). - private static let maxTracks = 500 - - init() {} - - func resolvePlaylist(playlistID: String) async throws -> ResolvedPlaylist { - guard var components = URLComponents(string: "https://www.youtube.com/playlist") else { - throw IngestError.invalidURL - } - // `hl=en` keeps labels/parsing predictable regardless of the device locale. - components.queryItems = [ - URLQueryItem(name: "list", value: playlistID), - URLQueryItem(name: "hl", value: "en"), - ] - guard let url = components.url else { throw IngestError.invalidURL } - - // Retry the first-page fetch+parse on transient failures; a 200-with-no-videos is treated - // as genuinely private/empty (not retried). Continuations below stay best-effort. - let (html, contents) = try await Retry.run { () -> (String, YouTubePlaylistContents) in - let html = try await self.fetchPlaylistHTML(url) - let contents = YouTubePlaylist.parse(html: html) - guard !contents.items.isEmpty else { throw IngestError.sourceUnavailable } - return (html, contents) - } - - var items = contents.items - var seen = Set(items.map(\.videoID)) - - // Follow continuations for playlists longer than one page. Best-effort: any failure just - // ends pagination with what we have. - if var token = contents.continuationToken, - let config = YouTubePlaylist.innerTubeConfig(html: html) { - var seenTokens: Set = [token] - while items.count < Self.maxTracks { - guard let page = try? await fetchContinuation(token: token, config: config), - !page.items.isEmpty else { break } - let before = items.count - for item in page.items where !seen.contains(item.videoID) { - seen.insert(item.videoID) - items.append(item) - } - // No-progress / token-cycle guard: a page of all-duplicates with a repeating - // token would otherwise loop forever (items.count is the loop's only bound). - guard items.count > before, - let next = page.continuationToken, seenTokens.insert(next).inserted else { break } - token = next - } - } - - return ResolvedPlaylist( - playlistID: playlistID, - title: contents.title, - items: Array(items.prefix(Self.maxTracks)) - ) - } - - /// GETs the playlist page, mapping the outcome to a retryability-aware `IngestError`. - private func fetchPlaylistHTML(_ url: URL) async throws -> String { - var request = URLRequest(url: url) - // A desktop UA returns the full `ytInitialData` blob we parse; the consent cookie skips - // the EU interstitial that would otherwise replace the page with a consent form. - request.setValue( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", - forHTTPHeaderField: "User-Agent" - ) - request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - request.setValue("CONSENT=YES+1", forHTTPHeaderField: "Cookie") - - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - throw IngestError.network(String(describing: error)) - } - if let http = response as? HTTPURLResponse { - switch http.statusCode { - case 200..<300: break - case 429: throw IngestError.rateLimited - case 500..<600: throw IngestError.network("playlist page HTTP \(http.statusCode)") - default: throw IngestError.resolveFailed("playlist page HTTP \(http.statusCode)") - } - } - guard let html = String(data: data, encoding: .utf8) else { - throw IngestError.decodeFailed("playlist page was not valid UTF-8") - } - return html - } - - /// One `youtubei/v1/browse` continuation call, parsed by ContinuityCore. - private func fetchContinuation( - token: String, - config: InnerTubeConfig - ) async throws -> (items: [YouTubePlaylistItem], continuationToken: String?) { - guard let url = URL(string: "https://www.youtube.com/youtubei/v1/browse?key=\(config.apiKey)") else { - throw IngestError.invalidURL - } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", - forHTTPHeaderField: "User-Agent" - ) - let body: [String: Any] = [ - "context": ["client": ["clientName": "WEB", "clientVersion": config.clientVersion, "hl": "en"]], - "continuation": token, - ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await URLSession.shared.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - throw IngestError.resolveFailed("continuation HTTP \(http.statusCode)") - } - return YouTubePlaylist.parseContinuationResponse(data) - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/YouTubeSearchResolver.swift b/Packages/ContinuityKit/Sources/Ingest/YouTubeSearchResolver.swift deleted file mode 100644 index 25ccbba..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/YouTubeSearchResolver.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -import ContinuityCore - -/// Finds the best-matching YouTube video ID for a text query (e.g. "Blinding Lights The Weeknd") -/// by fetching `youtube.com/results?search_query=…` and handing the HTML to -/// `ContinuityCore.YouTubeSearch`. -/// -/// Used to re-source Spotify tracks as YouTube audio. YouTubeKit has no search API, so we scrape -/// the results page — **fragile** like the other scrapers, so parsing lives in unit-tested -/// ContinuityCore and this type only does networking + error mapping. -final class YouTubeSearchResolver: YouTubeSearching { - - init() {} - - func firstVideoID(query: String) async throws -> String? { - guard var components = URLComponents(string: "https://www.youtube.com/results") else { - throw IngestError.invalidURL - } - components.queryItems = [ - URLQueryItem(name: "search_query", value: query), - URLQueryItem(name: "hl", value: "en"), - ] - guard let url = components.url else { throw IngestError.invalidURL } - - // Spotify playlist imports fire many searches; a single 429/5xx/blip shouldn't fail a track. - return try await Retry.run { - let html = try await self.fetchSearchHTML(url) - return YouTubeSearch.firstVideoID(html: html) - } - } - - /// GETs the results page, mapping the outcome to a retryability-aware `IngestError`. - private func fetchSearchHTML(_ url: URL) async throws -> String { - var request = URLRequest(url: url) - request.setValue( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", - forHTTPHeaderField: "User-Agent" - ) - request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - request.setValue("CONSENT=YES+1", forHTTPHeaderField: "Cookie") - - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - throw IngestError.network(String(describing: error)) - } - - if let http = response as? HTTPURLResponse { - switch http.statusCode { - case 200..<300: break - case 429: throw IngestError.rateLimited - case 500..<600: throw IngestError.network("YouTube search HTTP \(http.statusCode)") - default: throw IngestError.resolveFailed("YouTube search HTTP \(http.statusCode)") - } - } - guard let html = String(data: data, encoding: .utf8) else { - throw IngestError.decodeFailed("YouTube search page was not valid UTF-8") - } - return html - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/YouTubeStreamResolver.swift b/Packages/ContinuityKit/Sources/Ingest/YouTubeStreamResolver.swift deleted file mode 100644 index 0ce410d..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/YouTubeStreamResolver.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -import YouTubeKit -import ContinuityCore - -/// Resolves a YouTube video ID to a single downloadable audio stream via the -/// `YouTubeKit` extraction library. -/// -/// **Fragility note:** `YouTubeKit` scrapes/extracts stream URLs from YouTube's -/// player response, so it is inherently brittle — YouTube can change its player -/// at any time and break extraction without warning. Treat resolve failures as -/// expected, recoverable runtime errors rather than programmer errors. -/// -/// This type deliberately contains *no* selection policy: it only maps every -/// `YouTubeKit.Stream` onto the library-agnostic `ContinuityCore.AudioStreamCandidate` -/// and delegates the actual "which stream do we download" decision to -/// `ContinuityCore.AudioStreamSelector`, which is unit-tested and free of any -/// dependency on YouTubeKit's (changeable) shape. -final class YouTubeStreamResolver: AudioStreamResolving { - - public init() {} - - func resolveAudio(videoID: String) async throws -> ResolvedAudio { - // Extraction blips (timeouts, player-response churn) are the most common ingest failure; - // playlist resolvers already retry — keep the per-track path consistent. - try await Retry.run { - try await self.resolveOnce(videoID: videoID) - } - } - - private func resolveOnce(videoID: String) async throws -> ResolvedAudio { - let streams: [YouTubeKit.Stream] - do { - streams = try await YouTube(videoID: videoID).streams - } catch { - // YouTubeKit doesn't surface typed network vs. permanent failures. Treat library - // throws as transient so Retry.run can absorb blips; a truly empty candidate list - // still becomes `.noPlayableStream` below (non-retryable). - throw IngestError.network(String(describing: error)) - } - - let candidates: [AudioStreamCandidate] = streams.enumerated().map { index, stream in - // YouTubeKit's `ITag.itag` is internal, so the real itag number isn't reachable - // from our module. `itag` is only used by the selector for deterministic - // tie-breaking, so the stream's index is a fine, stable stand-in. - return AudioStreamCandidate( - itag: index, - container: Self.containerString(for: stream), - audioCodec: stream.audioCodec.map { String(describing: $0) }, - averageBitrate: stream.averageBitrate ?? stream.bitrate ?? 0, - isAudioOnly: stream.includesAudioTrack && !stream.includesVideoTrack, - // Our contract for `isNativelyPlayable` is specifically "AVAudioFile can decode - // it" (AAC/m4a). YouTubeKit's `Stream.isNativelyPlayable` is the broader AVPlayer - // notion and returns true for Dolby AC-3/EC-3 too, which AVAudioFile can't open — - // so derive the flag straight from the AAC codec instead. - isNativelyPlayable: stream.audioCodec == .mp4a, - urlString: stream.url.absoluteString - ) - } - - guard let best = AudioStreamSelector.selectBest(from: candidates) else { - throw IngestError.noPlayableStream - } - - guard let url = URL(string: best.urlString) else { - throw IngestError.noPlayableStream - } - - return ResolvedAudio( - videoID: videoID, - url: url, - itag: best.itag, - container: best.container, - isNativelyPlayable: best.isNativelyPlayable, - approxBitrate: best.averageBitrate - ) - } - - /// Lowercased container/extension string (e.g. "m4a", "webm") derived from the - /// stream's `fileExtension` enum. - private static func containerString(for stream: YouTubeKit.Stream) -> String { - // FileExtension is a String-backed enum, so rawValue is already e.g. "m4a"/"webm". - stream.fileExtension.rawValue - } -} diff --git a/project.yml b/project.yml index 9888599..30724c9 100644 --- a/project.yml +++ b/project.yml @@ -35,13 +35,6 @@ targets: product: Ingest - package: ContinuityKit product: Playback - - target: ContinuityShare - # App group shared with the extension — the share-sheet → app handoff channel. - entitlements: - path: App/Continuity/Continuity.entitlements - properties: - com.apple.security.application-groups: - - group.com.sanylax.continuity info: path: App/Continuity/Info.plist properties: @@ -58,11 +51,6 @@ targets: ITSAppUsesNonExemptEncryption: false UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - # continuity://import?url= — deep-link entry into the import flow. - CFBundleURLTypes: - - CFBundleURLName: com.sanylax.continuity - CFBundleURLSchemes: - - continuity settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.sanylax.continuity @@ -92,39 +80,3 @@ targets: fi done # Leave an empty Frameworks dir alone — other embeds (none today) may appear later. - - # Share-sheet entry point: stashes the shared URL in the app group for the app to import. - ContinuityShare: - type: app-extension - platform: iOS - deploymentTarget: "26.0" - sources: - - path: App/ShareExtension - dependencies: - # Same YouTube/Spotify classifiers the app uses — keeps "Added to Continuity" honest. - - package: ContinuityCore - entitlements: - path: App/ShareExtension/ContinuityShare.entitlements - properties: - com.apple.security.application-groups: - - group.com.sanylax.continuity - info: - path: App/ShareExtension/Info.plist - properties: - CFBundleDisplayName: Continuity - CFBundleShortVersionString: "$(MARKETING_VERSION)" - CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" - NSExtension: - NSExtensionPointIdentifier: com.apple.share-services - NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController - NSExtensionAttributes: - # Activate when any attachment is a URL — dictionary rules that only declare - # WebURL miss Spotify/YouTube shares that also attach image/text. Runtime still - # rejects non-music links via ContinuityCore before showing success. - NSExtensionActivationRule: 'SUBQUERY(extensionItems, $extensionItem, SUBQUERY($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text").@count >= 1).@count >= 1' - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.sanylax.continuity.share - GENERATE_INFOPLIST_FILE: NO - TARGETED_DEVICE_FAMILY: "1" - SWIFT_EMIT_LOC_STRINGS: YES