Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5da544dd7 | ||
|
|
270faeb507 | ||
|
|
abba1803fc | ||
|
|
ce35a20eba | ||
|
|
e5ce250392 | ||
|
|
0cca48adb1 | ||
|
|
d2f2f8a618 | ||
|
|
fcbcda7eac | ||
|
|
0a89ec3652 | ||
|
|
5a14863a8f |
@@ -0,0 +1,251 @@
|
||||
name: Release
|
||||
|
||||
# Overrides the default run name (the tagged commit's message) so the Actions
|
||||
# runs list shows "Release 1.6.7" instead.
|
||||
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
|
||||
|
||||
# Builds the Wails Linux binary via `make build`, packages
|
||||
# AniTrack-<version>.tar.gz from build/, and creates/updates a Gitea Release
|
||||
# named AniTrack-<version> with the archive attached. Triggered by a plain
|
||||
# version tag push (1.6.7), or manually via workflow_dispatch with a tag for
|
||||
# re-runs and secrets tests. Pushing to main does nothing, so
|
||||
# work-in-progress commits never ship.
|
||||
#
|
||||
# Local flow: `./release 1.6.7` (or `make release VERSION=1.6.7`) bumps
|
||||
# wails.json, commits the bump, creates an annotated tag carrying the
|
||||
# git-cliff notes, and pushes commit + tag. This workflow verifies
|
||||
# wails.json matches the tag before building.
|
||||
#
|
||||
# Secrets test (verifies environment.go wiring without shipping): tag the
|
||||
# current bumped commit with a suffix and push, e.g.
|
||||
# git tag 1.6.7-rc1 && git push origin 1.6.7-rc1
|
||||
# Tags containing '-' are published as pre-releases; delete the test
|
||||
# Release/tag afterwards.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]*.[0-9]*.[0-9]*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to release (e.g. 1.6.7)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
# Resolve the target tag for both triggers: explicit input on manual
|
||||
# dispatch, otherwise the pushed tag ref.
|
||||
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history ensures the tag annotation (the release notes) is present.
|
||||
fetch-depth: 0
|
||||
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
||||
|
||||
- name: Guard wails.json matches tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${TAG:?TAG is required}"
|
||||
# Normalize "v1.6.7" / "AniTrack-1.6.7" to plain "1.6.7" (make release
|
||||
# only ever creates plain tags; this tolerates manual typos).
|
||||
NORM="${TAG#v}"
|
||||
NORM="${NORM#AniTrack-}"
|
||||
BASE="${NORM%%-*}"
|
||||
WAILS_VER="$(python3 -c "import json; print(json.load(open('wails.json'))['info']['productVersion'])")"
|
||||
if [ "${NORM}" = "${BASE}" ]; then
|
||||
if [ "${WAILS_VER}" != "${NORM}" ]; then
|
||||
echo "::error::wails.json productVersion (${WAILS_VER}) != tag (${NORM}). Bump via ./release ${NORM} first." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Pre-release (e.g. 1.6.7-rc1): wails.json must match the base version.
|
||||
if [ "${WAILS_VER}" != "${BASE}" ]; then
|
||||
echo "::error::wails.json productVersion (${WAILS_VER}) != tag base (${BASE}). Bump via ./release ${BASE} first." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "VERSION=${NORM}" >> "${GITHUB_ENV}"
|
||||
echo "Version guard passed: wails.json=${WAILS_VER} tag=${NORM}"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install Wails Linux build dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential pkg-config \
|
||||
libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||
jq
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@v2.15.0
|
||||
echo "${HOME}/go/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Write environment.go from secrets
|
||||
env:
|
||||
ANILIST_SECRET_TOKEN: ${{ secrets.ANILIST_SECRET_TOKEN }}
|
||||
ANILIST_APP_ID: ${{ secrets.ANILIST_APP_ID }}
|
||||
ANILIST_APP_NAME: ${{ secrets.ANILIST_APP_NAME }}
|
||||
ANILIST_CALLBACK_URI: ${{ secrets.ANILIST_CALLBACK_URI }}
|
||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||
SIMKL_CALLBACK_URI: ${{ secrets.SIMKL_CALLBACK_URI }}
|
||||
MAL_CLIENT_ID: ${{ secrets.MAL_CLIENT_ID }}
|
||||
MAL_CLIENT_SECRET: ${{ secrets.MAL_CLIENT_SECRET }}
|
||||
MAL_CALLBACK_URI: ${{ secrets.MAL_CALLBACK_URI }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# All 10 fields come from Gitea Actions secrets (never committed).
|
||||
# Values are masked in logs; do not echo them or run with set -x.
|
||||
for v in ANILIST_SECRET_TOKEN ANILIST_APP_ID ANILIST_APP_NAME ANILIST_CALLBACK_URI SIMKL_CLIENT_ID SIMKL_CLIENT_SECRET SIMKL_CALLBACK_URI MAL_CLIENT_ID MAL_CLIENT_SECRET MAL_CALLBACK_URI; do
|
||||
if [ -z "${!v:-}" ]; then
|
||||
echo "::error::Missing secret ${v}. Add it under Settings → Secrets → Actions." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cat > environment.go <<EOF
|
||||
package main
|
||||
|
||||
var Environment = EnvironmentStruct{
|
||||
ANILIST_SECRET_TOKEN: "${ANILIST_SECRET_TOKEN}",
|
||||
ANILIST_APP_ID: "${ANILIST_APP_ID}",
|
||||
ANILIST_APP_NAME: "${ANILIST_APP_NAME}",
|
||||
ANILIST_CALLBACK_URI: "${ANILIST_CALLBACK_URI}",
|
||||
SIMKL_CLIENT_ID: "${SIMKL_CLIENT_ID}",
|
||||
SIMKL_CLIENT_SECRET: "${SIMKL_CLIENT_SECRET}",
|
||||
SIMKL_CALLBACK_URI: "${SIMKL_CALLBACK_URI}",
|
||||
MAL_CLIENT_ID: "${MAL_CLIENT_ID}",
|
||||
MAL_CLIENT_SECRET: "${MAL_CLIENT_SECRET}",
|
||||
MAL_CALLBACK_URI: "${MAL_CALLBACK_URI}",
|
||||
}
|
||||
EOF
|
||||
echo "Wrote environment.go from secrets."
|
||||
|
||||
- name: Build Linux binary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="${HOME}/go/bin:${PATH}"
|
||||
make build
|
||||
test -x build/bin/AniTrack || { echo "::error::build/bin/AniTrack missing after make build" >&2; exit 1; }
|
||||
|
||||
- name: Package release archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${VERSION:?VERSION missing from version-guard step}"
|
||||
STAGE="dist/AniTrack-${VERSION}"
|
||||
rm -rf dist "AniTrack-${VERSION}.tar.gz"
|
||||
mkdir -p "${STAGE}/bin"
|
||||
cp build/bin/AniTrack "${STAGE}/bin/"
|
||||
cp -r build/icon "${STAGE}/"
|
||||
cp build/AniTrack.desktop build/install_linux.sh build/README.md "${STAGE}/"
|
||||
chmod +x "${STAGE}/bin/AniTrack" "${STAGE}/install_linux.sh"
|
||||
tar -czf "AniTrack-${VERSION}.tar.gz" -C dist "AniTrack-${VERSION}"
|
||||
tar tzf "AniTrack-${VERSION}.tar.gz"
|
||||
echo "ARCHIVE=AniTrack-${VERSION}.tar.gz" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
|
||||
# token cannot create releases on this instance. The PAT must carry
|
||||
# write:repository scope. Idempotent: re-runs update an existing
|
||||
# release for this tag instead of failing with 409. On any HTTP error
|
||||
# the API response body is printed so a 403 names the missing scope.
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${TAG:?TAG is required}"
|
||||
: "${VERSION:?VERSION missing from version-guard step}"
|
||||
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||
AUTH="Authorization: token ${TOKEN}"
|
||||
# Release body = the annotated tag's message (the git-cliff notes).
|
||||
# Manual test tags without an annotation fall back to the tag name.
|
||||
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
|
||||
if [ -z "$(printf '%s' "${BODY}" | tr -d '[:space:]')" ]; then
|
||||
BODY="${TAG}"
|
||||
fi
|
||||
|
||||
# Tags containing a '-' (e.g. 1.6.7-rc1) are published as pre-releases.
|
||||
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg t "${TAG}" --arg n "AniTrack-${VERSION}" --arg b "${BODY}" --argjson p "${PRE}" \
|
||||
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
|
||||
|
||||
# POST/PATCH the release, surfacing Gitea's error message on failure
|
||||
# (e.g. "token does not have write scope") instead of failing silently.
|
||||
api_call() {
|
||||
local method="$1" url="$2" resp code rbody
|
||||
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
|
||||
-H "${AUTH}" -H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" "${url}")"
|
||||
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||
if [ "${code}" -ge 400 ]; then
|
||||
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
api_call PATCH "${API}/${EXISTING_ID}"
|
||||
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
|
||||
else
|
||||
api_call POST "${API}"
|
||||
echo "Created new release for ${TAG}"
|
||||
fi
|
||||
|
||||
- name: Upload release archive
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${TAG:?TAG is required}"
|
||||
: "${ARCHIVE:?ARCHIVE missing from packaging step}"
|
||||
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||
AUTH="Authorization: token ${TOKEN}"
|
||||
test -f "${ARCHIVE}" || { echo "::error::${ARCHIVE} not found" >&2; exit 1; }
|
||||
|
||||
RID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty')"
|
||||
if [ -z "${RID}" ]; then
|
||||
echo "::error::No release found for tag ${TAG} after create step" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Replace a same-named asset so re-runs stay idempotent.
|
||||
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ARCHIVE}" '.[] | select(.name==$n) | .id // empty')"
|
||||
if [ -n "${AID}" ]; then
|
||||
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
|
||||
echo "Deleted existing asset id=${AID} (${ARCHIVE})"
|
||||
fi
|
||||
|
||||
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
||||
-F "attachment=@${ARCHIVE}" "${API}/${RID}/assets?name=${ARCHIVE}")"
|
||||
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||
if [ "${code}" -ge 400 ]; then
|
||||
echo "::error::Asset upload ${code}: ${rbody}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploaded ${ARCHIVE} to release id=${RID}"
|
||||
+202
-89
@@ -7,26 +7,42 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
|
||||
reader, _ := json.Marshal(body)
|
||||
response, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(reader))
|
||||
reader, err := json.Marshal(body)
|
||||
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 {
|
||||
response.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken)
|
||||
request.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken)
|
||||
} else if login {
|
||||
return nil, "Please login to AniList to make this request"
|
||||
}
|
||||
response.Header.Add("Content-Type", "application/json")
|
||||
response.Header.Add("Accept", "application/json")
|
||||
request.Header.Add("Content-Type", "application/json")
|
||||
request.Header.Add("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
res, resErr := client.Do(response)
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
res, resErr := client.Do(request)
|
||||
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()
|
||||
@@ -39,6 +55,37 @@ func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
|
||||
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 {
|
||||
user := a.GetAniListLoggedInUser()
|
||||
|
||||
@@ -336,14 +383,20 @@ func (a *App) AniListSearch(query string) (interface{}, error) {
|
||||
ListType: "ANIME",
|
||||
},
|
||||
}
|
||||
returnedBody, status := AniListQuery(body, false)
|
||||
returnedBody, status := AniListQuery(body, a.CheckIfAniListLoggedIn())
|
||||
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{}
|
||||
err := json.Unmarshal(returnedBody, &post)
|
||||
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 post, nil
|
||||
@@ -558,7 +611,7 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) (An
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSingleAnime {
|
||||
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) (AniListGetSingleAnime, error) {
|
||||
body := struct {
|
||||
Query string `json:"query"`
|
||||
Variables AniListUpdateVariables `json:"variables"`
|
||||
@@ -584,84 +637,87 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
|
||||
startedAt: $startedAt
|
||||
completedAt: $completedAt
|
||||
) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
description
|
||||
coverImage {
|
||||
extraLarge
|
||||
large
|
||||
medium
|
||||
color
|
||||
}
|
||||
bannerImage
|
||||
format
|
||||
season
|
||||
seasonYear
|
||||
status
|
||||
episodes
|
||||
duration
|
||||
countryOfOrigin
|
||||
source
|
||||
synonyms
|
||||
averageScore
|
||||
meanScore
|
||||
popularity
|
||||
trending
|
||||
favourites
|
||||
isFavourite
|
||||
relations {
|
||||
nodes {
|
||||
id
|
||||
mediaId
|
||||
userId
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
description
|
||||
coverImage {
|
||||
extraLarge
|
||||
large
|
||||
medium
|
||||
color
|
||||
}
|
||||
bannerImage
|
||||
format
|
||||
season
|
||||
seasonYear
|
||||
status
|
||||
episodes
|
||||
duration
|
||||
countryOfOrigin
|
||||
source
|
||||
synonyms
|
||||
averageScore
|
||||
meanScore
|
||||
popularity
|
||||
trending
|
||||
favourites
|
||||
isFavourite
|
||||
relations {
|
||||
nodes {
|
||||
id
|
||||
title {
|
||||
userPreferred
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
nextAiringEpisode {
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
airingSchedule {
|
||||
nodes {
|
||||
id
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
mediaId
|
||||
}
|
||||
}
|
||||
genres
|
||||
tags {
|
||||
id
|
||||
name
|
||||
description
|
||||
rank
|
||||
isMediaSpoiler
|
||||
isAdult
|
||||
}
|
||||
isAdult
|
||||
}
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
nextAiringEpisode {
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
airingSchedule {
|
||||
nodes {
|
||||
id
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
mediaId
|
||||
}
|
||||
}
|
||||
genres
|
||||
tags {
|
||||
id
|
||||
name
|
||||
description
|
||||
rank
|
||||
isMediaSpoiler
|
||||
isAdult
|
||||
}
|
||||
isAdult
|
||||
}
|
||||
|
||||
status
|
||||
startedAt {
|
||||
year
|
||||
@@ -700,22 +756,53 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
|
||||
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
|
||||
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")
|
||||
}
|
||||
|
||||
var post AniListGetSingleAnime
|
||||
|
||||
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 {
|
||||
Id int `json:"id"`
|
||||
}
|
||||
@@ -740,15 +827,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
|
||||
err := json.Unmarshal(returnedBody, &post)
|
||||
if err != nil {
|
||||
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(
|
||||
|
||||
+60
-57
@@ -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"`
|
||||
|
||||
+4
-4
@@ -74,8 +74,8 @@ func (a *App) CheckIfMyAnimeListLoggedIn() bool {
|
||||
if (MyAnimeListJWT{} == myAnimeListJwt) {
|
||||
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
||||
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
||||
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
||||
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
||||
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
||||
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
||||
return false
|
||||
} else {
|
||||
@@ -99,8 +99,8 @@ func (a *App) MyAnimeListLogin() {
|
||||
fmt.Println("check logged in function failed")
|
||||
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
||||
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
||||
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
||||
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
||||
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
||||
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
||||
verifier, _ := verifier()
|
||||
getMyAnimeListCodeUrl := "https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=" + Environment.MAL_CLIENT_ID + "&redirect_uri=" + Environment.MAL_CALLBACK_URI + "&code_challenge=" + verifier.Value + "&code_challenge_method=plain"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
TAGS := webkit2_41
|
||||
|
||||
.PHONY: dev build clean
|
||||
.PHONY: dev build clean release
|
||||
|
||||
dev:
|
||||
wails dev -tags $(TAGS)
|
||||
@@ -10,3 +10,42 @@ build:
|
||||
|
||||
clean:
|
||||
rm -rf build/bin/*
|
||||
|
||||
# Create a version commit (wails.json bump, committed and pushed) plus an
|
||||
# annotated version tag carrying auto-generated release notes (git-cliff),
|
||||
# and push both. The tag push triggers .gitea/workflows/release.yml, which
|
||||
# verifies wails.json matches the tag, builds via `make build`, packages
|
||||
# AniTrack-<VERSION>.tar.gz from build/, and publishes a Gitea Release
|
||||
# named AniTrack-<VERSION> with the archive attached. Notes come entirely
|
||||
# from Conventional Commits — no hand-written message required.
|
||||
#
|
||||
# git-cliff's --latest needs the tag to exist to scope the notes, so we
|
||||
# create a throwaway lightweight tag, generate the notes, replace it with
|
||||
# an annotated tag, then push. --cleanup=verbatim keeps the markdown "###"
|
||||
# group headers (git's default cleanup would strip lines starting with "#").
|
||||
#
|
||||
# Tags are plain versions (1.6.7) to match wails.json productVersion and the
|
||||
# existing tag history. Pre-releases are created by tagging manually with a
|
||||
# suffix (e.g. 1.6.7-rc1 on top of the bumped commit) — the workflow marks
|
||||
# those as prerelease. Use ./release as a shortcut.
|
||||
#
|
||||
# Requires git-cliff: https://git-cliff.org/install
|
||||
# Usage: make release VERSION=1.6.7
|
||||
release:
|
||||
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=1.6.7"; exit 1; }
|
||||
@echo "$(VERSION)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "VERSION must be plain semver like 1.6.7 (no v prefix, no AniTrack- prefix, no suffix)"; exit 1; }
|
||||
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
||||
@command -v python3 >/dev/null 2>&1 || { echo "python3 not found — required to bump wails.json"; exit 1; }
|
||||
@git diff --quiet && git diff --cached --quiet || { echo "Working tree dirty — commit or stash first"; exit 1; }
|
||||
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
||||
@echo "Bumping wails.json to $(VERSION)..."
|
||||
@python3 -c "import json; p='wails.json'; d=json.load(open(p)); d['info']['productVersion']='$(VERSION)'; json.dump(d, open(p,'w'), indent=2); open(p,'a').write('\n')"
|
||||
@git add wails.json
|
||||
@git commit -m "chore(release): bump version to $(VERSION)"
|
||||
@echo "Generating release notes for $(VERSION)..."
|
||||
@git tag "$(VERSION)" HEAD && \
|
||||
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
||||
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
|
||||
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
|
||||
@git push origin main "$(VERSION)"
|
||||
@echo "Pushed $(VERSION) — Gitea Actions will build, package, and publish AniTrack-$(VERSION).tar.gz."
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# git-cliff configuration — generates the body of each Gitea Release from
|
||||
# Conventional Commits accumulated since the previous tag. Invoked locally by
|
||||
# `make release VERSION=...`; the annotated tag message becomes the Release
|
||||
# body in .gitea/workflows/release.yml. Tags are plain versions (1.6.7).
|
||||
# Docs: https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
header = ""
|
||||
body = """
|
||||
{% for group, commits in commits | group_by(attribute="group") %}\
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}\
|
||||
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
|
||||
{% endfor %}\
|
||||
{% endfor %}\
|
||||
"""
|
||||
trim = true
|
||||
footer = ""
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = false
|
||||
require_conventional = false
|
||||
split_commits = false
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^refactor", group = "Refactor" },
|
||||
{ message = "^docs", group = "Documentation" },
|
||||
{ message = "^test", group = "Tests" },
|
||||
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
|
||||
{ message = ".*", group = "Other" },
|
||||
]
|
||||
filter_commits = false
|
||||
tag_pattern = "[0-9].*"
|
||||
sort_commits = "oldest"
|
||||
@@ -4,6 +4,7 @@
|
||||
aniListLoggedIn,
|
||||
malAnime,
|
||||
malLoggedIn,
|
||||
setApiError,
|
||||
simklAnime,
|
||||
simklLoggedIn,
|
||||
watchlistNeedsRefresh,
|
||||
@@ -207,10 +208,15 @@
|
||||
completedAt: convertDateToAniList(completedAtDate),
|
||||
};
|
||||
await AniListUpdateEntry(body).then((value: AniListGetSingleAnime) => {
|
||||
value.data.MediaList.media.tags =
|
||||
currentAniListAnime.data.MediaList.media.tags;
|
||||
value.data.MediaList.media.genres =
|
||||
currentAniListAnime.data.MediaList.media.genres;
|
||||
if (!value.data?.MediaList || value.data.MediaList.mediaId === 0) {
|
||||
setApiError(
|
||||
"anilist",
|
||||
"AniList update failed: no saved entry was returned",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
aniListAnime.update((newValue) => {
|
||||
newValue = value;
|
||||
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) {
|
||||
let body: MALUploadStatus = {
|
||||
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 (currentSimklAnime.watched_episodes_count !== submitData.episodes) {
|
||||
await SimklSyncEpisodes(currentSimklAnime, submitData.episodes).then(
|
||||
@@ -359,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 () => {
|
||||
@@ -389,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({
|
||||
@@ -404,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({
|
||||
@@ -420,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;
|
||||
|
||||
@@ -5,23 +5,128 @@
|
||||
import {push} from "svelte-spa-router";
|
||||
|
||||
let aniSearch = ""
|
||||
let aniListSearch: AniSearchList
|
||||
let aniListSearchActive = false
|
||||
let aniListSearch: AniSearchList | null = null
|
||||
let dropdownOpen = false
|
||||
let isSearching = false
|
||||
let showSlowNotice = false
|
||||
let searchError: string | null = null
|
||||
let hasSearched = false
|
||||
let searchRequestId = 0
|
||||
|
||||
function runAniListSearch(): void {
|
||||
AniListSearch(aniSearch).then(result => {
|
||||
aniListSearch = result
|
||||
aniListSearchActive = true
|
||||
})
|
||||
const SLOW_NOTICE_MS = 8000
|
||||
const SEARCH_TIMEOUT_MS = 30000
|
||||
|
||||
function openDropdown(): void {
|
||||
dropdownOpen = true
|
||||
}
|
||||
|
||||
function searchDropdown(): void {
|
||||
let dropdown = document.querySelector("#aniListSearchDropdown")
|
||||
dropdown.classList.toggle("hidden")
|
||||
function closeDropdown(): void {
|
||||
dropdownOpen = false
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<svelte:window on:click={handleWindowClick} />
|
||||
|
||||
<div id="searchDropdown" class="relative w-64 md:w-48">
|
||||
<div class="flex">
|
||||
@@ -33,16 +138,20 @@
|
||||
placeholder="Search for Anime"
|
||||
on:keypress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
searchDropdown()
|
||||
if(aniSearch.length > 0) runAniListSearch()
|
||||
runAniListSearch()
|
||||
}
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
closeDropdown()
|
||||
}
|
||||
}}
|
||||
required/>
|
||||
<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={() => {
|
||||
searchDropdown()
|
||||
if(aniSearch.length > 0) runAniListSearch()
|
||||
runAniListSearch()
|
||||
}}>
|
||||
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 20 20">
|
||||
@@ -54,31 +163,38 @@
|
||||
</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">
|
||||
{#if aniListSearchActive}
|
||||
<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 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"
|
||||
aria-labelledby="aniListSearchButton">
|
||||
{#each aniListSearch.data.Page.media as media}
|
||||
{#each aniListSearch.data.Page.media as media (media.id)}
|
||||
<li class="w-full">
|
||||
<div class="flex w-full items-start p-1 hover:bg-gray-600 hover:text-white rounded-lg">
|
||||
<button on:click={() => {
|
||||
searchDropdown()
|
||||
push(`#/anime/${media.id}`)
|
||||
}}
|
||||
<button on:click={() => goToAnime(media.id)}
|
||||
>
|
||||
<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">
|
||||
<img class="rounded-bl-lg rounded-tl-lg max-w-24 max-h-24" src={media?.coverImage?.large}
|
||||
alt="{displayTitle(media)} Cover">
|
||||
</button>
|
||||
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => {
|
||||
searchDropdown()
|
||||
push(`#/anime/${media.id}`)
|
||||
}} >{media.title.english === '' || media.title.english === null ? media.title.romaji : media.title.english }</button>
|
||||
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => goToAnime(media.id)} >{displayTitle(media)}</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if aniSearch.length === 0}
|
||||
<div class="m-4">Please enter a search term...</div>
|
||||
{:else if hasSearched}
|
||||
<div class="m-4 text-gray-700 dark:text-gray-200">No results found for "{aniSearch.trim()}". Try a different title.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+161
-15
@@ -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;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env sh
|
||||
# Project-attached wrapper around `make release` so you can run:
|
||||
# ./release 1.6.7 (or) ./release v1.6.7 (or) ./release AniTrack-1.6.7
|
||||
# instead of:
|
||||
# make release VERSION=1.6.7
|
||||
# Lives in the repo (no machine-specific alias needed). Tags are plain
|
||||
# versions (1.6.7) to match wails.json productVersion.
|
||||
set -eu
|
||||
|
||||
[ "$#" -ge 1 ] || { echo "Usage: ./release 1.6.7" >&2; exit 1; }
|
||||
|
||||
# Normalize "v1.6.7" or "AniTrack-1.6.7" down to plain "1.6.7" (the Makefile
|
||||
# rejects anything that is not plain semver, so this just saves typing).
|
||||
VERSION="${1#v}"
|
||||
VERSION="${VERSION#AniTrack-}"
|
||||
|
||||
exec make release "VERSION=${VERSION}"
|
||||
+1
-1
@@ -12,6 +12,6 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "AniTrack",
|
||||
"productVersion": "1.5.5"
|
||||
"productVersion": "1.6.7"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user