From f45074a60afacc411c7b54e8531d44ab3473e7c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 00:18:20 +0000 Subject: [PATCH] Add catalog search: songs/albums split results and a music-tuned custom keyboard Searches the iTunes catalog (Apple's full commercial music database, no API key) for songs and albums. The search page has an expanding pill query bar, results split into two always-equal halves (songs on top, albums below), and an in-app QWERTY keyboard whose suggestion bar and space-bar autocorrect run against a vocabulary learned from the catalog results and the user's library (CatalogAutocorrect in ContinuityCore, unit-tested on Linux CI). Tapping a song adds it to a shared "From Search" playlist; tapping an album imports its full tracklist as a new playlist. Both reuse the existing search-query -> YouTube ingest pipeline, so results become playable tracks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013TJoWkqg8bzkGdzxWhWjWP --- App/Continuity/Views/LibrarySheetView.swift | 13 + App/Continuity/Views/SearchView.swift | 496 ++++++++++++++++++ .../ContinuityCore/CatalogAutocorrect.swift | 113 ++++ .../CatalogAutocorrectTests.swift | 54 ++ .../Sources/Ingest/MusicCatalog.swift | 164 ++++++ .../Ingest/PreparationQueue+Catalog.swift | 84 +++ 6 files changed, 924 insertions(+) create mode 100644 App/Continuity/Views/SearchView.swift create mode 100644 Packages/ContinuityCore/Sources/ContinuityCore/CatalogAutocorrect.swift create mode 100644 Packages/ContinuityCore/Tests/ContinuityCoreTests/CatalogAutocorrectTests.swift create mode 100644 Packages/ContinuityKit/Sources/Ingest/MusicCatalog.swift create mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift 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 + } +}