diff --git a/App/Continuity/Views/LibrarySheetView.swift b/App/Continuity/Views/LibrarySheetView.swift index b52b865..7bb4863 100644 --- a/App/Continuity/Views/LibrarySheetView.swift +++ b/App/Continuity/Views/LibrarySheetView.swift @@ -10,6 +10,7 @@ struct LibrarySheetView: View { @Environment(MainPagerState.self) private var pagerState @Environment(\.modelContext) private var modelContext @State private var showingAdd = false + @State private var showingSearch = false var body: some View { NavigationStack { @@ -27,6 +28,14 @@ struct LibrarySheetView: View { .disabled(!prepQueue.syncingPlaylistIDs.isEmpty) .accessibilityLabel("Sync library") } + ToolbarItem(placement: .primaryAction) { + Button { + showingSearch = true + } label: { + Image(systemName: "magnifyingglass") + } + .accessibilityLabel("Search music") + } ToolbarItem(placement: .primaryAction) { Button { showingAdd = true @@ -40,6 +49,10 @@ struct LibrarySheetView: View { .sheet(isPresented: $showingAdd) { AddMusicView() } + // Full-screen: the page owns its whole layout (pill / results / custom keyboard). + .fullScreenCover(isPresented: $showingSearch) { + SearchView() + } .safeAreaInset(edge: .bottom) { if player.currentTrack != nil { MiniPlayerView() diff --git a/App/Continuity/Views/SearchView.swift b/App/Continuity/Views/SearchView.swift new file mode 100644 index 0000000..6ffa470 --- /dev/null +++ b/App/Continuity/Views/SearchView.swift @@ -0,0 +1,496 @@ +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 + @Query private var allTracks: [Track] + + @State private var model = CatalogSearchModel() + + var body: some View { + VStack(spacing: 0) { + header + resultsSplit + MusicKeyboardView(model: model) + } + .background(Color(uiColor: .systemGroupedBackground)) + .task { + model.seedVocabulary(titles: allTracks.map(\.title) + allTracks.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) + .background(.thinMaterial, in: Capsule()) + .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) + .background(.thinMaterial, in: RoundedRectangle(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 { + AsyncImage(url: url) { image in + image.resizable().scaledToFill() + } placeholder: { + RoundedRectangle(cornerRadius: 8).fill(.quaternary) + .overlay { Image(systemName: "music.note").foregroundStyle(.secondary) } + } + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Custom keyboard + +/// 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", "-'&.,?!/", "@:;()$#"] + + private let haptic = UIImpactFeedbackGenerator(style: .light) + + var body: some View { + VStack(spacing: 7) { + suggestionBar + ForEach(showNumbers ? Self.numberRows : Self.letterRows, id: \.self) { row in + keyRow(row) + } + bottomRow + } + .padding(.horizontal, 4) + .padding(.top, 8) + .padding(.bottom, 6) + .background(.regularMaterial) + } + + private var suggestionBar: 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 { + haptic.impactOccurred() + model.accept(suggestion: word) + } label: { + Text(word) + .font(.subheadline) + .lineLimit(1) + .frame(maxWidth: .infinity) + .frame(height: 32) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 4) + } + + 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 { + haptic.impactOccurred() + 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) + .background( + prominent ? AnyShapeStyle(Color.accentColor.opacity(0.85)) : AnyShapeStyle(.thinMaterial), + in: RoundedRectangle(cornerRadius: 6) + ) + .foregroundStyle(prominent ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + } + .buttonStyle(.plain) + // Holding backspace repeats, like the real thing. + .buttonRepeatBehavior(symbol == "delete.left" ? .enabled : .disabled) + } +} diff --git a/Packages/ContinuityCore/Sources/ContinuityCore/CatalogAutocorrect.swift b/Packages/ContinuityCore/Sources/ContinuityCore/CatalogAutocorrect.swift new file mode 100644 index 0000000..c9b81fc --- /dev/null +++ b/Packages/ContinuityCore/Sources/ContinuityCore/CatalogAutocorrect.swift @@ -0,0 +1,113 @@ +import Foundation + +/// Autocorrect/suggestion engine for the in-app music-search keyboard, tuned to the catalog +/// being searched rather than general English: the vocabulary is *learned* from catalog +/// results (song titles, artists, album names), so "weeknd", "beyonce" or "sza" are first-class +/// words instead of typos. Pure Swift (no UIKit) so it unit-tests on Linux CI. +/// +/// Two operations, both word-level: +/// - `suggestions(for:)` ranks likely intended words for the partial word being typed +/// (prefix completions first, then close misspellings) — feeds the suggestion bar. +/// - `correction(for:)` returns a replacement only when it is *confidently* wrong: the typed +/// word is unknown and exactly one edit away from a known word — applied on space, like a +/// conventional keyboard autocorrect, but against the music vocabulary. +public struct CatalogAutocorrect: Sendable { + + /// Learned vocabulary: lowercase word → accumulated weight (frequency across learned + /// phrases, so words from many catalog hits outrank one-off matches). + private var vocabulary: [String: Int] = [:] + + public init() {} + + /// Number of distinct words learned so far. + public var wordCount: Int { vocabulary.count } + + // MARK: Learning + + /// Tokenizes catalog phrases (titles/artists/albums) into words and folds them into the + /// vocabulary. Weight lets callers boost trusted sources (e.g. the user's own library). + public mutating func learn(phrases: [String], weight: Int = 1) { + for phrase in phrases { + for word in Self.words(in: phrase) { + vocabulary[word, default: 0] += weight + } + } + } + + /// Lowercased alphanumeric word split — apostrophes survive ("don't"), everything else + /// separates. Single characters are dropped (they'd match everything at distance 1). + static func words(in phrase: String) -> [String] { + var words: [String] = [] + var current = "" + for scalar in phrase.lowercased().unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) || scalar == "'" { + current.unicodeScalars.append(scalar) + } else if !current.isEmpty { + words.append(current) + current = "" + } + } + if !current.isEmpty { words.append(current) } + return words.filter { $0.count > 1 } + } + + // MARK: Suggestions + + /// Ranked candidates for the word being typed: known prefix completions first (what the + /// user is most likely mid-way through), then close misspelling repairs; within a tier, + /// higher catalog frequency wins. The typed word itself is never suggested. + public func suggestions(for partial: String, limit: Int = 3) -> [String] { + let typed = partial.lowercased() + guard typed.count >= 2, limit > 0 else { return [] } + + var scored: [(word: String, tier: Int, weight: Int)] = [] + let maxDistance = typed.count <= 4 ? 1 : 2 + for (word, weight) in vocabulary where word != typed { + if word.hasPrefix(typed) { + scored.append((word, 0, weight)) + } else if abs(word.count - typed.count) <= maxDistance, + Self.editDistance(word, typed, limit: maxDistance) <= maxDistance { + scored.append((word, 1, weight)) + } + } + return scored + .sorted { ($0.tier, -$0.weight, $0.word) < ($1.tier, -$1.weight, $1.word) } + .prefix(limit) + .map(\.word) + } + + /// The confident space-bar replacement for a finished word, or nil to leave it alone. + /// Fires only when the word is NOT in the vocabulary and a single-edit repair exists — + /// known words (however weird; this is music) must never be "corrected". + public func correction(for word: String) -> String? { + let typed = word.lowercased() + guard typed.count >= 3, vocabulary[typed] == nil else { return nil } + return vocabulary + .filter { abs($0.key.count - typed.count) <= 1 && Self.editDistance($0.key, typed, limit: 1) <= 1 } + .max { ($0.value, $1.key) < ($1.value, $0.key) }? // heaviest wins; ties break alphabetically + .key + } + + // MARK: Edit distance + + /// Levenshtein distance with an early-out bound: returns `limit + 1` as soon as the + /// distance provably exceeds `limit` (the callers only care about "≤ limit"). + static func editDistance(_ a: String, _ b: String, limit: Int) -> Int { + let s = Array(a.unicodeScalars), t = Array(b.unicodeScalars) + if abs(s.count - t.count) > limit { return limit + 1 } + var previous = Array(0...t.count) + var current = [Int](repeating: 0, count: t.count + 1) + for i in 1...max(s.count, 1) where !s.isEmpty { + current[0] = i + var rowMin = i + for j in 1...max(t.count, 1) where !t.isEmpty { + let cost = s[i - 1] == t[j - 1] ? 0 : 1 + current[j] = min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost) + rowMin = min(rowMin, current[j]) + } + if rowMin > limit { return limit + 1 } + swap(&previous, ¤t) + } + return s.isEmpty ? t.count : previous[t.count] + } +} diff --git a/Packages/ContinuityCore/Tests/ContinuityCoreTests/CatalogAutocorrectTests.swift b/Packages/ContinuityCore/Tests/ContinuityCoreTests/CatalogAutocorrectTests.swift new file mode 100644 index 0000000..242fd47 --- /dev/null +++ b/Packages/ContinuityCore/Tests/ContinuityCoreTests/CatalogAutocorrectTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import ContinuityCore + +final class CatalogAutocorrectTests: XCTestCase { + + private func engine() -> CatalogAutocorrect { + var ac = CatalogAutocorrect() + ac.learn(phrases: [ + "Blinding Lights", "The Weeknd", "After Hours", + "Starboy", "Save Your Tears", "The Weeknd", // weeknd twice → heavier + "Beyoncé", "Renaissance", + ]) + return ac + } + + func testTokenizationLowercasesStripsPunctuationAndDropsSingles() { + XCTAssertEqual(CatalogAutocorrect.words(in: "Don't Stop (Remix) - A B4U!"), + ["don't", "stop", "remix", "b4u"]) + } + + func testPrefixCompletionsRankAboveRepairs() { + let ac = engine() + XCTAssertEqual(ac.suggestions(for: "wee").first, "weeknd") + } + + func testSuggestionsRepairCloseMisspelling() { + let ac = engine() + XCTAssertTrue(ac.suggestions(for: "weekend").contains("weeknd")) + } + + func testCorrectionFiresOnlyForUnknownWords() { + let ac = engine() + // "weeknd" is in the vocabulary — a catalog word must never be "corrected". + XCTAssertNil(ac.correction(for: "weeknd")) + // One edit away from a known word → confident repair. + XCTAssertEqual(ac.correction(for: "weekndd"), "weeknd") + // Nowhere near anything known → leave the user's word alone. + XCTAssertNil(ac.correction(for: "zzzzzz")) + } + + func testHeavierWordWinsCorrectionTies() { + var ac = CatalogAutocorrect() + ac.learn(phrases: ["cars"], weight: 1) + ac.learn(phrases: ["care"], weight: 5) + XCTAssertEqual(ac.correction(for: "carz"), "care") + } + + func testEditDistanceBoundedEarlyOut() { + XCTAssertEqual(CatalogAutocorrect.editDistance("abc", "abc", limit: 1), 0) + XCTAssertEqual(CatalogAutocorrect.editDistance("abc", "abd", limit: 1), 1) + XCTAssertGreaterThan(CatalogAutocorrect.editDistance("abcdef", "zyxwvu", limit: 2), 2) + XCTAssertEqual(CatalogAutocorrect.editDistance("", "ab", limit: 2), 2) + } +} diff --git a/Packages/ContinuityKit/Sources/Ingest/MusicCatalog.swift b/Packages/ContinuityKit/Sources/Ingest/MusicCatalog.swift new file mode 100644 index 0000000..aa3b859 --- /dev/null +++ b/Packages/ContinuityKit/Sources/Ingest/MusicCatalog.swift @@ -0,0 +1,164 @@ +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+Catalog.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift new file mode 100644 index 0000000..b99b21c --- /dev/null +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift @@ -0,0 +1,84 @@ +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) + enqueue(track, in: context) + } + 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 + } +}