Skip to content
Open
13 changes: 11 additions & 2 deletions Sources/Kaset/Models/AI/CommandBarParseResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import FoundationModels
@Generable
struct CommandBarParseResult: Equatable {
/// High-level action the user wants to perform.
@Guide(description: "Action: play, queue, search, inspectQueue, shuffle, clearQueue, like, dislike, skip, previous, pause, or resume.")
@Guide(description: "Action: play, queue, radio, search, inspectQueue, shuffle, clearQueue, removeFromQueue, removeDuplicates, like, dislike, skip, previous, pause, or resume.")
let action: CommandBarAction

/// Essential search words for content requests.
Expand Down Expand Up @@ -61,6 +61,12 @@ struct CommandBarParseResult: Equatable {
}
case .clearQueue:
.clearQueue
case .removeFromQueue:
.removeFromQueue(query: self.subject)
case .removeDuplicates:
.removeDuplicates
case .radio:
.queueRadio
case .like:
.like
case .dislike:
Expand Down Expand Up @@ -116,7 +122,7 @@ struct CommandBarParseResult: Equatable {
.pause
case .resume:
.resume
case .inspectQueue, .clearQueue:
case .radio, .inspectQueue, .clearQueue, .removeFromQueue, .removeDuplicates:
nil
}
}
Expand All @@ -129,10 +135,13 @@ struct CommandBarParseResult: Equatable {
enum CommandBarAction: String, CaseIterable {
case play
case queue
case radio
case search
case inspectQueue
case shuffle
case clearQueue
case removeFromQueue
case removeDuplicates
case like
case dislike
case skip
Expand Down
2 changes: 2 additions & 0 deletions Sources/Kaset/Models/AI/MusicIntent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ enum ContentSource: String, CustomStringConvertible {
case moodsAndGenres
/// Use Charts for popularity-based requests
case charts
/// Use the artist's YouTube Music mix (radio) seeded from their page
case artistMix

var description: String {
rawValue
Expand Down
164 changes: 163 additions & 1 deletion Sources/Kaset/Services/AI/CommandExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ struct CommandExecutor {
case clearQueue
case shuffleQueue
case toggleShuffle
case queueRadio
case removeFromQueue(query: String)
case removeDuplicates
case playSearch(query: String, description: String)
case queueSearch(query: String, description: String)
case openSearch(query: String)
Expand Down Expand Up @@ -58,7 +61,7 @@ struct CommandExecutor {

private let logger = DiagnosticsLogger.ai

func execute(_ request: Request) async -> Outcome {
func execute(_ request: Request) async -> Outcome { // swiftlint:disable:this cyclomatic_complexity
switch request {
case .pause:
HapticService.playback()
Expand Down Expand Up @@ -106,6 +109,24 @@ struct CommandExecutor {
let status = self.playerService.shuffleEnabled ? "on" : "off"
return .result("Shuffle is now \(status)")

case .queueRadio:
HapticService.success()
return await self.queueRadioFromCurrentTrack()

case let .removeFromQueue(query):
return self.removeMatchingFromQueue(query: query)

case .removeDuplicates:
let before = self.playerService.queue.count
self.playerService.removeDuplicateQueueEntries()
let removed = before - self.playerService.queue.count
guard removed > 0 else {
return .error(String(localized: "No duplicates in the queue"))
}
HapticService.toggle()
let songLabel = removed == 1 ? "duplicate" : "duplicates"
return .result("Removed \(removed) \(songLabel)")

case let .playSearch(query, description):
return await self.playSearchResult(query: query, description: description)

Expand Down Expand Up @@ -284,6 +305,63 @@ struct CommandExecutor {
}
}

private func removeMatchingFromQueue(query: String) -> Outcome {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)

// Empty subject is a safe no-op: never let a fumbled removal fall through to a full clear.
guard !trimmed.isEmpty else {
return .error(String(localized: "Tell me which songs to remove from the queue"))
}

let needle = trimmed.lowercased()
let matches = self.playerService.queue.filter {
$0.artistsDisplay.lowercased().contains(needle) || $0.title.lowercased().contains(needle)
}
let videoIds = Set(matches.map(\.videoId))

guard !videoIds.isEmpty else {
return .error(String(localized: "No songs matching \"\(trimmed)\" in the queue"))
}

HapticService.toggle()
let previousCount = self.playerService.queue.count
self.playerService.removeFromQueue(videoIds: videoIds)
let removed = previousCount - self.playerService.queue.count
self.logger.info("Removed \(removed) songs matching \(needle) from queue")

guard removed > 0 else {
return .error(String(localized: "Only the song playing matches \"\(trimmed)\""))
}

let songLabel = removed == 1 ? "song" : "songs"
return .result("Removed \(removed) \(songLabel) matching \"\(trimmed)\"")
}

private func queueRadioFromCurrentTrack() async -> Outcome {
guard let seed = self.playerService.currentTrack, !seed.videoId.isEmpty else {
return .error(String(localized: "Nothing is playing to build a radio from"))
}

do {
let radioSongs = try await self.client.getRadioQueue(videoId: seed.videoId)
let existingIds = Set(self.playerService.queue.map(\.videoId))
let newSongs = radioSongs.filter { !existingIds.contains($0.videoId) }

self.logger.info("Radio queue for \(seed.videoId) returned \(radioSongs.count), \(newSongs.count) new")

guard !newSongs.isEmpty else {
return .error(String(localized: "Couldn't find more songs like that"))
}

self.playerService.appendToQueue(newSongs)
let seedArtist = seed.artistsDisplay.isEmpty ? "" : " by \(seed.artistsDisplay)"
return .result("Added \(newSongs.count) songs like \"\(seed.title)\"\(seedArtist) to the queue")
} catch {
self.logger.error("Radio queue failed: \(error.localizedDescription)")
return .error(String(localized: "Couldn't build a radio from this song"))
}
}

private func playContent(
intent: MusicIntent,
query: String,
Expand All @@ -307,6 +385,13 @@ struct CommandExecutor {
}
return await self.playSearchResult(query: query, description: description)

case .artistMix:
if let outcome = await self.playArtistMix(intent: intent) {
return outcome
}
self.logger.info("No artist mix available, falling back to search")
return await self.playSearchResult(query: query, description: description)

case .search:
return await self.playSearchResult(query: query, description: description)
}
Expand Down Expand Up @@ -345,11 +430,88 @@ struct CommandExecutor {

return await self.queueSearchResult(query: query, description: description)

case .artistMix:
if let outcome = await self.queueArtistMix(intent: intent) {
return outcome
}
self.logger.info("No artist mix available, falling back to search")
return await self.queueSearchResult(query: query, description: description)

case .search:
return await self.queueSearchResult(query: query, description: description)
}
}

private struct ArtistMix {
let playlistId: String
let startVideoId: String?
let name: String
}

private func resolveArtistMix(artistName: String) async -> ArtistMix? {
do {
let response = try await self.client.searchArtists(query: artistName)
guard let artist = response.artists.first else {
self.logger.info("No artist found for \(artistName)")
return nil
}

let detail = try await self.client.getArtist(id: artist.id)
guard let playlistId = detail.mixPlaylistId else {
self.logger.info("No mix playlist for artist \(artist.name)")
return nil
}

return ArtistMix(playlistId: playlistId, startVideoId: detail.mixVideoId, name: artist.name)
} catch {
self.logger.error("Artist mix resolution failed: \(error.localizedDescription)")
return nil
}
}

private func playArtistMix(intent: MusicIntent) async -> Outcome? {
guard let mix = await self.resolveArtistMix(artistName: intent.artist) else {
return nil
}

// Fall back to search (return nil) if the mix was empty or failed to load, so the
// caller doesn't report "Playing X mix" when nothing actually started.
guard await self.playerService.playWithMix(playlistId: mix.playlistId, startVideoId: mix.startVideoId) else {
return nil
}
return .result("Playing \(mix.name) mix")
}

private func queueArtistMix(intent: MusicIntent) async -> Outcome? {
guard let mix = await self.resolveArtistMix(artistName: intent.artist) else {
return nil
}

do {
let result = try await self.client.getMixQueue(playlistId: mix.playlistId, startVideoId: mix.startVideoId)
guard !result.songs.isEmpty else {
return nil
}

if self.playerService.queue.isEmpty {
await self.playerService.playQueue(result.songs, startingAt: 0)
return .result("Playing \(mix.name) mix")
}

let existingIds = Set(self.playerService.queue.map(\.videoId))
let newSongs = result.songs.filter { !existingIds.contains($0.videoId) }
guard !newSongs.isEmpty else {
return nil
}

self.playerService.appendToQueue(newSongs)
return .result("Added \(mix.name) mix to queue")
} catch {
self.logger.error("Artist mix queue failed: \(error.localizedDescription)")
return nil
}
}

private func findSongsFromMoodsAndGenres(intent: MusicIntent) async -> [Song]? {
do {
let response = try await self.client.getMoodsAndGenres()
Expand Down
91 changes: 91 additions & 0 deletions Sources/Kaset/Services/AI/CommandIntentParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ struct CommandIntentParser {
return .shuffleQueue
}

if Self.radioCommands.contains(normalized) {
return .queueRadio
}

if Self.removeDuplicatesCommands.contains(normalized) {
return .removeDuplicates
}

return nil
}

Expand All @@ -63,6 +71,21 @@ struct CommandIntentParser {
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let lowered = trimmedQuery.lowercased()

if self.referencesCurrentTrackRadio(lowered) {
return .queueRadio
}

if lowered.contains("duplicate") {
return .removeDuplicates
}

if lowered.hasPrefix("remove ") || lowered.hasPrefix("delete ") || lowered.hasPrefix("take out ") {
let subject = self.removeQueueSubject(from: trimmedQuery)
if !subject.isEmpty {
return .removeFromQueue(query: subject)
}
}

if let searchQuery = self.explicitSearchQuery(from: trimmedQuery) {
return .openSearch(query: searchQuery)
}
Expand Down Expand Up @@ -137,6 +160,48 @@ struct CommandIntentParser {
return nil
}

private func removeQueueSubject(from query: String) -> String {
var cleaned = query.trimmingCharacters(in: .whitespacesAndNewlines)

for prefix in ["remove ", "delete ", "take out "] where cleaned.lowercased().hasPrefix(prefix) {
cleaned = String(cleaned.dropFirst(prefix.count))
break
}

for suffix in [" from the queue", " from queue", " out of the queue", " out of queue"]
where cleaned.lowercased().hasSuffix(suffix)
{
cleaned = String(cleaned.dropLast(suffix.count))
break
}

for suffix in [" songs", " tracks", " song", " track"] where cleaned.lowercased().hasSuffix(suffix) {
cleaned = String(cleaned.dropLast(suffix.count))
break
}

return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
}

private func referencesCurrentTrackRadio(_ lowered: String) -> Bool {
// "dislike this" / "don't like this" contain "like this" but mean the opposite,
// so bail on any negation before matching the current-track phrases below.
guard !lowered.contains("dislike"), !lowered.contains("n't like"), !lowered.contains("not like") else {
return false
}

// Only phrases anchored to "this/that" (the current track). Object-taking phrases
// like "similar songs to <artist>" are intentionally excluded here — they stay in
// the exact-match radioCommands set, where they unambiguously mean current-track radio.
let phrases = [
"like this", "like these", "like that",
"more of this", "more like this",
"keep it going", "keep this going",
"start a radio", "start radio",
]
return phrases.contains { lowered.contains($0) }
}

private func explicitSearchQuery(from query: String) -> String? {
let lowered = query.lowercased()
let prefixes = [
Expand Down Expand Up @@ -337,6 +402,32 @@ struct CommandIntentParser {
"shuffle the queue",
]

private static let removeDuplicatesCommands: Set<String> = [
"remove duplicates",
"remove duplicates from queue",
"remove duplicates from the queue",
"remove the duplicates",
"remove duplicate songs",
"delete duplicates",
"dedupe queue",
"dedupe the queue",
"deduplicate queue",
]

private static let radioCommands: Set<String> = [
"radio",
"start radio",
"start a radio",
"more like this",
"more songs like this",
"songs like this",
"play more like this",
"keep it going",
"keep this going",
"similar songs",
"play similar songs",
]

private static let queueInspectionPrefixes: Set<String> = [
"describe",
"how many",
Expand Down
9 changes: 9 additions & 0 deletions Sources/Kaset/Services/AI/ContentSourceResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ enum ContentSourceResolver {

static func suggestedContentSource(for intent: MusicIntent) -> ContentSource {
if !intent.artist.isEmpty {
// Open-ended artist listening ("play/queue some daft punk") maps to the artist's
// mix. Any specific modifier — hits, era, version, mood, or genre — keeps the
// existing search, since the mix is artist-seeded and honors none of them.
if !self.queryWantsHits(intent.query),
intent.era.isEmpty, intent.version.isEmpty,
intent.mood.isEmpty, intent.genre.isEmpty
{
return .artistMix
}
return .search
}

Expand Down
Loading
Loading