Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
bin/
anilist-mal-sync

.env
.token
.token*
bin/
.claude

config.yaml
*tmp.json
CLAUDE.md
.claude
anilist-mal-sync

*tmp.json
*.log
3 changes: 3 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ linters:
- text: "exported: exported"
linters:
- revive
- path: "_test\\.go"
linters:
- dupl
settings:
lll:
line-length: 140
Expand Down
223 changes: 223 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

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:
- AniList to MyAnimeList (default)
- MyAnimeList to AniList (with `-reverse-direction` flag)

## Architecture

The application follows a modular architecture with the following key components:

- **Main entry point**: `main.go` - handles CLI flags and application initialization
- **Configuration**: `config.go` - manages YAML configuration and environment variables
- **Application core**: `app.go` - coordinates OAuth clients and sync operations
- **API clients**: `anilist.go` and `myanimelist.go` - handle API interactions with respective services
- **Media types**: `anime.go` and `manga.go` - define data structures and transformations
- **Sync logic**: `updater.go` - handles the synchronization process between services
- **OAuth handling**: `oauth.go` - manages OAuth2 authentication flows
- **Statistics**: `statistics.go` - tracks sync operations and results
- **Strategies**: `strategies.go` - implements different sync strategies and entry matching logic

### Core Sync Pattern

The synchronization follows a generic pattern defined in `updater.go`:
- **Source/Target Interface**: Uses `Source` and `Target` interfaces for type-safe operations
- **Updater struct**: Contains function pointers for getting targets by ID/name and updating them
- **Comparison logic**: Implements progress comparison and diff generation between platforms
- **Ignore list**: Supports title-based filtering for entries that don't exist on target platform
- **Force sync**: Optional flag to bypass progress comparison and sync all entries
- **Strategy chain**: Uses configurable strategies for finding and matching entries between platforms

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.

## Common Development Commands

### Building
```bash
# Build the application
make build

# Build using go directly
go build -o anilist-mal-sync

# Build for Docker (used in multi-stage build)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=vendor -ldflags="-w -s" -o main
```

Note: The Makefile builds from `./cmd/main.go` but the actual main file is in the root directory as `main.go`.

### Testing
```bash
# Run all tests
make test

# Run tests with verbose output
go test ./... -v

# Test files:
# - anime_test.go: Tests for score normalization/denormalization (anime)
# - manga_test.go: Tests for score normalization/denormalization (manga)
```

### Code Formatting
```bash
# Format code with gofumpt
make fmt

# This ensures code follows gofumpt standards required by CI
```

### Linting
```bash
# Run linter using Docker (recommended)
make lint

# Run linter directly (if golangci-lint is installed locally)
golangci-lint run --new

# This uses golangci-lint v1.64.6 in Docker container
# Note: dupl linter is disabled for test files to allow identical test structures
```

### Running
```bash
# Run with default config
go run .

# Run with custom config file
go run . -c myconfig.yaml

# Run with dry run mode (no actual updates)
go run . -d

# Run with verbose logging
go run . -verbose

# Sync manga instead of anime
go run . -manga

# Sync both anime and manga
go run . -all

# Force sync all entries
go run . -f

# Reverse sync direction (MyAnimeList to AniList)
go run . -reverse-direction
```

### Cleanup
```bash
# Clean build artifacts and test cache
make clean
```

### Docker Development
```bash
# Build Docker image
docker build -t anilist-mal-sync .

# Run with pre-built image from GitHub Container Registry
docker pull ghcr.io/bigspawn/anilist-mal-sync:latest

# Run with Docker (requires config and token volumes)
docker run -p 18080:18080 \
-v /path/to/config.yaml:/etc/anilist-mal-sync/config.yaml \
-v /path/to/tokens:/home/appuser/.config/anilist-mal-sync \
ghcr.io/bigspawn/anilist-mal-sync:latest
```

## Configuration

The application uses a YAML configuration file (`config.yaml`) with the following structure:
- OAuth settings (port, redirect URI)
- AniList client credentials and settings
- MyAnimeList client credentials and settings
- Token file path for persistent authentication

Environment variables can override sensitive values:
- `PORT` - OAuth server port
- `CLIENT_SECRET_ANILIST` - AniList client secret
- `CLIENT_SECRET_MYANIMELIST` - MyAnimeList client secret

## Key Dependencies

- `golang.org/x/oauth2` - OAuth2 client implementation
- `gopkg.in/yaml.v2` - YAML configuration parsing
- `github.com/nstratos/go-myanimelist` - MyAnimeList API client
- `github.com/rl404/verniy` - AniList API client
- `github.com/cenkalti/backoff/v4` - Exponential backoff for API retry logic

### Important: Dependency Management
**Always run `go mod vendor` after `go get` to keep the vendor directory in sync with go.mod changes.**

## Authentication Flow

The application implements OAuth2 authentication for both services:
1. Starts a local server on port 18080 (configurable)
2. Opens browser to service authorization URL
3. Handles callback and exchanges code for access token
4. Stores tokens in `~/.config/anilist-mal-sync/token.json`

## Sync Process

1. Fetches user's anime/manga list from AniList
2. Fetches user's anime/manga list from MyAnimeList
3. Normalizes AniList scores to 0-10 format (see Score Normalization below)
4. Compares entries and identifies differences
5. Updates MyAnimeList entries to match AniList status
6. In reverse sync, denormalizes scores back to user's AniList format
7. Provides statistics on sync operations

### Score Normalization

The application handles score format differences between AniList and MyAnimeList:

**AniList Score Formats**:
- `POINT_100` (0-100) - e.g., 85/100
- `POINT_10_DECIMAL` (0-10.0) - e.g., 8.5/10
- `POINT_10` (0-10) - e.g., 8/10
- `POINT_5` (0-5) - e.g., 4/5
- `POINT_3` (1-3) - e.g., 2/3

**MyAnimeList Score Format**:
- Integer 0-10 only

**Implementation**:
- Scores are stored internally as `int` in normalized 0-10 format
- When reading from AniList: scores are normalized to 0-10
- When writing to AniList: scores are denormalized back to user's format
- When reading/writing MAL: no conversion needed (already 0-10)
- Functions: `normalizeScoreForMAL()` and `denormalizeScoreForAniList()` in `anime.go` and `manga.go`
- User's score format is retrieved once at startup via `GetUserScoreFormat()` in `anilist.go`

This architecture prevents MAL API errors when AniList scores exceed 10 (e.g., `400 invalid score bad_request`)

## Docker Support

The application includes Docker support with:
- Multi-stage build using Alpine base image
- Non-root user execution (appuser with UID 10001)
- Volume mounts for configuration and token storage
- Port exposure for OAuth callback (18080)
- Vendored dependencies for faster builds

## Development Notes

### Rate Limiting
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.

### Entry Matching
The sync process uses two methods to match entries:
1. **By ID**: When AniList entry has a MAL ID reference
2. **By Title**: When no ID is available, searches MAL by title and matches by media type

### Debug Output
Use the `-verbose` flag to enable detailed logging of the sync process, including API calls and comparison results.

### Ignore List
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).
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test lint help clean
.PHONY: build test lint fmt help clean

BINARY_NAME=anilist-mal-sync
LINT_VERSION=v1.64.6
Expand All @@ -14,6 +14,12 @@ build:
test:
go test ./... -v

# Format code with gofumpt
fmt:
@echo "Formatting code with gofumpt..."
@gofumpt -l -w .
@echo "Formatting complete!"

# Run linter using Docker
lint:
@echo "Running golangci-lint $(LINT_VERSION) in Docker..."
Expand All @@ -32,6 +38,7 @@ help:
@echo "Available commands:"
@echo " build - Build the application"
@echo " test - Run tests"
@echo " fmt - Format code with gofumpt"
@echo " lint - Run linter using Docker (golangci-lint $(LINT_VERSION))"
@echo " clean - Remove build artifacts, temporary files and clean test cache"
@echo " help - Show this help message"
31 changes: 31 additions & 0 deletions anilist.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,34 @@ func retryWithBackoff(ctx context.Context, operation func() error, operationName
},
)
}

// GetUserScoreFormat retrieves the user's score format preference from AniList
func (c *AnilistClient) GetUserScoreFormat(ctx context.Context) (verniy.ScoreFormat, error) {
var result *verniy.User

err := retryWithBackoff(ctx, func() error {
user, e := c.c.GetUserWithContext(ctx, c.username,
verniy.UserFieldMediaListOptions(
verniy.MediaListOptionsFieldScoreFormat,
),
)
if e != nil {
return fmt.Errorf("failed to get user score format: %w", e)
}
result = user
return nil
}, fmt.Sprintf("AniList get user score format: %s", c.username))
if err != nil {
return "", err
}

if result.MediaListOptions == nil {
return "", fmt.Errorf("user media list options is nil")
}

if result.MediaListOptions.ScoreFormat == nil {
return "", fmt.Errorf("user score format is nil")
}

return *result.MediaListOptions.ScoreFormat, nil
}
19 changes: 10 additions & 9 deletions anime.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ type Anime struct {
IDAnilist int
IDMal int
Progress int
Score float64
Score int
SeasonYear int
Status Status
TitleEN string
Expand Down Expand Up @@ -118,7 +118,7 @@ func (a Anime) SameProgressWithTarget(t Target) bool {
return false
}
if a.Score != b.Score {
DPrintf("Score: %f != %f", a.Score, b.Score)
DPrintf("Score: %d != %d", a.Score, b.Score)
return false
}
progress := a.Progress == b.Progress
Expand Down Expand Up @@ -224,7 +224,7 @@ func (a Anime) String() string {
sb.WriteString(fmt.Sprintf("TitleEN: %s, ", a.TitleEN))
sb.WriteString(fmt.Sprintf("TitleJP: %s, ", a.TitleJP))
sb.WriteString(fmt.Sprintf("MediaListStatus: %s, ", a.Status))
sb.WriteString(fmt.Sprintf("Score: %f, ", a.Score))
sb.WriteString(fmt.Sprintf("Score: %d, ", a.Score))
sb.WriteString(fmt.Sprintf("Progress: %d, ", a.Progress))
sb.WriteString(fmt.Sprintf("EpisodeNumber: %d, ", a.NumEpisodes))
sb.WriteString(fmt.Sprintf("SeasonYear: %d, ", a.SeasonYear))
Expand All @@ -234,11 +234,11 @@ func (a Anime) String() string {
return sb.String()
}

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

func newAnimeFromMediaListEntry(mediaList verniy.MediaList) (Anime, error) {
func newAnimeFromMediaListEntry(mediaList verniy.MediaList, scoreFormat verniy.ScoreFormat) (Anime, error) {
if mediaList.Media == nil {
return Anime{}, errors.New("media is nil")
}
Expand All @@ -263,9 +263,10 @@ func newAnimeFromMediaListEntry(mediaList verniy.MediaList) (Anime, error) {
return Anime{}, errors.New("title is nil")
}

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

var progress int
Expand Down Expand Up @@ -381,7 +382,7 @@ func newAnimeFromMalAnime(malAnime mal.Anime) (Anime, error) {
IDAnilist: anilistID,
IDMal: malAnime.ID,
Progress: malAnime.MyListStatus.NumEpisodesWatched,
Score: float64(malAnime.MyListStatus.Score),
Score: malAnime.MyListStatus.Score, // MAL score is already 0-10 int
SeasonYear: malAnime.StartSeason.Year,
Status: mapMalAnimeStatusToStatus(malAnime.MyListStatus.Status),
TitleEN: titleEN,
Expand Down
1 change: 1 addition & 0 deletions anime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package main
Loading