feat: AniList reading status integration

- Add full AniList integration: OAuth connect, series linking, push/pull sync
- Push: PLANNING/CURRENT/COMPLETED based on books read vs total_volumes (never auto-complete from owned books alone)
- Pull: update local reading progress from AniList list (per-user)
- Detailed sync/pull reports with per-series status and progress
- Local user selector in settings to scope sync to a specific user
- Rename "AniList" tab/buttons to generic "État de lecture" / "Reading status"
- Make Bédéthèque and AniList badges clickable links on series detail page
- Fix ON CONFLICT error on series link (provider column in PK)
- Migration 0054: fix series_metadata missing columns (authors, publishers, locked_fields, total_volumes, status)
- Align button heights on series detail page; move MarkSeriesReadButton to action row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 17:08:11 +01:00
parent 2a7881ac6e
commit e94a4a0b13
29 changed files with 2352 additions and 40 deletions

972
apps/api/src/anilist.rs Normal file
View File

@@ -0,0 +1,972 @@
use axum::extract::{Path, State};
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::Row;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::{error::ApiError, state::AppState};
// ─── AniList API client ───────────────────────────────────────────────────────
const ANILIST_API: &str = "https://graphql.anilist.co";
async fn anilist_graphql(
token: &str,
query: &str,
variables: Value,
) -> Result<Value, ApiError> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| ApiError::internal(format!("HTTP client error: {e}")))?;
let body = serde_json::json!({ "query": query, "variables": variables });
let resp = client
.post(ANILIST_API)
.bearer_auth(token)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&body)
.send()
.await
.map_err(|e| ApiError::internal(format!("AniList request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ApiError::internal(format!("AniList returned {status}: {text}")));
}
let data: Value = resp
.json()
.await
.map_err(|e| ApiError::internal(format!("Failed to parse AniList response: {e}")))?;
if let Some(errors) = data.get("errors") {
let msg = errors[0]["message"].as_str().unwrap_or("Unknown AniList error");
return Err(ApiError::internal(format!("AniList API error: {msg}")));
}
Ok(data["data"].clone())
}
/// Load AniList settings from DB: (access_token, anilist_user_id, local_user_id)
async fn load_anilist_settings(pool: &sqlx::PgPool) -> Result<(String, i64, Option<Uuid>), ApiError> {
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'anilist'")
.fetch_optional(pool)
.await?;
let value: Value = row
.ok_or_else(|| ApiError::bad_request("AniList not configured (missing settings)"))?
.get("value");
let token = value["access_token"]
.as_str()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::bad_request("AniList access token not configured"))?
.to_string();
let user_id = value["user_id"]
.as_i64()
.ok_or_else(|| ApiError::bad_request("AniList user_id not configured"))?;
let local_user_id = value["local_user_id"]
.as_str()
.and_then(|s| Uuid::parse_str(s).ok());
Ok((token, user_id, local_user_id))
}
// ─── Types ────────────────────────────────────────────────────────────────────
#[derive(Serialize, ToSchema)]
pub struct AnilistStatusResponse {
pub connected: bool,
pub user_id: i64,
pub username: String,
pub site_url: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct AnilistMediaResult {
pub id: i32,
pub title_romaji: Option<String>,
pub title_english: Option<String>,
pub title_native: Option<String>,
pub site_url: String,
pub status: Option<String>,
pub volumes: Option<i32>,
}
#[derive(Serialize, ToSchema)]
pub struct AnilistSeriesLinkResponse {
#[schema(value_type = String)]
pub library_id: Uuid,
pub series_name: String,
pub anilist_id: i32,
pub anilist_title: Option<String>,
pub anilist_url: Option<String>,
pub status: String,
#[schema(value_type = String)]
pub linked_at: DateTime<Utc>,
#[schema(value_type = Option<String>)]
pub synced_at: Option<DateTime<Utc>>,
}
#[derive(Serialize, ToSchema)]
pub struct AnilistSyncPreviewItem {
pub series_name: String,
pub anilist_id: i32,
pub anilist_title: Option<String>,
pub anilist_url: Option<String>,
/// Status that would be sent to AniList: PLANNING | CURRENT | COMPLETED
pub status: String,
pub progress_volumes: i32,
pub books_read: i64,
pub book_count: i64,
}
#[derive(Serialize, ToSchema)]
pub struct AnilistSyncItem {
pub series_name: String,
pub anilist_title: Option<String>,
pub anilist_url: Option<String>,
/// Status sent to AniList: PLANNING | CURRENT | COMPLETED
pub status: String,
pub progress_volumes: i32,
}
#[derive(Serialize, ToSchema)]
pub struct AnilistSyncReport {
pub synced: i32,
pub skipped: i32,
pub errors: Vec<String>,
pub items: Vec<AnilistSyncItem>,
}
#[derive(Serialize, ToSchema)]
pub struct AnilistPullItem {
pub series_name: String,
pub anilist_title: Option<String>,
pub anilist_url: Option<String>,
/// Status received from AniList: COMPLETED | CURRENT | PLANNING | etc.
pub anilist_status: String,
pub books_updated: i32,
}
#[derive(Serialize, ToSchema)]
pub struct AnilistPullReport {
pub updated: i32,
pub skipped: i32,
pub errors: Vec<String>,
pub items: Vec<AnilistPullItem>,
}
#[derive(Deserialize, ToSchema)]
pub struct AnilistSearchRequest {
pub query: String,
}
#[derive(Deserialize, ToSchema)]
pub struct AnilistLinkRequest {
pub anilist_id: i32,
/// Override display title (optional)
pub title: Option<String>,
/// Override URL (optional)
pub url: Option<String>,
}
#[derive(Deserialize, ToSchema)]
pub struct AnilistLibraryToggleRequest {
pub enabled: bool,
}
// ─── Handlers ─────────────────────────────────────────────────────────────────
/// Test AniList connection and return viewer info
#[utoipa::path(
get,
path = "/anilist/status",
tag = "anilist",
responses(
(status = 200, body = AnilistStatusResponse),
(status = 400, description = "AniList not configured"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_status(
State(state): State<AppState>,
) -> Result<Json<AnilistStatusResponse>, ApiError> {
let (token, _, _) = load_anilist_settings(&state.pool).await?;
let gql = r#"
query Viewer {
Viewer {
id
name
siteUrl
}
}
"#;
let data = anilist_graphql(&token, gql, serde_json::json!({})).await?;
let viewer = &data["Viewer"];
Ok(Json(AnilistStatusResponse {
connected: true,
user_id: viewer["id"].as_i64().unwrap_or(0),
username: viewer["name"].as_str().unwrap_or("").to_string(),
site_url: viewer["siteUrl"].as_str().unwrap_or("").to_string(),
}))
}
/// Search AniList manga by title
#[utoipa::path(
post,
path = "/anilist/search",
tag = "anilist",
request_body = AnilistSearchRequest,
responses(
(status = 200, body = Vec<AnilistMediaResult>),
(status = 400, description = "AniList not configured"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn search_manga(
State(state): State<AppState>,
Json(body): Json<AnilistSearchRequest>,
) -> Result<Json<Vec<AnilistMediaResult>>, ApiError> {
let (token, _, _) = load_anilist_settings(&state.pool).await?;
let gql = r#"
query SearchManga($search: String) {
Page(perPage: 10) {
media(search: $search, type: MANGA) {
id
title { romaji english native }
siteUrl
status
volumes
}
}
}
"#;
let data = anilist_graphql(
&token,
gql,
serde_json::json!({ "search": body.query }),
)
.await?;
let media = data["Page"]["media"]
.as_array()
.cloned()
.unwrap_or_default();
let results: Vec<AnilistMediaResult> = media
.iter()
.map(|m| AnilistMediaResult {
id: m["id"].as_i64().unwrap_or(0) as i32,
title_romaji: m["title"]["romaji"].as_str().map(String::from),
title_english: m["title"]["english"].as_str().map(String::from),
title_native: m["title"]["native"].as_str().map(String::from),
site_url: m["siteUrl"].as_str().unwrap_or("").to_string(),
status: m["status"].as_str().map(String::from),
volumes: m["volumes"].as_i64().map(|v| v as i32),
})
.collect();
Ok(Json(results))
}
/// Get AniList link for a specific series
#[utoipa::path(
get,
path = "/anilist/series/{library_id}/{series_name}",
tag = "anilist",
params(
("library_id" = String, Path, description = "Library UUID"),
("series_name" = String, Path, description = "Series name"),
),
responses(
(status = 200, body = AnilistSeriesLinkResponse),
(status = 404, description = "No AniList link for this series"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_series_link(
State(state): State<AppState>,
Path((library_id, series_name)): Path<(Uuid, String)>,
) -> Result<Json<AnilistSeriesLinkResponse>, ApiError> {
let row = sqlx::query(
"SELECT library_id, series_name, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
FROM anilist_series_links
WHERE library_id = $1 AND series_name = $2",
)
.bind(library_id)
.bind(&series_name)
.fetch_optional(&state.pool)
.await?;
let row = row.ok_or_else(|| ApiError::not_found("no AniList link for this series"))?;
Ok(Json(AnilistSeriesLinkResponse {
library_id: row.get("library_id"),
series_name: row.get("series_name"),
anilist_id: row.get("anilist_id"),
anilist_title: row.get("anilist_title"),
anilist_url: row.get("anilist_url"),
status: row.get("status"),
linked_at: row.get("linked_at"),
synced_at: row.get("synced_at"),
}))
}
/// Link a series to an AniList media ID
#[utoipa::path(
post,
path = "/anilist/series/{library_id}/{series_name}/link",
tag = "anilist",
params(
("library_id" = String, Path, description = "Library UUID"),
("series_name" = String, Path, description = "Series name"),
),
request_body = AnilistLinkRequest,
responses(
(status = 200, body = AnilistSeriesLinkResponse),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn link_series(
State(state): State<AppState>,
Path((library_id, series_name)): Path<(Uuid, String)>,
Json(body): Json<AnilistLinkRequest>,
) -> Result<Json<AnilistSeriesLinkResponse>, ApiError> {
// Try to fetch title/url from AniList if not provided
let (anilist_title, anilist_url) = if body.title.is_some() && body.url.is_some() {
(body.title, body.url)
} else {
// Fetch from AniList
match load_anilist_settings(&state.pool).await {
Ok((token, _, _)) => {
let gql = r#"
query GetMedia($id: Int) {
Media(id: $id, type: MANGA) {
title { romaji english }
siteUrl
}
}
"#;
match anilist_graphql(&token, gql, serde_json::json!({ "id": body.anilist_id })).await {
Ok(data) => {
let title = data["Media"]["title"]["english"]
.as_str()
.or_else(|| data["Media"]["title"]["romaji"].as_str())
.map(String::from);
let url = data["Media"]["siteUrl"].as_str().map(String::from);
(title, url)
}
Err(_) => (body.title, body.url),
}
}
Err(_) => (body.title, body.url),
}
};
let row = sqlx::query(
r#"
INSERT INTO anilist_series_links (library_id, series_name, provider, anilist_id, anilist_title, anilist_url, status, linked_at)
VALUES ($1, $2, 'anilist', $3, $4, $5, 'linked', NOW())
ON CONFLICT (library_id, series_name, provider) DO UPDATE
SET anilist_id = EXCLUDED.anilist_id,
anilist_title = EXCLUDED.anilist_title,
anilist_url = EXCLUDED.anilist_url,
status = 'linked',
linked_at = NOW(),
synced_at = NULL
RETURNING library_id, series_name, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
"#,
)
.bind(library_id)
.bind(&series_name)
.bind(body.anilist_id)
.bind(&anilist_title)
.bind(&anilist_url)
.fetch_one(&state.pool)
.await?;
Ok(Json(AnilistSeriesLinkResponse {
library_id: row.get("library_id"),
series_name: row.get("series_name"),
anilist_id: row.get("anilist_id"),
anilist_title: row.get("anilist_title"),
anilist_url: row.get("anilist_url"),
status: row.get("status"),
linked_at: row.get("linked_at"),
synced_at: row.get("synced_at"),
}))
}
/// Remove the AniList link for a series
#[utoipa::path(
delete,
path = "/anilist/series/{library_id}/{series_name}/unlink",
tag = "anilist",
params(
("library_id" = String, Path, description = "Library UUID"),
("series_name" = String, Path, description = "Series name"),
),
responses(
(status = 200, description = "Unlinked"),
(status = 404, description = "Link not found"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn unlink_series(
State(state): State<AppState>,
Path((library_id, series_name)): Path<(Uuid, String)>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query(
"DELETE FROM anilist_series_links WHERE library_id = $1 AND series_name = $2",
)
.bind(library_id)
.bind(&series_name)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("AniList link not found"));
}
Ok(Json(serde_json::json!({"unlinked": true})))
}
/// Toggle AniList sync for a library
#[utoipa::path(
patch,
path = "/anilist/libraries/{id}",
tag = "anilist",
params(("id" = String, Path, description = "Library UUID")),
request_body = AnilistLibraryToggleRequest,
responses(
(status = 200, description = "Updated"),
(status = 404, description = "Library not found"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn toggle_library(
State(state): State<AppState>,
Path(library_id): Path<Uuid>,
Json(body): Json<AnilistLibraryToggleRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let provider: Option<&str> = if body.enabled { Some("anilist") } else { None };
let result = sqlx::query("UPDATE libraries SET reading_status_provider = $2 WHERE id = $1")
.bind(library_id)
.bind(provider)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("library not found"));
}
Ok(Json(serde_json::json!({ "library_id": library_id, "reading_status_provider": provider })))
}
/// List series from AniList-enabled libraries that are not yet linked
#[utoipa::path(
get,
path = "/anilist/unlinked",
tag = "anilist",
responses(
(status = 200, description = "List of unlinked series"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn list_unlinked(
State(state): State<AppState>,
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
let rows = sqlx::query(
r#"
SELECT
l.id AS library_id,
l.name AS library_name,
COALESCE(NULLIF(b.series, ''), 'unclassified') AS series_name
FROM books b
JOIN libraries l ON l.id = b.library_id
LEFT JOIN anilist_series_links asl
ON asl.library_id = b.library_id
AND asl.series_name = COALESCE(NULLIF(b.series, ''), 'unclassified')
WHERE l.reading_status_provider = 'anilist'
AND asl.library_id IS NULL
GROUP BY l.id, l.name, COALESCE(NULLIF(b.series, ''), 'unclassified')
ORDER BY l.name, series_name
"#,
)
.fetch_all(&state.pool)
.await?;
let items: Vec<serde_json::Value> = rows
.iter()
.map(|row| {
let library_id: Uuid = row.get("library_id");
serde_json::json!({
"library_id": library_id,
"library_name": row.get::<String, _>("library_name"),
"series_name": row.get::<String, _>("series_name"),
})
})
.collect();
Ok(Json(items))
}
/// Preview what would be synced to AniList (dry-run, no writes)
#[utoipa::path(
get,
path = "/anilist/sync/preview",
tag = "anilist",
responses(
(status = 200, body = Vec<AnilistSyncPreviewItem>),
(status = 400, description = "AniList not configured"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn preview_sync(
State(state): State<AppState>,
) -> Result<Json<Vec<AnilistSyncPreviewItem>>, ApiError> {
let (_, _, local_user_id) = load_anilist_settings(&state.pool).await?;
let local_user_id = local_user_id
.ok_or_else(|| ApiError::bad_request("AniList local user not configured — please select a user in settings"))?;
let links = sqlx::query(
r#"
SELECT asl.library_id, asl.series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
FROM anilist_series_links asl
JOIN libraries l ON l.id = asl.library_id
WHERE l.reading_status_provider = 'anilist'
ORDER BY l.name, asl.series_name
"#,
)
.fetch_all(&state.pool)
.await?;
let mut items: Vec<AnilistSyncPreviewItem> = Vec::new();
for link in &links {
let library_id: Uuid = link.get("library_id");
let series_name: String = link.get("series_name");
let anilist_id: i32 = link.get("anilist_id");
let anilist_title: Option<String> = link.get("anilist_title");
let anilist_url: Option<String> = link.get("anilist_url");
let stats = sqlx::query(
r#"
SELECT
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read,
sm.total_volumes
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.series_name = $2
WHERE b.library_id = $1 AND COALESCE(NULLIF(b.series, ''), 'unclassified') = $2
GROUP BY sm.total_volumes
"#,
)
.bind(library_id)
.bind(&series_name)
.bind(local_user_id)
.fetch_one(&state.pool)
.await;
let (book_count, books_read, total_volumes) = match stats {
Ok(row) => {
let bc: i64 = row.get("book_count");
let br: i64 = row.get("books_read");
let tv: Option<i32> = row.get("total_volumes");
(bc, br, tv)
}
Err(_) => continue,
};
if book_count == 0 {
continue;
}
let (status, progress_volumes) = if books_read > 0 && total_volumes.is_some_and(|tv| books_read >= tv as i64) {
("COMPLETED".to_string(), books_read as i32)
} else if books_read > 0 {
("CURRENT".to_string(), books_read as i32)
} else {
("PLANNING".to_string(), 0i32)
};
items.push(AnilistSyncPreviewItem {
series_name,
anilist_id,
anilist_title,
anilist_url,
status,
progress_volumes,
books_read,
book_count,
});
}
Ok(Json(items))
}
/// Sync local reading progress to AniList for all enabled libraries
#[utoipa::path(
post,
path = "/anilist/sync",
tag = "anilist",
responses(
(status = 200, body = AnilistSyncReport),
(status = 400, description = "AniList not configured"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn sync_to_anilist(
State(state): State<AppState>,
) -> Result<Json<AnilistSyncReport>, ApiError> {
let (token, _, local_user_id) = load_anilist_settings(&state.pool).await?;
let local_user_id = local_user_id
.ok_or_else(|| ApiError::bad_request("AniList local user not configured — please select a user in settings"))?;
// Get all series that have AniList links in enabled libraries
let links = sqlx::query(
r#"
SELECT asl.library_id, asl.series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
FROM anilist_series_links asl
JOIN libraries l ON l.id = asl.library_id
WHERE l.reading_status_provider = 'anilist'
"#,
)
.fetch_all(&state.pool)
.await?;
let mut synced = 0i32;
let mut skipped = 0i32;
let mut errors: Vec<String> = Vec::new();
let mut items: Vec<AnilistSyncItem> = Vec::new();
let gql_update = r#"
mutation SaveEntry($mediaId: Int, $status: MediaListStatus, $progressVolumes: Int) {
SaveMediaListEntry(mediaId: $mediaId, status: $status, progressVolumes: $progressVolumes) {
id
status
progressVolumes
}
}
"#;
for link in &links {
let library_id: Uuid = link.get("library_id");
let series_name: String = link.get("series_name");
let anilist_id: i32 = link.get("anilist_id");
let anilist_title: Option<String> = link.get("anilist_title");
let anilist_url: Option<String> = link.get("anilist_url");
// Get reading progress + total_volumes from series metadata
let stats = sqlx::query(
r#"
SELECT
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read,
sm.total_volumes
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.series_name = $2
WHERE b.library_id = $1 AND COALESCE(NULLIF(b.series, ''), 'unclassified') = $2
GROUP BY sm.total_volumes
"#,
)
.bind(library_id)
.bind(&series_name)
.bind(local_user_id)
.fetch_one(&state.pool)
.await;
let (book_count, books_read, total_volumes) = match stats {
Ok(row) => {
let bc: i64 = row.get("book_count");
let br: i64 = row.get("books_read");
let tv: Option<i32> = row.get("total_volumes");
(bc, br, tv)
}
Err(e) => {
errors.push(format!("{series_name}: DB error: {e}"));
continue;
}
};
// COMPLETED only if books_read reaches the known total_volumes
// — never auto-complete based solely on owned books
let (status, progress_volumes) = if book_count == 0 {
skipped += 1;
continue;
} else if books_read > 0 && total_volumes.is_some_and(|tv| books_read >= tv as i64) {
("COMPLETED", books_read as i32)
} else if books_read > 0 {
("CURRENT", books_read as i32)
} else {
("PLANNING", 0i32)
};
let vars = serde_json::json!({
"mediaId": anilist_id,
"status": status,
"progressVolumes": progress_volumes,
});
match anilist_graphql(&token, gql_update, vars).await {
Ok(_) => {
// Update synced_at
let _ = sqlx::query(
"UPDATE anilist_series_links SET status = 'synced', synced_at = NOW() WHERE library_id = $1 AND series_name = $2",
)
.bind(library_id)
.bind(&series_name)
.execute(&state.pool)
.await;
items.push(AnilistSyncItem {
series_name: series_name.clone(),
anilist_title,
anilist_url,
status: status.to_string(),
progress_volumes,
});
synced += 1;
}
Err(e) => {
let _ = sqlx::query(
"UPDATE anilist_series_links SET status = 'error' WHERE library_id = $1 AND series_name = $2",
)
.bind(library_id)
.bind(&series_name)
.execute(&state.pool)
.await;
errors.push(format!("{series_name}: {}", e.message));
}
}
}
Ok(Json(AnilistSyncReport { synced, skipped, errors, items }))
}
/// Pull reading list from AniList and update local reading progress
#[utoipa::path(
post,
path = "/anilist/pull",
tag = "anilist",
responses(
(status = 200, body = AnilistPullReport),
(status = 400, description = "AniList not configured"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn pull_from_anilist(
State(state): State<AppState>,
) -> Result<Json<AnilistPullReport>, ApiError> {
let (token, user_id, local_user_id) = load_anilist_settings(&state.pool).await?;
let local_user_id = local_user_id
.ok_or_else(|| ApiError::bad_request("AniList local user not configured — please select a user in settings"))?;
let gql = r#"
query GetUserMangaList($userId: Int) {
MediaListCollection(userId: $userId, type: MANGA) {
lists {
entries {
media { id siteUrl }
status
progressVolumes
}
}
}
}
"#;
let data = anilist_graphql(&token, gql, serde_json::json!({ "userId": user_id })).await?;
let lists = data["MediaListCollection"]["lists"]
.as_array()
.cloned()
.unwrap_or_default();
// Build flat list of (anilist_id, status, progressVolumes)
let mut entries: Vec<(i32, String, i32)> = Vec::new();
for list in &lists {
if let Some(list_entries) = list["entries"].as_array() {
for entry in list_entries {
let media_id = entry["media"]["id"].as_i64().unwrap_or(0) as i32;
let status = entry["status"].as_str().unwrap_or("").to_string();
let progress = entry["progressVolumes"].as_i64().unwrap_or(0) as i32;
entries.push((media_id, status, progress));
}
}
}
// Find local series linked to these anilist IDs (in enabled libraries)
let link_rows = sqlx::query(
r#"
SELECT asl.library_id, asl.series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
FROM anilist_series_links asl
JOIN libraries l ON l.id = asl.library_id
WHERE l.reading_status_provider = 'anilist'
"#,
)
.fetch_all(&state.pool)
.await?;
// Build map: anilist_id → (library_id, series_name, anilist_title, anilist_url)
let mut link_map: std::collections::HashMap<i32, (Uuid, String, Option<String>, Option<String>)> =
std::collections::HashMap::new();
for row in &link_rows {
let aid: i32 = row.get("anilist_id");
let lib: Uuid = row.get("library_id");
let name: String = row.get("series_name");
let title: Option<String> = row.get("anilist_title");
let url: Option<String> = row.get("anilist_url");
link_map.insert(aid, (lib, name, title, url));
}
let mut updated = 0i32;
let mut skipped = 0i32;
let mut errors: Vec<String> = Vec::new();
let mut items: Vec<AnilistPullItem> = Vec::new();
for (anilist_id, anilist_status, progress_volumes) in &entries {
let Some((library_id, series_name, anilist_title, anilist_url)) = link_map.get(anilist_id) else {
skipped += 1;
continue;
};
// Map AniList status → local reading status
let local_status = match anilist_status.as_str() {
"COMPLETED" => "read",
"CURRENT" | "REPEATING" => "reading",
"PLANNING" | "PAUSED" | "DROPPED" => "unread",
_ => {
skipped += 1;
continue;
}
};
// Get all book IDs for this series, ordered by volume
let book_rows = sqlx::query(
"SELECT id, volume FROM books WHERE library_id = $1 AND COALESCE(NULLIF(series, ''), 'unclassified') = $2 ORDER BY volume NULLS LAST",
)
.bind(library_id)
.bind(series_name)
.fetch_all(&state.pool)
.await;
let book_rows = match book_rows {
Ok(r) => r,
Err(e) => {
errors.push(format!("{series_name}: {e}"));
continue;
}
};
if book_rows.is_empty() {
skipped += 1;
continue;
}
let total_books = book_rows.len() as i32;
let volumes_done = (*progress_volumes).min(total_books);
for (idx, book_row) in book_rows.iter().enumerate() {
let book_id: Uuid = book_row.get("id");
let book_status = if local_status == "read" || (idx as i32) < volumes_done {
"read"
} else if local_status == "reading" && idx as i32 == volumes_done {
"reading"
} else {
"unread"
};
let _ = sqlx::query(
r#"
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
VALUES ($1, $3, $2, NULL, NOW(), NOW())
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = EXCLUDED.status, updated_at = NOW()
WHERE book_reading_progress.status != EXCLUDED.status
"#,
)
.bind(book_id)
.bind(book_status)
.bind(local_user_id)
.execute(&state.pool)
.await;
}
items.push(AnilistPullItem {
series_name: series_name.clone(),
anilist_title: anilist_title.clone(),
anilist_url: anilist_url.clone(),
anilist_status: anilist_status.clone(),
books_updated: total_books,
});
updated += 1;
}
Ok(Json(AnilistPullReport { updated, skipped, errors, items }))
}
/// List all AniList series links
#[utoipa::path(
get,
path = "/anilist/links",
tag = "anilist",
responses(
(status = 200, body = Vec<AnilistSeriesLinkResponse>),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn list_links(
State(state): State<AppState>,
) -> Result<Json<Vec<AnilistSeriesLinkResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT library_id, series_name, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
FROM anilist_series_links
ORDER BY linked_at DESC",
)
.fetch_all(&state.pool)
.await?;
let links: Vec<AnilistSeriesLinkResponse> = rows
.iter()
.map(|row| AnilistSeriesLinkResponse {
library_id: row.get("library_id"),
series_name: row.get("series_name"),
anilist_id: row.get("anilist_id"),
anilist_title: row.get("anilist_title"),
anilist_url: row.get("anilist_url"),
status: row.get("status"),
linked_at: row.get("linked_at"),
synced_at: row.get("synced_at"),
})
.collect();
Ok(Json(links))
}

View File

@@ -30,6 +30,7 @@ pub struct LibraryResponse {
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
#[schema(value_type = Vec<String>)]
pub thumbnail_book_ids: Vec<Uuid>,
pub reading_status_provider: Option<String>,
}
#[derive(Deserialize, ToSchema)]
@@ -53,7 +54,7 @@ pub struct CreateLibraryRequest {
)]
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, l.fallback_metadata_provider, l.metadata_refresh_mode, l.next_metadata_refresh_at,
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, l.fallback_metadata_provider, l.metadata_refresh_mode, l.next_metadata_refresh_at, l.reading_status_provider,
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count,
(SELECT COUNT(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count,
COALESCE((
@@ -92,6 +93,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids: row.get("thumbnail_book_ids"),
reading_status_provider: row.get("reading_status_provider"),
})
.collect();
@@ -149,6 +151,7 @@ pub async fn create_library(
metadata_refresh_mode: "manual".to_string(),
next_metadata_refresh_at: None,
thumbnail_book_ids: vec![],
reading_status_provider: None,
}))
}
@@ -336,7 +339,7 @@ pub async fn update_monitoring(
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
let result = sqlx::query(
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5, metadata_refresh_mode = $6, next_metadata_refresh_at = $7 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at"
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5, metadata_refresh_mode = $6, next_metadata_refresh_at = $7 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at, reading_status_provider"
)
.bind(library_id)
.bind(input.monitor_enabled)
@@ -389,6 +392,7 @@ pub async fn update_monitoring(
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids,
reading_status_provider: row.get("reading_status_provider"),
}))
}
@@ -424,7 +428,7 @@ pub async fn update_metadata_provider(
let fallback = input.fallback_metadata_provider.as_deref().filter(|s| !s.is_empty());
let result = sqlx::query(
"UPDATE libraries SET metadata_provider = $2, fallback_metadata_provider = $3 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at"
"UPDATE libraries SET metadata_provider = $2, fallback_metadata_provider = $3 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at, reading_status_provider"
)
.bind(library_id)
.bind(provider)
@@ -473,5 +477,44 @@ pub async fn update_metadata_provider(
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids,
reading_status_provider: row.get("reading_status_provider"),
}))
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateReadingStatusProviderRequest {
pub reading_status_provider: Option<String>,
}
/// Update the reading status provider for a library
#[utoipa::path(
patch,
path = "/libraries/{id}/reading-status-provider",
tag = "libraries",
params(("id" = String, Path, description = "Library UUID")),
request_body = UpdateReadingStatusProviderRequest,
responses(
(status = 200, description = "Updated"),
(status = 404, description = "Library not found"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn update_reading_status_provider(
State(state): State<AppState>,
AxumPath(library_id): AxumPath<Uuid>,
Json(input): Json<UpdateReadingStatusProviderRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let provider = input.reading_status_provider.as_deref().filter(|s| !s.is_empty());
let result = sqlx::query("UPDATE libraries SET reading_status_provider = $2 WHERE id = $1")
.bind(library_id)
.bind(provider)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("library not found"));
}
Ok(Json(serde_json::json!({ "reading_status_provider": provider })))
}

View File

@@ -1,3 +1,4 @@
mod anilist;
mod auth;
mod authors;
mod books;
@@ -94,6 +95,7 @@ async fn main() -> anyhow::Result<()> {
.route("/libraries/:id", delete(libraries::delete_library))
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
.route("/libraries/:id/reading-status-provider", axum::routing::patch(libraries::update_reading_status_provider))
.route("/books/:id", axum::routing::patch(books::update_book))
.route("/books/:id/convert", axum::routing::post(books::convert_book))
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
@@ -120,6 +122,17 @@ async fn main() -> anyhow::Result<()> {
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
.route("/komga/reports", get(komga::list_sync_reports))
.route("/komga/reports/:id", get(komga::get_sync_report))
.route("/anilist/status", get(anilist::get_status))
.route("/anilist/search", axum::routing::post(anilist::search_manga))
.route("/anilist/unlinked", get(anilist::list_unlinked))
.route("/anilist/sync/preview", get(anilist::preview_sync))
.route("/anilist/sync", axum::routing::post(anilist::sync_to_anilist))
.route("/anilist/pull", axum::routing::post(anilist::pull_from_anilist))
.route("/anilist/links", get(anilist::list_links))
.route("/anilist/libraries/:id", axum::routing::patch(anilist::toggle_library))
.route("/anilist/series/:library_id/:series_name", get(anilist::get_series_link))
.route("/anilist/series/:library_id/:series_name/link", axum::routing::post(anilist::link_series))
.route("/anilist/series/:library_id/:series_name/unlink", delete(anilist::unlink_series))
.route("/metadata/search", axum::routing::post(metadata::search_metadata))
.route("/metadata/match", axum::routing::post(metadata::create_metadata_match))
.route("/metadata/approve/:id", axum::routing::post(metadata::approve_metadata))

View File

@@ -19,6 +19,8 @@ pub struct SeriesItem {
pub series_status: Option<String>,
pub missing_count: Option<i64>,
pub metadata_provider: Option<String>,
pub anilist_id: Option<i32>,
pub anilist_url: Option<String>,
}
#[derive(Serialize, ToSchema)]
@@ -202,12 +204,15 @@ pub async fn list_series(
sb.id as first_book_id,
sm.status as series_status,
mc.missing_count,
ml.provider as metadata_provider
ml.provider as metadata_provider,
asl.anilist_id,
asl.anilist_url
FROM series_counts sc
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.name = sc.name
LEFT JOIN missing_counts mc ON mc.series_name = sc.name
LEFT JOIN metadata_links ml ON ml.series_name = sc.name AND ml.library_id = $1
LEFT JOIN anilist_series_links asl ON asl.library_id = $1 AND asl.series_name = sc.name AND asl.provider = 'anilist'
WHERE TRUE
{q_cond}
{count_rs_cond}
@@ -269,6 +274,8 @@ pub async fn list_series(
series_status: row.get("series_status"),
missing_count: row.get("missing_count"),
metadata_provider: row.get("metadata_provider"),
anilist_id: row.get("anilist_id"),
anilist_url: row.get("anilist_url"),
})
.collect();
@@ -496,12 +503,15 @@ pub async fn list_all_series(
sb.library_id,
sm.status as series_status,
mc.missing_count,
ml.provider as metadata_provider
ml.provider as metadata_provider,
asl.anilist_id,
asl.anilist_url
FROM series_counts sc
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
LEFT JOIN series_metadata sm ON sm.library_id = sc.library_id AND sm.name = sc.name
LEFT JOIN missing_counts mc ON mc.series_name = sc.name AND mc.library_id = sc.library_id
LEFT JOIN metadata_links ml ON ml.series_name = sc.name AND ml.library_id = sc.library_id
LEFT JOIN anilist_series_links asl ON asl.library_id = sc.library_id AND asl.series_name = sc.name AND asl.provider = 'anilist'
WHERE TRUE
{q_cond}
{rs_cond}
@@ -566,6 +576,8 @@ pub async fn list_all_series(
series_status: row.get("series_status"),
missing_count: row.get("missing_count"),
metadata_provider: row.get("metadata_provider"),
anilist_id: row.get("anilist_id"),
anilist_url: row.get("anilist_url"),
})
.collect();
@@ -711,6 +723,8 @@ pub async fn ongoing_series(
series_status: None,
missing_count: None,
metadata_provider: None,
anilist_id: None,
anilist_url: None,
})
.collect();