diff --git a/App/Continuity/Library/LinkImporter.swift b/App/Continuity/Library/LinkImporter.swift deleted file mode 100644 index a139582..0000000 --- a/App/Continuity/Library/LinkImporter.swift +++ /dev/null @@ -1,144 +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 { - guard RemoteAudioIngest.isEnabled else { throw IngestError.sourceUnavailable } - 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: - if !RemoteAudioIngest.isEnabled { - return "Importing music isn't available in this build." - } - 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 6e59b7e..abb9af0 100644 --- a/App/Continuity/Views/AddMusicView.swift +++ b/App/Continuity/Views/AddMusicView.swift @@ -3,97 +3,70 @@ import UniformTypeIdentifiers import Ingest import Domain import SwiftData -import ContinuityCore -/// Sheet for adding music. On `main` (remote ingest enabled): paste a YouTube/Spotify link. -/// On External TestFlight / App Store builds: pick local audio files from Files. +/// Sheet for adding music: pick local audio files from Files. (On `main` this sheet instead +/// takes a YouTube/Spotify link; this branch ships without remote ingest.) 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 isImporting = false @State private var showingFilePicker = 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 - } - var body: some View { NavigationStack { - if RemoteAudioIngest.isEnabled { - remoteImportForm - } else { - localImportForm - } - } - .fileImporter( - isPresented: $showingFilePicker, - allowedContentTypes: [.audio, .mp3, .mpeg4Audio, .wav, .aiff], - allowsMultipleSelection: true - ) { result in - handlePickedFiles(result) - } - } - - // MARK: - Local files (External TF / App Store) - - private var localImportForm: some View { - Form { - Section { - Text("Add songs from the Files app. Continuity copies them into its library — no YouTube download.") - .font(.subheadline) - .foregroundStyle(.secondary) - } + Form { + Section { + Text("Add songs from the Files app. Continuity copies them into its library.") + .font(.subheadline) + .foregroundStyle(.secondary) + } - Section { - Button { - showingFilePicker = true - } label: { - HStack { - if isImporting { - ProgressView() - Text("Importing…") - } else { - Label("Choose Audio Files", systemImage: "folder.badge.plus") + Section { + Button { + showingFilePicker = true + } label: { + HStack { + if isImporting { + ProgressView() + Text("Importing…") + } else { + Label("Choose Audio Files", systemImage: "folder.badge.plus") + } } + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .disabled(isImporting) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .font(.footnote) } - .frame(maxWidth: .infinity) } - .buttonStyle(.glassProminent) - .disabled(isImporting) } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - - if let errorMessage { - Section { - Label(errorMessage, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - .font(.footnote) + .navigationTitle("Add Music") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isImporting) } } } - .navigationTitle("Add Music") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - .disabled(isImporting) - } + .fileImporter( + isPresented: $showingFilePicker, + allowedContentTypes: [.audio, .mp3, .mpeg4Audio, .wav, .aiff], + allowsMultipleSelection: true + ) { result in + handlePickedFiles(result) } } @@ -116,86 +89,4 @@ struct AddMusicView: View { } } } - - // MARK: - Remote links (main / private builds) - - private var remoteImportForm: some View { - 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: addRemote) { - HStack { - if isImporting { - ProgressView() - Text("Importing…") - } else { - Label( - isImportAction ? "Import Playlist" : "Add", - systemImage: isImportAction ? "music.note.list" : "arrow.down.circle.fill" - ) - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.glassProminent) - .disabled(trimmed.isEmpty || isImporting) - } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } - .navigationTitle("Add Music") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - .disabled(isImporting) - } - } - } - - @ViewBuilder - private var footerContent: some View { - if let errorMessage { - Label(errorMessage, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - .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.") - } - } - } - - private func addRemote() { - guard let link = detected else { - errorMessage = "Couldn't find a YouTube or Spotify link in that text." - return - } - isImporting = true - errorMessage = nil - let source = trimmed - 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) - } - } - } } diff --git a/App/Continuity/Views/LibrarySheetView.swift b/App/Continuity/Views/LibrarySheetView.swift index 2e2d2fd..26eba86 100644 --- a/App/Continuity/Views/LibrarySheetView.swift +++ b/App/Continuity/Views/LibrarySheetView.swift @@ -6,8 +6,6 @@ import Playback /// browse/add/delete playlists, with the mini player and the detailed Now Playing sheet intact. struct LibrarySheetView: View { @Environment(Player.self) private var player - @Environment(PreparationQueue.self) private var prepQueue - @Environment(\.modelContext) private var modelContext @State private var showNowPlaying = false @State private var showingAdd = false @@ -16,19 +14,6 @@ struct LibrarySheetView: View { LibraryView() .navigationTitle("Continuity") .toolbar { - if RemoteAudioIngest.isEnabled { - // 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 3e21313..ab7535e 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 files = playlist.tracks.map(LibraryCleanup.DeletedTrackFiles.init) player.handleDeleted(trackIDs: trackIDs) modelContext.delete(playlist) // cascade deletes its tracks try? modelContext.save() - LibraryCleanup.removeOrphanedFiles(videoIDs: videoIDs, in: modelContext) + LibraryCleanup.removeOrphanedFiles(for: files, in: modelContext) } } diff --git a/App/Continuity/Views/PlaylistDetailView.swift b/App/Continuity/Views/PlaylistDetailView.swift index ac9a809..7f400d2 100644 --- a/App/Continuity/Views/PlaylistDetailView.swift +++ b/App/Continuity/Views/PlaylistDetailView.swift @@ -7,11 +7,8 @@ import Domain struct PlaylistDetailView: View { @Bindable var playlist: Playlist @Environment(Player.self) private var player - @Environment(PreparationQueue.self) private var prepQueue @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 @@ -25,14 +22,9 @@ 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 { - if RemoteAudioIngest.isEnabled { - prepQueue.enqueue(track, in: modelContext) - } - } else { - player.play(tracks: playlist.orderedTracks, startAt: index) - } + // A failed track has no audio to play; this build can't re-download it. + guard track.prepState != .failed else { return } + player.play(tracks: playlist.orderedTracks, startAt: index) } .contextMenu { Button { @@ -58,14 +50,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 files = LibraryCleanup.DeletedTrackFiles(track) 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(for: [files], in: modelContext) } private var header: some View { @@ -83,33 +73,6 @@ struct PlaylistDetailView: View { } .buttonStyle(.glassProminent) .padding(.top, 4) - - // Source-backed playlists mirror a remote list: manual sync + the auto-sync opt-out. - if RemoteAudioIngest.isEnabled, 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) @@ -165,9 +128,9 @@ private struct TrackRow: View { ProgressView() .controlSize(.mini) case .failed: - // Tapping the row retries a failed ingest — the retry glyph signals it's actionable. - // Builds without remote ingest can't retry, so show a plain warning instead. - Image(systemName: RemoteAudioIngest.isEnabled ? "arrow.clockwise" : "exclamationmark.triangle") + // The audio file is missing and this build can't re-download it — plain warning, + // not a retry affordance. + 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 5e39d49..cd3d0c6 100644 --- a/App/Continuity/Views/RootView.swift +++ b/App/Continuity/Views/RootView.swift @@ -1,5 +1,4 @@ import SwiftUI -import UIKit import Ingest import Playback import Domain @@ -8,26 +7,21 @@ import SwiftData /// Top-level shell: the app always opens onto the minimal Now Playing screen, resuming the /// previous session's song (or staging COMË N GO on first launch). The library lives in a /// sheet behind its corner button. +/// +/// On `main` this view also handles the continuity:// URL scheme, shared URLs, and clipboard +/// link detection; this branch has no remote import, so music enters only via Add Music → +/// Choose Audio Files. 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. - @AppStorage("lastCheckedPasteboardChange") private var lastCheckedPasteboardChange = -1 var body: some View { NowPlayingView(mode: .home) - // On launch: drop cached files orphaned by deletions, resume unfinished ingestion, + // On launch: drop cached files orphaned by deletions, heal persisted tracks, // 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) } @@ -47,124 +41,9 @@ struct RootView: View { LibraryCleanup.sweepOrphanedFiles(in: modelContext) prepQueue.resumePreparation(in: modelContext) restorePlaybackSession() - // Launch-time polling pass over source-backed playlists (per-playlist opt-out). - if RemoteAudioIngest.isEnabled { - prepQueue.autoSyncIfNeeded(in: modelContext) - } - } - .onOpenURL(perform: handleIncomingLink) - // `initial: true` covers cold launch; later .active transitions cover foregrounding. - .onChange(of: scenePhase, initial: true) { _, phase in - if phase == .active, RemoteAudioIngest.isEnabled { - // Explicit share beats a stale clipboard hit for the confirmation slot. - consumePendingSharedURL() - checkClipboardForImportableLink() - } - } - .alert( - Text(pendingImport.map { "Import from \($0.link.sourceName)?" } ?? "Import?"), - isPresented: Binding( - get: { pendingImport != nil }, - set: { if !$0 { pendingImport = nil } } - ), - presenting: pendingImport - ) { pending in - Button("Import") { startImport(pending) } - Button("Cancel", role: .cancel) {} - } message: { pending in - Text(pending.host) - } - .alert( - "Import Failed", - isPresented: Binding( - get: { importError != nil }, - set: { if !$0 { importError = nil } } - ) - ) { - Button("OK", role: .cancel) {} - } message: { - Text(importError ?? "") } } - // MARK: - Link handling - - /// `continuity://import?url=` carries the real link; a directly-shared - /// http(s) URL *is* the link. Anything unclassifiable is silently ignored. - private func handleIncomingLink(_ url: URL) { - guard RemoteAudioIngest.isEnabled else { return } - 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 } - lastOfferedClipboardURL = raw - offer(link, rawURL: raw) - } - } - - private func offer(_ link: LinkImporter.Link, rawURL: String) { - // 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 } - let host = URLComponents(string: rawURL.contains("://") ? rawURL : "https://" + rawURL)? - .host ?? link.sourceName - pendingImport = PendingLinkImport(link: link, rawURL: rawURL, host: host) - } - - /// 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 /// Restores the persisted session — same song, position, skip budget, and history — or, on a @@ -190,11 +69,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/Packages/ContinuityKit/Package.swift b/Packages/ContinuityKit/Package.swift index 1e8ec78..f4b233a 100644 --- a/Packages/ContinuityKit/Package.swift +++ b/Packages/ContinuityKit/Package.swift @@ -16,7 +16,6 @@ let package = Package( ], dependencies: [ .package(path: "../ContinuityCore"), - .package(url: "https://github.com/alexeichhorn/YouTubeKit", branch: "main"), .package(url: "https://github.com/microsoft/onnxruntime-swift-package-manager", exact: "1.20.0"), ], targets: [ @@ -28,14 +27,13 @@ let package = Package( dependencies: [.product(name: "ContinuityCore", package: "ContinuityCore")], swiftSettings: [.swiftLanguageMode(.v5)] ), - // Ingest: downloading, resolving playlists/streams, stem separation, track analysis. - // Depends on Domain + ContinuityCore; owns the external YouTubeKit/onnxruntime deps. + // Ingest: local-file import, stem separation, track analysis. + // Depends on Domain + ContinuityCore; owns the external onnxruntime dep. .target( name: "Ingest", dependencies: [ "Domain", .product(name: "ContinuityCore", package: "ContinuityCore"), - .product(name: "YouTubeKit", package: "YouTubeKit"), .product(name: "onnxruntime", package: "onnxruntime-swift-package-manager"), ], swiftSettings: [.swiftLanguageMode(.v5)] diff --git a/Packages/ContinuityKit/Sources/Domain/Track.swift b/Packages/ContinuityKit/Sources/Domain/Track.swift index d6273e0..91f76ec 100644 --- a/Packages/ContinuityKit/Sources/Domain/Track.swift +++ b/Packages/ContinuityKit/Sources/Domain/Track.swift @@ -70,6 +70,11 @@ public final class Track { youtubeVideoID == nil && searchQuery == nil && localRelativePath == nil } + /// Stable key for the stem cache (and other per-source on-disk caches). Legacy + /// YouTube-sourced tracks keep their video ID (existing stems stay linked); locally + /// imported tracks use their own UUID. + public var stemKey: String { youtubeVideoID ?? id.uuidString } + /// Real cover art for YouTube-sourced tracks: the video's thumbnail, served from YouTube's /// deterministic thumbnail CDN (no API call needed). nil for demo tracks → gradient artwork. public var artworkURL: URL? { diff --git a/Packages/ContinuityKit/Sources/Ingest/AudioDownloader.swift b/Packages/ContinuityKit/Sources/Ingest/AudioDownloader.swift deleted file mode 100644 index ab3085f..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/AudioDownloader.swift +++ /dev/null @@ -1,123 +0,0 @@ -import Foundation -import Domain - -/// Downloads a resolved audio stream to the on-disk cache. -/// -/// The download stage of the M1 ingestion pipeline (resolve → download → ready). -/// -/// **Why ranged?** YouTube throttles a single full-file `GET` of a `googlevideo` URL down to a -/// crawl (and frequently drops the connection), but serves small HTTP `Range` requests at full -/// speed — the same trick browsers use. So we pull the file in sequential byte-range chunks and -/// reassemble it, then atomically publish into the cache. Stateless and safely `Sendable`. -final class AudioDownloader: AudioFileDownloading { - /// Size of each range request. ~1 MiB keeps each request well under the throttling threshold. - private let chunkSize: Int - /// Per-chunk retry budget for transient network blips. - private let maxRetriesPerChunk: Int - - init(chunkSize: Int = 1_048_576, maxRetriesPerChunk: Int = 3) { - self.chunkSize = chunkSize - self.maxRetriesPerChunk = maxRetriesPerChunk - } - - func downloadAudio(_ resolved: ResolvedAudio) async throws -> 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) = try await fetchChunk(url: url, from: offset, to: upperBound) - if let reportedTotal { totalSize = reportedTotal } - if data.isEmpty { break } // nothing more to read - 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 plus 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`). - private func fetchChunk(url: URL, from: Int, to: Int) async throws -> (Data, Int?) { - 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..cfdc4a4 100644 --- a/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift +++ b/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift @@ -1,99 +1,8 @@ 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 from the local-file import pipeline. (The YouTube/Spotify resolve → download +/// contracts lived here on `main`; this branch imports audio from Files only.) 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 6c4b017..e3fa7cb 100644 --- a/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift +++ b/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift @@ -2,48 +2,74 @@ 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) for deleted tracks — but only when +/// no surviving track still references the same source (a YouTube-sourced video can appear in +/// several playlists; its audio/stems are keyed by video ID and shared. Locally imported files +/// are keyed by the track's own UUID and never shared). /// /// Call AFTER the track models have been deleted and saved, so the reference check sees the /// post-deletion library. public enum LibraryCleanup { + /// What a track owned on disk, captured BEFORE the model is deleted. + public struct DeletedTrackFiles: Sendable { + let stemKey: String + let youtubeVideoID: String? + let audioRelativePath: String? + + public init(_ track: Track) { + stemKey = track.stemKey + youtubeVideoID = track.youtubeVideoID + audioRelativePath = track.localRelativePath + } + } + @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 } + public static func removeOrphanedFiles(for deleted: [DeletedTrackFiles], in context: ModelContext) { + for item in deleted { + // Shared-source check only applies to YouTube-keyed files; a local import's UUID + // key dies with its track. + if let videoID = item.youtubeVideoID { + 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) + // Legacy audio is named "."; 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) + } } + } else if let relativePath = item.audioRelativePath { + try? FileManager.default.removeItem(at: AudioCache.url(forRelativePath: relativePath)) } - StemCache.removeStems(key: videoID) + StemCache.removeStems(key: item.stemKey) } } - /// 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 no surviving track references. Catches + /// stems that were still in flight 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)) + // Audio files are referenced by exact relative path (local imports use a random UUID + // filename unrelated to the track id); stems are keyed by `stemKey`. + let referencedAudio = Set(tracks.compactMap { + $0.localRelativePath.map { AudioCache.url(forRelativePath: $0).lastPathComponent } + }) + let referencedStemKeys = Set(tracks.map(\.stemKey)) if let files = try? FileManager.default.contentsOfDirectory( at: AudioCache.directory, includingPropertiesForKeys: nil ) { - for file in files where !referenced.contains(file.deletingPathExtension().lastPathComponent) { + for file in files where !referencedAudio.contains(file.lastPathComponent) { try? FileManager.default.removeItem(at: file) } } @@ -52,12 +78,12 @@ public enum LibraryCleanup { at: StemCache.directory, includingPropertiesForKeys: nil ) { for file in files { - // Stem names are "-vocals.caf" / "-accompaniment.caf". + // Stem names are "-vocals.caf" / "-accompaniment.caf". let base = file.deletingPathExtension().lastPathComponent let key = base .replacingOccurrences(of: "-vocals", with: "") .replacingOccurrences(of: "-accompaniment", with: "") - if !referenced.contains(key) { + if !referencedStemKeys.contains(key) { try? FileManager.default.removeItem(at: file) } } diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift deleted file mode 100644 index f0c9e35..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift +++ /dev/null @@ -1,101 +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 { - guard RemoteAudioIngest.isEnabled else { throw IngestError.sourceUnavailable } - 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 { - guard RemoteAudioIngest.isEnabled else { throw IngestError.sourceUnavailable } - 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+Stems.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift index 87fb4a5..f5d78d2 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift @@ -11,10 +11,10 @@ extension PreparationQueue { /// **never** separated eagerly — only the neighborhood vocal-aware transitions need next. /// Also refreshes the LRU clock and heals links whose files were evicted. public func ensureStems(for tracks: [Track], in context: ModelContext) { - protectedStemKeys = Set(tracks.compactMap(\.youtubeVideoID)) + protectedStemKeys = Set(tracks.map(\.stemKey)) for track in tracks { - guard !track.isDemo, track.prepState == .ready, - let key = track.youtubeVideoID, track.localRelativePath != nil else { continue } + guard !track.isDemo, track.prepState == .ready, track.localRelativePath != nil else { continue } + let key = track.stemKey reconcileStemLinks(track, in: context) if track.hasStems { StemCache.markUsed(key: key) // actively played material stays cache-resident @@ -34,7 +34,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) { @@ -57,8 +57,9 @@ 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, + guard let relativePath = track.localRelativePath, !stemsInFlight.contains(track.id) else { return } + let key = track.stemKey stemsInFlight.insert(track.id) let inputURL = AudioCache.url(forRelativePath: relativePath) diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift deleted file mode 100644 index 3d0532d..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift +++ /dev/null @@ -1,174 +0,0 @@ -import AVFoundation -import Domain -import Foundation -import SwiftData -import ContinuityCore -import os - -extension PreparationQueue { - /// Launch-time polling pass: refreshes each source-backed playlist that has auto-sync on - /// (the opt-out) and hasn't synced recently. - public func autoSyncIfNeeded(in context: ModelContext) { - guard RemoteAudioIngest.isEnabled else { return } - guard let playlists = try? context.fetch(FetchDescriptor()) else { return } - for playlist in playlists where playlist.isSourceBacked && playlist.autoSyncEnabled { - 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 RemoteAudioIngest.isEnabled else { return } - 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 RemoteAudioIngest.isEnabled else { return } - 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 { - switch kind { - case .youtube: - let resolved = try await playlistResolver.resolvePlaylist(playlistID: sourceID) - guard playlist.modelContext != nil, !resolved.items.isEmpty else { return } - 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 } - applySpotifySync(resolved.tracks, to: playlist, in: context) - } - playlist.lastSyncedAt = Date() - try? context.save() - Logger.sync.info("synced \(playlist.title, privacy: .public)") - } catch { - // The local playlist is never modified on a failed fetch; next sync retries. - Logger.sync.error("sync failed for \(playlist.title, privacy: .public): \(String(describing: error), privacy: .public)") - } - } - - /// Applies a fresh remote YouTube tracklist: key = video ID. - private func applyYouTubeSync(_ remote: [YouTubePlaylistItem], to playlist: Playlist, in context: ModelContext) { - 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() } - } - - /// Applies a fresh remote Spotify tracklist: key = the YouTube search query (title + artist), - /// the identity Spotify-sourced tracks carry locally. - private func applySpotifySync(_ remote: [SpotifyTrack], to playlist: Playlist, in context: ModelContext) { - 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() } - } - - /// 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 bee4f3a..89cbae4 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift @@ -6,89 +6,40 @@ import ContinuityCore import os extension Logger { - /// Resolve → download → analyse failures (the path that lands tracks in `.failed`). + /// Import/analysis 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. +/// Prepares library tracks for playback: local-file import (`importLocalAudio`), launch-time +/// healing of persisted tracks, background analysis, and demand-driven stem separation — +/// writing the resulting `prepState` / `localRelativePath` / 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. +/// On `main` this type also drives the YouTube/Spotify resolve → download pipeline; this +/// branch ships without remote ingest, so audio only enters via the Files importer. /// /// 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 import/analyse work (file-I/O and CPU-bound). 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()) } - - 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 - } - - /// 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`. - public func enqueue(_ track: Track, in context: ModelContext) { - guard RemoteAudioIngest.isEnabled else { - Logger.ingest.error("enqueue blocked — remote audio ingest disabled in this build") - return - } - track.prepState = .pending - try? context.save() - Task { await process(track, in: context) } - } + public init() {} - /// 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. + /// Heals a persisted library at launch: trues up stem links and display details for tracks + /// whose audio is present, and marks tracks whose audio file went missing (or that were + /// interrupted mid-pipeline by a kill) as `.failed` — this build can't re-download them. 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. + // Demo tracks have no source and play synthesized audio — there is nothing to + // resume. Heal any that a past build left non-ready. if track.isDemo { if track.prepState != .ready { track.prepState = .ready; try? context.save() } continue @@ -101,12 +52,8 @@ public final class PreparationQueue { if hasAudio { // Stems are demand-driven from the play queue (`ensureStems`) — never // separated library-wide at launch. Just true-up links vs the disk. - // Local-only maintenance, so it runs even when remote ingest is disabled - // (App Store builds import audio from Files and still need this). reconcileStemLinks(track, in: context) backfillTrackDetails(track, in: context) - } else if RemoteAudioIngest.isEnabled { - enqueue(track, in: context) // file lost/evicted → re-fetch end to end } else { // Audio gone and this build can't re-download — surface as failed rather // than leaving a "ready" track that silently won't play. @@ -114,145 +61,33 @@ public final class PreparationQueue { try? context.save() } case .pending, .preparing: - if RemoteAudioIngest.isEnabled { - enqueue(track, in: context) // interrupted before finishing → pick back up - } else { - track.prepState = .failed // can't resume a download this build won't do - try? context.save() - } + track.prepState = .failed // can't resume a download this build won't do + try? context.save() case .failed: break } } } - // MARK: - Source sync - - /// Playlists currently syncing (drives spinners and disables sync buttons). - public internal(set) var syncingPlaylistIDs: Set = [] - - /// 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. + /// Coordination hook: when tracks are deleted, the live `Player` must drop them from its + /// queue BEFORE the models die. Wired to `Player.handleDeleted` at startup. public var onTracksDeleted: ((Set) -> Void)? - /// How stale a playlist may get before launch-time auto-sync refreshes it. Sync is **polling** - /// (at launch + manual): push would need server infrastructure neither YouTube nor Spotify - /// offers a client-only app. - static let autoSyncStaleness: TimeInterval = 6 * 60 * 60 - - /// 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. - private func process(_ track: Track, in context: ModelContext) async { - guard RemoteAudioIngest.isEnabled else { - Logger.ingest.error("process blocked — remote audio ingest disabled in this build") - if track.modelContext != nil { track.prepState = .failed; try? context.save() } - return - } - 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 - 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 - } - 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 (") - } - /// Best-effort, fire-and-forget healing of a ready track's display 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 both the import path 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). + /// launch backfill can't fan out into dozens of simultaneous file opens / PCM decodes + /// (which hitch launch and thrash memory). private func backfillTrackDetails(_ track: Track, in context: ModelContext) { - let needsTitle = Self.hasPlaceholderMetadata(track) 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 } @@ -295,15 +130,6 @@ public final class PreparationQueue { track.analysisVersion = TrackAnalyzer.analysisVersion } } - if needsTitle, RemoteAudioIngest.isEnabled, 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() } @@ -315,6 +141,4 @@ public final class PreparationQueue { var protectedStemKeys: Set = [] /// Tracks whose separation is currently running (dedup across repeated `ensureStems` calls). var stemsInFlight: Set = [] - - } diff --git a/Packages/ContinuityKit/Sources/Ingest/RemoteAudioIngest.swift b/Packages/ContinuityKit/Sources/Ingest/RemoteAudioIngest.swift deleted file mode 100644 index 51f291e..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/RemoteAudioIngest.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -/// Gates YouTube stream download and Spotify→YouTube audio matching. -/// -/// Disabled on `release/external-testflight` and `release/app-store` so External TestFlight / -/// App Store builds cannot download YouTube audio (ToS / App Review). Demo tracks still play -/// via `ToneSynth`. Main keeps remote ingest enabled. -public enum RemoteAudioIngest { - public static let isEnabled = 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 50bcf4e..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/YouTubePlaylistResolver.swift +++ /dev/null @@ -1,131 +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) { - while items.count < Self.maxTracks { - guard let page = try? await fetchContinuation(token: token, config: config), - !page.items.isEmpty else { break } - for item in page.items where !seen.contains(item.videoID) { - seen.insert(item.videoID) - items.append(item) - } - guard let next = page.continuationToken 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 - } -}