Skip to content

Commit 9f3190d

Browse files
authored
Fix #12: Add score normalization for AniList formats (#14)
* Fix #12: Add score normalization for AniList formats - Add GetUserScoreFormat() to retrieve user's AniList score format - Change Score type from float64 to int (always stored as 0-10) - Implement normalizeScoreForMAL() to convert AniList scores to 0-10 - Implement denormalizeScoreForAniList() to convert back to user's format - Support all 5 AniList formats: POINT_100, POINT_10_DECIMAL, POINT_10, POINT_5, POINT_3 - Add comprehensive tests for normalization/denormalization functions - Apply same changes to both anime and manga This fixes MAL API rejecting scores > 10 with '400 invalid score bad_request' * Fix gofumpt formatting * Add fmt command to Makefile for code formatting * Fix golangci-lint issues - Exclude dupl linter for test files in .golangci.yml - Remove unused nolint:dupl directives from app.go * Update CLAUDE.md with testing, formatting, and score normalization info * Refactor: Extract score normalization to separate file - Move score normalization functions to score.go - Move score normalization tests to score_test.go - Add detailed comments explaining intentional code duplication - Remove duplicate code from anime.go and manga.go - Remove duplicate tests from anime_test.go and manga_test.go Addresses Copilot review comments about documenting intentional duplication in fetch functions.
1 parent cdc189c commit 9f3190d

12 files changed

Lines changed: 787 additions & 37 deletions

File tree

.gitignore

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
bin/
2+
anilist-mal-sync
3+
4+
.env
15
.token
26
.token*
3-
bin/
7+
.claude
8+
49
config.yaml
5-
*tmp.json
610
CLAUDE.md
7-
.claude
8-
anilist-mal-sync
11+
12+
*tmp.json
13+
*.log

.golangci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ linters:
5353
- text: "exported: exported"
5454
linters:
5555
- revive
56+
- path: "_test\\.go"
57+
linters:
58+
- dupl
5659
settings:
5760
lll:
5861
line-length: 140

CLAUDE.md

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
This is a Go application that synchronizes anime and manga lists between AniList and MyAnimeList accounts. The application uses OAuth2 authentication to access both services and supports bidirectional synchronization:
8+
- AniList to MyAnimeList (default)
9+
- MyAnimeList to AniList (with `-reverse-direction` flag)
10+
11+
## Architecture
12+
13+
The application follows a modular architecture with the following key components:
14+
15+
- **Main entry point**: `main.go` - handles CLI flags and application initialization
16+
- **Configuration**: `config.go` - manages YAML configuration and environment variables
17+
- **Application core**: `app.go` - coordinates OAuth clients and sync operations
18+
- **API clients**: `anilist.go` and `myanimelist.go` - handle API interactions with respective services
19+
- **Media types**: `anime.go` and `manga.go` - define data structures and transformations
20+
- **Sync logic**: `updater.go` - handles the synchronization process between services
21+
- **OAuth handling**: `oauth.go` - manages OAuth2 authentication flows
22+
- **Statistics**: `statistics.go` - tracks sync operations and results
23+
- **Strategies**: `strategies.go` - implements different sync strategies and entry matching logic
24+
25+
### Core Sync Pattern
26+
27+
The synchronization follows a generic pattern defined in `updater.go`:
28+
- **Source/Target Interface**: Uses `Source` and `Target` interfaces for type-safe operations
29+
- **Updater struct**: Contains function pointers for getting targets by ID/name and updating them
30+
- **Comparison logic**: Implements progress comparison and diff generation between platforms
31+
- **Ignore list**: Supports title-based filtering for entries that don't exist on target platform
32+
- **Force sync**: Optional flag to bypass progress comparison and sync all entries
33+
- **Strategy chain**: Uses configurable strategies for finding and matching entries between platforms
34+
35+
The `App` struct instantiates separate `Updater` instances for anime and manga in both directions (normal and reverse), each with their own function implementations for API operations.
36+
37+
## Common Development Commands
38+
39+
### Building
40+
```bash
41+
# Build the application
42+
make build
43+
44+
# Build using go directly
45+
go build -o anilist-mal-sync
46+
47+
# Build for Docker (used in multi-stage build)
48+
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=vendor -ldflags="-w -s" -o main
49+
```
50+
51+
Note: The Makefile builds from `./cmd/main.go` but the actual main file is in the root directory as `main.go`.
52+
53+
### Testing
54+
```bash
55+
# Run all tests
56+
make test
57+
58+
# Run tests with verbose output
59+
go test ./... -v
60+
61+
# Test files:
62+
# - anime_test.go: Tests for score normalization/denormalization (anime)
63+
# - manga_test.go: Tests for score normalization/denormalization (manga)
64+
```
65+
66+
### Code Formatting
67+
```bash
68+
# Format code with gofumpt
69+
make fmt
70+
71+
# This ensures code follows gofumpt standards required by CI
72+
```
73+
74+
### Linting
75+
```bash
76+
# Run linter using Docker (recommended)
77+
make lint
78+
79+
# Run linter directly (if golangci-lint is installed locally)
80+
golangci-lint run --new
81+
82+
# This uses golangci-lint v1.64.6 in Docker container
83+
# Note: dupl linter is disabled for test files to allow identical test structures
84+
```
85+
86+
### Running
87+
```bash
88+
# Run with default config
89+
go run .
90+
91+
# Run with custom config file
92+
go run . -c myconfig.yaml
93+
94+
# Run with dry run mode (no actual updates)
95+
go run . -d
96+
97+
# Run with verbose logging
98+
go run . -verbose
99+
100+
# Sync manga instead of anime
101+
go run . -manga
102+
103+
# Sync both anime and manga
104+
go run . -all
105+
106+
# Force sync all entries
107+
go run . -f
108+
109+
# Reverse sync direction (MyAnimeList to AniList)
110+
go run . -reverse-direction
111+
```
112+
113+
### Cleanup
114+
```bash
115+
# Clean build artifacts and test cache
116+
make clean
117+
```
118+
119+
### Docker Development
120+
```bash
121+
# Build Docker image
122+
docker build -t anilist-mal-sync .
123+
124+
# Run with pre-built image from GitHub Container Registry
125+
docker pull ghcr.io/bigspawn/anilist-mal-sync:latest
126+
127+
# Run with Docker (requires config and token volumes)
128+
docker run -p 18080:18080 \
129+
-v /path/to/config.yaml:/etc/anilist-mal-sync/config.yaml \
130+
-v /path/to/tokens:/home/appuser/.config/anilist-mal-sync \
131+
ghcr.io/bigspawn/anilist-mal-sync:latest
132+
```
133+
134+
## Configuration
135+
136+
The application uses a YAML configuration file (`config.yaml`) with the following structure:
137+
- OAuth settings (port, redirect URI)
138+
- AniList client credentials and settings
139+
- MyAnimeList client credentials and settings
140+
- Token file path for persistent authentication
141+
142+
Environment variables can override sensitive values:
143+
- `PORT` - OAuth server port
144+
- `CLIENT_SECRET_ANILIST` - AniList client secret
145+
- `CLIENT_SECRET_MYANIMELIST` - MyAnimeList client secret
146+
147+
## Key Dependencies
148+
149+
- `golang.org/x/oauth2` - OAuth2 client implementation
150+
- `gopkg.in/yaml.v2` - YAML configuration parsing
151+
- `github.com/nstratos/go-myanimelist` - MyAnimeList API client
152+
- `github.com/rl404/verniy` - AniList API client
153+
- `github.com/cenkalti/backoff/v4` - Exponential backoff for API retry logic
154+
155+
### Important: Dependency Management
156+
**Always run `go mod vendor` after `go get` to keep the vendor directory in sync with go.mod changes.**
157+
158+
## Authentication Flow
159+
160+
The application implements OAuth2 authentication for both services:
161+
1. Starts a local server on port 18080 (configurable)
162+
2. Opens browser to service authorization URL
163+
3. Handles callback and exchanges code for access token
164+
4. Stores tokens in `~/.config/anilist-mal-sync/token.json`
165+
166+
## Sync Process
167+
168+
1. Fetches user's anime/manga list from AniList
169+
2. Fetches user's anime/manga list from MyAnimeList
170+
3. Normalizes AniList scores to 0-10 format (see Score Normalization below)
171+
4. Compares entries and identifies differences
172+
5. Updates MyAnimeList entries to match AniList status
173+
6. In reverse sync, denormalizes scores back to user's AniList format
174+
7. Provides statistics on sync operations
175+
176+
### Score Normalization
177+
178+
The application handles score format differences between AniList and MyAnimeList:
179+
180+
**AniList Score Formats**:
181+
- `POINT_100` (0-100) - e.g., 85/100
182+
- `POINT_10_DECIMAL` (0-10.0) - e.g., 8.5/10
183+
- `POINT_10` (0-10) - e.g., 8/10
184+
- `POINT_5` (0-5) - e.g., 4/5
185+
- `POINT_3` (1-3) - e.g., 2/3
186+
187+
**MyAnimeList Score Format**:
188+
- Integer 0-10 only
189+
190+
**Implementation**:
191+
- Scores are stored internally as `int` in normalized 0-10 format
192+
- When reading from AniList: scores are normalized to 0-10
193+
- When writing to AniList: scores are denormalized back to user's format
194+
- When reading/writing MAL: no conversion needed (already 0-10)
195+
- Functions: `normalizeScoreForMAL()` and `denormalizeScoreForAniList()` in `anime.go` and `manga.go`
196+
- User's score format is retrieved once at startup via `GetUserScoreFormat()` in `anilist.go`
197+
198+
This architecture prevents MAL API errors when AniList scores exceed 10 (e.g., `400 invalid score bad_request`)
199+
200+
## Docker Support
201+
202+
The application includes Docker support with:
203+
- Multi-stage build using Alpine base image
204+
- Non-root user execution (appuser with UID 10001)
205+
- Volume mounts for configuration and token storage
206+
- Port exposure for OAuth callback (18080)
207+
- Vendored dependencies for faster builds
208+
209+
## Development Notes
210+
211+
### Rate Limiting
212+
Both AniList and MyAnimeList have rate limits. The application may appear to freeze due to API timeouts. If this occurs, stop the application and wait before retrying.
213+
214+
### Entry Matching
215+
The sync process uses two methods to match entries:
216+
1. **By ID**: When AniList entry has a MAL ID reference
217+
2. **By Title**: When no ID is available, searches MAL by title and matches by media type
218+
219+
### Debug Output
220+
Use the `-verbose` flag to enable detailed logging of the sync process, including API calls and comparison results.
221+
222+
### Ignore List
223+
Hard-coded ignore lists in `app.go` skip entries that don't exist on the target platform (e.g., "Scott Pilgrim Takes Off" is not available on MAL).

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build test lint help clean
1+
.PHONY: build test lint fmt help clean
22

33
BINARY_NAME=anilist-mal-sync
44
LINT_VERSION=v1.64.6
@@ -14,6 +14,12 @@ build:
1414
test:
1515
go test ./... -v
1616

17+
# Format code with gofumpt
18+
fmt:
19+
@echo "Formatting code with gofumpt..."
20+
@gofumpt -l -w .
21+
@echo "Formatting complete!"
22+
1723
# Run linter using Docker
1824
lint:
1925
@echo "Running golangci-lint $(LINT_VERSION) in Docker..."
@@ -32,6 +38,7 @@ help:
3238
@echo "Available commands:"
3339
@echo " build - Build the application"
3440
@echo " test - Run tests"
41+
@echo " fmt - Format code with gofumpt"
3542
@echo " lint - Run linter using Docker (golangci-lint $(LINT_VERSION))"
3643
@echo " clean - Remove build artifacts, temporary files and clean test cache"
3744
@echo " help - Show this help message"

anilist.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,34 @@ func retryWithBackoff(ctx context.Context, operation func() error, operationName
448448
},
449449
)
450450
}
451+
452+
// GetUserScoreFormat retrieves the user's score format preference from AniList
453+
func (c *AnilistClient) GetUserScoreFormat(ctx context.Context) (verniy.ScoreFormat, error) {
454+
var result *verniy.User
455+
456+
err := retryWithBackoff(ctx, func() error {
457+
user, e := c.c.GetUserWithContext(ctx, c.username,
458+
verniy.UserFieldMediaListOptions(
459+
verniy.MediaListOptionsFieldScoreFormat,
460+
),
461+
)
462+
if e != nil {
463+
return fmt.Errorf("failed to get user score format: %w", e)
464+
}
465+
result = user
466+
return nil
467+
}, fmt.Sprintf("AniList get user score format: %s", c.username))
468+
if err != nil {
469+
return "", err
470+
}
471+
472+
if result.MediaListOptions == nil {
473+
return "", fmt.Errorf("user media list options is nil")
474+
}
475+
476+
if result.MediaListOptions.ScoreFormat == nil {
477+
return "", fmt.Errorf("user score format is nil")
478+
}
479+
480+
return *result.MediaListOptions.ScoreFormat, nil
481+
}

anime.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ type Anime struct {
6969
IDAnilist int
7070
IDMal int
7171
Progress int
72-
Score float64
72+
Score int
7373
SeasonYear int
7474
Status Status
7575
TitleEN string
@@ -118,7 +118,7 @@ func (a Anime) SameProgressWithTarget(t Target) bool {
118118
return false
119119
}
120120
if a.Score != b.Score {
121-
DPrintf("Score: %f != %f", a.Score, b.Score)
121+
DPrintf("Score: %d != %d", a.Score, b.Score)
122122
return false
123123
}
124124
progress := a.Progress == b.Progress
@@ -224,7 +224,7 @@ func (a Anime) String() string {
224224
sb.WriteString(fmt.Sprintf("TitleEN: %s, ", a.TitleEN))
225225
sb.WriteString(fmt.Sprintf("TitleJP: %s, ", a.TitleJP))
226226
sb.WriteString(fmt.Sprintf("MediaListStatus: %s, ", a.Status))
227-
sb.WriteString(fmt.Sprintf("Score: %f, ", a.Score))
227+
sb.WriteString(fmt.Sprintf("Score: %d, ", a.Score))
228228
sb.WriteString(fmt.Sprintf("Progress: %d, ", a.Progress))
229229
sb.WriteString(fmt.Sprintf("EpisodeNumber: %d, ", a.NumEpisodes))
230230
sb.WriteString(fmt.Sprintf("SeasonYear: %d, ", a.SeasonYear))
@@ -234,11 +234,11 @@ func (a Anime) String() string {
234234
return sb.String()
235235
}
236236

237-
func newAnimesFromMediaListGroups(groups []verniy.MediaListGroup) []Anime {
237+
func newAnimesFromMediaListGroups(groups []verniy.MediaListGroup, scoreFormat verniy.ScoreFormat) []Anime {
238238
res := make([]Anime, 0, len(groups))
239239
for _, group := range groups {
240240
for _, mediaList := range group.Entries {
241-
a, err := newAnimeFromMediaListEntry(mediaList)
241+
a, err := newAnimeFromMediaListEntry(mediaList, scoreFormat)
242242
if err != nil {
243243
log.Printf("Error creating anime from media list entry: %v", err)
244244
continue
@@ -250,7 +250,7 @@ func newAnimesFromMediaListGroups(groups []verniy.MediaListGroup) []Anime {
250250
return res
251251
}
252252

253-
func newAnimeFromMediaListEntry(mediaList verniy.MediaList) (Anime, error) {
253+
func newAnimeFromMediaListEntry(mediaList verniy.MediaList, scoreFormat verniy.ScoreFormat) (Anime, error) {
254254
if mediaList.Media == nil {
255255
return Anime{}, errors.New("media is nil")
256256
}
@@ -263,9 +263,10 @@ func newAnimeFromMediaListEntry(mediaList verniy.MediaList) (Anime, error) {
263263
return Anime{}, errors.New("title is nil")
264264
}
265265

266-
var score float64
266+
var score int
267267
if mediaList.Score != nil {
268-
score = *mediaList.Score
268+
// Normalize AniList score to MAL format (0-10)
269+
score = normalizeScoreForMAL(*mediaList.Score, scoreFormat)
269270
}
270271

271272
var progress int
@@ -381,7 +382,7 @@ func newAnimeFromMalAnime(malAnime mal.Anime) (Anime, error) {
381382
IDAnilist: anilistID,
382383
IDMal: malAnime.ID,
383384
Progress: malAnime.MyListStatus.NumEpisodesWatched,
384-
Score: float64(malAnime.MyListStatus.Score),
385+
Score: malAnime.MyListStatus.Score, // MAL score is already 0-10 int
385386
SeasonYear: malAnime.StartSeason.Year,
386387
Status: mapMalAnimeStatusToStatus(malAnime.MyListStatus.Status),
387388
TitleEN: titleEN,

anime_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package main

0 commit comments

Comments
 (0)