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
35 changes: 35 additions & 0 deletions AppleNugs/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ final class AppModel {
private(set) var isLoggingIn = false
var loginError: String?

/// A deep link received while logged out / still booting — replayed once
/// logged in (drained by RootView's main layout). Single slot: if two links
/// arrive before login the most recent wins (newest user intent). See
/// `receiveDeepLink` / `handleDeepLink` and DeepLinkRouter.
var pendingDeepLink: DeepLink?

/// Serializes deep-link handling. handle() has many awaits (catalog fetches),
/// each releasing the MainActor; without serialization two links — or a link
/// racing the post-login drain — could interleave their navigate+play steps
/// and leave the nav stack and the player queue showing different shows.
private var deepLinkTask: Task<Void, Never>?

private var cachedArtists: [ArtistEntry]?

var isLoggedIn: Bool {
Expand Down Expand Up @@ -139,4 +151,27 @@ final class AppModel {
cachedArtists = parsed
return parsed
}

// MARK: - deep links

/// Entry point from `.onOpenURL`: act now if logged in, else stash for the
/// post-login drain (RootView's main layout).
func receiveDeepLink(_ link: DeepLink, ui: UIState) {
guard isLoggedIn else { pendingDeepLink = link; return }
handleDeepLink(link, ui: ui)
}

/// Run the router for one link, chained after any in-flight link so the
/// previous link's navigation + playback finish before the next begins.
/// Re-checks login at the last moment (a logout can land between receipt and
/// execution) and re-stashes rather than running against a logged-out client.
func handleDeepLink(_ link: DeepLink, ui: UIState) {
let previous = deepLinkTask
deepLinkTask = Task { [weak self] in
await previous?.value
guard let self else { return }
guard self.isLoggedIn else { self.pendingDeepLink = link; return }
await DeepLinkRouter.handle(link, app: self, ui: ui)
}
}
}
7 changes: 7 additions & 0 deletions AppleNugs/App/AppleNugsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ struct AppleNugsApp: App {
// RootView — it sets NSWindow.minSize directly so it correctly
// accounts for the inspector column (250pt) when it is open.
.frame(minHeight: 600)
// Deep links (applenugs://show/…). Parse here; AppModel decides
// whether to act now (serialized) or stash for RootView to replay
// after login/bootstrap completes.
.onOpenURL { url in
guard let link = DeepLink.parse(url) else { return }
app.receiveDeepLink(link, ui: ui)
}
#if DEBUG
.modifier(UITestWindowSize())
#endif
Expand Down
108 changes: 108 additions & 0 deletions AppleNugs/Core/DeepLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Foundation

/// A parsed `applenugs://` deep link. Pure value type — no I/O — so it unit-tests
/// cleanly. Contract lives in goose-almanac `docs/integrations/applenugs-deeplink.md`:
/// applenugs://show/<date>?artist=Goose[&venue=][&song=&set=&pos=][&media=audio|video]
struct DeepLink: Equatable {
enum Media: String { case audio, video }

var date: String // "yyyy-MM-dd" — the join key
var artist: String // e.g. "Goose"
var venue: String? // tie-break for two-show days
var song: String? // track title to start at (track-level link)
var setNumber: String? // elgoose setNumber ("1","2","e"…) — tie-break only
var position: Int? // elgoose position — tie-break only
var media: Media

/// Parse `applenugs://show/<date>?artist=…&venue=…&song=…&set=…&pos=…&media=…`.
/// Returns nil for anything that isn't a well-formed `show` link.
static func parse(_ url: URL) -> DeepLink? {
guard url.scheme == "applenugs",
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
comps.host == "show"
else { return nil }

// host is "show"; the date is the first path segment (path == "/<date>").
guard let date = comps.path.split(separator: "/").first.map(String.init),
!date.isEmpty
else { return nil }

// URLComponents percent-decodes queryItems for us. (The Almanac emits %20,
// never +, precisely because URLComponents does NOT turn + into a space.)
// uniquingKeysWith avoids a trap-on-duplicate-key crash from malformed links.
let items = Dictionary(
(comps.queryItems ?? []).map { ($0.name, $0.value ?? "") },
uniquingKeysWith: { first, _ in first })

guard let artist = items["artist"], !artist.isEmpty else { return nil }

func nonEmpty(_ key: String) -> String? {
items[key].flatMap { $0.isEmpty ? nil : $0 }
}

return DeepLink(
date: DeepLinkMatch.normalizedISODate(date), // pad so "2024-4-20" matches catalog dateText
artist: artist,
venue: nonEmpty("venue"),
song: nonEmpty("song"),
setNumber: nonEmpty("set"),
position: items["pos"].flatMap { Int($0) },
media: items["media"].flatMap(Media.init(rawValue:)) ?? .audio)
}
}

/// Pure, app-type-free fuzzy matching for the deep-link router. Kept separate
/// from `DeepLinkRouter` (which needs AppModel/Catalog) so this logic compiles
/// into the host-free logic-test bundle and unit-tests cleanly.
enum DeepLinkMatch {

/// Lowercased, diacritic-folded, punctuation-stripped, whitespace-trimmed.
/// Makes title/venue comparison robust across elgoose vs nugs spellings.
static func normalize(_ s: String) -> String {
s.lowercased()
.folding(options: .diacriticInsensitive, locale: nil)
.filter { $0.isLetter || $0.isNumber || $0 == " " }
.trimmingCharacters(in: .whitespaces)
}

/// Bidirectional normalized containment. A venue hint like "Salt Shed" should
/// match "The Salt Shed, Chicago" and vice-versa. nil left side never matches.
static func venueMatches(_ a: String?, _ b: String) -> Bool {
guard let a else { return false }
let na = normalize(a), nb = normalize(b)
guard !na.isEmpty, !nb.isEmpty else { return false }
return na.contains(nb) || nb.contains(na)
}

/// Title match within ONE show's track list — a small candidate set, so
/// normalized matching is safe. Titles are in performance order, so the
/// returned index lines up with the playback queue. elgoose set/pos are
/// tie-breakers only; titles win. Precedence (most→least confident):
/// 1. exact normalized equality
/// 2. a title that CONTAINS the song (e.g. "Hot Tea" → "Hot Tea Reprise")
/// 3. the song CONTAINS a title (segue link "Madhuvan > Hot Tea" → track
/// "Madhuvan"): pick the LONGEST contained title — the most specific —
/// rather than the first, so an incidental earlier short track doesn't win.
static func bestTrackIndex(matching song: String, inTitles titles: [String]) -> Int? {
let target = normalize(song)
guard !target.isEmpty else { return nil }
let norm = titles.map(normalize)
if let i = norm.firstIndex(of: target) { return i }
if let i = norm.firstIndex(where: { !$0.isEmpty && $0.contains(target) }) { return i }
return norm.enumerated()
.filter { $0.element.count >= 4 && target.contains($0.element) }
.max(by: { $0.element.count < $1.element.count })?
.offset
}

/// Zero-pad a `yyyy-M-d` date to `yyyy-MM-dd` so a non-padded but otherwise
/// valid inbound date still matches the catalog's always-padded dateText.
/// Returns the input unchanged if it isn't three numeric components.
static func normalizedISODate(_ s: String) -> String {
let parts = s.split(separator: "-")
guard parts.count == 3,
let y = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2])
else { return s }
return String(format: "%04d-%02d-%02d", y, m, d)
}
}
129 changes: 129 additions & 0 deletions AppleNugs/Core/DeepLinkRouter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import Foundation

/// Resolves a parsed `DeepLink` to a nugs container and navigates + starts
/// playback, reusing the existing client/catalog/player/navigation surface.
/// For a track link it starts at that track; for a show link it plays from the
/// top; for a video it opens the video detail (which drives its own playback).
@MainActor
enum DeepLinkRouter {

/// Entry point. Resolve + act; surface failures as a toast rather than
/// throwing into the UI. The user arrived from a "Listen"/"Watch" link, so
/// the show is navigated to AND playback starts.
static func handle(_ link: DeepLink, app: AppModel, ui: UIState) async {
do {
switch link.media {
case .audio: try await openAudio(link, app: app, ui: ui)
case .video: try await openVideo(link, app: app, ui: ui)
}
} catch {
ui.showToast("Couldn't open that show on nugs")
}
}

// MARK: - audio

private static func openAudio(_ link: DeepLink, app: AppModel, ui: UIState) async throws {
guard let containerId = try await resolveContainerId(link, app: app) else {
ui.showToast("That show isn't on nugs")
return
}
// Do the fallible fetch + queue build BEFORE touching navigation, so a
// failure leaves the UI untouched (just the catch's toast) rather than
// stranding the user on an empty album detail.
let album = Catalog.album(from: try await app.client.album(id: containerId), id: containerId)
let queue = album.tracks.map {
QueueTrack(trackId: $0.id, title: $0.title, artist: album.artistName,
show: album.title, artworkPath: album.imagePath, showId: album.id)
}
ui.open(.album(id: containerId, title: nil)) // now navigate…
guard !queue.isEmpty else { return }
// …and start playback (the user came from a "Listen" link).
if let song = link.song,
let idx = DeepLinkMatch.bestTrackIndex(matching: song, inTitles: album.tracks.map(\.title)) {
app.player.play(queue, startAt: idx) // jump to the linked performance
} else {
app.player.play(queue) // play the whole show
}
}

/// Find the live container for artist+date (venue tie-breaks), paging the
/// artist's shows like the video path, then falling back to catalog search.
private static func resolveContainerId(_ link: DeepLink, app: AppModel) async throws -> String? {
guard let artistId = try await artistId(matching: link.artist, app: app) else {
return try await searchContainerId(link, app: app)
}
if let id = try await pagedContainerId(artistId: artistId, link: link, app: app) { return id }
return try await searchContainerId(link, app: app)
}

/// Page through the artist's shows (bounded) for a live container matching the
/// date. The UI only ever loads the first page, so a deep link to an older show
/// would otherwise miss; mirror openVideo's bounded paging.
private static func pagedContainerId(artistId: String, link: DeepLink, app: AppModel) async throws -> String? {
let pageSize = 100
let maxPages = 10 // safety cap (≤1000 shows)
var offset = 1
for _ in 0..<maxPages {
let page = Catalog.containers(
from: try await app.client.artistShows(id: artistId, offset: offset, limit: pageSize))
let sameDay = page.filter { $0.isLiveShow && $0.dateText == link.date }
if let hit = pick(sameDay, venue: link.venue) { return hit.id }
if page.count < pageSize { break } // last page
offset += page.count
}
return nil
}

private static func searchContainerId(_ link: DeepLink, app: AppModel) async throws -> String? {
let model = Catalog.search(from: try await app.client.search("\(link.artist) \(link.date)"))
for section in model.sections {
for item in section.items {
guard case .container(let id) = item.kind, item.dateText == link.date else { continue }
if let v = link.venue, !DeepLinkMatch.venueMatches(item.venue, v) { continue }
return id
}
}
return nil
}

// MARK: - video

/// Per-artist video list isn't searchable (catalog.search has no video kind)
/// and the UI only ever loads its first page, so a deep link to an older show
/// would miss. Page through (bounded) until the date is found or the list ends.
private static func openVideo(_ link: DeepLink, app: AppModel, ui: UIState) async throws {
guard let artistId = try await artistId(matching: link.artist, app: app) else {
ui.showToast("That show isn't on nugs"); return
}
let pageSize = 100
let maxPages = 10 // safety cap (≤1000 videos)
var offset = 1
for _ in 0..<maxPages {
let page = try await app.client.artistVideos(id: artistId, offset: offset, limit: pageSize)
// VideoSummary has no venue field, so disambiguation is date-only.
if let hit = page.first(where: { $0.dateText == link.date }) {
ui.open(.video(id: hit.id, title: hit.title)) // VideoDetailView drives playback
return
}
if page.count < pageSize { break } // last page
offset += page.count
}
ui.showToast("No video for that show on nugs")
}

// MARK: - helpers

private static func artistId(matching name: String, app: AppModel) async throws -> String? {
try await app.artists().first { $0.name.caseInsensitiveCompare(name) == .orderedSame }?.id
}

/// Date already filtered. With a venue hint, REQUIRE a venue match — return
/// nil on mismatch so the caller keeps paging / falls back to search rather
/// than silently autoplaying the wrong show on a two-show day. Without a hint,
/// take the first same-day show. (Matches searchContainerId's venue semantics.)
private static func pick(_ cs: [ContainerSummary], venue: String?) -> ContainerSummary? {
guard let venue else { return cs.first }
return cs.first { DeepLinkMatch.venueMatches($0.venue, venue) }
}
}
10 changes: 10 additions & 0 deletions AppleNugs/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@ struct RootView: View {
// the inspector's 250pt minimum, so the window could be dragged narrower
// than sidebar+detail+inspector, clipping the sidebar off the left edge.
.background(WindowMinSizeUpdater(inspectorOpen: ui.inspectorOpen))
// Replay a deep link that arrived before login/bootstrap finished. This
// layout only exists once `.loggedIn`, so the task fires exactly when the
// app is ready. Goes through the serialized channel so it can't interleave
// with a link that arrives at almost the same moment.
.task(id: app.isLoggedIn) {
if app.isLoggedIn, let link = app.pendingDeepLink {
app.pendingDeepLink = nil
app.handleDeepLink(link, ui: ui)
}
}
}

@ViewBuilder
Expand Down
Loading
Loading