From 0a89ec3652a40aee5fb8952eb364ce4e3d00cfd5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 1 Sep 2026 20:18:42 -0400 Subject: [PATCH] fix(anilist): decode array-shaped airingSchedule/relations and isolate per-service sync errors The 1.6.1 fix exposed a latent decoding bug: AniList returns airingSchedule.nodes and relations.nodes as arrays, but the Go Media struct declared Nodes as single structs. Every full-media response failed json.Unmarshal partway through - the update path turned that into a rejected promise which skipped the MAL and Simkl syncs and all table updates, while page loads silently continued with partially decoded data (the reason the old tags/genres copy workaround existed). - Media.AiringSchedule.Nodes is now []AiringScheduleNode and Media.Relations is an exported []MediaRelation (previously an unexported field silently dropped by encoding/json); title and fuzzy-date sub-structs promoted to named types. - Regenerate wailsjs models for the new shapes. - Anime.svelte: handleSubmit and deleteEntries now wrap each service in its own try/catch surfaced via setApiError/ErrorModal, so one service failing can no longer skip the others; removed the obsolete tags/genres copy workaround. - AniListUpdateEntry/AniListDeleteEntry log HTTP status and response body on failure for terminal diagnostics. Bump productVersion to 1.6.2. --- AniListFunctions.go | 5 +- AniListTypes.go | 117 +++++++------- frontend/src/helperComponents/Anime.svelte | 86 ++++++++-- frontend/wailsjs/go/models.ts | 176 +++++++++++++++++++-- wails.json | 2 +- 5 files changed, 296 insertions(+), 90 deletions(-) diff --git a/AniListFunctions.go b/AniListFunctions.go index cf4441d..13a8d49 100644 --- a/AniListFunctions.go +++ b/AniListFunctions.go @@ -726,17 +726,19 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) (AniListGetS return AniListGetSingleAnime{}, fmt.Errorf("AniList API error: %s", badPost.Errors[0].Message) } if status != "200 OK" { + log.Printf("AniListUpdateEntry failed with status: %s, body: %s\n", status, string(returnedBody)) return AniListGetSingleAnime{}, fmt.Errorf("API request failed with status: %s", status) } var returnedJson AniListUpdateReturn err := json.Unmarshal(returnedBody, &returnedJson) if err != nil { - log.Printf("Failed at unmarshal, %s\n", err) + log.Printf("AniListUpdateEntry failed to unmarshal response: %s, body: %s\n", err, string(returnedBody)) return AniListGetSingleAnime{}, fmt.Errorf("failed to parse AniList update response") } if returnedJson.Data.SaveMediaListEntry.MediaID == 0 { + log.Printf("AniListUpdateEntry returned no saved entry, body: %s\n", string(returnedBody)) return AniListGetSingleAnime{}, fmt.Errorf("AniList returned no saved entry") } @@ -795,6 +797,7 @@ func (a *App) AniListDeleteEntry(mediaListId int) (DeleteAniListReturn, error) { return DeleteAniListReturn{}, fmt.Errorf("AniList API error: %s", badPost.Errors[0].Message) } if status != "200 OK" { + log.Printf("AniListDeleteEntry failed with status: %s, body: %s\n", status, string(returnedBody)) return DeleteAniListReturn{}, fmt.Errorf("API request failed with status: %s", status) } diff --git a/AniListTypes.go b/AniListTypes.go index 991e111..10926fa 100644 --- a/AniListTypes.go +++ b/AniListTypes.go @@ -64,74 +64,43 @@ type AniListUpdateReturn struct { } type Media struct { - ID int `json:"id"` - IDMal int `json:"idMal"` - Title struct { - UserPreferred string `json:"userPreferred"` - Romaji string `json:"romaji"` - English string `json:"english"` - Native string `json:"native"` - } `json:"title"` - Description string `json:"description"` + ID int `json:"id"` + IDMal int `json:"idMal"` + Title MediaTitle `json:"title"` + Description string `json:"description"` CoverImage struct { ExtraLarge string Large string `json:"large"` Medium string Color string } `json:"coverImage"` - BannerImage string - Format string - Season string `json:"season"` - SeasonYear int `json:"seasonYear"` - Status string `json:"status"` - Episodes int `json:"episodes"` - Duration int - CountryOfOrigin string - Source string - Synonyms []string - AverageScore int - MeanScore int - Popularity int - Trending int - Favourites int - isFavourite bool - Relations struct { - nodes struct { - id int - Title struct { - UserPreferred string `json:"userPreferred"` - Romaji string `json:"romaji"` - English string `json:"english"` - Native string `json:"native"` - } `json:"title"` - } - } - StartDate struct { - Year int - Month int - Day int - } - EndDate struct { - Year int - Month int - Day int - } + BannerImage string + Format string + Season string `json:"season"` + SeasonYear int `json:"seasonYear"` + Status string `json:"status"` + Episodes int `json:"episodes"` + Duration int + CountryOfOrigin string + Source string + Synonyms []string + AverageScore int + MeanScore int + Popularity int + Trending int + Favourites int + isFavourite bool + Relations MediaRelations `json:"relations"` + StartDate MediaFuzzyDate `json:"startDate"` + EndDate MediaFuzzyDate `json:"endDate"` NextAiringEpisode struct { AiringAt int `json:"airingAt"` TimeUntilAiring int `json:"timeUntilAiring"` Episode int `json:"episode"` } `json:"nextAiringEpisode"` - AiringSchedule struct { - Nodes struct { - Id int - AiringAt int - TimeUntilAiring int - Episode int - MediaId int - } - } - Genres []string `json:"genres"` - Tags []struct { + AiringSchedule MediaAiringSchedule `json:"airingSchedule"` + Genres []string `json:"genres"` + Tags []struct { Id int `json:"id"` Name string `json:"name"` Description string `json:"description"` @@ -142,6 +111,40 @@ type Media struct { IsAdult bool `json:"isAdult"` } +type MediaTitle struct { + UserPreferred string `json:"userPreferred"` + Romaji string `json:"romaji"` + English string `json:"english"` + Native string `json:"native"` +} + +type MediaRelations struct { + Nodes []MediaRelation `json:"nodes"` +} + +type MediaRelation struct { + Id int `json:"id"` + Title MediaTitle `json:"title"` +} + +type MediaFuzzyDate struct { + Year int `json:"year"` + Month int `json:"month"` + Day int `json:"day"` +} + +type MediaAiringSchedule struct { + Nodes []AiringScheduleNode `json:"nodes"` +} + +type AiringScheduleNode struct { + Id int `json:"id"` + AiringAt int `json:"airingAt"` + TimeUntilAiring int `json:"timeUntilAiring"` + Episode int `json:"episode"` + MediaId int `json:"mediaId"` +} + type MediaList struct { ID int `json:"id"` MediaID int `json:"mediaId"` diff --git a/frontend/src/helperComponents/Anime.svelte b/frontend/src/helperComponents/Anime.svelte index db3c67a..4b446bb 100644 --- a/frontend/src/helperComponents/Anime.svelte +++ b/frontend/src/helperComponents/Anime.svelte @@ -217,10 +217,6 @@ ); return; } - value.data.MediaList.media.tags = - currentAniListAnime.data.MediaList.media.tags; - value.data.MediaList.media.genres = - currentAniListAnime.data.MediaList.media.genres; aniListAnime.update((newValue) => { newValue = value; return newValue; @@ -243,7 +239,18 @@ }); }); } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error("Error submitting AniList changes:", error); + setApiError( + "anilist", + `Failed to sync AniList: ${errorMsg}`, + undefined, + true, + ); + } + try { if (malLoggedIn && currentMalAnime.id !== 0) { let body: MALUploadStatus = { status: submitData.status.mal, @@ -296,7 +303,18 @@ }, ); } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error("Error submitting MyAnimeList changes:", error); + setApiError( + "mal", + `Failed to sync MyAnimeList: ${errorMsg}`, + undefined, + true, + ); + } + try { if (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) { if (currentSimklAnime.watched_episodes_count !== submitData.episodes) { await SimklSyncEpisodes(currentSimklAnime, submitData.episodes).then( @@ -369,13 +387,19 @@ } } } catch (error) { - console.error("Error submitting changes:", error); - } finally { - submitting.set(false); - submitSuccess.set(true); - watchlistNeedsRefresh.set(true); - setTimeout(() => submitSuccess.set(false), 2000); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error("Error submitting Simkl changes:", error); + setApiError( + "simkl", + `Failed to sync Simkl: ${errorMsg}`, + undefined, + true, + ); } + submitting.set(false); + submitSuccess.set(true); + watchlistNeedsRefresh.set(true); + setTimeout(() => submitSuccess.set(false), 2000); }; const deleteEntries = async () => { @@ -399,6 +423,18 @@ notes: "", }); } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error("Error deleting AniList entry:", error); + setApiError( + "anilist", + `Failed to delete AniList entry: ${errorMsg}`, + undefined, + true, + ); + } + + try { if (malLoggedIn && currentMalAnime.id !== 0) { await DeleteMyAnimeListEntry(currentMalAnime.id); AddAnimeServiceToTable({ @@ -414,6 +450,18 @@ notes: "", }); } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error("Error deleting MyAnimeList entry:", error); + setApiError( + "mal", + `Failed to delete MyAnimeList entry: ${errorMsg}`, + undefined, + true, + ); + } + + try { if (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) { await SimklSyncRemove(currentSimklAnime); AddAnimeServiceToTable({ @@ -430,13 +478,19 @@ }); } } catch (error) { - console.error("Error deleting entries:", error); - } finally { - submitting.set(false); - submitSuccess.set(true); - watchlistNeedsRefresh.set(true); - setTimeout(() => submitSuccess.set(false), 2000); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error("Error deleting Simkl entry:", error); + setApiError( + "simkl", + `Failed to delete Simkl entry: ${errorMsg}`, + undefined, + true, + ); } + submitting.set(false); + submitSuccess.set(true); + watchlistNeedsRefresh.set(true); + setTimeout(() => submitSuccess.set(false), 2000); }; let max = 999; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 066ba9b..74c68f0 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,5 +1,25 @@ export namespace main { + export class AiringScheduleNode { + id: number; + airingAt: number; + timeUntilAiring: number; + episode: number; + mediaId: number; + + static createFrom(source: any = {}) { + return new AiringScheduleNode(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.airingAt = source["airingAt"]; + this.timeUntilAiring = source["timeUntilAiring"]; + this.episode = source["episode"]; + this.mediaId = source["mediaId"]; + } + } export class AniListCurrentUserWatchList { data: struct { Page struct { PageInfo struct { Total int "json:\"total\""; PerPage int "json:\"perPage\""; CurrentPage int "json:\"currentPage\""; LastPage int "json:\"lastPage\""; HasNextPage bool "json:\"hasNextPage\"" } "json:\"pageInfo\""; MediaList []main.; @@ -381,11 +401,136 @@ export namespace main { this.isAdult = source["isAdult"]; } } + export class MediaAiringSchedule { + nodes: AiringScheduleNode[]; + + static createFrom(source: any = {}) { + return new MediaAiringSchedule(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.nodes = this.convertValues(source["nodes"], AiringScheduleNode); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class MediaFuzzyDate { + year: number; + month: number; + day: number; + + static createFrom(source: any = {}) { + return new MediaFuzzyDate(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.year = source["year"]; + this.month = source["month"]; + this.day = source["day"]; + } + } + export class MediaRelation { + id: number; + title: MediaTitle; + + static createFrom(source: any = {}) { + return new MediaRelation(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.title = this.convertValues(source["title"], MediaTitle); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class MediaRelations { + nodes: MediaRelation[]; + + static createFrom(source: any = {}) { + return new MediaRelations(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.nodes = this.convertValues(source["nodes"], MediaRelation); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class MediaTitle { + userPreferred: string; + romaji: string; + english: string; + native: string; + + static createFrom(source: any = {}) { + return new MediaTitle(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.userPreferred = source["userPreferred"]; + this.romaji = source["romaji"]; + this.english = source["english"]; + this.native = source["native"]; + } + } export class Media { id: number; idMal: number; - // Go type: struct { UserPreferred string "json:\"userPreferred\""; Romaji string "json:\"romaji\""; English string "json:\"english\""; Native string "json:\"native\"" } - title: any; + title: MediaTitle; description: string; // Go type: struct { ExtraLarge string; Large string "json:\"large\""; Medium string; Color string } coverImage: any; @@ -404,16 +549,12 @@ export namespace main { Popularity: number; Trending: number; Favourites: number; - // Go type: struct { nodes struct { id int; Title struct { UserPreferred string "json:\"userPreferred\""; Romaji string "json:\"romaji\""; English string "json:\"english\""; Native string "json:\"native\"" } "json:\"title\"" } } - Relations: any; - // Go type: struct { Year int; Month int; Day int } - StartDate: any; - // Go type: struct { Year int; Month int; Day int } - EndDate: any; + relations: MediaRelations; + startDate: MediaFuzzyDate; + endDate: MediaFuzzyDate; // Go type: struct { AiringAt int "json:\"airingAt\""; TimeUntilAiring int "json:\"timeUntilAiring\""; Episode int "json:\"episode\"" } nextAiringEpisode: any; - // Go type: struct { Nodes struct { Id int; AiringAt int; TimeUntilAiring int; Episode int; MediaId int } } - AiringSchedule: any; + airingSchedule: MediaAiringSchedule; genres: string[]; tags: []; isAdult: boolean; @@ -426,7 +567,7 @@ export namespace main { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; this.idMal = source["idMal"]; - this.title = this.convertValues(source["title"], Object); + this.title = this.convertValues(source["title"], MediaTitle); this.description = source["description"]; this.coverImage = this.convertValues(source["coverImage"], Object); this.BannerImage = source["BannerImage"]; @@ -444,11 +585,11 @@ export namespace main { this.Popularity = source["Popularity"]; this.Trending = source["Trending"]; this.Favourites = source["Favourites"]; - this.Relations = this.convertValues(source["Relations"], Object); - this.StartDate = this.convertValues(source["StartDate"], Object); - this.EndDate = this.convertValues(source["EndDate"], Object); + this.relations = this.convertValues(source["relations"], MediaRelations); + this.startDate = this.convertValues(source["startDate"], MediaFuzzyDate); + this.endDate = this.convertValues(source["endDate"], MediaFuzzyDate); this.nextAiringEpisode = this.convertValues(source["nextAiringEpisode"], Object); - this.AiringSchedule = this.convertValues(source["AiringSchedule"], Object); + this.airingSchedule = this.convertValues(source["airingSchedule"], MediaAiringSchedule); this.genres = source["genres"]; this.tags = this.convertValues(source["tags"], ); this.isAdult = source["isAdult"]; @@ -472,6 +613,8 @@ export namespace main { return a; } } + + export class MediaList { id: number; mediaId: number; @@ -527,6 +670,9 @@ export namespace main { return a; } } + + + export class MyAnimeListUser { id: id; name: name; diff --git a/wails.json b/wails.json index 8ad828a..a86290e 100644 --- a/wails.json +++ b/wails.json @@ -12,6 +12,6 @@ }, "info": { "productName": "AniTrack", - "productVersion": "1.6.1" + "productVersion": "1.6.2" } }