Compare commits

...
3 Commits
Author SHA1 Message Date
john-okeefe 0cca48adb1 chore(release): bump version to 1.6.5 2026-09-03 19:12:18 -04:00
john-okeefe d2f2f8a618 fix(ui): make header search states explicit with slow-day timeouts
The search dropdown toggled blindly before the async call settled
and only handled success, so API failures left a blank dropdown
that looked like search did nothing.

- Replace toggle with explicit open/close, Escape and outside-click
  to dismiss, and disabled search button while a request is in flight
- Add isSearching/searchError/hasSearched states: loading text,
  failure panel with message plus Retry, empty prompt, and a
  no-results message for the searched term
- Guard null coverImage/title with a romaji/native fallback and a
  keyed each block; ignore stale late responses via request ids
- Tolerate slow AniList days: slow notice at 8s, 30s Promise.race
  fail-safe (backend allows 20s), and an immediate offline message
  via navigator.onLine so the UI never spins indefinitely
2026-09-03 19:09:44 -04:00
john-okeefe fcbcda7eac fix(anilist): harden AniList query transport and search errors
AniListQuery previously ignored json.Marshal/http.NewRequest errors,
logged the wrong variable on client.Do failure, and dereferenced a
nil response body, which could panic and leave callers hanging.

- Return early with user-safe messages on encode/request failures
- Add 20s http.Client timeout and nil guards for res/res.Body
- Keep logs generic so no auth material is ever printed
- Add aniListGraphQLErrorMessage/aniListSearchStatusError helpers
  mapping 429 to a rate-limit retry message, 5xx to a temporary
  outage message, and GraphQL errors[] (even on HTTP 200) to a
  surfaced message
- AniListSearch rejects empty bodies and unparseable payloads with
  actionable errors instead of failing silently downstream
2026-09-03 19:09:40 -04:00
3 changed files with 212 additions and 43 deletions
+64 -11
View File
@@ -7,26 +7,42 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"strings"
"time"
) )
func AniListQuery(body interface{}, login bool) (json.RawMessage, string) { func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
reader, _ := json.Marshal(body) reader, err := json.Marshal(body)
response, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(reader))
if err != nil { if err != nil {
log.Printf("Failed at response, %s\n", err) log.Printf("AniList request failed: could not encode request body\n")
return nil, "Could not prepare the AniList request."
}
request, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(reader))
if err != nil {
log.Printf("AniList request failed: could not create request\n")
return nil, "Could not reach AniList. Please check your connection and try again."
} }
if login && (AniListJWT{}) != aniListJwt { if login && (AniListJWT{}) != aniListJwt {
response.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken) request.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken)
} else if login { } else if login {
return nil, "Please login to AniList to make this request" return nil, "Please login to AniList to make this request"
} }
response.Header.Add("Content-Type", "application/json") request.Header.Add("Content-Type", "application/json")
response.Header.Add("Accept", "application/json") request.Header.Add("Accept", "application/json")
client := &http.Client{} client := &http.Client{Timeout: 20 * time.Second}
res, resErr := client.Do(response) res, resErr := client.Do(request)
if resErr != nil { if resErr != nil {
log.Printf("Failed at res, %s\n", err) log.Printf("AniList request failed: network error\n")
return nil, "Could not reach AniList. Please check your connection and try again."
}
if res == nil {
log.Printf("AniList request failed: empty response\n")
return nil, "Could not reach AniList. Please check your connection and try again."
}
if res.Body == nil {
log.Printf("AniList request failed: empty response body\n")
return nil, "Could not reach AniList. Please check your connection and try again."
} }
defer res.Body.Close() defer res.Body.Close()
@@ -39,6 +55,37 @@ func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
return returnedBody, res.Status return returnedBody, res.Status
} }
func aniListGraphQLErrorMessage(returnedBody json.RawMessage) string {
var gqlErr struct {
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(returnedBody, &gqlErr); err != nil {
return ""
}
if len(gqlErr.Errors) > 0 && strings.TrimSpace(gqlErr.Errors[0].Message) != "" {
return strings.TrimSpace(gqlErr.Errors[0].Message)
}
return ""
}
func aniListSearchStatusError(status string, returnedBody json.RawMessage) error {
if msg := aniListGraphQLErrorMessage(returnedBody); msg != "" {
return fmt.Errorf("AniList search failed: %s", msg)
}
if strings.Contains(status, "429") {
return fmt.Errorf("AniList is rate-limiting search right now. Please wait a moment and try again.")
}
if strings.Contains(status, "500") || strings.Contains(status, "502") || strings.Contains(status, "503") || strings.Contains(status, "504") {
return fmt.Errorf("AniList is temporarily unavailable (%s). Please try again shortly.", status)
}
if strings.HasPrefix(status, "Could not") || strings.HasPrefix(status, "Please login") {
return fmt.Errorf("%s", status)
}
return fmt.Errorf("AniList search failed (%s). Please try again.", status)
}
func (a *App) GetAniListItem(aniId int, login bool) AniListGetSingleAnime { func (a *App) GetAniListItem(aniId int, login bool) AniListGetSingleAnime {
user := a.GetAniListLoggedInUser() user := a.GetAniListLoggedInUser()
@@ -338,12 +385,18 @@ func (a *App) AniListSearch(query string) (interface{}, error) {
} }
returnedBody, status := AniListQuery(body, false) returnedBody, status := AniListQuery(body, false)
if status != "200 OK" { if status != "200 OK" {
return nil, fmt.Errorf("API search failed with status: %s", status) return nil, aniListSearchStatusError(status, returnedBody)
}
if len(returnedBody) == 0 {
return nil, fmt.Errorf("AniList returned an empty response. Please try again.")
}
if msg := aniListGraphQLErrorMessage(returnedBody); msg != "" {
return nil, fmt.Errorf("AniList search failed: %s", msg)
} }
var post interface{} var post interface{}
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 search results\n")
return nil, fmt.Errorf("Failed to parse search results") return nil, fmt.Errorf("Failed to parse search results")
} }
return post, nil return post, nil
+146 -30
View File
@@ -5,23 +5,128 @@
import {push} from "svelte-spa-router"; import {push} from "svelte-spa-router";
let aniSearch = "" let aniSearch = ""
let aniListSearch: AniSearchList let aniListSearch: AniSearchList | null = null
let aniListSearchActive = false let dropdownOpen = false
let isSearching = false
let showSlowNotice = false
let searchError: string | null = null
let hasSearched = false
let searchRequestId = 0
function runAniListSearch(): void { const SLOW_NOTICE_MS = 8000
AniListSearch(aniSearch).then(result => { const SEARCH_TIMEOUT_MS = 30000
aniListSearch = result
aniListSearchActive = true function openDropdown(): void {
}) dropdownOpen = true
} }
function searchDropdown(): void { function closeDropdown(): void {
let dropdown = document.querySelector("#aniListSearchDropdown") dropdownOpen = false
dropdown.classList.toggle("hidden") }
function displayTitle(media: { title?: { english?: string | null; romaji?: string | null; native?: string | null } }): string {
const english = media?.title?.english
if (english !== undefined && english !== null && english !== "") {
return english
}
const romaji = media?.title?.romaji
if (romaji !== undefined && romaji !== null && romaji !== "") {
return romaji
}
return media?.title?.native ?? "Unknown title"
}
function resultCount(): number {
const media = aniListSearch?.data?.Page?.media
return Array.isArray(media) ? media.length : 0
}
async function runAniListSearch(): Promise<void> {
const term = aniSearch.trim()
openDropdown()
if (term.length === 0) {
searchRequestId += 1
aniListSearch = null
searchError = null
hasSearched = false
isSearching = false
showSlowNotice = false
return
}
if (isSearching) {
return
}
if (typeof navigator !== "undefined" && !navigator.onLine) {
aniListSearch = null
hasSearched = true
searchError = "You appear to be offline. Check your connection and try again."
return
}
isSearching = true
showSlowNotice = false
searchError = null
hasSearched = true
const myRequest = searchRequestId + 1
searchRequestId = myRequest
let slowTimer: ReturnType<typeof setTimeout> | null = null
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
try {
slowTimer = setTimeout(() => {
if (searchRequestId === myRequest && isSearching) {
showSlowNotice = true
}
}, SLOW_NOTICE_MS)
const timeout = new Promise<never>((_, reject) => {
timeoutTimer = setTimeout(() => {
reject(new Error("AniList is taking too long to respond. Please try again."))
}, SEARCH_TIMEOUT_MS)
})
const result = await Promise.race([AniListSearch(term), timeout])
if (searchRequestId !== myRequest) {
return
}
aniListSearch = result as AniSearchList
if (!Array.isArray(aniListSearch?.data?.Page?.media)) {
aniListSearch = null
}
} catch (e) {
if (searchRequestId !== myRequest) {
return
}
aniListSearch = null
searchError = e instanceof Error ? e.message : String(e)
} finally {
if (slowTimer !== null) {
clearTimeout(slowTimer)
}
if (timeoutTimer !== null) {
clearTimeout(timeoutTimer)
}
if (searchRequestId === myRequest) {
isSearching = false
showSlowNotice = false
}
}
}
function goToAnime(id: number): void {
closeDropdown()
push(`#/anime/${id}`)
}
function handleWindowClick(e: MouseEvent): void {
if (!dropdownOpen) {
return
}
const container = document.querySelector("#searchDropdown")
if (container && e.target instanceof Node && !container.contains(e.target)) {
closeDropdown()
}
} }
</script> </script>
<svelte:window on:click={handleWindowClick} />
<div id="searchDropdown" class="relative w-64 md:w-48"> <div id="searchDropdown" class="relative w-64 md:w-48">
<div class="flex"> <div class="flex">
@@ -33,16 +138,20 @@
placeholder="Search for Anime" placeholder="Search for Anime"
on:keypress={(e) => { on:keypress={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
searchDropdown() runAniListSearch()
if(aniSearch.length > 0) runAniListSearch() }
}}
on:keydown={(e) => {
if (e.key === "Escape") {
closeDropdown()
} }
}} }}
required/> required/>
<button id="aniListSearchButton" <button id="aniListSearchButton"
class="absolute top-0 end-0 h-full p-2.5 text-sm font-medium rounded-e-lg border focus:ring-4 focus:outline-none bg-blue-600 hover:bg-blue-700 focus:ring-blue-800" class="absolute top-0 end-0 h-full p-2.5 text-sm font-medium rounded-e-lg border focus:ring-4 focus:outline-none bg-blue-600 hover:bg-blue-700 focus:ring-blue-800 disabled:opacity-50"
disabled={isSearching}
on:click={() => { on:click={() => {
searchDropdown() runAniListSearch()
if(aniSearch.length > 0) runAniListSearch()
}}> }}>
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" <svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 20 20"> viewBox="0 0 20 20">
@@ -54,31 +163,38 @@
</div> </div>
</div> </div>
<div id="aniListSearchDropdown" class="z-10 absolute left-0 hidden bg-white rounded-lg shadow w-60 2xl:w-80 dark:bg-gray-700"> <div id="aniListSearchDropdown" class:hidden={!dropdownOpen} class="z-10 absolute left-0 bg-white rounded-lg shadow w-60 2xl:w-80 dark:bg-gray-700">
{#if aniListSearchActive} {#if isSearching}
<div class="m-4 text-gray-700 dark:text-gray-200">{showSlowNotice ? "AniList is slow today, still trying..." : "Searching AniList..."}</div>
{:else if searchError}
<div class="m-4">
<p class="text-red-600 dark:text-red-400 font-medium">Search failed</p>
<p class="mt-1 text-sm text-gray-700 dark:text-gray-200">{searchError}</p>
<button class="mt-3 text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
on:click={() => runAniListSearch()}>
Retry search
</button>
</div>
{:else if !hasSearched && aniSearch.trim().length === 0}
<div class="m-4 text-gray-700 dark:text-gray-200">Please enter a search term...</div>
{:else if resultCount() > 0 && aniListSearch}
<ul class="h-56 w-full py-2 overflow-y-auto text-gray-700 dark:text-gray-200" <ul class="h-56 w-full py-2 overflow-y-auto text-gray-700 dark:text-gray-200"
aria-labelledby="aniListSearchButton"> aria-labelledby="aniListSearchButton">
{#each aniListSearch.data.Page.media as media} {#each aniListSearch.data.Page.media as media (media.id)}
<li class="w-full"> <li class="w-full">
<div class="flex w-full items-start p-1 hover:bg-gray-600 hover:text-white rounded-lg"> <div class="flex w-full items-start p-1 hover:bg-gray-600 hover:text-white rounded-lg">
<button on:click={() => { <button on:click={() => goToAnime(media.id)}
searchDropdown()
push(`#/anime/${media.id}`)
}}
> >
<img class="rounded-bl-lg rounded-tl-lg max-w-24 max-h-24" src={media.coverImage.large} <img class="rounded-bl-lg rounded-tl-lg max-w-24 max-h-24" src={media?.coverImage?.large}
alt="{media.title.english === '' || media.title.english === null ? media.title.romaji : media.title.english} Cover"> alt="{displayTitle(media)} Cover">
</button> </button>
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => { <button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => goToAnime(media.id)} >{displayTitle(media)}</button>
searchDropdown()
push(`#/anime/${media.id}`)
}} >{media.title.english === '' || media.title.english === null ? media.title.romaji : media.title.english }</button>
</div> </div>
</li> </li>
{/each} {/each}
</ul> </ul>
{:else if aniSearch.length === 0} {:else if hasSearched}
<div class="m-4">Please enter a search term...</div> <div class="m-4 text-gray-700 dark:text-gray-200">No results found for "{aniSearch.trim()}". Try a different title.</div>
{/if} {/if}
</div> </div>
</div> </div>
+1 -1
View File
@@ -12,6 +12,6 @@
}, },
"info": { "info": {
"productName": "AniTrack", "productName": "AniTrack",
"productVersion": "1.6.2" "productVersion": "1.6.5"
} }
} }