audioplayer/src/jellyfin/library.jai
2026-05-01 11:40:59 +03:00

258 lines
8.2 KiB
Plaintext

//
// 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);
}