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.