// // Library browsing — async. The UI calls library_refresh_artists / // library_select_artist / library_select_album, which submit HTTP tasks. // When each task completes the on_done callback parses JSON and writes the // result into app.library on the main thread. // // Stale-result guard: each "select" stores a generation id. Late callbacks // compare against the current id and discard if the user has navigated // elsewhere. // Artist :: struct { id: string; name: string; } Album :: struct { id: string; name: string; artist: string; artist_id: string; } Track :: struct { id: string; name: string; album: string; album_id: string; artist: string; artist_id: string; duration_ticks: s64; index_number: int; is_favourite: bool; } Library_State :: struct { artists: [..] Artist; albums: [..] Album; tracks: [..] Track; selected_artist_id: string; selected_album_id: string; artists_loaded: bool; albums_loaded: bool; tracks_loaded: bool; artists_loading: bool; albums_loading: bool; tracks_loading: bool; // Generation counters used by the async callbacks to discard stale // results when the user has navigated away. albums_request_gen: int; tracks_request_gen: int; artists_scroll: float; albums_scroll: float; tracks_scroll: float; } #scope_module // Jellyfin returns the artist ID inside AlbumArtists[].Id, not as a flat // AlbumArtistId field on track items. Artist_Ref :: struct { Id: string; Name: string; } User_Data_Dto :: struct { IsFavorite: bool; } Item_Summary :: struct { Id: string; Name: string; Album: string; AlbumId: string; AlbumArtist: string; AlbumArtistId: string; // populated for album items AlbumArtists: [..] Artist_Ref; // populated for track items UserData: User_Data_Dto; RunTimeTicks: s64; IndexNumber: int; } Items_Response :: struct { Items: [..] Item_Summary; TotalRecordCount: int; } #scope_export // // Public — kick off async fetches. // library_refresh_artists :: () { if app.library.artists_loading return; app.library.artists_loading = true; path := tprint("/Users/%/Items?IncludeItemTypes=MusicArtist&Recursive=true&SortBy=SortName&Limit=2000", app.jellyfin.user_id); log_info("library: fetching artists"); http_submit("GET", path, on_done=on_artists_loaded); } library_select_artist :: (artist_id: string) { if app.library.selected_artist_id == artist_id && app.library.albums_loaded return; free(app.library.selected_artist_id); app.library.selected_artist_id = copy_string(artist_id); free_albums(*app.library.albums); app.library.albums_loaded = false; app.library.albums_loading = true; app.library.albums_request_gen += 1; app.library.albums_scroll = 0; free_tracks(*app.library.tracks); free(app.library.selected_album_id); app.library.selected_album_id = ""; app.library.tracks_loaded = false; app.library.tracks_loading = false; app.library.tracks_scroll = 0; path := tprint("/Users/%/Items?IncludeItemTypes=MusicAlbum&ArtistIds=%&Recursive=true&SortBy=ProductionYear,SortName&Limit=2000", app.jellyfin.user_id, artist_id); log_info("library: fetching albums for %", artist_id); http_submit("GET", path, on_done=on_albums_loaded, user_data=cast(*void) app.library.albums_request_gen); } library_select_album :: (album_id: string) { if app.library.selected_album_id == album_id && app.library.tracks_loaded return; free(app.library.selected_album_id); app.library.selected_album_id = copy_string(album_id); free_tracks(*app.library.tracks); app.library.tracks_loaded = false; app.library.tracks_loading = true; app.library.tracks_request_gen += 1; app.library.tracks_scroll = 0; path := tprint("/Users/%/Items?ParentId=%&IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName&Limit=2000", app.jellyfin.user_id, album_id); log_info("library: fetching tracks for %", album_id); http_submit("GET", path, on_done=on_tracks_loaded, user_data=cast(*void) app.library.tracks_request_gen); } #scope_file // // Callbacks — run on the main thread once the worker thread finishes. // on_artists_loaded :: (task: *Http_Task) { app.library.artists_loading = false; if !task.response.ok { log_error("artists: status=% body=%", task.response.status_code, slice(task.response.body, 0, min(300, task.response.body.count))); return; } ok, parsed := Jaison.json_parse_string(task.response.body, Items_Response); if !ok { log_error("artists: json parse failed"); return; } free_artists(*app.library.artists); for parsed.Items { a: Artist; a.id = copy_string(it.Id); a.name = copy_string(it.Name); array_add(*app.library.artists, a); } app.library.artists_loaded = true; log_info("library: % artists loaded", app.library.artists.count); // Prefetch every artist thumb. The image cache caps concurrency at 4 // so this just queues — it doesn't blow up the network. for app.library.artists image_request(it.id, .THUMB); } on_albums_loaded :: (task: *Http_Task) { gen := cast(int) task.user_data; if gen != app.library.albums_request_gen return; // user moved on; discard app.library.albums_loading = false; if !task.response.ok { log_error("albums: status=% body=%", task.response.status_code, slice(task.response.body, 0, min(300, task.response.body.count))); return; } ok, parsed := Jaison.json_parse_string(task.response.body, Items_Response); if !ok { log_error("albums: json parse failed"); return; } free_albums(*app.library.albums); for parsed.Items { a: Album; a.id = copy_string(it.Id); a.name = copy_string(it.Name); a.artist = copy_string(it.AlbumArtist); a.artist_id = copy_string(it.AlbumArtistId); array_add(*app.library.albums, a); } app.library.albums_loaded = true; log_info("library: % albums loaded", app.library.albums.count); // Prefetch every album thumb up front so scrolling is instant. for app.library.albums image_request(it.id, .THUMB); } on_tracks_loaded :: (task: *Http_Task) { gen := cast(int) task.user_data; if gen != app.library.tracks_request_gen return; app.library.tracks_loading = false; if !task.response.ok { log_error("tracks: status=% body=%", task.response.status_code, slice(task.response.body, 0, min(300, task.response.body.count))); return; } ok, parsed := Jaison.json_parse_string(task.response.body, Items_Response); if !ok { log_error("tracks: json parse failed"); return; } free_tracks(*app.library.tracks); for parsed.Items { t: Track; t.id = copy_string(it.Id); t.name = copy_string(it.Name); t.album = copy_string(it.Album); t.album_id = copy_string(it.AlbumId); t.artist = copy_string(it.AlbumArtist); t.artist_id = ifx it.AlbumArtists.count > 0 then copy_string(it.AlbumArtists[0].Id) else copy_string(it.AlbumArtistId); // fallback t.duration_ticks = it.RunTimeTicks; t.index_number = it.IndexNumber; t.is_favourite = it.UserData.IsFavorite; array_add(*app.library.tracks, t); } app.library.tracks_loaded = true; log_info("library: % tracks loaded", app.library.tracks.count); } free_artists :: (xs: *[..] Artist) { for xs.* { free(it.id); free(it.name); } array_reset(xs); } free_albums :: (xs: *[..] Album) { for xs.* { free(it.id); free(it.name); free(it.artist); free(it.artist_id); } array_reset(xs); } free_tracks :: (xs: *[..] Track) { for xs.* { free(it.id); free(it.name); free(it.album); free(it.album_id); free(it.artist); free(it.artist_id); } array_reset(xs); }