From 4d4eedf5c5a4536cc3f64ebd03d51741f981fc8c Mon Sep 17 00:00:00 2001 From: sanylax_macbook Date: Tue, 21 Jul 2026 03:05:49 -0400 Subject: [PATCH] Import Apple Music playlists as tracklists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth library toolbar action: read the user's Apple Music playlists and import the ones they pick. Apple Music audio is DRM-locked the same way Spotify's is, so this imports metadata only — each track carries a searchQuery and the existing pipeline re-sources the recording from YouTube. MediaPlayer rather than MusicKit on purpose: MPMediaQuery gives us title + artist with only NSAppleMusicUsageDescription, while MusicKit would need the App Service enabled on the App ID — a provisioning change that breaks cloud-signed TestFlight builds. The reader sits behind AppleMusicLibraryReading so a MusicKit implementation can replace it. - ContinuityCore: AppleMusicTrack/AppleMusicPlaylistContents plus title normalization that strips release-edition noise ("Remastered 2009", "- Deluxe Edition") which YouTube titles don't repeat, while keeping the qualifiers that name a different recording (feat./Live/Remix). - MetadataSourcedTrack unifies the Spotify and Apple Music shapes, so applySpotifySync generalizes to applyMetadataSync and both paths share one sync implementation. - Playlists are source-backed by MPMediaPlaylist persistentID, so sync re-reads the local library and works offline; a playlist deleted in Music leaves the local import untouched rather than wiping it. Co-Authored-By: Claude Opus 4.8 --- App/Continuity/Info.plist | 2 + .../Views/AppleMusicImportView.swift | 211 ++++++++++++++++++ App/Continuity/Views/LibrarySheetView.swift | 13 ++ .../ContinuityCore/AppleMusicLibrary.swift | 136 +++++++++++ .../ContinuityCore/MetadataSourcedTrack.swift | 14 ++ .../AppleMusicLibraryTests.swift | 122 ++++++++++ .../Sources/Domain/Playlist.swift | 3 + .../Ingest/AppleMusicLibraryReader.swift | 91 ++++++++ .../Sources/Ingest/IngestContracts.swift | 22 ++ .../Ingest/PreparationQueue+AppleMusic.swift | 89 ++++++++ .../Ingest/PreparationQueue+Sync.swift | 37 ++- .../Sources/Ingest/PreparationQueue.swift | 4 + project.yml | 3 + 13 files changed, 740 insertions(+), 7 deletions(-) create mode 100644 App/Continuity/Views/AppleMusicImportView.swift create mode 100644 Packages/ContinuityCore/Sources/ContinuityCore/AppleMusicLibrary.swift create mode 100644 Packages/ContinuityCore/Sources/ContinuityCore/MetadataSourcedTrack.swift create mode 100644 Packages/ContinuityCore/Tests/ContinuityCoreTests/AppleMusicLibraryTests.swift create mode 100644 Packages/ContinuityKit/Sources/Ingest/AppleMusicLibraryReader.swift create mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift diff --git a/App/Continuity/Info.plist b/App/Continuity/Info.plist index 047e700..ddeccd2 100644 --- a/App/Continuity/Info.plist +++ b/App/Continuity/Info.plist @@ -33,6 +33,8 @@ $(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/Views/AppleMusicImportView.swift b/App/Continuity/Views/AppleMusicImportView.swift new file mode 100644 index 0000000..a453f0a --- /dev/null +++ b/App/Continuity/Views/AppleMusicImportView.swift @@ -0,0 +1,211 @@ +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/LibrarySheetView.swift b/App/Continuity/Views/LibrarySheetView.swift index 3f3f129..d016e64 100644 --- a/App/Continuity/Views/LibrarySheetView.swift +++ b/App/Continuity/Views/LibrarySheetView.swift @@ -13,6 +13,7 @@ struct LibrarySheetView: View { @State private var showingAdd = false @State private var showingSearch = false @State private var showingLocalImport = false + @State private var showingAppleMusic = false /// Non-nil while a picked folder/files are being scanned + copied in. @State private var isImportingLocal = false @@ -40,6 +41,15 @@ struct LibrarySheetView: View { } .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 { @@ -59,6 +69,9 @@ struct LibrarySheetView: View { .sheet(isPresented: $showingAdd) { AddMusicView() } + .sheet(isPresented: $showingAppleMusic) { + AppleMusicImportView() + } // Full-screen: the page owns its whole layout (pill / results / custom keyboard). .fullScreenCover(isPresented: $showingSearch) { SearchView() diff --git a/Packages/ContinuityCore/Sources/ContinuityCore/AppleMusicLibrary.swift b/Packages/ContinuityCore/Sources/ContinuityCore/AppleMusicLibrary.swift new file mode 100644 index 0000000..fd6514b --- /dev/null +++ b/Packages/ContinuityCore/Sources/ContinuityCore/AppleMusicLibrary.swift @@ -0,0 +1,136 @@ +import Foundation + +/// One song read out of the user's Apple Music / iTunes library. There is **no audio here** — +/// Apple Music catalog items are DRM-protected and unusable by our engine — only the metadata we +/// need to re-source the song from YouTube (title + artist), exactly like the Spotify path. +public struct AppleMusicTrack: Equatable, Sendable { + public var title: String + public var artist: String? + public var durationSeconds: Int? + + public init(title: String, artist: String? = nil, durationSeconds: Int? = nil) { + self.title = title + self.artist = artist + self.durationSeconds = durationSeconds + } + + /// Query used to find this song on YouTube, e.g. "Blinding Lights The Weeknd". + /// + /// Apple Music titles carry release-edition noise ("Song (Remastered 2011)", + /// "Song - 2009 Remaster") that YouTube titles almost never repeat, so it's stripped first — + /// leaving it in reliably pushes the real upload out of the top search result. + public var youtubeSearchQuery: String { + [AppleMusicLibrary.normalizedTitle(title), artist].compactMap { $0 }.joined(separator: " ") + } +} + +/// The contents of one Apple Music library playlist: its songs (in order) and its name. +public struct AppleMusicPlaylistContents: Equatable, Sendable, Identifiable { + /// `MPMediaPlaylist.persistentID` rendered as a string — the sync identity. + public var persistentID: String + public var name: String? + public var tracks: [AppleMusicTrack] + + public var id: String { persistentID } + public var isEmpty: Bool { tracks.isEmpty } + + public init(persistentID: String, name: String? = nil, tracks: [AppleMusicTrack]) { + self.persistentID = persistentID + self.name = name + self.tracks = tracks + } +} + +/// Pure normalization of Apple Music library metadata. **No MediaPlayer, no networking** — the +/// Ingest layer reads `MPMediaItem`s and hands plain values in, so this stays Linux-testable. +public enum AppleMusicLibrary { + + /// Edition/marketing qualifiers that identify a *release variant*, not a different song. + /// Matched case-insensitively as a substring of one parenthetical or trailing-dash segment. + private static let editionNoise = [ + "remaster", // "Remastered", "2009 Remaster", "Remastered Version" + "deluxe", + "bonus track", + "expanded edition", + "anniversary edition", + "special edition", + "explicit", + "clean version", + "album version", + "single version", + "digital remaster" + ] + + /// Strips release-edition noise from a song title, leaving everything meaningful intact. + /// + /// Removes `(…)`/`[…]` groups and trailing ` - …` segments that are *only* edition + /// qualifiers. Segments that carry real identity — `(feat. …)`, `(Live)`, `(Acoustic)`, + /// remix credits — are kept, because YouTube uploads name them too and dropping them would + /// match the wrong recording. + public static func normalizedTitle(_ title: String) -> String { + var result = removeBracketedNoise(in: title) + result = removeTrailingDashNoise(in: result) + let cleaned = result.trimmingCharacters(in: .whitespacesAndNewlines) + // Never hand back an empty query: a title that is *entirely* noise stays as it was. + return cleaned.isEmpty ? title.trimmingCharacters(in: .whitespacesAndNewlines) : cleaned + } + + /// Whether a segment's text is purely an edition qualifier. + private static func isEditionNoise(_ segment: String) -> Bool { + let lowered = segment.lowercased() + return editionNoise.contains { lowered.contains($0) } + } + + /// Drops `(…)` and `[…]` groups whose contents are edition noise. Scans manually rather than + /// via regex so unbalanced brackets in a messy tag degrade to "leave it alone". + private static func removeBracketedNoise(in title: String) -> String { + var result = "" + var segment = "" + var closing: Character? + + for character in title { + if let expected = closing { + if character == expected { + if !isEditionNoise(segment) { + result.append(expected == ")" ? "(" : "[") + result.append(segment) + result.append(expected) + } + segment = "" + closing = nil + } else { + segment.append(character) + } + } else if character == "(" { + closing = ")" + } else if character == "[" { + closing = "]" + } else { + result.append(character) + } + } + // Unbalanced opener — restore the tail verbatim rather than silently truncating. + if let expected = closing { + result.append(expected == ")" ? "(" : "[") + result.append(segment) + } + return collapsingSpaces(result) + } + + /// Drops trailing ` - Remastered 2011`-style segments (Apple Music's preferred form). + /// Only the trailing segments are considered, so "Marquee Moon - Remastered" loses the + /// qualifier while "Sgt. Pepper - Reprise" keeps its dash. + private static func removeTrailingDashNoise(in title: String) -> String { + var parts = title.components(separatedBy: " - ") + while parts.count > 1, let last = parts.last, isEditionNoise(last) { + parts.removeLast() + } + return parts.joined(separator: " - ") + } + + private static func collapsingSpaces(_ value: String) -> String { + value.split(separator: " ", omittingEmptySubsequences: true) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Packages/ContinuityCore/Sources/ContinuityCore/MetadataSourcedTrack.swift b/Packages/ContinuityCore/Sources/ContinuityCore/MetadataSourcedTrack.swift new file mode 100644 index 0000000..0120688 --- /dev/null +++ b/Packages/ContinuityCore/Sources/ContinuityCore/MetadataSourcedTrack.swift @@ -0,0 +1,14 @@ +import Foundation + +/// A song we know only by its metadata, because its own service won't give us audio — Spotify +/// and Apple Music are both DRM-locked. The pipeline re-sources the recording from YouTube via +/// `youtubeSearchQuery`, which is therefore also the track's local identity for playlist sync. +public protocol MetadataSourcedTrack: Sendable { + var title: String { get } + var artist: String? { get } + var durationSeconds: Int? { get } + var youtubeSearchQuery: String { get } +} + +extension SpotifyTrack: MetadataSourcedTrack {} +extension AppleMusicTrack: MetadataSourcedTrack {} diff --git a/Packages/ContinuityCore/Tests/ContinuityCoreTests/AppleMusicLibraryTests.swift b/Packages/ContinuityCore/Tests/ContinuityCoreTests/AppleMusicLibraryTests.swift new file mode 100644 index 0000000..8d05796 --- /dev/null +++ b/Packages/ContinuityCore/Tests/ContinuityCoreTests/AppleMusicLibraryTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import ContinuityCore + +final class AppleMusicLibraryTests: XCTestCase { + + // MARK: Titles that must survive untouched + + func testPlainTitleIsUnchanged() { + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("Blinding Lights"), "Blinding Lights") + } + + func testKeepsFeaturedArtistCredit() { + XCTAssertEqual( + AppleMusicLibrary.normalizedTitle("Sunflower (feat. Post Malone)"), + "Sunflower (feat. Post Malone)" + ) + } + + func testKeepsRecordingVariantsThatIdentifyADifferentTake() { + // These name a genuinely different recording — YouTube titles carry them too. + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("Layla (Acoustic)"), "Layla (Acoustic)") + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("Alive (Live)"), "Alive (Live)") + XCTAssertEqual( + AppleMusicLibrary.normalizedTitle("Levels (Skrillex Remix)"), + "Levels (Skrillex Remix)" + ) + } + + func testKeepsDashSegmentThatIsNotEditionNoise() { + XCTAssertEqual( + AppleMusicLibrary.normalizedTitle("Sgt. Pepper's Lonely Hearts Club Band - Reprise"), + "Sgt. Pepper's Lonely Hearts Club Band - Reprise" + ) + } + + // MARK: Edition noise that must be stripped + + func testStripsParentheticalRemaster() { + XCTAssertEqual( + AppleMusicLibrary.normalizedTitle("Come Together (Remastered 2009)"), + "Come Together" + ) + } + + func testStripsBracketedRemaster() { + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("Paranoid [Remastered]"), "Paranoid") + } + + func testStripsTrailingDashRemaster() { + XCTAssertEqual( + AppleMusicLibrary.normalizedTitle("Marquee Moon - 2003 Remaster"), + "Marquee Moon" + ) + } + + func testStripsMultipleTrailingDashSegments() { + XCTAssertEqual( + AppleMusicLibrary.normalizedTitle("Dreams - Deluxe Edition - Remastered"), + "Dreams" + ) + } + + func testStripsDeluxeAndBonusTrackMarkers() { + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("Runaway (Deluxe Edition)"), "Runaway") + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("Hey Now (Bonus Track)"), "Hey Now") + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("Rockstar (Explicit)"), "Rockstar") + } + + func testStripsNoiseButKeepsMeaningfulSegment() { + XCTAssertEqual( + AppleMusicLibrary.normalizedTitle("Whole Lotta Love (Live) (Remastered)"), + "Whole Lotta Love (Live)" + ) + } + + func testMatchingIsCaseInsensitive() { + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("Roads (REMASTERED)"), "Roads") + } + + // MARK: Degenerate input + + func testTitleThatIsEntirelyNoiseIsKeptVerbatim() { + // Better a bad query than an empty one — an empty search matches nothing at all. + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("(Remastered)"), "(Remastered)") + } + + func testUnbalancedBracketIsLeftAlone() { + XCTAssertEqual(AppleMusicLibrary.normalizedTitle("Untitled (unclosed"), "Untitled (unclosed") + } + + func testCollapsesWhitespaceLeftBehind() { + XCTAssertEqual( + AppleMusicLibrary.normalizedTitle("Karma Police (Remastered) "), + "Karma Police" + ) + } + + // MARK: Search-query assembly + + func testSearchQueryJoinsNormalizedTitleAndArtist() { + let track = AppleMusicTrack(title: "Come Together (Remastered 2009)", artist: "The Beatles") + XCTAssertEqual(track.youtubeSearchQuery, "Come Together The Beatles") + } + + func testSearchQueryOmitsMissingArtist() { + let track = AppleMusicTrack(title: "Untitled Demo") + XCTAssertEqual(track.youtubeSearchQuery, "Untitled Demo") + } + + func testPlaylistContentsReportsEmptiness() { + let empty = AppleMusicPlaylistContents(persistentID: "1", name: "Empty", tracks: []) + XCTAssertTrue(empty.isEmpty) + XCTAssertEqual(empty.id, "1") + + let full = AppleMusicPlaylistContents( + persistentID: "2", + name: "Road Trip", + tracks: [AppleMusicTrack(title: "Go", artist: "Band")] + ) + XCTAssertFalse(full.isEmpty) + } +} diff --git a/Packages/ContinuityKit/Sources/Domain/Playlist.swift b/Packages/ContinuityKit/Sources/Domain/Playlist.swift index 9f9af78..d107456 100644 --- a/Packages/ContinuityKit/Sources/Domain/Playlist.swift +++ b/Packages/ContinuityKit/Sources/Domain/Playlist.swift @@ -6,6 +6,9 @@ public enum PlaylistSource: String, Codable, Sendable { case youtube case spotifyPlaylist case spotifyAlbum + /// A playlist in the user's on-device Apple Music library; `sourceID` is its persistent ID. + /// Unlike the others this re-fetches locally, so sync works offline. + case appleMusic } /// A playlist or album. For M0 both concepts are represented by this one model; the diff --git a/Packages/ContinuityKit/Sources/Ingest/AppleMusicLibraryReader.swift b/Packages/ContinuityKit/Sources/Ingest/AppleMusicLibraryReader.swift new file mode 100644 index 0000000..23ac799 --- /dev/null +++ b/Packages/ContinuityKit/Sources/Ingest/AppleMusicLibraryReader.swift @@ -0,0 +1,91 @@ +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/IngestContracts.swift b/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift index 9e2e4f0..0a56b41 100644 --- a/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift +++ b/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift @@ -35,6 +35,8 @@ public enum IngestError: Error, Sendable { /// 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. @@ -88,6 +90,26 @@ 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 { diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift new file mode 100644 index 0000000..7f35361 --- /dev/null +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift @@ -0,0 +1,89 @@ +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) + } + 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+Sync.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift index 5d37111..b23852c 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift @@ -53,7 +53,24 @@ extension PreparationQueue { let link = SpotifyLink(kind: kind == .spotifyAlbum ? .album : .playlist, id: sourceID) let resolved = try await spotifyResolver.resolvePlaylist(link) guard playlist.modelContext != nil, !resolved.tracks.isEmpty else { return } - changed = applySpotifySync(resolved.tracks, to: playlist, in: context) + 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 @@ -125,16 +142,22 @@ extension PreparationQueue { return changed } - /// Applies a fresh remote Spotify tracklist: key = the YouTube search query (title + artist), - /// the identity Spotify-sourced tracks carry locally. Returns whether membership or order - /// actually changed (drives `touch()` and `onPlaylistSynced`). - private func applySpotifySync(_ remote: [SpotifyTrack], to playlist: Playlist, in context: ModelContext) -> Bool { + /// 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(\.youtubeSearchQuery)) + 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) @@ -171,7 +194,7 @@ extension PreparationQueue { changed = true } } - playlist.subtitle = "From Spotify · \(remote.count) tracks" + playlist.subtitle = subtitle if changed { playlist.touch() } return changed } diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift index 14e4297..91c9f88 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift @@ -33,6 +33,8 @@ public final class PreparationQueue { 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). @@ -53,6 +55,7 @@ public final class PreparationQueue { resolver: AudioStreamResolving = YouTubeStreamResolver(), playlistResolver: PlaylistResolving = YouTubePlaylistResolver(), spotifyResolver: SpotifyPlaylistResolving = SpotifyPlaylistResolver(), + appleMusicLibrary: AppleMusicLibraryReading = AppleMusicLibraryReader(), searcher: YouTubeSearching = YouTubeSearchResolver(), metadataResolver: VideoMetadataResolving = YouTubeOEmbedResolver(), downloader: AudioFileDownloading = AudioDownloader() @@ -60,6 +63,7 @@ public final class PreparationQueue { self.resolver = resolver self.playlistResolver = playlistResolver self.spotifyResolver = spotifyResolver + self.appleMusicLibrary = appleMusicLibrary self.searcher = searcher self.metadataResolver = metadataResolver self.downloader = downloader diff --git a/project.yml b/project.yml index 9888599..4941ce9 100644 --- a/project.yml +++ b/project.yml @@ -56,6 +56,9 @@ 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.