From 8d990d6967075ecc172cc789308e27acd8e501c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 05:14:41 +0000 Subject: [PATCH] Ship App Store v1: local files only, drop YouTube ingest Strip YouTubeKit, remote resolvers, share extension, and catalog/Apple Music import so App Review has nothing to reject. Audio enters through Files into Local Files; stems, analysis, and transitions stay on-device. Adds PrivacyInfo.xcprivacy, a privacy policy, VoiceOver labels on transport, and versions the app as 1.0.0. Co-authored-by: sanylax0 --- App/Continuity/Info.plist | 13 - App/Continuity/Library/LinkImporter.swift | 140 ----- .../Resources/PrivacyInfo.xcprivacy | 48 ++ App/Continuity/Views/AddMusicView.swift | 124 ---- .../Views/AppleMusicImportView.swift | 211 ------- App/Continuity/Views/ContinuityApp.swift | 2 +- App/Continuity/Views/DownloadsView.swift | 102 ---- App/Continuity/Views/LibrarySheetView.swift | 97 +--- App/Continuity/Views/LibraryView.swift | 30 +- App/Continuity/Views/MiniPlayerView.swift | 3 + App/Continuity/Views/NowPlayingView.swift | 9 +- App/Continuity/Views/PlaylistDetailView.swift | 66 +-- App/Continuity/Views/RootView.swift | 191 +----- App/Continuity/Views/SearchView.swift | 545 ------------------ .../Views/TransitionSettingsView.swift | 1 + App/Continuity/Views/UpNextView.swift | 15 +- PRIVACY.md | 26 + Packages/ContinuityKit/Package.resolved | 9 - Packages/ContinuityKit/Package.swift | 6 +- .../Ingest/AppleMusicLibraryReader.swift | 91 --- .../Sources/Ingest/AudioDownloader.swift | 170 ------ .../Sources/Ingest/IngestContracts.swift | 175 +----- .../Sources/Ingest/IngestThrottle.swift | 92 --- .../Sources/Ingest/MusicCatalog.swift | 164 ------ .../Ingest/PreparationQueue+AppleMusic.swift | 89 --- .../Ingest/PreparationQueue+Catalog.swift | 85 --- .../Ingest/PreparationQueue+Import.swift | 99 ---- .../Ingest/PreparationQueue+Jobs.swift | 100 ---- .../Ingest/PreparationQueue+Sync.swift | 211 ------- .../Sources/Ingest/PreparationQueue.swift | 405 +------------ .../ContinuityKit/Sources/Ingest/Retry.swift | 57 -- .../Ingest/SpotifyPlaylistResolver.swift | 74 --- .../Ingest/YouTubeOEmbedResolver.swift | 57 -- .../Ingest/YouTubePlaylistResolver.swift | 136 ----- .../Ingest/YouTubeSearchResolver.swift | 76 --- .../Ingest/YouTubeStreamResolver.swift | 84 --- .../IngestTests/LiveIngestProbeTests.swift | 116 ---- project.yml | 55 +- 38 files changed, 148 insertions(+), 3826 deletions(-) delete mode 100644 App/Continuity/Library/LinkImporter.swift create mode 100644 App/Continuity/Resources/PrivacyInfo.xcprivacy delete mode 100644 App/Continuity/Views/AddMusicView.swift delete mode 100644 App/Continuity/Views/AppleMusicImportView.swift delete mode 100644 App/Continuity/Views/DownloadsView.swift delete mode 100644 App/Continuity/Views/SearchView.swift create mode 100644 PRIVACY.md delete mode 100644 Packages/ContinuityKit/Sources/Ingest/AppleMusicLibraryReader.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/AudioDownloader.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/IngestThrottle.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/MusicCatalog.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift delete mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Jobs.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 delete mode 100644 Packages/ContinuityKit/Tests/IngestTests/LiveIngestProbeTests.swift diff --git a/App/Continuity/Info.plist b/App/Continuity/Info.plist index ddeccd2..2cb81ab 100644 --- a/App/Continuity/Info.plist +++ b/App/Continuity/Info.plist @@ -18,23 +18,10 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) - CFBundleURLTypes - - - CFBundleURLName - com.sanylax.continuity - CFBundleURLSchemes - - continuity - - - CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption - NSAppleMusicUsageDescription - Continuity reads your Apple Music playlists so you can import them into your library. UIBackgroundModes audio 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/Resources/PrivacyInfo.xcprivacy b/App/Continuity/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..073a872 --- /dev/null +++ b/App/Continuity/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,48 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + + diff --git a/App/Continuity/Views/AddMusicView.swift b/App/Continuity/Views/AddMusicView.swift deleted file mode 100644 index 7ad4439..0000000 --- a/App/Continuity/Views/AddMusicView.swift +++ /dev/null @@ -1,124 +0,0 @@ -import SwiftUI -import Ingest -import Domain -import SwiftData -import ContinuityCore - -/// 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. -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 - - 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 { - 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) { - 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.") - } - } - } - - /// 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 - } - 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/AppleMusicImportView.swift b/App/Continuity/Views/AppleMusicImportView.swift deleted file mode 100644 index a453f0a..0000000 --- a/App/Continuity/Views/AppleMusicImportView.swift +++ /dev/null @@ -1,211 +0,0 @@ -import SwiftUI -import ContinuityCore -import Ingest - -/// Sheet for importing playlists out of the user's Apple Music library. -/// -/// **Metadata only.** Apple Music audio is DRM-protected and can't feed our engine, so an -/// imported playlist keeps each song's title + artist and the ingest pipeline re-sources the -/// recording from YouTube — the same trade the Spotify importer makes. -struct AppleMusicImportView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - @Environment(PreparationQueue.self) private var prepQueue - @Environment(\.openURL) private var openURL - - /// What the sheet is currently showing. Access state and load state are one enum because - /// they're mutually exclusive — there's no "denied but also listing playlists". - private enum Phase { - case askingPermission - case denied - case loading - case loaded([AppleMusicPlaylistContents]) - case failed(String) - } - - @State private var phase: Phase = .loading - @State private var selection: Set = [] - @State private var isImporting = false - - var body: some View { - NavigationStack { - content - .navigationTitle("Apple Music") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - .disabled(isImporting) - } - } - } - .task { await start() } - } - - @ViewBuilder - private var content: some View { - switch phase { - case .askingPermission, .loading: - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - - case .denied: - message( - symbol: "lock.fill", - title: "No Access to Apple Music", - detail: "Continuity needs permission to read your library. Enable Media & Apple Music in Settings." - ) { - Button("Open Settings") { - if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) } - } - .buttonStyle(.glassProminent) - } - - case .failed(let reason): - message(symbol: "exclamationmark.triangle.fill", title: "Couldn't Read Library", detail: reason) { - Button("Try Again") { Task { await load() } } - .buttonStyle(.glassProminent) - } - - case .loaded(let playlists) where playlists.isEmpty: - message( - symbol: "music.note.list", - title: "No Playlists", - detail: "Playlists you create in the Music app will show up here." - ) { EmptyView() } - - case .loaded(let playlists): - playlistList(playlists) - } - } - - private func playlistList(_ playlists: [AppleMusicPlaylistContents]) -> some View { - List { - Section { - ForEach(playlists) { playlist in - Button { - toggle(playlist.id) - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(playlist.name ?? "Untitled Playlist") - .foregroundStyle(.primary) - Text("\(playlist.tracks.count) songs") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if selection.contains(playlist.id) { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } footer: { - Text("Songs are matched to YouTube for audio — Apple Music's own files are protected and can't be mixed.") - } - - Section { - Button(action: importSelected) { - HStack { - if isImporting { - ProgressView() - Text("Importing…") - } else { - Label(importLabel, systemImage: "square.and.arrow.down") - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.glassProminent) - .disabled(selection.isEmpty || isImporting) - } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } - } - - private var importLabel: String { - selection.count <= 1 ? "Import Playlist" : "Import \(selection.count) Playlists" - } - - /// Shared empty/error layout so the three non-list states look like one screen. - private func message( - symbol: String, - title: String, - detail: String, - @ViewBuilder action: () -> Action - ) -> some View { - VStack(spacing: 12) { - Image(systemName: symbol) - .font(.largeTitle) - .foregroundStyle(.secondary) - Text(title).font(.headline) - Text(detail) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - action() - .padding(.top, 4) - } - .padding(32) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func toggle(_ id: String) { - if selection.contains(id) { selection.remove(id) } else { selection.insert(id) } - } - - /// Prompts on first open, then loads. Re-opening after a grant skips straight to loading. - private func start() async { - switch prepQueue.appleMusicAccess { - case .authorized: - await load() - case .denied: - phase = .denied - case .notDetermined: - phase = .askingPermission - let granted = await prepQueue.requestAppleMusicAccess() - if granted == .authorized { await load() } else { phase = .denied } - } - } - - private func load() async { - phase = .loading - do { - phase = .loaded(try await prepQueue.appleMusicPlaylists()) - } catch { - phase = .failed("Your library couldn't be read. Try again in a moment.") - } - } - - /// Imports every checked playlist. A single failure doesn't abandon the rest — the sheet - /// stays open only if nothing at all got imported. - private func importSelected() { - guard case .loaded(let playlists) = phase else { return } - let picked = playlists.filter { selection.contains($0.id) } - guard !picked.isEmpty else { return } - - isImporting = true - Task { - defer { isImporting = false } - var imported = 0 - for playlist in picked { - do { - try await prepQueue.importAppleMusicPlaylist(playlist, in: modelContext) - imported += 1 - } catch { - continue - } - } - if imported > 0 { - dismiss() - } else { - phase = .failed("Those playlists couldn't be imported.") - } - } - } -} diff --git a/App/Continuity/Views/ContinuityApp.swift b/App/Continuity/Views/ContinuityApp.swift index 689b454..8239690 100644 --- a/App/Continuity/Views/ContinuityApp.swift +++ b/App/Continuity/Views/ContinuityApp.swift @@ -8,7 +8,7 @@ import SwiftData struct ContinuityApp: App { let container: ModelContainer @State private var player = Player() - /// Drives YouTube ingestion (resolve → download → ready) for newly added tracks. + /// Local-file import, launch-time healing, analysis, and demand-driven stem separation. @State private var prepQueue = PreparationQueue() init() { diff --git a/App/Continuity/Views/DownloadsView.swift b/App/Continuity/Views/DownloadsView.swift deleted file mode 100644 index 22ff1ad..0000000 --- a/App/Continuity/Views/DownloadsView.swift +++ /dev/null @@ -1,102 +0,0 @@ -import SwiftUI -import Domain -import Ingest -import SwiftData - -/// Live ingest queue: every track currently downloading, analysing, or waiting for a slot. -struct DownloadsView: View { - @Environment(PreparationQueue.self) private var prepQueue - @Environment(\.modelContext) private var modelContext - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - Group { - if prepQueue.ingestJobs.isEmpty { - ContentUnavailableView( - "Nothing downloading", - systemImage: "arrow.down.circle", - description: Text("Imported songs show up here until their audio is ready.") - ) - } else { - List { - ForEach(prepQueue.ingestJobs) { job in - jobRow(job) - } - } - .listStyle(.insetGrouped) - } - } - .navigationTitle("Downloads") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - } - } - } - } - - private func jobRow(_ job: IngestJob) -> some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Text(job.title).lineLimit(1) - if job.isPrioritized { - Image(systemName: "arrow.up") - .font(.caption2.weight(.bold)) - .foregroundStyle(.tint) - .accessibilityLabel("Prioritized") - } - } - Text(job.artist) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - phaseLabel(job) - .font(.caption2) - .foregroundStyle(.tertiary) - if job.phase == .downloading, let fraction = job.fraction { - ProgressView(value: fraction) - .padding(.top, 2) - } else if job.phase != .queued { - ProgressView() - .padding(.top, 2) - } - } - Spacer(minLength: 8) - if !job.isPrioritized { - Button { - prioritize(job) - } label: { - Image(systemName: "arrow.up.to.line") - } - .buttonStyle(.borderless) - .accessibilityLabel("Download first") - } - } - .padding(.vertical, 4) - } - - private func phaseLabel(_ job: IngestJob) -> Text { - switch job.phase { - case .queued: - return Text("Waiting") - case .downloading: - if let fraction = job.fraction { - return Text("Downloading \(Int((fraction * 100).rounded()))%") - } - return Text("Downloading") - case .analyzing: - return Text("Analyzing") - } - } - - private func prioritize(_ job: IngestJob) { - let jobID = job.id - var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == jobID }) - descriptor.fetchLimit = 1 - guard let track = try? modelContext.fetch(descriptor).first else { return } - prepQueue.prioritize(track, in: modelContext) - } -} diff --git a/App/Continuity/Views/LibrarySheetView.swift b/App/Continuity/Views/LibrarySheetView.swift index 6ed28b8..3058dd3 100644 --- a/App/Continuity/Views/LibrarySheetView.swift +++ b/App/Continuity/Views/LibrarySheetView.swift @@ -7,13 +7,8 @@ import Playback /// with a mini player that jumps back to the home page. struct LibrarySheetView: View { @Environment(PreparationQueue.self) private var prepQueue - @Environment(MainPagerState.self) private var pagerState @Environment(\.modelContext) private var modelContext - @State private var showingAdd = false - @State private var showingSearch = false @State private var showingLocalImport = false - @State private var showingAppleMusic = false - @State private var showingDownloads = false /// Non-nil while a picked folder/files are being scanned + copied in. @State private var isImportingLocal = false @@ -23,38 +18,6 @@ struct LibrarySheetView: View { .miniPlayerDock() .navigationTitle("Continuity") .toolbar { - ToolbarItem(placement: .topBarLeading) { - DownloadsToolbarButton(showingDownloads: $showingDownloads) - } - // Every action is a primaryAction so nothing collapses into a dead "…" - // overflow menu (secondaryAction items did, and looked broken). - ToolbarItem(placement: .primaryAction) { - Button { - showingSearch = true - } label: { - Image(systemName: "magnifyingglass") - } - .accessibilityLabel("Search music") - } - // Add-by-download: import from other services (YouTube, Spotify links). - ToolbarItem(placement: .primaryAction) { - Button { - showingAdd = true - } label: { - AddBadgeIcon(base: "arrow.down") - } - .accessibilityLabel("Add from YouTube or Spotify") - } - // Add-by-library: import playlists from the user's Apple Music library. - ToolbarItem(placement: .primaryAction) { - Button { - showingAppleMusic = true - } label: { - AddBadgeIcon(base: "music.note") - } - .accessibilityLabel("Import from Apple Music") - } - // Add-by-upload: import songs from the user's own files. ToolbarItem(placement: .primaryAction) { Button { showingLocalImport = true @@ -62,7 +25,7 @@ struct LibrarySheetView: View { if isImportingLocal { ProgressView() } else { - AddBadgeIcon(base: "arrow.up") + Image(systemName: "plus") } } .disabled(isImportingLocal) @@ -70,19 +33,6 @@ struct LibrarySheetView: View { } } } - .sheet(isPresented: $showingAdd) { - AddMusicView() - } - .sheet(isPresented: $showingDownloads) { - DownloadsView() - } - .sheet(isPresented: $showingAppleMusic) { - AppleMusicImportView() - } - // Full-screen: the page owns its whole layout (pill / results / custom keyboard). - .fullScreenCover(isPresented: $showingSearch) { - SearchView() - } // Local import: pick audio files OR a whole folder — folders are scanned recursively // for music (audio type + song-sized) and imported in bulk into "Local Files". // iOS sandboxing means the app can't read the Files "music folder" unprompted; a @@ -101,48 +51,3 @@ struct LibrarySheetView: View { } } } - -/// A toolbar glyph composed of a base symbol (arrow.down / arrow.up) with a small plus badge — -/// "add by downloading" vs "add by uploading". SF Symbols has no built-in plus-badged arrows, -/// so the badge is drawn as an overlay. -private struct AddBadgeIcon: View { - let base: String - - var body: some View { - Image(systemName: base) - .overlay(alignment: .topTrailing) { - Image(systemName: "plus") - .font(.system(size: 8, weight: .heavy)) - .offset(x: 7, y: -4) - } - .padding(.trailing, 4) // room for the badge inside the tap target - } -} - -/// Isolated so byte-level download progress only invalidates this control, not the library grid. -private struct DownloadsToolbarButton: View { - @Environment(PreparationQueue.self) private var prepQueue - @Binding var showingDownloads: Bool - - var body: some View { - let count = prepQueue.ingestJobs.count - return Button { - showingDownloads = true - } label: { - Image(systemName: count == 0 ? "arrow.down.circle" : "arrow.down.circle.fill") - } - .accessibilityLabel("Downloads") - .overlay(alignment: .topTrailing) { - if count > 0 { - Text("\(count)") - .font(.system(size: 9, weight: .bold)) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background(.tint, in: Capsule()) - .foregroundStyle(.white) - .offset(x: 8, y: -8) - .accessibilityHidden(true) - } - } - } -} diff --git a/App/Continuity/Views/LibraryView.swift b/App/Continuity/Views/LibraryView.swift index 500905b..88b9270 100644 --- a/App/Continuity/Views/LibraryView.swift +++ b/App/Continuity/Views/LibraryView.swift @@ -10,7 +10,6 @@ struct LibraryView: View { @Query(sort: \Playlist.createdAt) private var playlists: [Playlist] @Environment(Player.self) private var player @Environment(\.modelContext) private var modelContext - @Environment(PreparationQueue.self) private var prepQueue @State private var searchText = "" /// Playlist awaiting destructive confirmation — set from the context menu, cleared on dismiss. @State private var playlistPendingDelete: Playlist? @@ -69,14 +68,8 @@ struct LibraryView: View { PlaylistCard(playlist: playlist) } .buttonStyle(.plain) + .accessibilityLabel(playlist.title) .contextMenu { - if playlist.tracks.contains(where: { !$0.isDemo && $0.prepState != .ready }) { - Button { - prepQueue.prioritize(playlist: playlist, in: modelContext) - } label: { - Label("Download First", systemImage: "arrow.up.to.line") - } - } Button(role: .destructive) { playlistPendingDelete = playlist } label: { @@ -109,8 +102,6 @@ private struct SearchResultsView: View { let playlists: [Playlist] let query: String @Environment(Player.self) private var player - @Environment(PreparationQueue.self) private var prepQueue - @Environment(\.modelContext) private var modelContext @Environment(MainPagerState.self) private var pagerState private var matchingPlaylists: [Playlist] { @@ -158,15 +149,6 @@ private struct SearchResultsView: View { } } } - .contextMenu { - if playlist.tracks.contains(where: { !$0.isDemo && $0.prepState != .ready }) { - Button { - prepQueue.prioritize(playlist: playlist, in: modelContext) - } label: { - Label("Download First", systemImage: "arrow.up.to.line") - } - } - } } } } @@ -203,8 +185,6 @@ private struct SearchSongRow: View { let track: Track let play: () -> Void @Environment(Player.self) private var player - @Environment(PreparationQueue.self) private var prepQueue - @Environment(\.modelContext) private var modelContext var body: some View { Button(action: play) { @@ -226,19 +206,13 @@ private struct SearchSongRow: View { } } .buttonStyle(.plain) + .accessibilityLabel("\(track.title), \(track.artist)") .contextMenu { Button { player.playNext(track) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") } - if !track.isDemo, track.prepState != .ready { - Button { - prepQueue.prioritize(track, in: modelContext) - } label: { - Label("Download First", systemImage: "arrow.up.to.line") - } - } } } } diff --git a/App/Continuity/Views/MiniPlayerView.swift b/App/Continuity/Views/MiniPlayerView.swift index 7e756aa..811d881 100644 --- a/App/Continuity/Views/MiniPlayerView.swift +++ b/App/Continuity/Views/MiniPlayerView.swift @@ -15,6 +15,7 @@ struct MiniPlayerView: View { barContent } .buttonStyle(.plain) + .accessibilityLabel(player.currentTrack.map { "\($0.title) by \($0.artist)" } ?? "Now Playing") .accessibilityHint("Opens Now Playing") } @@ -36,6 +37,7 @@ struct MiniPlayerView: View { .frame(width: 32, height: 32) } .buttonStyle(.plain) + .accessibilityLabel(player.isPlaying ? "Pause" : "Play") Button { player.next() } label: { @@ -44,6 +46,7 @@ struct MiniPlayerView: View { .frame(width: 32, height: 32) } .buttonStyle(.plain) + .accessibilityLabel("Next") .disabled(player.skipsRemaining == 0) .opacity(player.skipsRemaining == 0 ? 0.35 : 1) } diff --git a/App/Continuity/Views/NowPlayingView.swift b/App/Continuity/Views/NowPlayingView.swift index b158080..6b43956 100644 --- a/App/Continuity/Views/NowPlayingView.swift +++ b/App/Continuity/Views/NowPlayingView.swift @@ -140,12 +140,12 @@ struct NowPlayingView: View { private var transport: some View { HStack(spacing: 48) { // Previous — unlimited, so no counter. - controlGlyph("backward.fill") { player.previous() } + controlGlyph("backward.fill", accessibility: "Previous") { player.previous() } playButton // Next — spends one of the limited forward skips; the remaining count rides below it. - skipGated(controlGlyph("forward.fill") { player.next() }, disabledOpacity: 0.3) + skipGated(controlGlyph("forward.fill", accessibility: "Next") { player.next() }, disabledOpacity: 0.3) .overlay(alignment: .bottom) { skipBadge.offset(y: 30) } } .foregroundStyle(.white) @@ -195,7 +195,7 @@ struct NowPlayingView: View { } /// A plain white transport glyph with a comfortable tap target. - private func controlGlyph(_ system: String, action: @escaping () -> Void) -> some View { + private func controlGlyph(_ system: String, accessibility: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: system) .font(.system(size: 30, weight: .medium)) @@ -203,6 +203,7 @@ struct NowPlayingView: View { .contentShape(Circle()) } .buttonStyle(.plain) + .accessibilityLabel(accessibility) } /// Remaining forward skips, as a subtle glass pill under Next. Real Liquid Glass — the @@ -318,6 +319,8 @@ private struct ScrubberBar: View { } ) .tint(.white) + .accessibilityLabel("Playback position") + .accessibilityValue(Theme.time(isEditing ? scrubValue : player.position)) HStack { Text(Theme.time(isEditing ? scrubValue : player.position)) Spacer() diff --git a/App/Continuity/Views/PlaylistDetailView.swift b/App/Continuity/Views/PlaylistDetailView.swift index 45961e2..9138d89 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 { // Resolved once per body evaluation: `orderedTracks` sorts and copies the whole // relationship array, and this body used to call it three times (rows, tap handler, @@ -30,13 +27,9 @@ struct PlaylistDetailView: View { TrackRow(track: track) .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: tracks, startAt: index) - pagerState.goToNowPlaying() - } + guard track.prepState != .failed else { return } + player.play(tracks: tracks, startAt: index) + pagerState.goToNowPlaying() } .contextMenu { Button { @@ -44,13 +37,6 @@ struct PlaylistDetailView: View { } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") } - if !track.isDemo, track.prepState != .ready { - Button { - prepQueue.prioritize(track, in: modelContext) - } label: { - Label("Download First", systemImage: "arrow.up.to.line") - } - } } .swipeActions(edge: .trailing) { Button(role: .destructive) { @@ -95,44 +81,8 @@ struct PlaylistDetailView: View { .frame(maxWidth: 200) } .buttonStyle(.glassProminent) + .accessibilityLabel("Play \(playlist.title)") .padding(.top, 4) - - if tracks.contains(where: { !$0.isDemo && $0.prepState != .ready }) { - Button { - prepQueue.prioritize(playlist: playlist, in: modelContext) - } label: { - Label("Download First", systemImage: "arrow.up.to.line") - .frame(maxWidth: 200) - } - .buttonStyle(.bordered) - } - - // 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) @@ -182,6 +132,9 @@ private struct TrackRow: View { .foregroundStyle(.secondary) } .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(track.title), \(track.artist)") + .accessibilityAddTraits(isCurrent ? .isSelected : []) } /// Subtle trailing badge reflecting the track's ingest state. Ready tracks show nothing. @@ -193,10 +146,11 @@ 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") + // Missing audio in this build can't be re-fetched — re-import the file. + Image(systemName: "exclamationmark.triangle") .font(.caption.weight(.semibold)) .foregroundStyle(.orange) + .accessibilityLabel("Unavailable") case .ready: EmptyView() } diff --git a/App/Continuity/Views/RootView.swift b/App/Continuity/Views/RootView.swift index 458bfe2..76323bd 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,17 @@ 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). + // On launch: drop cached files orphaned by deletions, resume unfinished + // preparation, 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 @@ -273,11 +100,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/Continuity/Views/SearchView.swift b/App/Continuity/Views/SearchView.swift deleted file mode 100644 index 7e834c6..0000000 --- a/App/Continuity/Views/SearchView.swift +++ /dev/null @@ -1,545 +0,0 @@ -import SwiftUI -import SwiftData -import Ingest -import Domain -import ContinuityCore - -// MARK: - Model - -/// Drives the catalog search page: debounced iTunes-catalog queries, the two result lists, -/// and the keyboard's autocorrect engine (which learns its vocabulary from the catalog -/// results themselves plus the user's library, so corrections favor real music words). -@Observable -@MainActor -final class CatalogSearchModel { - private(set) var query = "" - private(set) var songs: [CatalogSong] = [] - private(set) var albums: [CatalogAlbum] = [] - private(set) var isSearching = false - private(set) var errorMessage: String? - - /// Songs already added / albums already imported this session (drives the ✓ badges). - private(set) var addedSongIDs: Set = [] - private(set) var importedAlbumIDs: Set = [] - private(set) var importingAlbumIDs: Set = [] - - private var autocorrect = CatalogAutocorrect() - private let catalog = MusicCatalog() - private var searchTask: Task? - - /// The word currently being typed (after the last space) — what suggestions complete. - private var partialWord: String { - query.hasSuffix(" ") ? "" : String(query.split(separator: " ").last ?? "") - } - - /// Suggestion-bar candidates for the in-progress word. - var suggestions: [String] { - autocorrect.suggestions(for: partialWord, limit: 3) - } - - /// Seeds the vocabulary from the user's own library so their music's words are trusted - /// from the first keystroke (heavier weight than transient catalog hits). - func seedVocabulary(titles: [String]) { - autocorrect.learn(phrases: titles, weight: 5) - } - - // MARK: Keyboard input - - func type(_ text: String) { - query += text - scheduleSearch() - } - - func backspace() { - guard !query.isEmpty else { return } - query.removeLast() - scheduleSearch() - } - - /// Space applies the confident autocorrect to the word just finished (like a system - /// keyboard), except against the music vocabulary instead of English. - func space() { - let word = partialWord - if let fixed = autocorrect.correction(for: word), !word.isEmpty { - query = String(query.dropLast(word.count)) + fixed - } - query += " " - scheduleSearch() - } - - /// Replaces the in-progress word with a tapped suggestion and searches immediately. - func accept(suggestion: String) { - query = String(query.dropLast(partialWord.count)) + suggestion + " " - searchNow() - } - - func clear() { - query = "" - scheduleSearch() - } - - // MARK: Searching - - /// Debounced live search — every keystroke re-arms it, so only pauses in typing hit the - /// network. - private func scheduleSearch() { - searchTask?.cancel() - let term = query - searchTask = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(350)) - guard !Task.isCancelled else { return } - await self?.run(term: term) - } - } - - func searchNow() { - searchTask?.cancel() - let term = query - searchTask = Task { [weak self] in - await self?.run(term: term) - } - } - - private func run(term: String) async { - let trimmed = term.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { - songs = []; albums = []; errorMessage = nil; isSearching = false - return - } - isSearching = true - defer { isSearching = false } - do { - let results = try await catalog.search(trimmed) - guard !Task.isCancelled, term == query else { return } // stale response - songs = results.songs - albums = results.albums - errorMessage = nil - // Every result set teaches the keyboard the vocabulary the user is exploring. - autocorrect.learn(phrases: - results.songs.map(\.title) + results.songs.map(\.artist) - + results.albums.map(\.title) + results.albums.map(\.artist)) - } catch { - guard term == query else { return } - errorMessage = LinkImporter.errorMessage(error, noun: "search") - } - } - - // MARK: Adding to the library - - func add(song: CatalogSong, queue: PreparationQueue, context: ModelContext) { - guard !addedSongIDs.contains(song.id) else { return } - queue.addCatalogSong(song, in: context) - addedSongIDs.insert(song.id) - } - - func importAlbum(_ album: CatalogAlbum, queue: PreparationQueue, context: ModelContext) { - guard !importedAlbumIDs.contains(album.id), !importingAlbumIDs.contains(album.id) else { return } - importingAlbumIDs.insert(album.id) - Task { - defer { importingAlbumIDs.remove(album.id) } - do { - try await queue.importCatalogAlbum(album, in: context) - importedAlbumIDs.insert(album.id) - } catch { - errorMessage = LinkImporter.errorMessage(error, noun: "album") - } - } - } -} - -// MARK: - Page - -/// Full-screen catalog search: expanding pill search bar up top, results split into two -/// always-equal halves (songs / albums), and the app's own catalog-tuned keyboard at the -/// bottom — the system keyboard never appears (there is no focused text field). -struct SearchView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - @Environment(PreparationQueue.self) private var prepQueue - - @State private var model = CatalogSearchModel() - - var body: some View { - VStack(spacing: 0) { - header - resultsSplit - MusicKeyboardView(model: model) - } - .background(Color(uiColor: .systemGroupedBackground)) - .task { - // One-shot seed, fetching only the two strings we read — @Query here hydrated - // every Track (beatTimes arrays included) and kept a live subscription re-firing - // on any library change for as long as search stayed open. - var descriptor = FetchDescriptor() - descriptor.propertiesToFetch = [\.title, \.artist] - let tracks = (try? modelContext.fetch(descriptor)) ?? [] - model.seedVocabulary(titles: tracks.map(\.title) + tracks.map(\.artist)) - } - } - - // MARK: Search pill - - private var header: some View { - HStack(spacing: 10) { - // The pill hugs its content — a compact circle-ish pill when empty that grows - // with the text (fixedSize gives it its natural width inside the leading slot). - HStack(spacing: 6) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - if model.query.isEmpty { - Text("Search") - .foregroundStyle(.tertiary) - } else { - Text(model.query) - .lineLimit(1) - .truncationMode(.head) - } - // Blinking caret — this is a live input surface, just not a system one. - Caret() - } - .font(.body) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .continuityGlassCapsule() - .fixedSize(horizontal: true, vertical: false) - .frame(maxWidth: .infinity, alignment: .leading) - .animation(.snappy(duration: 0.2), value: model.query) - - if !model.query.isEmpty { - Button { - model.clear() - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .accessibilityLabel("Clear search") - } - Button("Done") { dismiss() } - .fontWeight(.semibold) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - } - - // MARK: Results - - /// Two flexible children in a VStack split the leftover space exactly in half — the - /// halves stay equal no matter how many results either side has. - private var resultsSplit: some View { - VStack(spacing: 0) { - resultHalf(title: "Songs", count: model.songs.count) { - ForEach(model.songs) { song in - SongResultRow(song: song, added: model.addedSongIDs.contains(song.id)) { - model.add(song: song, queue: prepQueue, context: modelContext) - } - } - } - Divider() - resultHalf(title: "Albums", count: model.albums.count) { - ForEach(model.albums) { album in - AlbumResultRow( - album: album, - imported: model.importedAlbumIDs.contains(album.id), - importing: model.importingAlbumIDs.contains(album.id) - ) { - model.importAlbum(album, queue: prepQueue, context: modelContext) - } - } - } - } - .overlay { - if let message = model.errorMessage { - Label(message, systemImage: "exclamationmark.triangle.fill") - .font(.footnote) - .padding(10) - .continuityGlass(cornerRadius: 12) - .padding() - } - } - } - - @ViewBuilder - private func resultHalf(title: String, count: Int, @ViewBuilder rows: () -> Rows) -> some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text(title) - .font(.headline) - Spacer() - if model.isSearching { - ProgressView().controlSize(.small) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 6) - if count == 0 { - Text(model.query.isEmpty ? "Type to search the catalog" : "No \(title.lowercased()) found") - .font(.subheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - LazyVStack(spacing: 0) { rows() } - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -/// Blinking text caret for the pill (purely cosmetic — input comes from the custom keyboard). -private struct Caret: View { - @State private var visible = true - - var body: some View { - RoundedRectangle(cornerRadius: 1) - .fill(Color.accentColor) - .frame(width: 2, height: 20) - .opacity(visible ? 1 : 0) - .task { - while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(530)) - withAnimation(.easeInOut(duration: 0.15)) { visible.toggle() } - } - } - } -} - -// MARK: - Result rows - -private struct SongResultRow: View { - let song: CatalogSong - let added: Bool - let action: () -> Void - - var body: some View { - HStack(spacing: 12) { - CatalogArtwork(url: song.artworkURL) - VStack(alignment: .leading, spacing: 1) { - Text(song.title).font(.subheadline.weight(.medium)).lineLimit(1) - Text(song.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - Spacer(minLength: 8) - Button(action: action) { - Image(systemName: added ? "checkmark.circle.fill" : "plus.circle.fill") - .font(.title3) - .foregroundStyle(added ? Color.green : Color.accentColor) - } - .buttonStyle(.plain) - .disabled(added) - .accessibilityLabel(added ? "Added" : "Add \(song.title)") - } - .padding(.horizontal, 16) - .padding(.vertical, 6) - } -} - -private struct AlbumResultRow: View { - let album: CatalogAlbum - let imported: Bool - let importing: Bool - let action: () -> Void - - var body: some View { - HStack(spacing: 12) { - CatalogArtwork(url: album.artworkURL) - VStack(alignment: .leading, spacing: 1) { - Text(album.title).font(.subheadline.weight(.medium)).lineLimit(1) - Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - Spacer(minLength: 8) - if importing { - ProgressView().controlSize(.small) - } else { - Button(action: action) { - Image(systemName: imported ? "checkmark.circle.fill" : "plus.circle.fill") - .font(.title3) - .foregroundStyle(imported ? Color.green : Color.accentColor) - } - .buttonStyle(.plain) - .disabled(imported) - .accessibilityLabel(imported ? "Imported" : "Import \(album.title)") - } - } - .padding(.horizontal, 16) - .padding(.vertical, 6) - } - - private var subtitle: String { - var parts = [album.artist] - if let year = album.releaseYear { parts.append(String(year)) } - if album.trackCount > 0 { parts.append("\(album.trackCount) tracks") } - return parts.joined(separator: " · ") - } -} - -private struct CatalogArtwork: View { - let url: URL? - - var body: some View { - Group { - if let url { - // Same shared decode + bounded cache as library artwork: search results scroll, - // and `AsyncImage` re-fetched and re-decoded every row that came back on screen. - CachedArtworkImage(url: url, cornerRadius: 8, cropsLetterbox: false) { - placeholder - } - } else { - placeholder - } - } - .frame(width: 44, height: 44) - } - - private var placeholder: some View { - RoundedRectangle(cornerRadius: 8).fill(.quaternary) - .overlay { Image(systemName: "music.note").foregroundStyle(.secondary) } - } -} - -// MARK: - Custom keyboard - -/// One shared, pre-armed feedback generator for the in-app keyboard. -/// -/// The keyboard used to hold `let haptic = UIImpactFeedbackGenerator(...)` as a stored property, -/// which cost twice: the Taptic Engine was re-armed from cold on every keypress (so the first -/// tap of each was late), and — because `SearchView.body` re-runs on every keystroke — the new -/// object reference made SwiftUI treat the keyboard as changed and re-evaluate all ~40 keys, -/// which is exactly what the `SuggestionBar` leaf split was meant to prevent. -@MainActor -private enum KeyboardHaptics { - private static let generator: UIImpactFeedbackGenerator = { - let generator = UIImpactFeedbackGenerator(style: .light) - generator.prepare() - return generator - }() - - /// Fires, then re-arms for the next keypress. - static func tap() { - generator.impactOccurred() - generator.prepare() - } -} - -/// The app's own keyboard: a QWERTY grid rendered in SwiftUI (the system keyboard never -/// appears). Its suggestion bar and space-bar autocorrect run against the catalog vocabulary -/// via `CatalogAutocorrect`, so it repairs toward music words, not English ones. -private struct MusicKeyboardView: View { - let model: CatalogSearchModel - @State private var showNumbers = false - - private static let letterRows = ["qwertyuiop", "asdfghjkl", "zxcvbnm"] - private static let numberRows = ["1234567890", "-'&.,?!/", "@:;()$#"] - - var body: some View { - // One container for the whole key grid: sibling Liquid Glass elements inside a - // container are composited in a single pass rather than each sampling what is behind - // it — which is what ~40 separate material backdrops used to cost, every frame the - // keyboard was on screen. Spacing 0 keeps neighbouring keys from merging into blobs. - GlassEffectContainer(spacing: 0) { - VStack(spacing: 7) { - // Leaf view: suggestions change on every keystroke; inlined, they re-built the - // entire ~40-button key grid per keypress instead of just this bar. - SuggestionBar(model: model) - ForEach(showNumbers ? Self.numberRows : Self.letterRows, id: \.self) { row in - keyRow(row) - } - bottomRow - } - } - .padding(.horizontal, 4) - .padding(.top, 8) - .padding(.bottom, 6) - // The plane the glass keys sit on stays an opaque fill — Liquid Glass layered directly - // on more Liquid Glass is exactly what Apple's guidance rules out. - .background(Color(uiColor: .secondarySystemBackground)) - } - - private func keyRow(_ characters: String) -> some View { - HStack(spacing: 5) { - ForEach(Array(characters), id: \.self) { character in - key(String(character)) { - model.type(String(character)) - } - } - } - // Center shorter rows (like the system keyboard's a…l row). - .padding(.horizontal, characters.count < 10 ? 14 : 0) - } - - private var bottomRow: some View { - // Space is the only flexible key; the modifiers keep fixed widths like the system - // keyboard's bottom row. - HStack(spacing: 5) { - key(showNumbers ? "abc" : "123", width: 56) { - showNumbers.toggle() - } - key("space") { - model.space() - } - key(symbol: "delete.left", width: 52) { - model.backspace() - } - key(symbol: "magnifyingglass", width: 52, prominent: true) { - model.searchNow() - } - } - } - - private func key(_ label: String = "", symbol: String? = nil, width: CGFloat? = nil, - prominent: Bool = false, action: @escaping () -> Void) -> some View { - Button { - KeyboardHaptics.tap() - action() - } label: { - Group { - if let symbol { - Image(systemName: symbol) - } else { - Text(label) - } - } - .font(label.count > 1 ? .subheadline : .title3) - .frame(maxWidth: width ?? .infinity) - .frame(width: width, height: 42) - .foregroundStyle(prominent ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) - .glassEffect( - prominent ? .regular.tint(Color.accentColor).interactive() : .regular.interactive(), - in: RoundedRectangle(cornerRadius: 6, style: .continuous) - ) - } - .buttonStyle(.plain) - // Holding backspace repeats, like the real thing. - .buttonRepeatBehavior(symbol == "delete.left" ? .enabled : .disabled) - } -} - -/// Leaf: the keyboard's only per-keystroke invalidation surface (see MusicKeyboardView.body). -private struct SuggestionBar: View { - let model: CatalogSearchModel - - var body: some View { - HStack(spacing: 6) { - let suggestions = model.suggestions - if suggestions.isEmpty { - // Fixed height so the keyboard never jumps as suggestions come and go. - Color.clear.frame(height: 32) - } else { - ForEach(suggestions, id: \.self) { word in - Button { - KeyboardHaptics.tap() - model.accept(suggestion: word) - } label: { - Text(word) - .font(.subheadline) - .lineLimit(1) - .frame(maxWidth: .infinity) - .frame(height: 32) - .continuityGlass(cornerRadius: 8, interactive: true) - } - .buttonStyle(.plain) - } - } - } - .padding(.horizontal, 4) - } -} diff --git a/App/Continuity/Views/TransitionSettingsView.swift b/App/Continuity/Views/TransitionSettingsView.swift index de8721f..ac59a9c 100644 --- a/App/Continuity/Views/TransitionSettingsView.swift +++ b/App/Continuity/Views/TransitionSettingsView.swift @@ -120,6 +120,7 @@ struct TransitionSettingsView: View { .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } + .accessibilityLabel("Done") } } } diff --git a/App/Continuity/Views/UpNextView.swift b/App/Continuity/Views/UpNextView.swift index 0c3429d..ca131f7 100644 --- a/App/Continuity/Views/UpNextView.swift +++ b/App/Continuity/Views/UpNextView.swift @@ -1,7 +1,6 @@ import SwiftUI import Playback import Domain -import Ingest import ContinuityCore /// The queue page (below Now Playing): what plays next, with drag-to-reorder and @@ -9,8 +8,6 @@ import ContinuityCore /// key/tempo-compatible DJ sequence. struct UpNextView: View { @Environment(Player.self) private var player - @Environment(PreparationQueue.self) private var prepQueue - @Environment(\.modelContext) private var modelContext @Environment(MainPagerState.self) private var pagerState // Persisted as a mode label; toggling ON reorders once, toggling OFF is not an undo. @AppStorage("flowMode.v1") private var flowMode = false @@ -42,6 +39,7 @@ struct UpNextView: View { } ToolbarItem(placement: .topBarLeading) { Toggle("Flow", systemImage: "wand.and.stars", isOn: $flowMode) + .accessibilityLabel("Flow") } ToolbarItem(placement: .topBarTrailing) { EditButton() @@ -84,15 +82,8 @@ struct UpNextView: View { Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) } } - .contextMenu { - if !track.isDemo, track.prepState != .ready { - Button { - prepQueue.prioritize(track, in: modelContext) - } label: { - Label("Download First", systemImage: "arrow.up.to.line") - } - } - } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(track.title), \(track.artist)") } /// Reorders only the upcoming tracks. The current track is passed as the chain's anchor — diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..7b48a5a --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,26 @@ +# Privacy Policy — Continuity + +**Last updated:** 16 September 2026 + +Continuity is a personal music player. It does not have accounts, analytics, advertising, or any server that stores your library. + +## What stays on your device + +- Audio files you import from the Files app +- Artwork extracted from those files +- Playback position, skip history, and transition settings +- On-device analysis (tempo, key, loudness) and stem-separated audio used for blends + +None of that is uploaded. + +## Network use + +The only network request Continuity makes is a one-time download of the on-device stem-separation model (~158 MB, MIT-licensed HT-Demucs weights hosted on Hugging Face) the first time vocal-aware blends need it. That download is a model file, not your music. + +## Data we do not collect + +Continuity does not collect names, emails, identifiers, location, contacts, browsing history, or usage analytics. There is no tracking and no third-party SDK that phones home. + +## Contact + +Questions: [github.com/ContinuityInc/Continuity/issues](https://github.com/ContinuityInc/Continuity/issues) diff --git a/Packages/ContinuityKit/Package.resolved b/Packages/ContinuityKit/Package.resolved index cd33aa7..fb51e58 100644 --- a/Packages/ContinuityKit/Package.resolved +++ b/Packages/ContinuityKit/Package.resolved @@ -9,15 +9,6 @@ "revision" : "12ce7374c86944e1f68f3a866d10105d8357f074", "version" : "1.20.0" } - }, - { - "identity" : "youtubekit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/alexeichhorn/YouTubeKit", - "state" : { - "revision" : "e5b7d0396ce12bf3444f0d209e8436c83373b7af", - "version" : "0.4.9" - } } ], "version" : 3 diff --git a/Packages/ContinuityKit/Package.swift b/Packages/ContinuityKit/Package.swift index 4662772..e8c3efd 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", exact: "0.4.9"), .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/Ingest/AppleMusicLibraryReader.swift b/Packages/ContinuityKit/Sources/Ingest/AppleMusicLibraryReader.swift deleted file mode 100644 index 23ac799..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/AppleMusicLibraryReader.swift +++ /dev/null @@ -1,91 +0,0 @@ -import ContinuityCore -import Foundation -import MediaPlayer - -/// Reads the user's Apple Music / iTunes library through **MediaPlayer**, returning plain -/// metadata (title, artist, duration) — never audio. -/// -/// Why MediaPlayer and not MusicKit: everything we need is title + artist for the YouTube -/// re-source, and `MPMediaQuery` needs only `NSAppleMusicUsageDescription`. MusicKit would -/// additionally require the MusicKit App Service enabled on the App ID, which changes the -/// provisioning profile — and the TestFlight pipeline signs in the cloud, where a capability -/// mismatch fails the build with "No profiles for 'com.sanylax.continuity'". If richer catalog -/// metadata is ever wanted, swap in a MusicKit implementation behind `AppleMusicLibraryReading`. -/// -/// Stateless, so it's trivially `Sendable`; the library queries run off the main actor. -struct AppleMusicLibraryReader: AppleMusicLibraryReading { - - var access: AppleMusicAccess { - Self.map(MPMediaLibrary.authorizationStatus()) - } - - func requestAccess() async -> AppleMusicAccess { - // `requestAuthorization` invokes its handler immediately once the status has settled, - // so this is also the cheap re-check path. - let status = await withCheckedContinuation { continuation in - MPMediaLibrary.requestAuthorization { continuation.resume(returning: $0) } - } - return Self.map(status) - } - - func playlists() async throws -> [AppleMusicPlaylistContents] { - try await read { query in - (query.collections ?? []).compactMap { Self.contents(of: $0) } - } - } - - func playlist(persistentID: String) async throws -> AppleMusicPlaylistContents? { - guard let id = MPMediaEntityPersistentID(persistentID) else { return nil } - return try await read { query in - query.addFilterPredicate( - MPMediaPropertyPredicate( - value: NSNumber(value: id), - forProperty: MPMediaPlaylistPropertyPersistentID - ) - ) - return (query.collections ?? []).compactMap { Self.contents(of: $0) } - }.first - } - - /// Runs a playlist query off the main actor and hands back Sendable value types — the - /// `MPMedia*` objects never escape this closure. - private func read( - _ body: @escaping @Sendable (MPMediaQuery) -> [AppleMusicPlaylistContents] - ) async throws -> [AppleMusicPlaylistContents] { - guard access == .authorized else { throw IngestError.appleMusicAccessDenied } - return await Task.detached(priority: .userInitiated) { - body(MPMediaQuery.playlists()) - }.value - } - - /// Converts one library playlist into value types, dropping the rows we can't use: - /// folders (containers, no songs of their own) and empty or untitled playlists. - private static func contents(of collection: MPMediaItemCollection) -> AppleMusicPlaylistContents? { - guard let playlist = collection as? MPMediaPlaylist else { return nil } - let tracks: [AppleMusicTrack] = playlist.items.compactMap { item in - guard let title = item.title, !title.isEmpty else { return nil } - let artist = item.artist.flatMap { $0.isEmpty ? nil : $0 } - // A zero duration means Music hasn't got the metadata yet; leave it unknown so the - // ingest pipeline fills it in from the downloaded file rather than persisting 0. - let duration = item.playbackDuration > 0 ? Int(item.playbackDuration.rounded()) : nil - return AppleMusicTrack(title: title, artist: artist, durationSeconds: duration) - } - guard !tracks.isEmpty else { return nil } - - let name = playlist.value(forProperty: MPMediaPlaylistPropertyName) as? String - return AppleMusicPlaylistContents( - persistentID: String(playlist.persistentID), - name: name?.isEmpty == false ? name : nil, - tracks: tracks - ) - } - - private static func map(_ status: MPMediaLibraryAuthorizationStatus) -> AppleMusicAccess { - switch status { - case .authorized: return .authorized - case .notDetermined: return .notDetermined - // `.restricted` (Screen Time / MDM) is as final as `.denied` from our side. - default: return .denied - } - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/AudioDownloader.swift b/Packages/ContinuityKit/Sources/Ingest/AudioDownloader.swift deleted file mode 100644 index f9a3d65..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/AudioDownloader.swift +++ /dev/null @@ -1,170 +0,0 @@ -import Foundation -import Domain -import ContinuityCore - -/// 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, - progress: (@Sendable (Int, Int?) -> Void)? - ) 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) { - progress?(1, 1) - 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, progress: progress) - - // 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) { - progress?(1, 1) - 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, - progress: (@Sendable (Int, Int?) -> Void)? - ) 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 - progress?(offset, totalSize) - } while totalSize == nil || offset < totalSize! - - try handle.close() - - // A truncated or empty body is a transient server-side symptom (throttling, dropped - // connection), not a permanent property of the video — classify it retryable so the - // track-level backoff gets another pass rather than failing the row outright. - if let totalSize, offset < totalSize { - throw IngestError.network("incomplete download: \(offset)/\(totalSize) bytes") - } - if offset == 0 { - throw IngestError.network("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 1...max(1, maxRetriesPerChunk) { - // Share the app-wide cool-down: if the source is throttling other tracks' resolves, - // pushing chunk requests through anyway just deepens it. - await IngestThrottle.shared.gate() - do { - var request = URLRequest(url: url) - request.setValue("bytes=\(from)-\(to)", forHTTPHeaderField: "Range") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let http = response as? HTTPURLResponse else { - await IngestThrottle.shared.noteSuccess() - return (data, nil, false) - } - switch http.statusCode { - case 206: - // Partial content — the normal ranged path. - await IngestThrottle.shared.noteSuccess() - let total = Self.totalSize(fromContentRange: http.value(forHTTPHeaderField: "Content-Range")) - return (data, total, false) - case 200: - // Server ignored Range and sent the whole file in one shot. - await IngestThrottle.shared.noteSuccess() - return (data, data.count, true) - case 416: - // Requested range not satisfiable — we've already read past the end. - await IngestThrottle.shared.noteSuccess() - return (Data(), nil, false) - case 403, 410: - // Signed googlevideo URL went stale (they're short-lived, and a throttled - // client gets them invalidated early). Retrying this URL is guaranteed to - // fail; bail out immediately so the caller can re-resolve for a fresh one. - throw IngestError.streamURLExpired - case 429: - throw IngestError.rateLimited - default: - throw IngestError.network("range fetch HTTP \(http.statusCode)") - } - } catch let error as IngestError where error.needsFreshStreamURL { - throw error // pointless to retry — needs a different URL - } catch { - lastError = error - let ingestError = error as? IngestError - await IngestThrottle.shared.noteThrottled(isRateLimit: ingestError?.isRateLimited ?? false) - guard !IngestBackoff.isFinalAttempt(attempt, maxAttempts: maxRetriesPerChunk) else { break } - try Task.checkCancellation() - // Exponential backoff + jitter, matching the resolve path. The old linear - // 0.2/0.4/0.6s schedule burned its whole budget inside a single throttle window. - let policy: IngestBackoff.Policy = (ingestError?.isRateLimited ?? false) ? .rateLimited : .request - let delay = IngestBackoff.delay(afterAttempt: attempt, policy: policy) - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - } - if let ingestError = lastError as? IngestError { throw ingestError } - throw IngestError.downloadFailed(String(describing: lastError ?? IngestError.downloadFailed("range fetch failed"))) - } - - /// Parses the total length from a `Content-Range: bytes 0-1048575/3449447` header. - private static func totalSize(fromContentRange header: String?) -> 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 1b14216..655a3ed 100644 --- a/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift +++ b/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift @@ -1,178 +1,9 @@ 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 the personal prototype; this App Store cut 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 `googlevideo` URL was rejected (403/410): its signature expired, or the source - /// invalidated it mid-download. Retrying the same URL can never work — the video must be - /// re-resolved for a fresh one. - case streamURLExpired - /// The source was reached and understood, but has no usable content - /// (private, empty, region-locked, or deleted). Retrying won't help. - case sourceUnavailable - /// The user hasn't granted (or has revoked) access to their Apple Music library. - case appleMusicAccessDenied - - /// 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. - /// - /// `.streamURLExpired` is deliberately excluded: it needs a *different* URL, not another - /// attempt at this one. `process` handles it by re-resolving. - var isRetryable: Bool { - switch self { - case .network, .rateLimited: return true - default: return false - } - } - - /// Whether a fresh resolve could plausibly fix this — i.e. the failure is about the URL we - /// used, not about the video or the connection. - var needsFreshStreamURL: Bool { - if case .streamURLExpired = self { return true } - 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 -} - -/// Whether the user has let us read their Apple Music / iTunes library. -public enum AppleMusicAccess: Sendable { - case notDetermined - case authorized - /// Declined, or blocked by Screen Time / MDM restrictions — Settings is the only way back. - case denied -} - -/// Reads playlists out of the user's on-device Apple Music library (metadata only). -/// Implemented by `AppleMusicLibraryReader`. -protocol AppleMusicLibraryReading: Sendable { - var access: AppleMusicAccess { get } - /// Prompts on first call; returns the settled status afterwards without re-prompting. - func requestAccess() async -> AppleMusicAccess - /// Every non-empty playlist in the library, in the order Music shows them. - func playlists() async throws -> [AppleMusicPlaylistContents] - /// One playlist by persistent ID, or nil if it's been deleted from the library. - func playlist(persistentID: String) async throws -> AppleMusicPlaylistContents? -} - -/// 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 -} - -/// One track currently in the ingest pipeline. Surfaced by `PreparationQueue.ingestJobs` so -/// the Downloads screen can show queued vs in-flight work without walking every SwiftData row. -public struct IngestJob: Identifiable, Equatable, Sendable { - public enum Phase: String, Sendable { - case queued, downloading, analyzing - } - - public let id: UUID - public var title: String - public var artist: String - public var phase: Phase - /// 0...1 while `phase == .downloading` and the server reported a size; nil otherwise. - public var fraction: Double? - public var isPrioritized: Bool - - public init( - id: UUID, - title: String, - artist: String, - phase: Phase, - fraction: Double?, - isPrioritized: Bool - ) { - self.id = id - self.title = title - self.artist = artist - self.phase = phase - self.fraction = fraction - self.isPrioritized = isPrioritized - } -} - -/// Downloads a resolved stream to local storage and returns the on-disk file URL. -/// Implemented by `AudioDownloader`. -protocol AudioFileDownloading: Sendable { - /// `progress` reports `(bytesWritten, totalBytes?)` as ranged chunks land. `totalBytes` is - /// nil until the first `Content-Range` (or a whole-file 200) arrives. - func downloadAudio( - _ resolved: ResolvedAudio, - progress: (@Sendable (Int, Int?) -> Void)? - ) async throws -> URL -} - -extension AudioFileDownloading { - func downloadAudio(_ resolved: ResolvedAudio) async throws -> URL { - try await downloadAudio(resolved, progress: nil) - } } diff --git a/Packages/ContinuityKit/Sources/Ingest/IngestThrottle.swift b/Packages/ContinuityKit/Sources/Ingest/IngestThrottle.swift deleted file mode 100644 index 05049c4..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/IngestThrottle.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation -import ContinuityCore -import os - -/// Process-wide cool-down shared by every outbound request to a scraped source. -/// -/// **Why this exists.** Importing a playlist enqueues N tracks at once; each runs its own -/// `Retry` loop with no knowledge of the others. When YouTube starts throttling (429, or a -/// bot-gated page that parses to nothing), all N loops independently retried *into the same -/// throttle window* — which extends it — and then all N gave up within a few seconds of each -/// other. That's why a fresh import failed every song rather than a few: the retries were the -/// amplifier, not the cure. -/// -/// The fix is to make the whole app back off as **one** client. Any request that sees a throttle -/// signal arms a cool-down here; every other in-flight request waits it out before its next -/// attempt. Success clears the streak. A minimum spacing between requests also paces the initial -/// burst, so an import ramps up instead of hitting the source with a wall of connections. -actor IngestThrottle { - /// The one instance every resolver/downloader consults. - static let shared = IngestThrottle() - - /// Minimum spacing between outbound requests to a scraped source. Slow enough that a 50-track - /// Spotify import doesn't read as a scraper, fast enough to stay invisible next to the - /// per-track resolve+download time. - private let minimumSpacing: TimeInterval = 0.25 - - /// Consecutive throttle signals since the last success; drives the cool-down curve. - private var failureStreak = 0 - /// No request may start before this instant. - private var openAt = Date.distantPast - /// When the last request was released, for `minimumSpacing`. - private var lastReleasedAt = Date.distantPast - - /// Waits until the source is ready for another request: any active cool-down elapses, then - /// the minimum spacing since the previous caller. Call immediately before each attempt. - func gate() async { - // Loop rather than sleep once: a concurrent failure can push `openAt` further out while - // we're sleeping, and that new cool-down must be honoured too. - while true { - let now = Date() - let readyAt = max(openAt, lastReleasedAt.addingTimeInterval(minimumSpacing)) - guard readyAt > now else { break } - let wait = readyAt.timeIntervalSince(now) - // `openAt` is only ever pushed out by other tasks, so re-checking after the sleep - // converges; the spacing term can only move by `minimumSpacing`. - try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) - if Task.isCancelled { break } - } - lastReleasedAt = Date() - } - - /// The source answered normally — clear the streak so the next blip starts from `base` again. - /// - /// Deliberately does **not** clear `openAt`: during a rate limit some requests still succeed - /// (a cached page, a different endpoint), and letting one of those cancel an armed cool-down - /// would release the whole import back into the throttle and oscillate. An armed cool-down - /// always expires on its own schedule. - func noteSuccess() { - failureStreak = 0 - } - - /// The source throttled or refused us. Arms (or extends) the shared cool-down. - /// - /// `isRateLimit` distinguishes an explicit 429 — which needs a much longer window than a - /// dropped connection — from a generic transient network failure. - func noteThrottled(isRateLimit: Bool) { - failureStreak += 1 - let policy: IngestBackoff.Policy = isRateLimit ? .rateLimited : .sourceCooldown - let delay = IngestBackoff.delay(afterAttempt: failureStreak, policy: policy) - let candidate = Date().addingTimeInterval(delay) - // Never pull the gate in: overlapping failures should compound, not reset each other. - if candidate > openAt { - openAt = candidate - Logger.ingest.notice( - "source cool-down \(delay, format: .fixed(precision: 1))s (streak \(self.failureStreak), rateLimit \(isRateLimit))" - ) - } - } - - /// How long callers must currently wait — used to decide whether a whole-track retry is worth - /// scheduling now or should be pushed past the cool-down. - func remainingCooldown() -> TimeInterval { - max(0, openAt.timeIntervalSince(Date())) - } - - /// Test seam: forget all throttle state. - func reset() { - failureStreak = 0 - openAt = .distantPast - lastReleasedAt = .distantPast - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/MusicCatalog.swift b/Packages/ContinuityKit/Sources/Ingest/MusicCatalog.swift deleted file mode 100644 index aa3b859..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/MusicCatalog.swift +++ /dev/null @@ -1,164 +0,0 @@ -import Foundation - -/// A song hit from the music catalog search. -public struct CatalogSong: Identifiable, Hashable, Sendable { - public let id: Int - public let title: String - public let artist: String - public let album: String? - public let durationSeconds: Double - public let artworkURL: URL? - - /// The text used to source this song's audio on YouTube (same shape the Spotify - /// import uses — "title artist"). - public var youtubeSearchQuery: String { "\(title) \(artist)" } -} - -/// An album hit from the music catalog search. -public struct CatalogAlbum: Identifiable, Hashable, Sendable { - public let id: Int - public let title: String - public let artist: String - public let trackCount: Int - public let releaseYear: Int? - public let artworkURL: URL? -} - -/// Client for the iTunes Search API — Apple's full commercial music catalog (over 100 million -/// songs), freely queryable with no API key. Chosen over MusicBrainz (richer metadata but hard -/// 1 req/s rate limit and patchy artwork) for an interactive, per-keystroke search UI. -/// -/// Stateless and Sendable: each call is a single GET returning decoded results; errors map to -/// the shared `IngestError` cases so the UI reuses the existing user-facing messages. -public struct MusicCatalog: Sendable { - - public init() {} - - /// Searches songs and albums for a free-text term, concurrently (two entity queries — - /// the API has no combined mode). Empty/whitespace terms return empty results. - public func search(_ term: String, limit: Int = 25) async throws -> (songs: [CatalogSong], albums: [CatalogAlbum]) { - let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return ([], []) } - async let songResults = fetch(query: [ - URLQueryItem(name: "term", value: trimmed), - URLQueryItem(name: "media", value: "music"), - URLQueryItem(name: "entity", value: "song"), - URLQueryItem(name: "limit", value: String(limit)), - ]) - async let albumResults = fetch(query: [ - URLQueryItem(name: "term", value: trimmed), - URLQueryItem(name: "media", value: "music"), - URLQueryItem(name: "entity", value: "album"), - URLQueryItem(name: "limit", value: String(limit)), - ]) - let (songs, albums) = try await (songResults, albumResults) - return (songs.compactMap(CatalogSong.init(result:)), - albums.compactMap(CatalogAlbum.init(result:))) - } - - /// The full tracklist of an album (for importing it as a playlist), in track order. - public func albumSongs(albumID: Int) async throws -> [CatalogSong] { - let results = try await fetch(path: "/lookup", query: [ - URLQueryItem(name: "id", value: String(albumID)), - URLQueryItem(name: "entity", value: "song"), - URLQueryItem(name: "limit", value: "200"), - ]) - // The lookup echoes the album itself as the first result — songs only. - return results - .filter { $0.wrapperType == "track" } - .compactMap(CatalogSong.init(result:)) - } - - // MARK: Networking - - private func fetch(path: String = "/search", query: [URLQueryItem]) async throws -> [ITunesResult] { - var components = URLComponents(string: "https://itunes.apple.com")! - components.path = path - components.queryItems = query - guard let url = components.url else { throw IngestError.invalidURL } - - 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 403, 429: throw IngestError.rateLimited - case 500..<600: throw IngestError.network("catalog HTTP \(http.statusCode)") - default: throw IngestError.resolveFailed("catalog HTTP \(http.statusCode)") - } - } - do { - return try JSONDecoder().decode(ITunesResponse.self, from: data).results - } catch { - throw IngestError.decodeFailed("catalog response: \(error)") - } - } -} - -// MARK: - Wire format - -private struct ITunesResponse: Decodable { - let results: [ITunesResult] -} - -/// One row of the iTunes Search/Lookup response — a superset of song and album fields, all -/// optional because the two entity kinds share this shape. -private struct ITunesResult: Decodable { - let wrapperType: String? - let trackId: Int? - let collectionId: Int? - let trackName: String? - let collectionName: String? - let artistName: String? - let trackTimeMillis: Double? - let trackCount: Int? - let artworkUrl100: String? - let releaseDate: String? - - /// Artwork CDN URLs embed the size in the path — swap 100×100 for a crisp 600×600. - var artworkURL: URL? { - artworkUrl100.flatMap { URL(string: $0.replacingOccurrences(of: "100x100", with: "600x600")) } - } - - var releaseYear: Int? { - releaseDate.flatMap { Int($0.prefix(4)) } - } -} - -private extension CatalogSong { - init?(result: ITunesResult) { - guard let id = result.trackId, let title = result.trackName, let artist = result.artistName else { - return nil - } - self.init( - id: id, - title: title, - artist: artist, - album: result.collectionName, - durationSeconds: (result.trackTimeMillis ?? 0) / 1000, - artworkURL: result.artworkURL - ) - } -} - -private extension CatalogAlbum { - init?(result: ITunesResult) { - guard let id = result.collectionId, let title = result.collectionName, - let artist = result.artistName else { - return nil - } - self.init( - id: id, - title: title, - artist: artist, - trackCount: result.trackCount ?? 0, - releaseYear: result.releaseYear, - artworkURL: result.artworkURL - ) - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift deleted file mode 100644 index c7d6b89..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift +++ /dev/null @@ -1,89 +0,0 @@ -import ContinuityCore -import Domain -import Foundation -import SwiftData - -extension PreparationQueue { - - /// Current Apple Music library permission, without prompting. - public var appleMusicAccess: AppleMusicAccess { appleMusicLibrary.access } - - /// Prompts for Apple Music library access (first call) and returns the settled status. - public func requestAppleMusicAccess() async -> AppleMusicAccess { - await appleMusicLibrary.requestAccess() - } - - /// Every non-empty playlist in the user's Apple Music library, for the import picker. - /// Throws `IngestError.appleMusicAccessDenied` if permission was never granted. - public func appleMusicPlaylists() async throws -> [AppleMusicPlaylistContents] { - try await appleMusicLibrary.playlists() - } - - /// Imports one Apple Music library playlist: creates a matching library `Playlist` and - /// enqueues one `Track` per song. **Metadata only** — Apple Music catalog audio is - /// DRM-protected and can't feed our engine, so each track carries a `searchQuery` and the - /// ingest pipeline re-sources the audio from YouTube (identical to the Spotify path). - /// - /// Re-importing the same playlist updates the existing one instead of creating a duplicate, - /// since the persistent ID is stable across imports. - @discardableResult - public func importAppleMusicPlaylist( - _ contents: AppleMusicPlaylistContents, - in context: ModelContext - ) async throws -> Playlist { - guard !contents.isEmpty else { throw IngestError.sourceUnavailable } - - if let existing = existingAppleMusicPlaylist(persistentID: contents.persistentID, in: context) { - await syncPlaylist(existing, in: context) - return existing - } - - let title = contents.name?.isEmpty == false ? contents.name! : "Apple Music Playlist" - // Deterministic gradient seed from the persistent ID so the card colour is stable. - let seed = contents.persistentID.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } % 90 + 10 - - let playlist = Playlist( - title: title, - subtitle: Self.appleMusicSubtitle(count: contents.tracks.count), - artworkSymbol: "music.note.list", - gradientSeed: seed - ) - playlist.sourceKind = .appleMusic - playlist.sourceID = contents.persistentID - playlist.lastSyncedAt = Date() - context.insert(playlist) - - for (index, song) in contents.tracks.enumerated() { - let track = Track( - title: song.title, - artist: song.artist ?? "Unknown Artist", - durationSeconds: Double(song.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: song.youtubeSearchQuery - ) - playlist.tracks.append(track) - context.insert(track) - enqueue(track, in: context, saving: false) - } - playlist.touch() // creation + initial tracks count as a content change - try? context.save() - return playlist - } - - /// The already-imported playlist mirroring `persistentID`, if any. - func existingAppleMusicPlaylist(persistentID: String, in context: ModelContext) -> Playlist? { - // Filter in memory: `sourceKind` is a computed wrapper over a private raw column, so it - // isn't expressible in a SwiftData #Predicate. - try? context.fetch(FetchDescriptor()).first { - $0.sourceKind == .appleMusic && $0.sourceID == persistentID - } - } - - static func appleMusicSubtitle(count: Int) -> String { - "From Apple Music · \(count) tracks" - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift deleted file mode 100644 index d14c3d3..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Domain -import Foundation -import SwiftData - -extension PreparationQueue { - /// Adds one catalog search hit to the shared "From Search" playlist and enqueues it. - /// Like Spotify imports, the track carries only a `searchQuery` — the ingest pipeline - /// resolves it to real YouTube audio during preparation. - @discardableResult - public func addCatalogSong(_ song: CatalogSong, in context: ModelContext) -> Track { - let playlist = findOrCreateSearchPlaylist(in: context) - let track = Track( - title: song.title, - artist: song.artist, - durationSeconds: song.durationSeconds, - artworkSymbol: playlist.artworkSymbol, - gradientSeed: playlist.gradientSeed * 100 + playlist.tracks.count, - sortIndex: playlist.tracks.count, - prepState: .pending, - searchQuery: song.youtubeSearchQuery - ) - playlist.tracks.append(track) - context.insert(track) - playlist.touch() // membership changed → resort the library - enqueue(track, in: context) - try? context.save() - return track - } - - /// Imports a catalog album as its own playlist: looks up the album's tracklist, creates - /// the `Playlist`, and enqueues one search-query `Track` per song (the same re-sourcing - /// path Spotify imports use). Throws if the tracklist can't be fetched or is empty. - @discardableResult - public func importCatalogAlbum(_ album: CatalogAlbum, in context: ModelContext) async throws -> Playlist { - let songs = try await MusicCatalog().albumSongs(albumID: album.id) - guard !songs.isEmpty else { throw IngestError.sourceUnavailable } - - let seed = album.id % 90 + 10 - let playlist = Playlist( - title: album.title, - subtitle: "\(album.artist) · \(songs.count) tracks", - artworkSymbol: "opticaldisc", - gradientSeed: seed - ) - context.insert(playlist) - - for (index, song) in songs.enumerated() { - let track = Track( - title: song.title, - artist: song.artist, - durationSeconds: song.durationSeconds, - artworkSymbol: playlist.artworkSymbol, - gradientSeed: seed * 100 + index, - sortIndex: index, - prepState: .pending, - searchQuery: song.youtubeSearchQuery - ) - playlist.tracks.append(track) - context.insert(track) - // Saved once after the loop (see `enqueue(_:in:saving:)`). - enqueue(track, in: context, saving: false) - } - playlist.touch() // creation + initial tracks count as a content change - try? context.save() - return playlist - } - - /// Returns the shared "From Search" playlist, creating and inserting it if missing. - private func findOrCreateSearchPlaylist(in context: ModelContext) -> Playlist { - let title = "From Search" - var descriptor = FetchDescriptor(predicate: #Predicate { $0.title == title }) - descriptor.fetchLimit = 1 - if let existing = try? context.fetch(descriptor).first { - return existing - } - let playlist = Playlist( - title: title, - subtitle: "Added from catalog search", - artworkSymbol: "magnifyingglass", - gradientSeed: 37 - ) - context.insert(playlist) - return playlist - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift deleted file mode 100644 index 0ab2d66..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, saving: false) - } - 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, saving: false) - } - playlist.touch() // creation + initial tracks count as a content change - try? context.save() - return playlist - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Jobs.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Jobs.swift deleted file mode 100644 index d527de9..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Jobs.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Domain -import Foundation -import SwiftData - -extension PreparationQueue { - static let songPriority = 100 - static let playlistPriority = 50 - - /// Jump `track` to the head of the ingest queue. Failed rows are re-enqueued. - /// Already-ready tracks are a no-op — there's nothing to download. - public func prioritize(_ track: Track, in context: ModelContext) { - guard !track.isDemo, track.prepState != .ready else { return } - ingestPriority[track.id] = max(ingestPriority[track.id] ?? 0, Self.songPriority) - if let idx = ingestJobs.firstIndex(where: { $0.id == track.id }) { - ingestJobs[idx].isPrioritized = true - sortJobs() - } - let id = track.id - Task { await ingestLimiter.bump(ids: [id], to: Self.songPriority) } - if track.prepState == .failed { - enqueue(track, in: context) - } - } - - /// Jump every not-yet-ready track in a playlist/album ahead of the rest of the library. - public func prioritize(playlist: Playlist, in context: ModelContext) { - let tracks = playlist.tracks.filter { !$0.isDemo && $0.prepState != .ready } - guard !tracks.isEmpty else { return } - let ids = Set(tracks.map(\.id)) - for track in tracks { - ingestPriority[track.id] = max(ingestPriority[track.id] ?? 0, Self.playlistPriority) - } - for i in ingestJobs.indices where ids.contains(ingestJobs[i].id) { - ingestJobs[i].isPrioritized = true - } - sortJobs() - Task { await ingestLimiter.bump(ids: ids, to: Self.playlistPriority) } - var enqueued = false - for track in tracks where track.prepState == .failed { - enqueue(track, in: context, saving: false) - enqueued = true - } - if enqueued { try? context.save() } - } - - func upsertJob(_ track: Track, phase: IngestJob.Phase) { - let prioritized = (ingestPriority[track.id] ?? 0) > 0 - if let idx = ingestJobs.firstIndex(where: { $0.id == track.id }) { - ingestJobs[idx].title = track.title - ingestJobs[idx].artist = track.artist - ingestJobs[idx].phase = phase - ingestJobs[idx].isPrioritized = prioritized - if phase != .downloading { ingestJobs[idx].fraction = nil } - } else { - ingestJobs.append(IngestJob( - id: track.id, - title: track.title, - artist: track.artist, - phase: phase, - fraction: nil, - isPrioritized: prioritized - )) - } - sortJobs() - } - - func updateJobProgress(_ id: UUID, bytes: Int, total: Int?) { - guard let idx = ingestJobs.firstIndex(where: { $0.id == id }) else { return } - ingestJobs[idx].phase = .downloading - guard let total, total > 0 else { return } - let fraction = min(1, Double(bytes) / Double(total)) - // Skip sub-percent redraws — ranged chunks are 1 MiB, so this still updates often. - if let current = ingestJobs[idx].fraction, abs(current - fraction) < 0.01 { return } - ingestJobs[idx].fraction = fraction - } - - func removeJob(_ id: UUID) { - ingestJobs.removeAll { $0.id == id } - } - - /// Active downloads first, then analysis, then the waiting queue. Prioritized rows float up - /// within a phase so "Download first" is visible at the top of the screen. - private func sortJobs() { - ingestJobs.sort { a, b in - if a.isPrioritized != b.isPrioritized { return a.isPrioritized && !b.isPrioritized } - let pa = Self.phaseOrder(a.phase) - let pb = Self.phaseOrder(b.phase) - if pa != pb { return pa < pb } - return a.title.localizedCaseInsensitiveCompare(b.title) == .orderedAscending - } - } - - private static func phaseOrder(_ phase: IngestJob.Phase) -> Int { - switch phase { - case .downloading: return 0 - case .analyzing: return 1 - case .queued: return 2 - } - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift deleted file mode 100644 index f716731..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift +++ /dev/null @@ -1,211 +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 = applyMetadataSync( - resolved.tracks, - subtitle: "From Spotify · \(resolved.tracks.count) tracks", - to: playlist, - in: context - ) - case .appleMusic: - // Reads the on-device library, so this succeeds offline — but a playlist the - // user deleted in Music resolves to nil, and we leave the local copy alone - // rather than wiping an import they may still want. - guard let contents = try await appleMusicLibrary.playlist(persistentID: sourceID) else { return } - guard playlist.modelContext != nil, !contents.isEmpty else { return } - changed = applyMetadataSync( - contents.tracks, - subtitle: Self.appleMusicSubtitle(count: contents.tracks.count), - 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, saving: false) - changed = true - } - } - playlist.subtitle = "From YouTube · \(remote.count) tracks" - if changed { playlist.touch() } - return changed - } - - /// Applies a fresh metadata-only tracklist (Spotify or Apple Music): key = the YouTube search - /// query (title + artist), the identity such tracks carry locally since they have no video ID. - /// Returns whether membership or order actually changed (drives `touch()` and - /// `onPlaylistSynced`). - private func applyMetadataSync( - _ remote: [any MetadataSourcedTrack], - subtitle: String, - 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 { $0.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, saving: false) - changed = true - } - } - playlist.subtitle = subtitle - 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 keys = tracks.map(\.stemKey) - for track in tracks { context.delete(track) } - LibraryCleanup.removeOrphanedFiles(keys: keys, in: context) - } -} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift index 4f865a1..f92f268 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift @@ -2,142 +2,48 @@ import AVFoundation import Domain import Foundation import SwiftData -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 (`importLocalFiles`), 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. +/// This App Store cut 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 - /// Reads playlists out of the user's on-device Apple Music library (metadata only). - let appleMusicLibrary: AppleMusicLibraryReading - /// 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) - /// In-flight ingest work for the Downloads screen. Not persisted; rebuilt by `enqueue`. - /// `internal(set)` so `PreparationQueue+Jobs` can mutate it from another file in Ingest. - public internal(set) var ingestJobs: [IngestJob] = [] - /// User-raised ingest priority per track. 100 = one song, 50 = whole playlist/album. - var ingestPriority: [UUID: Int] = [:] - - /// 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(), - appleMusicLibrary: AppleMusicLibraryReading = AppleMusicLibraryReader(), - searcher: YouTubeSearching = YouTubeSearchResolver(), - metadataResolver: VideoMetadataResolving = YouTubeOEmbedResolver(), - downloader: AudioFileDownloading = AudioDownloader() - ) { - self.resolver = resolver - self.playlistResolver = playlistResolver - self.spotifyResolver = spotifyResolver - self.appleMusicLibrary = appleMusicLibrary - 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`. - /// - /// User-initiated calls (tapping a failed row) reset the track's backoff — an explicit retry - /// means "try now", not "resume the curve where it left off". - /// - /// Bulk callers — playlist import, sync, the launch resume pass — pass `saving: false` and - /// save once when they're finished: a `save()` per track froze the main actor for the whole - /// length of a 500-track import, and each save costs more as the object graph grows. - public func enqueue(_ track: Track, in context: ModelContext, saving: Bool = true) { - ingestAttempts[track.id] = nil - retryScheduledTrackIDs.remove(track.id) - enqueueInternal(track, in: context, saving: saving) - } - - /// Enqueue without clearing the retry budget — used by the automatic backoff so a track that - /// keeps failing keeps climbing its curve instead of looping at `base` forever. - func enqueueInternal(_ track: Track, in context: ModelContext, saving: Bool = true) { - track.prepState = .pending - upsertJob(track, phase: .queued) - if saving { try? context.save() } - Task { await process(track, in: context) } - } - - /// Re-enqueues every `.failed` track (optionally only within one playlist) with a fresh - /// backoff budget. Surfaces as the "Retry all" affordance after an import that hit a - /// sustained throttle. - public func retryFailedTracks(in context: ModelContext, playlist: Playlist? = nil) { - let candidates: [Track] - if let playlist { - candidates = playlist.tracks - } else { - candidates = (try? context.fetch(FetchDescriptor())) ?? [] - } - var enqueued = false - for track in candidates where track.prepState == .failed && !track.isDemo { - enqueue(track, in: context, saving: false) - enqueued = true - } - if enqueued { try? context.save() } - } + 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. /// /// Yields every 40 rows so a thousand-track library doesn't occupy the main actor for the /// whole pass before the first frame can land. public func resumePreparation(in context: ModelContext) async { guard let tracks = try? context.fetch(FetchDescriptor()) else { return } - // One directory listing per cache instead of up to five `fileExists` probes per track: - // a thousand-track library meant thousands of stat calls on the main thread, at launch, - // before the first frame. + // One directory listing per cache instead of up to five `fileExists` probes per track. let cacheIndex = CacheIndex.snapshot() - // Demo healing used to `save()` once per track; batched into a single save at the end. var needsSave = false for (i, track) in tracks.enumerated() { - // 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 @@ -148,17 +54,19 @@ public final class PreparationQueue { switch track.prepState { case .ready: let hasAudio = track.localRelativePath.map { cacheIndex.hasAudio($0) } ?? false - if !hasAudio { - enqueue(track, in: context, saving: false) // file lost/evicted → re-fetch end to end - needsSave = true - } else { + if hasAudio { // Stems are demand-driven from the play queue (`ensureStems`) — never // separated library-wide at launch. Just true-up links vs the disk. reconcileStemLinks(track, in: context, using: cacheIndex) backfillTrackDetails(track, in: context) + } else { + // Audio gone and this build can't re-download — surface as failed rather + // than leaving a "ready" track that silently won't play. + track.prepState = .failed + needsSave = true } case .pending, .preparing: - enqueue(track, in: context, saving: false) // interrupted before finishing → pick back up + track.prepState = .failed needsSave = true case .failed: break @@ -168,265 +76,25 @@ public final class PreparationQueue { if needsSave { try? context.save() } } - // 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. + /// 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)? - /// 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. - private func process(_ track: Track, in context: ModelContext) async { - let trackID = track.id - retryScheduledTrackIDs.remove(trackID) - upsertJob(track, phase: .queued) - - // 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 - removeJob(trackID) - try? context.save() - return - } - - // Stay `.pending` until a limiter slot is actually ours — otherwise a 200-track import - // looks like 200 simultaneous downloads. `bump` can reorder this waiter meanwhile. - await ingestLimiter.acquire(id: trackID, priority: ingestPriority[trackID] ?? 0) - // Deleted while queued. - guard track.modelContext != nil else { - ingestAttempts[trackID] = nil - removeJob(trackID) - await ingestLimiter.release() - return - } - - track.prepState = .preparing - try? context.save() - upsertJob(track, phase: .downloading) - - var prepared = false - var failure: Error? - 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: URL - do { - fileURL = try await downloader.downloadAudio(resolved, progress: { [weak self] done, total in - Task { @MainActor in - self?.updateJobProgress(trackID, bytes: done, total: total) - } - }) - } catch let error as IngestError where error.needsFreshStreamURL { - // Signed `googlevideo` URLs are short-lived, and a throttled client gets them - // invalidated early — so a queued track's URL can be dead by the time its turn - // comes. Retrying the dead URL can never work; re-resolve for a fresh one. - Logger.ingest.notice("stream URL expired for \(id, privacy: .public) — re-resolving") - let refreshed = try await resolver.resolveAudio(videoID: id) - fileURL = try await downloader.downloadAudio(refreshed, progress: { [weak self] done, total in - Task { @MainActor in - self?.updateJobProgress(trackID, bytes: done, total: total) - } - }) - } - // 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. - upsertJob(track, phase: .analyzing) - 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 - failure = error - } - await ingestLimiter.release() - - // Don't touch a track that was deleted while we worked. - guard track.modelContext != nil else { - ingestAttempts[trackID] = nil - removeJob(trackID) - try? context.save() - return - } - - if prepared { - ingestAttempts[trackID] = nil - ingestPriority[trackID] = nil - track.prepState = .ready - removeJob(trackID) - } else if let failure, scheduleRetry(track, after: failure, in: context) { - // Stays `.pending`, not `.failed`: the row keeps its in-progress badge, and if the - // app is killed before the retry fires, `resumePreparation` picks it up at launch. - track.prepState = .pending - upsertJob(track, phase: .queued) - } else { - ingestAttempts[trackID] = nil - ingestPriority[trackID] = nil - track.prepState = .failed - removeJob(trackID) - } - 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) - } - } - - // MARK: - Track-level retry - - /// Consecutive failed ingest attempts per track, in memory (like `syncBackoff` for playlists). - /// Deliberately not persisted: a relaunch is itself a fresh start, and a `.pending` track is - /// re-enqueued by `resumePreparation` anyway. - var ingestAttempts: [UUID: Int] = [:] - - /// Tracks with a backoff retry already queued, so overlapping failures can't stack timers. - var retryScheduledTrackIDs: Set = [] - - /// Whole-track attempts before a track is finally marked `.failed`. With the `.track` curve - /// this spans roughly five minutes — long enough to outlast the bot-detection window that a - /// fresh playlist import trips. - static let maxIngestAttempts = 5 - - /// Schedules a backed-off re-attempt for a track whose ingest just failed, and reports whether - /// one was queued (`false` → the caller should mark the track `.failed`). - /// - /// This is the fix for the reported bug: a freshly imported playlist fires N resolves at once, - /// YouTube throttles, and every track used to burn its few seconds of retries inside the same - /// throttle window and land in `.failed` permanently — the whole playlist dead, recoverable - /// only by tapping each row. Now a transient failure keeps the track alive on an exponential - /// curve, and the shared `IngestThrottle` cool-down is added on top so the retry lands *after* - /// the source has stopped throttling rather than during. - private func scheduleRetry(_ track: Track, after error: Error, in context: ModelContext) -> Bool { - // Only transient failures earn a retry. A private/deleted video or a query that matches - // nothing will fail identically forever — surfacing that immediately is the honest answer. - guard let ingestError = error as? IngestError, - ingestError.isRetryable || ingestError.needsFreshStreamURL else { return false } - guard !retryScheduledTrackIDs.contains(track.id) else { return true } - - let attempt = (ingestAttempts[track.id] ?? 0) + 1 - ingestAttempts[track.id] = attempt - guard !IngestBackoff.isFinalAttempt(attempt, maxAttempts: Self.maxIngestAttempts) else { - return false - } - - let trackID = track.id - retryScheduledTrackIDs.insert(trackID) - let label = track.youtubeVideoID ?? track.searchQuery ?? track.title - - let maxAttempts = Self.maxIngestAttempts - Task { [weak self] in - // Wait out the curve, then whatever remains of the app-wide cool-down — a per-track - // delay alone would still let 50 tracks resume simultaneously into a live throttle. - let backoff = IngestBackoff.delay(afterAttempt: attempt, policy: .track) - try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) - let cooldown = await IngestThrottle.shared.remainingCooldown() - if cooldown > 0 { - try? await Task.sleep(nanoseconds: UInt64(cooldown * 1_000_000_000)) - } - guard let self else { return } - guard self.retryScheduledTrackIDs.remove(trackID) != nil else { return } // superseded - // Re-fetch rather than capture the `@Model` across a minutes-long sleep — holding one - // (and its context) alive that long is a known memory-pressure pattern here. This also - // naturally drops tracks deleted while we waited. - var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == trackID }) - descriptor.fetchLimit = 1 - guard let fresh = try? context.fetch(descriptor).first, - fresh.prepState != .ready else { return } // healed by a manual retry meanwhile - Logger.ingest.notice( - "retrying \(label, privacy: .public) (attempt \(attempt + 1)/\(maxAttempts))" - ) - self.enqueueInternal(fresh, in: context) - } - return true - } - - /// 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). 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 } @@ -473,15 +141,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() } @@ -491,9 +150,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 video/UUID, + /// not 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 @@ -511,6 +170,4 @@ public final class PreparationQueue { /// Keys a queued pass must protect on top of the play-queue neighborhood (a stem written /// after the queue moved on), drained into the next pass. var budgetExtraProtectedKeys: Set = [] - - } diff --git a/Packages/ContinuityKit/Sources/Ingest/Retry.swift b/Packages/ContinuityKit/Sources/Ingest/Retry.swift deleted file mode 100644 index 6ca81f7..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/Retry.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import ContinuityCore - -/// Retries a transient-failing async operation with exponential backoff + jitter, coordinated -/// across the whole app by `IngestThrottle`. -/// -/// Only `IngestError`s that report themselves `isRetryable` (network blips, rate limits, expired -/// stream URLs) are retried; a definitively-empty source or any other error propagates -/// immediately. The scraped endpoints (Spotify embed, YouTube playlist/search pages, YouTubeKit -/// extraction) fail transiently often enough that a single blip shouldn't surface to the user as -/// "playlist unavailable". -/// -/// Two things matter here beyond "sleep and try again": -/// - **The gate is shared.** Every attempt waits on `IngestThrottle` first, so N concurrently -/// importing tracks back off together instead of each discovering the same rate limit alone. -/// - **The delay outlasts the throttle.** The previous schedule (0.7s then 1.4s) retried inside -/// the same window that had just rejected us, which extended the throttle and failed the track. -enum Retry { - static func run( - // Bounded deliberately: this loop holds an `ingestLimiter` slot while it sleeps, so a - // long rate-limit curve here would stall the import. Past this budget the *track-level* - // backoff in `PreparationQueue.process` takes over, which retries without holding a slot. - maxAttempts: Int = 4, - policy: IngestBackoff.Policy = .request, - _ operation: () async throws -> T - ) async throws -> T { - var attempt = 1 - while true { - await IngestThrottle.shared.gate() - do { - let value = try await operation() - await IngestThrottle.shared.noteSuccess() - return value - } catch let error as IngestError where error.isRetryable { - // Tell the shared gate first — even on the final attempt, so sibling tracks that - // are still going learn about the throttle from this failure. - await IngestThrottle.shared.noteThrottled(isRateLimit: error.isRateLimited) - guard !IngestBackoff.isFinalAttempt(attempt, maxAttempts: maxAttempts) else { throw error } - try Task.checkCancellation() - - // Rate limits escalate on their own, much slower curve; other transients use the - // caller's policy. The shared cool-down armed above is additive on top of this. - let effective = error.isRateLimited ? IngestBackoff.Policy.rateLimited : policy - let delay = IngestBackoff.delay(afterAttempt: attempt, policy: effective) - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - attempt += 1 - } - } - } -} - -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 e04b5f1..0000000 --- a/Packages/ContinuityKit/Sources/Ingest/YouTubeSearchResolver.swift +++ /dev/null @@ -1,76 +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) - switch YouTubeSearch.outcome(html: html) { - case .found(let id): - return id - case .noResults: - // A real results page with nothing in it — the query genuinely has no match. - return nil - case .unreadable: - // HTTP 200 but no `ytInitialData`: a consent interstitial or bot wall, which is - // what a burst of import searches provokes. This used to collapse into the same - // `nil` as `.noResults` and fail the track permanently — the main reason a fresh - // Spotify import failed every song. It's transient: retry it, and let the shared - // throttle back the whole import off. - throw IngestError.network("YouTube search page had no ytInitialData (bot wall?)") - } - } - } - - /// 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/Packages/ContinuityKit/Tests/IngestTests/LiveIngestProbeTests.swift b/Packages/ContinuityKit/Tests/IngestTests/LiveIngestProbeTests.swift deleted file mode 100644 index d80ade2..0000000 --- a/Packages/ContinuityKit/Tests/IngestTests/LiveIngestProbeTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -import XCTest -import SwiftData -import OSLog -import Domain -@testable import Ingest - -/// Opt-in, live-network diagnostic for the ingest pipeline. Skipped unless -/// `CONTINUITY_LIVE_PROBE=1` is in the environment, so normal test runs stay hermetic. -/// -/// Drives the real `PreparationQueue` (YouTubeKit resolve → ranged download → analysis) for a -/// handful of well-known videos plus two search-query tracks, then prints every -/// `com.continuity.app` log line the run produced. When YouTube changes something and every -/// imported track starts spinning and then failing, this shows the failing stage and the exact -/// error in one run — no device, no debugger. Run it against an iOS Simulator destination: -/// -/// cd Packages/ContinuityKit && CONTINUITY_LIVE_PROBE=1 \ -/// DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild test \ -/// -scheme ContinuityKit-Package -destination 'platform=iOS Simulator,id=' \ -/// -only-testing:IngestTests/LiveIngestProbeTests -/// -/// (The env var must reach the test process: xcodebuild forwards `TEST_RUNNER_`-prefixed -/// variables, so `TEST_RUNNER_CONTINUITY_LIVE_PROBE=1` also works.) -/// -/// History: on 2026-09-10 this reproduced "every track spins, then shows the orange retry -/// badge" — every ranged download 403'd (`streamURLExpired`) because the pinned YouTubeKit -/// still asked the ANDROID_VR client for stream URLs, which YouTube stopped serving beyond the -/// first chunk in mid-August 2026. YouTubeKit 0.4.9 (visionOS/web clients) fixed it: 8/8 ready. -@MainActor -final class LiveIngestProbeTests: XCTestCase { - - private static let videoIDs = ["dQw4w9WgXcQ", "9bZkp7q19f0", "kJQP7kiw5Fk", "JGwWNGJdvx8"] - private static let queries = ["Blinding Lights The Weeknd", "bad guy Billie Eilish"] - - override func setUp() async throws { - let env = ProcessInfo.processInfo.environment - try XCTSkipUnless(env["CONTINUITY_LIVE_PROBE"] == "1" || env["TEST_RUNNER_CONTINUITY_LIVE_PROBE"] == "1", - "live-network probe; set CONTINUITY_LIVE_PROBE=1 to run") - } - - func testLiveIngest() async throws { - let start = Date() - let budget: TimeInterval = Double(ProcessInfo.processInfo.environment["CONTINUITY_LIVE_PROBE_SECONDS"] ?? "") ?? 300 - print("PROBE start os=\(ProcessInfo.processInfo.operatingSystemVersionString) budget=\(Int(budget))s") - - // Playlist page scrape (metadata only) — the first stage of a YouTube playlist import. - do { - let resolved = try await YouTubePlaylistResolver().resolvePlaylist(playlistID: "PLFgquLnL59alCl_2TQvOiD5Vgm1hCaGSI") - print("PROBE playlist resolve OK: \(resolved.items.count) items, title=\(resolved.title ?? "-")") - } catch { - print("PROBE playlist resolve FAILED: \(error)") - } - - let schema = Schema([Playlist.self, Track.self]) - let container = try ModelContainer(for: schema, configurations: [ModelConfiguration(isStoredInMemoryOnly: true)]) - let context = container.mainContext - let queue = PreparationQueue() - - let playlist = Playlist(title: "Probe", subtitle: "probe", gradientSeed: 1) - context.insert(playlist) - var tracks: [Track] = [] - for (i, id) in Self.videoIDs.enumerated() { - // Force a real download: evict any cached copy from an earlier run. - for container in ["m4a", "webm", "mp4"] { - try? FileManager.default.removeItem(at: AudioCache.fileURL(videoID: id, container: container)) - } - let track = Track(title: "vid \(id)", artist: "probe", durationSeconds: 0, gradientSeed: i, sortIndex: i, - prepState: .pending, youtubeVideoID: id, - sourceURLString: "https://www.youtube.com/watch?v=\(id)") - playlist.tracks.append(track); context.insert(track); tracks.append(track) - } - for (i, query) in Self.queries.enumerated() { - let track = Track(title: query, artist: "probe", durationSeconds: 0, gradientSeed: 100 + i, sortIndex: 100 + i, - prepState: .pending, searchQuery: query) - playlist.tracks.append(track); context.insert(track); tracks.append(track) - } - try context.save() - for track in tracks { queue.enqueue(track, in: context) } - - var lastStates = "" - while Date().timeIntervalSince(start) < budget { - try await Task.sleep(nanoseconds: 3_000_000_000) - let states = tracks.map { "\($0.youtubeVideoID ?? $0.searchQuery ?? "?")=\($0.prepState.rawValue)" }.joined(separator: " ") - if states != lastStates { - print("PROBE t+\(Int(Date().timeIntervalSince(start)))s \(states)") - lastStates = states - } - if tracks.allSatisfy({ $0.prepState == .ready || $0.prepState == .failed }) { break } - } - - print("PROBE ===== LOG DUMP (com.continuity.app) =====") - do { - let store = try OSLogStore(scope: .currentProcessIdentifier) - let entries = try store.getEntries(at: store.position(date: start)) - var printed = 0 - for case let entry as OSLogEntryLog in entries where entry.subsystem == "com.continuity.app" { - printed += 1 - if printed > 400 { print("PROBE ... (truncated)"); break } - let ts = String(format: "%.1f", entry.date.timeIntervalSince(start)) - print("PROBE LOG t+\(ts)s [\(entry.category)] \(entry.level.rawValue): \(entry.composedMessage)") - } - print("PROBE log entries printed: \(printed)") - } catch { - print("PROBE OSLogStore unavailable: \(error)") - } - - print("PROBE ===== FINAL =====") - for track in tracks { - let path = track.localRelativePath.map { AudioCache.url(forRelativePath: $0).path } ?? "-" - let bytes = (try? FileManager.default.attributesOfItem(atPath: path)[.size] as? Int) ?? 0 - print("PROBE FINAL \(track.youtubeVideoID ?? track.searchQuery ?? "?") state=\(track.prepState.rawValue) dur=\(Int(track.durationSeconds))s bytes=\(bytes) bpm=\(track.bpm ?? 0)") - } - let ready = tracks.filter { $0.prepState == .ready }.count - print("PROBE RESULT ready=\(ready)/\(tracks.count) elapsed=\(Int(Date().timeIntervalSince(start)))s") - XCTAssertEqual(ready, tracks.count, "not every probe track became ready — see PROBE LOG lines above") - } -} diff --git a/project.yml b/project.yml index c18cb07..5969673 100644 --- a/project.yml +++ b/project.yml @@ -15,9 +15,8 @@ packages: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "0.1.0" + MARKETING_VERSION: "1.0.0" CURRENT_PROJECT_VERSION: "2" - DEVELOPMENT_TEAM: "KP832RV67A" CODE_SIGN_STYLE: Automatic configs: # Release archives must use Distribution. Automatic + Development on ephemeral @@ -41,8 +40,8 @@ targets: product: Ingest - package: ContinuityKit product: Playback - - target: ContinuityShare - # App group shared with the extension — the share-sheet → app handoff channel. + # App group kept so existing installs can still migrate a legacy SwiftData store + # out of the group container. The share extension is not shipped in this build. entitlements: path: App/Continuity/Continuity.entitlements properties: @@ -52,8 +51,7 @@ targets: path: App/Continuity/Info.plist properties: CFBundleDisplayName: Continuity - # Keep in sync with settings.base MARKETING_VERSION / CURRENT_PROJECT_VERSION — - # hard-coded 1.0 here drifted from project.yml's 0.1.0. + # Keep in sync with settings.base MARKETING_VERSION / CURRENT_PROJECT_VERSION. CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" UILaunchScreen: {} @@ -62,16 +60,8 @@ targets: UIBackgroundModes: - audio ITSAppUsesNonExemptEncryption: false - # MPMediaQuery reads playlist titles/artists only — Apple Music audio is DRM-protected, - # so imported songs are re-sourced from YouTube like the Spotify path. - NSAppleMusicUsageDescription: Continuity reads your Apple Music playlists so you can import them into your library. UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - # continuity://import?url= — deep-link entry into the import flow. - CFBundleURLTypes: - - CFBundleURLName: com.sanylax.continuity - CFBundleURLSchemes: - - continuity # Shared scheme so `xcodebuild -scheme Continuity` works on a fresh checkout / CI without # relying on Xcode's per-user scheme auto-creation (which xcodebuild does not perform). scheme: @@ -115,38 +105,5 @@ targets: 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 + # ContinuityShare is not built on the App Store cut — link import is gone, and App Review + # rejects a share extension that can only say "Import not available". Sources stay in-tree.