Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cca48adb1 | ||
|
|
d2f2f8a618 | ||
|
|
fcbcda7eac |
+64
-11
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -12,6 +12,6 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "AniTrack",
|
"productName": "AniTrack",
|
||||||
"productVersion": "1.6.2"
|
"productVersion": "1.6.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user