2 Commits
Author SHA1 Message Date
john-okeefe 0a89ec3652 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.
2026-09-01 20:18:42 -04:00
john-okeefe 5a14863a8f fix(anilist): repair SaveMediaListEntry mutation and surface update failures
The AniListUpdateEntry mutation was mangled in 54c109a when the
standardized media field block was pasted in without the media { }
wrapper: media-level fields (idMal, title, ...) sat directly on
SaveMediaListEntry (which returns MediaList), the MediaList fields
(status, startedAt, ..., user) ended up at the Mutation root, and a
stray closing brace made the document a guaranteed 400. Because the
response status was discarded, the frontend received a zeroed
AniListGetSingleAnime and blanked the anime page after every submit,
making AniList appear logged out (and the change was never saved).

- Restore the mutation to match the tested bruno request: id/mediaId/
  userId, standard media block inside media { }, MediaList fields
  inside the selection, balanced braces.
- AniListUpdateEntry and AniListDeleteEntry now return an error on
  403/non-200/unparseable responses (mirroring
  GetAniListUserWatchingList) instead of silently returning zero
  values.
- Anime.svelte guards against replacing the page data with an empty
  response and raises the API error modal instead.

Bump productVersion to 1.6.1.
2026-09-01 19:49:47 -04:00
5 changed files with 439 additions and 166 deletions
+69 -9
View File
@@ -558,7 +558,7 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) (An
return post, nil return post, nil
} }
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSingleAnime { func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) (AniListGetSingleAnime, error) {
body := struct { body := struct {
Query string `json:"query"` Query string `json:"query"`
Variables AniListUpdateVariables `json:"variables"` Variables AniListUpdateVariables `json:"variables"`
@@ -584,6 +584,10 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
startedAt: $startedAt startedAt: $startedAt
completedAt: $completedAt completedAt: $completedAt
) { ) {
id
mediaId
userId
media {
id id
idMal idMal
title { title {
@@ -660,8 +664,7 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
isAdult isAdult
} }
isAdult isAdult
} }
status status
startedAt { startedAt {
year year
@@ -700,22 +703,53 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
Variables: updateBody, Variables: updateBody,
} }
returnedBody, _ := AniListQuery(body, true) returnedBody, status := AniListQuery(body, true)
var badPost struct {
Errors []struct {
Message string `json:"message"`
Status int `json:"status"`
Locations []struct {
Line int `json:"line"`
Column int `json:"column"`
} `json:"locations"`
} `json:"errors"`
Data any `json:"data"`
}
if status == "403 Forbidden" {
err := json.Unmarshal(returnedBody, &badPost)
if err != nil {
log.Printf("Failed at unmarshal, %s\n", err)
return AniListGetSingleAnime{}, fmt.Errorf("API authentication error")
}
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 var returnedJson AniListUpdateReturn
err := json.Unmarshal(returnedBody, &returnedJson) err := json.Unmarshal(returnedBody, &returnedJson)
if err != nil { 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")
} }
var post AniListGetSingleAnime var post AniListGetSingleAnime
post.Data.MediaList = returnedJson.Data.SaveMediaListEntry post.Data.MediaList = returnedJson.Data.SaveMediaListEntry
return post return post, nil
} }
func (a *App) AniListDeleteEntry(mediaListId int) DeleteAniListReturn { func (a *App) AniListDeleteEntry(mediaListId int) (DeleteAniListReturn, error) {
type Variables = struct { type Variables = struct {
Id int `json:"id"` Id int `json:"id"`
} }
@@ -740,15 +774,41 @@ func (a *App) AniListDeleteEntry(mediaListId int) DeleteAniListReturn {
}, },
} }
returnedBody, _ := AniListQuery(body, true) returnedBody, status := AniListQuery(body, true)
var badPost struct {
Errors []struct {
Message string `json:"message"`
Status int `json:"status"`
Locations []struct {
Line int `json:"line"`
Column int `json:"column"`
} `json:"locations"`
} `json:"errors"`
Data any `json:"data"`
}
if status == "403 Forbidden" {
err := json.Unmarshal(returnedBody, &badPost)
if err != nil {
log.Printf("Failed at unmarshal, %s\n", err)
return DeleteAniListReturn{}, fmt.Errorf("API authentication 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)
}
var post DeleteAniListReturn var post DeleteAniListReturn
err := json.Unmarshal(returnedBody, &post) err := json.Unmarshal(returnedBody, &post)
if err != nil { if err != nil {
log.Printf("Failed at unmarshal, %s\n", err) log.Printf("Failed at unmarshal, %s\n", err)
return DeleteAniListReturn{}, fmt.Errorf("failed to parse AniList delete response")
} }
return post return post, nil
} }
func (a *App) AniListBrowse( func (a *App) AniListBrowse(
+39 -36
View File
@@ -66,12 +66,7 @@ type AniListUpdateReturn struct {
type Media struct { type Media struct {
ID int `json:"id"` ID int `json:"id"`
IDMal int `json:"idMal"` IDMal int `json:"idMal"`
Title struct { Title MediaTitle `json:"title"`
UserPreferred string `json:"userPreferred"`
Romaji string `json:"romaji"`
English string `json:"english"`
Native string `json:"native"`
} `json:"title"`
Description string `json:"description"` Description string `json:"description"`
CoverImage struct { CoverImage struct {
ExtraLarge string ExtraLarge string
@@ -95,41 +90,15 @@ type Media struct {
Trending int Trending int
Favourites int Favourites int
isFavourite bool isFavourite bool
Relations struct { Relations MediaRelations `json:"relations"`
nodes struct { StartDate MediaFuzzyDate `json:"startDate"`
id int EndDate MediaFuzzyDate `json:"endDate"`
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
}
NextAiringEpisode struct { NextAiringEpisode struct {
AiringAt int `json:"airingAt"` AiringAt int `json:"airingAt"`
TimeUntilAiring int `json:"timeUntilAiring"` TimeUntilAiring int `json:"timeUntilAiring"`
Episode int `json:"episode"` Episode int `json:"episode"`
} `json:"nextAiringEpisode"` } `json:"nextAiringEpisode"`
AiringSchedule struct { AiringSchedule MediaAiringSchedule `json:"airingSchedule"`
Nodes struct {
Id int
AiringAt int
TimeUntilAiring int
Episode int
MediaId int
}
}
Genres []string `json:"genres"` Genres []string `json:"genres"`
Tags []struct { Tags []struct {
Id int `json:"id"` Id int `json:"id"`
@@ -142,6 +111,40 @@ type Media struct {
IsAdult bool `json:"isAdult"` 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 { type MediaList struct {
ID int `json:"id"` ID int `json:"id"`
MediaID int `json:"mediaId"` MediaID int `json:"mediaId"`
+74 -10
View File
@@ -4,6 +4,7 @@
aniListLoggedIn, aniListLoggedIn,
malAnime, malAnime,
malLoggedIn, malLoggedIn,
setApiError,
simklAnime, simklAnime,
simklLoggedIn, simklLoggedIn,
watchlistNeedsRefresh, watchlistNeedsRefresh,
@@ -207,10 +208,15 @@
completedAt: convertDateToAniList(completedAtDate), completedAt: convertDateToAniList(completedAtDate),
}; };
await AniListUpdateEntry(body).then((value: AniListGetSingleAnime) => { await AniListUpdateEntry(body).then((value: AniListGetSingleAnime) => {
value.data.MediaList.media.tags = if (!value.data?.MediaList || value.data.MediaList.mediaId === 0) {
currentAniListAnime.data.MediaList.media.tags; setApiError(
value.data.MediaList.media.genres = "anilist",
currentAniListAnime.data.MediaList.media.genres; "AniList update failed: no saved entry was returned",
undefined,
true,
);
return;
}
aniListAnime.update((newValue) => { aniListAnime.update((newValue) => {
newValue = value; newValue = value;
return newValue; return newValue;
@@ -233,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) { if (malLoggedIn && currentMalAnime.id !== 0) {
let body: MALUploadStatus = { let body: MALUploadStatus = {
status: submitData.status.mal, status: submitData.status.mal,
@@ -286,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 (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) {
if (currentSimklAnime.watched_episodes_count !== submitData.episodes) { if (currentSimklAnime.watched_episodes_count !== submitData.episodes) {
await SimklSyncEpisodes(currentSimklAnime, submitData.episodes).then( await SimklSyncEpisodes(currentSimklAnime, submitData.episodes).then(
@@ -359,13 +387,19 @@
} }
} }
} catch (error) { } catch (error) {
console.error("Error submitting changes:", error); const errorMsg = error instanceof Error ? error.message : String(error);
} finally { console.error("Error submitting Simkl changes:", error);
setApiError(
"simkl",
`Failed to sync Simkl: ${errorMsg}`,
undefined,
true,
);
}
submitting.set(false); submitting.set(false);
submitSuccess.set(true); submitSuccess.set(true);
watchlistNeedsRefresh.set(true); watchlistNeedsRefresh.set(true);
setTimeout(() => submitSuccess.set(false), 2000); setTimeout(() => submitSuccess.set(false), 2000);
}
}; };
const deleteEntries = async () => { const deleteEntries = async () => {
@@ -389,6 +423,18 @@
notes: "", 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) { if (malLoggedIn && currentMalAnime.id !== 0) {
await DeleteMyAnimeListEntry(currentMalAnime.id); await DeleteMyAnimeListEntry(currentMalAnime.id);
AddAnimeServiceToTable({ AddAnimeServiceToTable({
@@ -404,6 +450,18 @@
notes: "", 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) { if (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) {
await SimklSyncRemove(currentSimklAnime); await SimklSyncRemove(currentSimklAnime);
AddAnimeServiceToTable({ AddAnimeServiceToTable({
@@ -420,13 +478,19 @@
}); });
} }
} catch (error) { } catch (error) {
console.error("Error deleting entries:", error); const errorMsg = error instanceof Error ? error.message : String(error);
} finally { console.error("Error deleting Simkl entry:", error);
setApiError(
"simkl",
`Failed to delete Simkl entry: ${errorMsg}`,
undefined,
true,
);
}
submitting.set(false); submitting.set(false);
submitSuccess.set(true); submitSuccess.set(true);
watchlistNeedsRefresh.set(true); watchlistNeedsRefresh.set(true);
setTimeout(() => submitSuccess.set(false), 2000); setTimeout(() => submitSuccess.set(false), 2000);
}
}; };
let max = 999; let max = 999;
+161 -15
View File
@@ -1,5 +1,25 @@
export namespace main { 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 { 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.; 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"]; 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 { export class Media {
id: number; id: number;
idMal: number; idMal: number;
// Go type: struct { UserPreferred string "json:\"userPreferred\""; Romaji string "json:\"romaji\""; English string "json:\"english\""; Native string "json:\"native\"" } title: MediaTitle;
title: any;
description: string; description: string;
// Go type: struct { ExtraLarge string; Large string "json:\"large\""; Medium string; Color string } // Go type: struct { ExtraLarge string; Large string "json:\"large\""; Medium string; Color string }
coverImage: any; coverImage: any;
@@ -404,16 +549,12 @@ export namespace main {
Popularity: number; Popularity: number;
Trending: number; Trending: number;
Favourites: 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: MediaRelations;
Relations: any; startDate: MediaFuzzyDate;
// Go type: struct { Year int; Month int; Day int } endDate: MediaFuzzyDate;
StartDate: any;
// Go type: struct { Year int; Month int; Day int }
EndDate: any;
// Go type: struct { AiringAt int "json:\"airingAt\""; TimeUntilAiring int "json:\"timeUntilAiring\""; Episode int "json:\"episode\"" } // Go type: struct { AiringAt int "json:\"airingAt\""; TimeUntilAiring int "json:\"timeUntilAiring\""; Episode int "json:\"episode\"" }
nextAiringEpisode: any; nextAiringEpisode: any;
// Go type: struct { Nodes struct { Id int; AiringAt int; TimeUntilAiring int; Episode int; MediaId int } } airingSchedule: MediaAiringSchedule;
AiringSchedule: any;
genres: string[]; genres: string[];
tags: []; tags: [];
isAdult: boolean; isAdult: boolean;
@@ -426,7 +567,7 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"]; this.id = source["id"];
this.idMal = source["idMal"]; this.idMal = source["idMal"];
this.title = this.convertValues(source["title"], Object); this.title = this.convertValues(source["title"], MediaTitle);
this.description = source["description"]; this.description = source["description"];
this.coverImage = this.convertValues(source["coverImage"], Object); this.coverImage = this.convertValues(source["coverImage"], Object);
this.BannerImage = source["BannerImage"]; this.BannerImage = source["BannerImage"];
@@ -444,11 +585,11 @@ export namespace main {
this.Popularity = source["Popularity"]; this.Popularity = source["Popularity"];
this.Trending = source["Trending"]; this.Trending = source["Trending"];
this.Favourites = source["Favourites"]; this.Favourites = source["Favourites"];
this.Relations = this.convertValues(source["Relations"], Object); this.relations = this.convertValues(source["relations"], MediaRelations);
this.StartDate = this.convertValues(source["StartDate"], Object); this.startDate = this.convertValues(source["startDate"], MediaFuzzyDate);
this.EndDate = this.convertValues(source["EndDate"], Object); this.endDate = this.convertValues(source["endDate"], MediaFuzzyDate);
this.nextAiringEpisode = this.convertValues(source["nextAiringEpisode"], Object); 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.genres = source["genres"];
this.tags = this.convertValues(source["tags"], ); this.tags = this.convertValues(source["tags"], );
this.isAdult = source["isAdult"]; this.isAdult = source["isAdult"];
@@ -472,6 +613,8 @@ export namespace main {
return a; return a;
} }
} }
export class MediaList { export class MediaList {
id: number; id: number;
mediaId: number; mediaId: number;
@@ -527,6 +670,9 @@ export namespace main {
return a; return a;
} }
} }
export class MyAnimeListUser { export class MyAnimeListUser {
id: id; id: id;
name: name; name: name;
+1 -1
View File
@@ -12,6 +12,6 @@
}, },
"info": { "info": {
"productName": "AniTrack", "productName": "AniTrack",
"productVersion": "1.5.5" "productVersion": "1.6.2"
} }
} }