Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions App/Continuity/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppleMusicUsageDescription</key>
<string>Continuity reads your Apple Music playlists so you can import them into your library.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
Expand Down
211 changes: 211 additions & 0 deletions App/Continuity/Views/AppleMusicImportView.swift
Original file line number Diff line number Diff line change
@@ -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<String> = []
@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<Action: View>(
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.")
}
}
}
}
13 changes: 13 additions & 0 deletions App/Continuity/Views/LibrarySheetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
136 changes: 136 additions & 0 deletions Packages/ContinuityCore/Sources/ContinuityCore/AppleMusicLibrary.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading