Compare commits

..
14 Commits
Author SHA1 Message Date
John O'Keefe 336a1f353f chore(release): bump version to 1.6.8
Release / release (push) Canceled after 20s
2026-09-12 21:27:12 -04:00
John O'Keefe a19080ef39 Revert "docs: add Wails v3 + Android migration plan"
The migration plan belongs to the wailsv3 branch, not main. It was merged forward into wailsv3 (now at origin/wailsv3) before this revert, so no content is lost. Main keeps only the v2 keyring error-handling fix.
2026-09-12 21:26:25 -04:00
John O'Keefe 2bddc6a224 docs: add Wails v3 + Android migration plan
Skimmable phased plan for getting AniTrack onto Android for personal
sideload use (no Play Store, no official F-Droid). Written for both the
maintainer and as working context for AI-assisted implementation.

Covers: desktop v3 parity as a mechanical step (main.go, runtime call
mapping, wailsjs import rewrite), per-file Android storage shims
(keyring on desktop vs EncryptedSharedPreferences via
Android.Secure*), the localhost :6734 to anitrack:// deep-link OAuth
redesign with per-provider dashboard changes, and the sideload run/
package commands plus Obtainium updates. Includes guardrails: small
diffs, per-service file ownership, build-tagged platform files only,
runnable check per phase, and open decisions (first provider, desktop
parallel builds, redirect URI registration).
2026-09-12 21:23:14 -04:00
John O'Keefe 3bf5d8290e fix(auth): handle keyring open/set errors per service
Stop ignoring keyring.Open failures in all three login files. A failed
Open previously left a nil ring behind the blank identifier, so the next
Get/Set panicked with no message. Each service now opens its ring in
init, logs a service-prefixed warning when storage is unavailable, and
guards every use with a Ready check that fails safe to logged-out.

- AniListUserFunctions.go: add aniRingReady/aniRingSet helpers, log
  Open and per-key Set failures, reject invalid ExpiresIn instead of
  silently storing 0, clear in-memory JWT on logout even when storage
  is missing
- MALUserFunctions.go: add malRingReady/malRingSet helpers covering
  login, OAuth callback, and token-refresh saves; same Open/Set/
  ExpiresIn/logout treatment
- SimklUserFunctions.go: add simklRingReady/simklRingSet helpers;
  same Open/Set/logout treatment

No wallet, key names, or login flow changed. Same ServiceName
AniTrack, same keys, same OAuth callback behavior.
2026-09-12 21:22:58 -04:00
John O'Keefe a5da544dd7 chore(release): bump version to 1.6.7
Release / release (push) Successful in 11m52s
2026-09-11 16:22:45 -04:00
John O'Keefe 270faeb507 feat(ci): automate Linux release builds and Gitea releases
Release / release (push) Successful in 13m50s
Local flow is now ./release 1.6.7 (or make release VERSION=1.6.7):
bump wails.json productVersion, commit the bump, generate git-cliff
notes into an annotated plain-version tag, and push commit plus tag.
The tag push fires the new Gitea Actions workflow.

- cliff.toml: git-cliff groups matching Bookhoard, tag_pattern for
  plain versions so --latest scopes correctly.
- Makefile release target: strict plain-semver validation, dirty-tree
  and existing-tag guards, python3 wails.json bump committed as
  chore(release), throwaway-tag notes generation, push of main + tag.
- release: wrapper normalizing v/AniTrack- prefixes to plain versions.
- .gitea/workflows/release.yml: version guard (wails.json vs tag,
  base-match for -suffix pre-releases), Go 1.25 / Node 20 / Wails
  v2.15.0 + webkit2_41 system deps, environment.go from Actions
  secrets, make build, AniTrack-<version>.tar.gz packaged from
  build/ (bin, icon, desktop, install script, README), idempotent
  Release create/update named AniTrack-<version> with archive
  attached via REGISTRY_TOKEN PAT.
2026-09-11 16:00:37 -04:00
John O'Keefe abba1803fc chore(release): bump version to 1.6.6
Update productVersion in wails.json from 1.6.5 to 1.6.6 to accompany
the MAL token variable fix and the authenticated AniList search fix.
2026-09-11 15:23:43 -04:00
John O'Keefe ce35a20eba fix(anilist): send auth token on search when logged in
AniListSearch hardcoded false for the authenticated flag to
AniListQuery, forcing anonymous searches even with a valid AniList
session. Pass a.CheckIfAniListLoggedIn() instead so the stored token
is attached when available, enabling user-specific data and
authenticated rate limits while keeping anonymous search as fallback.
2026-09-11 15:23:40 -04:00
John O'Keefe e5ce250392 fix(mal): correct swapped access and refresh token variables
In CheckIfMyAnimeListLoggedIn and MyAnimeListLogin the keyring lookups
assigned MyAnimeListAccessToken to refreshToken and
MyAnimeListRefreshToken to accessToken. Downstream code checks
len(accessToken.Data) for login state and assigns
accessToken.Data -> myAnimeListJwt.AccessToken, so the swap gated
login on the refresh token and stored each token in the wrong field.

Swap the variable names to match their keyring keys so login detection,
JWT restoration, and the empty-token fallback in MyAnimeListLogin use
the correct values.
2026-09-11 15:23:29 -04:00
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
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
13 changed files with 1167 additions and 302 deletions
+251
View File
@@ -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}"
+133 -20
View File
@@ -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,6 +637,10 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
startedAt: $startedAt
completedAt: $completedAt
) {
id
mediaId
userId
media {
id
idMal
title {
@@ -661,7 +718,6 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
}
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(
+39 -36
View File
@@ -66,12 +66,7 @@ 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"`
Title MediaTitle `json:"title"`
Description string `json:"description"`
CoverImage struct {
ExtraLarge string
@@ -95,41 +90,15 @@ type Media struct {
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
}
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
}
}
AiringSchedule MediaAiringSchedule `json:"airingSchedule"`
Genres []string `json:"genres"`
Tags []struct {
Id int `json:"id"`
@@ -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"`
+55 -20
View File
@@ -19,18 +19,48 @@ import (
var aniListJwt AniListJWT
var aniRing, _ = keyring.Open(keyring.Config{
var aniRing keyring.Keyring
func init() {
var err error
aniRing, err = keyring.Open(keyring.Config{
ServiceName: "AniTrack",
KeychainName: "AniTrack",
KeychainSynchronizable: false,
KeychainTrustApplication: true,
KeychainAccessibleWhenUnlocked: true,
})
if err != nil {
log.Printf("anilist: secure storage unavailable: %s", err)
}
}
func aniRingReady() bool {
if aniRing == nil {
log.Println("anilist: secure storage unavailable")
return false
}
return true
}
func aniRingSet(key string, data []byte) error {
if !aniRingReady() {
return errors.New("anilist: secure storage unavailable")
}
if err := aniRing.Set(keyring.Item{Key: key, Data: data}); err != nil {
log.Printf("anilist: save %s failed: %s", key, err)
return err
}
return nil
}
var aniCtxShutdown, aniCancel = context.WithCancel(context.Background())
func (a *App) CheckIfAniListLoggedIn() bool {
if (AniListJWT{} == aniListJwt) {
if !aniRingReady() {
return false
}
tokenType, tokenErr := aniRing.Get("anilistTokenType")
expiresIn, expiresInErr := aniRing.Get("anilistTokenExpiresIn")
refreshToken, refreshTokenErr := aniRing.Get("anilistRefreshToken")
@@ -38,10 +68,15 @@ func (a *App) CheckIfAniListLoggedIn() bool {
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
return false
} else {
var expiresInConvertErr error
aniListJwt.TokenType = string(tokenType.Data)
aniListJwt.AccessToken = string(accessToken.Data)
aniListJwt.RefreshToken = string(refreshToken.Data)
aniListJwt.ExpiresIn, _ = strconv.Atoi(string(expiresIn.Data))
aniListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
if expiresInConvertErr != nil {
log.Printf("anilist: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
return false
}
return true
}
} else {
@@ -51,6 +86,10 @@ func (a *App) CheckIfAniListLoggedIn() bool {
func (a *App) AniListLogin() {
if (AniListJWT{} == aniListJwt) {
if !aniRingReady() {
log.Println("anilist: cannot check login, secure storage unavailable")
return
}
tokenType, tokenErr := aniRing.Get("anilistTokenType")
expiresIn, expiresInErr := aniRing.Get("anilistTokenExpiresIn")
refreshToken, refreshTokenErr := aniRing.Get("anilistRefreshToken")
@@ -64,10 +103,14 @@ func (a *App) AniListLogin() {
a.handleAniListCallback(serverDone)
serverDone.Wait()
} else {
var expiresInConvertErr error
aniListJwt.TokenType = string(tokenType.Data)
aniListJwt.AccessToken = string(accessToken.Data)
aniListJwt.RefreshToken = string(refreshToken.Data)
aniListJwt.ExpiresIn, _ = strconv.Atoi(string(expiresIn.Data))
aniListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
if expiresInConvertErr != nil {
log.Printf("anilist: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
}
}
}
}
@@ -85,22 +128,10 @@ func (a *App) handleAniListCallback(wg *sync.WaitGroup) {
content := r.FormValue("code")
if content != "" {
aniListJwt = getAniListAuthorizationToken(content)
_ = aniRing.Set(keyring.Item{
Key: "anilistTokenType",
Data: []byte(aniListJwt.TokenType),
})
_ = aniRing.Set(keyring.Item{
Key: "anilistTokenExpiresIn",
Data: []byte(strconv.Itoa(aniListJwt.ExpiresIn)),
})
_ = aniRing.Set(keyring.Item{
Key: "anilistAccessToken",
Data: []byte(aniListJwt.AccessToken),
})
_ = aniRing.Set(keyring.Item{
Key: "anilistRefreshToken",
Data: []byte(aniListJwt.RefreshToken),
})
_ = aniRingSet("anilistTokenType", []byte(aniListJwt.TokenType))
_ = aniRingSet("anilistTokenExpiresIn", []byte(strconv.Itoa(aniListJwt.ExpiresIn)))
_ = aniRingSet("anilistAccessToken", []byte(aniListJwt.AccessToken))
_ = aniRingSet("anilistRefreshToken", []byte(aniListJwt.RefreshToken))
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
Title: "AniList Authorization",
Message: "It is now safe to close your browser tab",
@@ -208,12 +239,16 @@ func (a *App) GetAniListLoggedInUser() AniListUser {
func (a *App) LogoutAniList() string {
if (AniListJWT{} != aniListJwt) {
if !aniRingReady() {
aniListJwt = AniListJWT{}
return "AniList Logged Out Successfully"
}
typeErr := aniRing.Remove("anilistTokenType")
expiresInErr := aniRing.Remove("anilistTokenExpiresIn")
accessTokenErr := aniRing.Remove("anilistAccessToken")
refreshTokenErr := aniRing.Remove("anilistRefreshToken")
if typeErr != nil || expiresInErr != nil || accessTokenErr != nil || refreshTokenErr != nil {
fmt.Println("AniList Logout Failed")
log.Printf("anilist: logout cleanup failed (type=%v expires=%v access=%v refresh=%v)", typeErr, expiresInErr, accessTokenErr, refreshTokenErr)
}
aniListJwt = AniListJWT{}
}
+55 -40
View File
@@ -23,13 +23,40 @@ import (
var myAnimeListJwt MyAnimeListJWT
var myAnimeListRing, _ = keyring.Open(keyring.Config{
var myAnimeListRing keyring.Keyring
func init() {
var err error
myAnimeListRing, err = keyring.Open(keyring.Config{
ServiceName: "AniTrack",
KeychainName: "AniTrack",
KeychainSynchronizable: false,
KeychainTrustApplication: true,
KeychainAccessibleWhenUnlocked: true,
})
if err != nil {
log.Printf("mal: secure storage unavailable: %s", err)
}
}
func malRingReady() bool {
if myAnimeListRing == nil {
log.Println("mal: secure storage unavailable")
return false
}
return true
}
func malRingSet(key string, data []byte) error {
if !malRingReady() {
return errors.New("mal: secure storage unavailable")
}
if err := myAnimeListRing.Set(keyring.Item{Key: key, Data: data}); err != nil {
log.Printf("mal: save %s failed: %s", key, err)
return err
}
return nil
}
var myAnimeListCtxShutdown, myAnimeListCancel = context.WithCancel(context.Background())
@@ -72,10 +99,13 @@ func (v *CodeVerifier) CodeChallengeS256() string {
func (a *App) CheckIfMyAnimeListLoggedIn() bool {
if (MyAnimeListJWT{} == myAnimeListJwt) {
if !malRingReady() {
return false
}
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 {
@@ -83,7 +113,8 @@ func (a *App) CheckIfMyAnimeListLoggedIn() bool {
myAnimeListJwt.TokenType = string(tokenType.Data)
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
if expiresInConvertErr != nil {
fmt.Println("unable to convert string to int")
log.Printf("mal: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
return false
}
myAnimeListJwt.AccessToken = string(accessToken.Data)
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
@@ -97,10 +128,14 @@ func (a *App) CheckIfMyAnimeListLoggedIn() bool {
func (a *App) MyAnimeListLogin() {
if !a.CheckIfMyAnimeListLoggedIn() {
fmt.Println("check logged in function failed")
if !malRingReady() {
log.Println("mal: cannot check login, secure storage unavailable")
return
}
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"
@@ -114,7 +149,7 @@ func (a *App) MyAnimeListLogin() {
myAnimeListJwt.TokenType = string(tokenType.Data)
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
if expiresInConvertErr != nil {
fmt.Println("unable to convert string to int in Login function")
log.Printf("mal: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
}
myAnimeListJwt.AccessToken = string(accessToken.Data)
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
@@ -136,22 +171,10 @@ func (a *App) handleMyAnimeListCallback(wg *sync.WaitGroup, verifier *CodeVerifi
if content != "" {
myAnimeListJwt = getMyAnimeListAuthorizationToken(content, verifier)
_ = myAnimeListRing.Set(keyring.Item{
Key: "MyAnimeListTokenType",
Data: []byte(myAnimeListJwt.TokenType),
})
_ = myAnimeListRing.Set(keyring.Item{
Key: "MyAnimeListExpiresIn",
Data: []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)),
})
_ = myAnimeListRing.Set(keyring.Item{
Key: "MyAnimeListAccessToken",
Data: []byte(myAnimeListJwt.AccessToken),
})
_ = myAnimeListRing.Set(keyring.Item{
Key: "MyAnimeListRefreshToken",
Data: []byte(myAnimeListJwt.RefreshToken),
})
_ = malRingSet("MyAnimeListTokenType", []byte(myAnimeListJwt.TokenType))
_ = malRingSet("MyAnimeListExpiresIn", []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)))
_ = malRingSet("MyAnimeListAccessToken", []byte(myAnimeListJwt.AccessToken))
_ = malRingSet("MyAnimeListRefreshToken", []byte(myAnimeListJwt.RefreshToken))
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
Title: "MyAnimeList Authorization",
Message: "It is now safe to close your browser tab",
@@ -292,22 +315,10 @@ func refreshMyAnimeListAuthorizationToken() bool {
myAnimeListJwt = refreshed
_ = myAnimeListRing.Set(keyring.Item{
Key: "MyAnimeListTokenType",
Data: []byte(myAnimeListJwt.TokenType),
})
_ = myAnimeListRing.Set(keyring.Item{
Key: "MyAnimeListExpiresIn",
Data: []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)),
})
_ = myAnimeListRing.Set(keyring.Item{
Key: "MyAnimeListAccessToken",
Data: []byte(myAnimeListJwt.AccessToken),
})
_ = myAnimeListRing.Set(keyring.Item{
Key: "MyAnimeListRefreshToken",
Data: []byte(myAnimeListJwt.RefreshToken),
})
_ = malRingSet("MyAnimeListTokenType", []byte(myAnimeListJwt.TokenType))
_ = malRingSet("MyAnimeListExpiresIn", []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)))
_ = malRingSet("MyAnimeListAccessToken", []byte(myAnimeListJwt.AccessToken))
_ = malRingSet("MyAnimeListRefreshToken", []byte(myAnimeListJwt.RefreshToken))
return true
}
@@ -367,12 +378,16 @@ func createUser() MyAnimeListUser {
func (a *App) LogoutMyAnimeList() string {
if (MyAnimeListJWT{} != myAnimeListJwt) {
if !malRingReady() {
myAnimeListJwt = MyAnimeListJWT{}
return "MAL Logged Out Successfully"
}
typeErr := myAnimeListRing.Remove("MyAnimeListTokenType")
expiresInErr := myAnimeListRing.Remove("MyAnimeListExpiresIn")
accessTokenErr := myAnimeListRing.Remove("MyAnimeListAccessToken")
refreshTokenErr := myAnimeListRing.Remove("MyAnimeListRefreshToken")
if typeErr != nil || expiresInErr != nil || accessTokenErr != nil || refreshTokenErr != nil {
fmt.Println("MAL Logout Failed")
log.Printf("mal: logout cleanup failed (type=%v expires=%v access=%v refresh=%v)", typeErr, expiresInErr, accessTokenErr, refreshTokenErr)
}
myAnimeListJwt = MyAnimeListJWT{}
}
+40 -1
View File
@@ -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."
+43 -14
View File
@@ -17,18 +17,48 @@ import (
var simklJwt SimklJWT
var simklRing, _ = keyring.Open(keyring.Config{
var simklRing keyring.Keyring
func init() {
var err error
simklRing, err = keyring.Open(keyring.Config{
ServiceName: "AniTrack",
KeychainName: "AniTrack",
KeychainSynchronizable: false,
KeychainTrustApplication: true,
KeychainAccessibleWhenUnlocked: true,
})
if err != nil {
log.Printf("simkl: secure storage unavailable: %s", err)
}
}
func simklRingReady() bool {
if simklRing == nil {
log.Println("simkl: secure storage unavailable")
return false
}
return true
}
func simklRingSet(key string, data []byte) error {
if !simklRingReady() {
return errors.New("simkl: secure storage unavailable")
}
if err := simklRing.Set(keyring.Item{Key: key, Data: data}); err != nil {
log.Printf("simkl: save %s failed: %s", key, err)
return err
}
return nil
}
var simklCtxShutdown, simklCancel = context.WithCancel(context.Background())
func (a *App) CheckIfSimklLoggedIn() bool {
if (SimklJWT{} == simklJwt) {
if !simklRingReady() {
return false
}
tokenType, tokenTypeErr := simklRing.Get("SimklTokenType")
accessToken, accessTokenErr := simklRing.Get("SimklAccessToken")
scope, scopeErr := simklRing.Get("SimklScope")
@@ -47,6 +77,10 @@ func (a *App) CheckIfSimklLoggedIn() bool {
func (a *App) SimklLogin() {
if !a.CheckIfSimklLoggedIn() {
if !simklRingReady() {
log.Println("simkl: cannot check login, secure storage unavailable")
return
}
tokenType, tokenTypeErr := simklRing.Get("SimklTokenType")
accessToken, accessTokenErr := simklRing.Get("SimklAccessToken")
scope, scopeErr := simklRing.Get("SimklScope")
@@ -80,18 +114,9 @@ func (a *App) handleSimklCallback(wg *sync.WaitGroup) {
if content != "" {
simklJwt = getSimklAuthorizationToken(content)
_ = simklRing.Set(keyring.Item{
Key: "SimklTokenType",
Data: []byte(simklJwt.TokenType),
})
_ = simklRing.Set(keyring.Item{
Key: "SimklAccessToken",
Data: []byte(simklJwt.AccessToken),
})
_ = simklRing.Set(keyring.Item{
Key: "SimklScope",
Data: []byte(simklJwt.Scope),
})
_ = simklRingSet("SimklTokenType", []byte(simklJwt.TokenType))
_ = simklRingSet("SimklAccessToken", []byte(simklJwt.AccessToken))
_ = simklRingSet("SimklScope", []byte(simklJwt.Scope))
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
Title: "Simkl Authorization",
Message: "It is now safe to close your browser tab",
@@ -218,12 +243,16 @@ func (a *App) GetSimklLoggedInUser() SimklUser {
func (a *App) LogoutSimkl() string {
if (SimklJWT{} != simklJwt) {
if !simklRingReady() {
simklJwt = SimklJWT{}
return "Simkl Logged Out Successfully"
}
tokenTypeErr := simklRing.Remove("SimklTokenType")
accessTokenErr := simklRing.Remove("SimklAccessToken")
scopeErr := simklRing.Remove("SimklScope")
if tokenTypeErr != nil || accessTokenErr != nil || scopeErr != nil {
fmt.Println("Simkl Logout Failed")
log.Printf("simkl: logout cleanup failed (type=%v access=%v scope=%v)", tokenTypeErr, accessTokenErr, scopeErr)
}
simklJwt = SimklJWT{}
}
+37
View File
@@ -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"
+74 -10
View File
@@ -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 {
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 {
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;
+146 -30
View File
@@ -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>
+161 -15
View File
@@ -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;
Executable
+17
View File
@@ -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
View File
@@ -12,6 +12,6 @@
},
"info": {
"productName": "AniTrack",
"productVersion": "1.5.5"
"productVersion": "1.6.8"
}
}