From e94a4a0b132c336da81d1157bfa11a86a6df458b Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Tue, 24 Mar 2026 17:08:11 +0100 Subject: [PATCH] feat: AniList reading status integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/anilist.rs | 972 ++++++++++++++++++ apps/api/src/libraries.rs | 49 +- apps/api/src/main.rs | 13 + apps/api/src/series.rs | 18 +- .../app/(app)/anilist/callback/page.tsx | 97 ++ .../libraries/[id]/series/[name]/page.tsx | 46 +- apps/backoffice/app/(app)/libraries/page.tsx | 1 + apps/backoffice/app/(app)/series/page.tsx | 39 +- .../app/(app)/settings/SettingsPage.tsx | 418 +++++++- apps/backoffice/app/(app)/settings/page.tsx | 5 +- .../app/api/anilist/libraries/[id]/route.ts | 20 + .../backoffice/app/api/anilist/links/route.ts | 12 + apps/backoffice/app/api/anilist/pull/route.ts | 12 + .../app/api/anilist/search/route.ts | 16 + .../series/[libraryId]/[seriesName]/route.ts | 46 + .../app/api/anilist/status/route.ts | 12 + .../app/api/anilist/sync/preview/route.ts | 12 + apps/backoffice/app/api/anilist/sync/route.ts | 12 + .../app/api/anilist/unlinked/route.ts | 12 + .../app/components/ExternalLinkBadge.tsx | 21 + .../app/components/LibraryActions.tsx | 36 + .../app/components/MarkSeriesReadButton.tsx | 14 +- .../app/components/MetadataSearchModal.tsx | 7 - .../app/components/ReadingStatusModal.tsx | 242 +++++ apps/backoffice/app/components/ui/Icon.tsx | 6 +- apps/backoffice/lib/api.ts | 84 ++ apps/backoffice/lib/i18n/en.ts | 70 ++ apps/backoffice/lib/i18n/fr.ts | 70 ++ .../0054_fix_series_metadata_columns.sql | 30 + 29 files changed, 2352 insertions(+), 40 deletions(-) create mode 100644 apps/api/src/anilist.rs create mode 100644 apps/backoffice/app/(app)/anilist/callback/page.tsx create mode 100644 apps/backoffice/app/api/anilist/libraries/[id]/route.ts create mode 100644 apps/backoffice/app/api/anilist/links/route.ts create mode 100644 apps/backoffice/app/api/anilist/pull/route.ts create mode 100644 apps/backoffice/app/api/anilist/search/route.ts create mode 100644 apps/backoffice/app/api/anilist/series/[libraryId]/[seriesName]/route.ts create mode 100644 apps/backoffice/app/api/anilist/status/route.ts create mode 100644 apps/backoffice/app/api/anilist/sync/preview/route.ts create mode 100644 apps/backoffice/app/api/anilist/sync/route.ts create mode 100644 apps/backoffice/app/api/anilist/unlinked/route.ts create mode 100644 apps/backoffice/app/components/ExternalLinkBadge.tsx create mode 100644 apps/backoffice/app/components/ReadingStatusModal.tsx create mode 100644 infra/migrations/0054_fix_series_metadata_columns.sql diff --git a/apps/api/src/anilist.rs b/apps/api/src/anilist.rs new file mode 100644 index 0000000..26c35a4 --- /dev/null +++ b/apps/api/src/anilist.rs @@ -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 { + 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), 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, + pub title_english: Option, + pub title_native: Option, + pub site_url: String, + pub status: Option, + pub volumes: Option, +} + +#[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, + pub anilist_url: Option, + pub status: String, + #[schema(value_type = String)] + pub linked_at: DateTime, + #[schema(value_type = Option)] + pub synced_at: Option>, +} + +#[derive(Serialize, ToSchema)] +pub struct AnilistSyncPreviewItem { + pub series_name: String, + pub anilist_id: i32, + pub anilist_title: Option, + pub anilist_url: Option, + /// 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, + pub anilist_url: Option, + /// 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, + pub items: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct AnilistPullItem { + pub series_name: String, + pub anilist_title: Option, + pub anilist_url: Option, + /// 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, + pub items: Vec, +} + +#[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, + /// Override URL (optional) + pub url: Option, +} + +#[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, +) -> Result, 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), + (status = 400, description = "AniList not configured"), + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) +)] +pub async fn search_manga( + State(state): State, + Json(body): Json, +) -> Result>, 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 = 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, + Path((library_id, series_name)): Path<(Uuid, String)>, +) -> Result, 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, + Path((library_id, series_name)): Path<(Uuid, String)>, + Json(body): Json, +) -> Result, 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, + Path((library_id, series_name)): Path<(Uuid, String)>, +) -> Result, 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, + Path(library_id): Path, + Json(body): Json, +) -> Result, 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, +) -> Result>, 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 = rows + .iter() + .map(|row| { + let library_id: Uuid = row.get("library_id"); + serde_json::json!({ + "library_id": library_id, + "library_name": row.get::("library_name"), + "series_name": row.get::("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), + (status = 400, description = "AniList not configured"), + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) +)] +pub async fn preview_sync( + State(state): State, +) -> Result>, 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 = 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 = link.get("anilist_title"); + let anilist_url: Option = 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 = 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, +) -> Result, 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 = Vec::new(); + let mut items: Vec = 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 = link.get("anilist_title"); + let anilist_url: Option = 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 = 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, +) -> Result, 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, Option)> = + 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 = row.get("anilist_title"); + let url: Option = row.get("anilist_url"); + link_map.insert(aid, (lib, name, title, url)); + } + + let mut updated = 0i32; + let mut skipped = 0i32; + let mut errors: Vec = Vec::new(); + let mut items: Vec = 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), + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) +)] +pub async fn list_links( + State(state): State, +) -> Result>, 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 = 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)) +} diff --git a/apps/api/src/libraries.rs b/apps/api/src/libraries.rs index f534c1a..370625e 100644 --- a/apps/api/src/libraries.rs +++ b/apps/api/src/libraries.rs @@ -30,6 +30,7 @@ pub struct LibraryResponse { /// First book IDs from up to 5 distinct series (for thumbnail fan display) #[schema(value_type = Vec)] pub thumbnail_book_ids: Vec, + pub reading_status_provider: Option, } #[derive(Deserialize, ToSchema)] @@ -53,7 +54,7 @@ pub struct CreateLibraryRequest { )] pub async fn list_libraries(State(state): State) -> Result>, 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) -> Result, +} + +/// 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, + AxumPath(library_id): AxumPath, + Json(input): Json, +) -> Result, 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 }))) +} diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index cd55e21..7d642a3 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -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)) diff --git a/apps/api/src/series.rs b/apps/api/src/series.rs index 069c6e6..86f93e5 100644 --- a/apps/api/src/series.rs +++ b/apps/api/src/series.rs @@ -19,6 +19,8 @@ pub struct SeriesItem { pub series_status: Option, pub missing_count: Option, pub metadata_provider: Option, + pub anilist_id: Option, + pub anilist_url: Option, } #[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(); diff --git a/apps/backoffice/app/(app)/anilist/callback/page.tsx b/apps/backoffice/app/(app)/anilist/callback/page.tsx new file mode 100644 index 0000000..fdd0777 --- /dev/null +++ b/apps/backoffice/app/(app)/anilist/callback/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function AnilistCallbackPage() { + const router = useRouter(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + const [message, setMessage] = useState(""); + + useEffect(() => { + async function handleCallback() { + const hash = window.location.hash.slice(1); // remove leading # + const params = new URLSearchParams(hash); + const accessToken = params.get("access_token"); + + if (!accessToken) { + setStatus("error"); + setMessage("Aucun token trouvé dans l'URL de callback."); + return; + } + + try { + // Read existing settings to preserve client_id + const existingResp = await fetch("/api/settings/anilist").catch(() => null); + const existing = existingResp?.ok ? await existingResp.json().catch(() => ({})) : {}; + + const save = (extra: Record) => + fetch("/api/settings/anilist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: { ...existing, access_token: accessToken, ...extra } }), + }); + + const saveResp = await save({}); + if (!saveResp.ok) throw new Error("Impossible de sauvegarder le token"); + + // Auto-fetch user info to populate user_id + const statusResp = await fetch("/api/anilist/status"); + if (statusResp.ok) { + const data = await statusResp.json(); + if (data.user_id) { + await save({ user_id: data.user_id }); + } + setMessage(`Connecté en tant que ${data.username}`); + } else { + setMessage("Token sauvegardé."); + } + + setStatus("success"); + setTimeout(() => router.push("/settings?tab=anilist"), 2000); + } catch (e) { + setStatus("error"); + setMessage(e instanceof Error ? e.message : "Erreur inconnue"); + } + } + + handleCallback(); + }, [router]); + + return ( +
+
+ {status === "loading" && ( + <> +
+

Connexion AniList en cours…

+ + )} + {status === "success" && ( + <> +
+ + + +
+

{message}

+

Redirection vers les paramètres…

+ + )} + {status === "error" && ( + <> +
+ + + +
+

{message}

+ + Retour aux paramètres + + + )} +
+
+ ); +} diff --git a/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx b/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx index 50f2d7a..f5d3143 100644 --- a/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx +++ b/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx @@ -1,7 +1,8 @@ -import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "@/lib/api"; +import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, getReadingStatusLink, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto, AnilistSeriesLinkDto } from "@/lib/api"; import { BooksGrid, EmptyState } from "@/app/components/BookCard"; import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton"; import { MarkBookReadButton } from "@/app/components/MarkBookReadButton"; +import { ProviderIcon, providerLabel } from "@/app/components/ProviderIcon"; import nextDynamic from "next/dynamic"; import { OffsetPagination } from "@/app/components/ui"; import { SafeHtml } from "@/app/components/SafeHtml"; @@ -14,6 +15,9 @@ const EditSeriesForm = nextDynamic( const MetadataSearchModal = nextDynamic( () => import("@/app/components/MetadataSearchModal").then(m => m.MetadataSearchModal) ); +const ReadingStatusModal = nextDynamic( + () => import("@/app/components/ReadingStatusModal").then(m => m.ReadingStatusModal) +); const ProwlarrSearchModal = nextDynamic( () => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal) ); @@ -37,7 +41,7 @@ export default async function SeriesDetailPage({ const seriesName = decodeURIComponent(name); - const [library, booksPage, seriesMeta, metadataLinks] = await Promise.all([ + const [library, booksPage, seriesMeta, metadataLinks, readingStatusLink] = await Promise.all([ fetchLibraries().then((libs) => libs.find((l) => l.id === id)), fetchBooks(id, seriesName, page, limit).catch(() => ({ items: [] as BookDto[], @@ -47,6 +51,7 @@ export default async function SeriesDetailPage({ })), fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null), getMetadataLink(id, seriesName).catch(() => [] as ExternalMetadataLinkDto[]), + getReadingStatusLink(id, seriesName).catch(() => null as AnilistSeriesLinkDto | null), ]); const existingLink = metadataLinks.find((l) => l.status === "approved") ?? metadataLinks[0] ?? null; @@ -126,6 +131,37 @@ export default async function SeriesDetailPage({ {t(`seriesStatus.${seriesMeta.status}` as any) || seriesMeta.status} )} + {existingLink?.status === "approved" && ( + existingLink.external_url ? ( + + + {providerLabel(existingLink.provider)} + + ) : ( + + + {providerLabel(existingLink.provider)} + + ) + )} + {readingStatusLink && ( + + + + + AniList + + )}
{seriesMeta?.description && ( @@ -206,6 +242,12 @@ export default async function SeriesDetailPage({ existingLink={existingLink} initialMissing={missingData} /> + diff --git a/apps/backoffice/app/(app)/libraries/page.tsx b/apps/backoffice/app/(app)/libraries/page.tsx index f746320..b9a0d83 100644 --- a/apps/backoffice/app/(app)/libraries/page.tsx +++ b/apps/backoffice/app/(app)/libraries/page.tsx @@ -146,6 +146,7 @@ export default async function LibrariesPage() { metadataProvider={lib.metadata_provider} fallbackMetadataProvider={lib.fallback_metadata_provider} metadataRefreshMode={lib.metadata_refresh_mode} + readingStatusProvider={lib.reading_status_provider} />
diff --git a/apps/backoffice/app/(app)/series/page.tsx b/apps/backoffice/app/(app)/series/page.tsx index 417cd8b..5e8df80 100644 --- a/apps/backoffice/app/(app)/series/page.tsx +++ b/apps/backoffice/app/(app)/series/page.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, OffsetPagination } from "@/app/components/ui"; import Image from "next/image"; import Link from "next/link"; import { ProviderIcon } from "@/app/components/ProviderIcon"; +import { ExternalLinkBadge } from "@/app/components/ExternalLinkBadge"; export const dynamic = "force-dynamic"; @@ -122,13 +123,9 @@ export default async function SeriesPage({ <>
{series.map((s) => ( - +
= s.book_count ? "opacity-50" : "" }`} > @@ -149,13 +146,15 @@ export default async function SeriesPage({

{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}

- +
+ +
-
+
{s.series_status && ( )} + {s.anilist_id && ( + + AL + + )}
- + {/* Link overlay covering the full card — below interactive elements */} + +
))} diff --git a/apps/backoffice/app/(app)/settings/SettingsPage.tsx b/apps/backoffice/app/(app)/settings/SettingsPage.tsx index d0d6af2..fad1d73 100644 --- a/apps/backoffice/app/(app)/settings/SettingsPage.tsx +++ b/apps/backoffice/app/(app)/settings/SettingsPage.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui"; import { ProviderIcon } from "@/app/components/ProviderIcon"; -import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto } from "@/lib/api"; +import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto, AnilistStatusDto, AnilistSyncReportDto, AnilistPullReportDto, AnilistSyncPreviewItemDto, AnilistSyncItemDto, AnilistPullItemDto } from "@/lib/api"; import { useTranslation } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/types"; @@ -12,9 +12,10 @@ interface SettingsPageProps { initialCacheStats: CacheStats; initialThumbnailStats: ThumbnailStats; users: UserDto[]; + initialTab?: string; } -export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users }: SettingsPageProps) { +export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab }: SettingsPageProps) { const { t, locale, setLocale } = useTranslation(); const [settings, setSettings] = useState({ ...initialSettings, @@ -153,11 +154,14 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi } } - const [activeTab, setActiveTab] = useState<"general" | "integrations" | "notifications">("general"); + const [activeTab, setActiveTab] = useState<"general" | "integrations" | "anilist" | "notifications">( + initialTab === "anilist" || initialTab === "integrations" || initialTab === "notifications" ? initialTab : "general" + ); const tabs = [ { id: "general" as const, label: t("settings.general"), icon: "settings" as const }, { id: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const }, + { id: "anilist" as const, label: t("settings.anilist"), icon: "link" as const }, { id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const }, ]; @@ -848,6 +852,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi {/* Telegram Notifications */} )} + + {activeTab === "anilist" && ( + + )} ); } @@ -1753,3 +1761,407 @@ function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri ); } + +// --------------------------------------------------------------------------- +// AniList sub-component +// --------------------------------------------------------------------------- + +function AnilistTab({ + handleUpdateSetting, + users, +}: { + handleUpdateSetting: (key: string, value: unknown) => Promise; + users: UserDto[]; +}) { + const { t } = useTranslation(); + + const [clientId, setClientId] = useState(""); + const [token, setToken] = useState(""); + const [userId, setUserId] = useState(""); + const [localUserId, setLocalUserId] = useState(""); + const [isTesting, setIsTesting] = useState(false); + const [viewer, setViewer] = useState(null); + const [testError, setTestError] = useState(null); + + const [isSyncing, setIsSyncing] = useState(false); + const [syncReport, setSyncReport] = useState(null); + const [isPulling, setIsPulling] = useState(false); + const [pullReport, setPullReport] = useState(null); + const [actionError, setActionError] = useState(null); + const [isPreviewing, setIsPreviewing] = useState(false); + const [previewItems, setPreviewItems] = useState(null); + + + useEffect(() => { + fetch("/api/settings/anilist") + .then((r) => r.ok ? r.json() : null) + .then((data) => { + if (data) { + if (data.client_id) setClientId(String(data.client_id)); + if (data.access_token) setToken(data.access_token); + if (data.user_id) setUserId(String(data.user_id)); + if (data.local_user_id) setLocalUserId(String(data.local_user_id)); + } + }) + .catch(() => {}); + }, []); + + function buildAnilistSettings() { + return { + client_id: clientId || undefined, + access_token: token || undefined, + user_id: userId ? Number(userId) : undefined, + local_user_id: localUserId || undefined, + }; + } + + function handleConnect() { + if (!clientId) return; + // Save client_id first, then open OAuth URL + handleUpdateSetting("anilist", buildAnilistSettings()).then(() => { + window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${encodeURIComponent(clientId)}&response_type=token`; + }); + } + + async function handleSaveToken() { + await handleUpdateSetting("anilist", buildAnilistSettings()); + } + + async function handleTestConnection() { + setIsTesting(true); + setViewer(null); + setTestError(null); + try { + // Save token first so the API reads the current value + await handleUpdateSetting("anilist", buildAnilistSettings()); + const resp = await fetch("/api/anilist/status"); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || "Connection failed"); + setViewer(data); + if (!userId && data.user_id) setUserId(String(data.user_id)); + } catch (e) { + setTestError(e instanceof Error ? e.message : "Connection failed"); + } finally { + setIsTesting(false); + } + } + + async function handlePreview() { + setIsPreviewing(true); + setPreviewItems(null); + setActionError(null); + try { + const resp = await fetch("/api/anilist/sync/preview"); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || "Preview failed"); + setPreviewItems(data); + } catch (e) { + setActionError(e instanceof Error ? e.message : "Preview failed"); + } finally { + setIsPreviewing(false); + } + } + + async function handleSync() { + setIsSyncing(true); + setSyncReport(null); + setActionError(null); + try { + const resp = await fetch("/api/anilist/sync", { method: "POST" }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || "Sync failed"); + setSyncReport(data); + } catch (e) { + setActionError(e instanceof Error ? e.message : "Sync failed"); + } finally { + setIsSyncing(false); + } + } + + async function handlePull() { + setIsPulling(true); + setPullReport(null); + setActionError(null); + try { + const resp = await fetch("/api/anilist/pull", { method: "POST" }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || "Pull failed"); + setPullReport(data); + } catch (e) { + setActionError(e instanceof Error ? e.message : "Pull failed"); + } finally { + setIsPulling(false); + } + } + + return ( + <> + + + + + {t("settings.anilistTitle")} + + {t("settings.anilistDesc")} + + +

{t("settings.anilistConnectDesc")}

+ {/* Redirect URL info */} +
+

{t("settings.anilistRedirectUrlLabel")}

+ {typeof window !== "undefined" ? `${window.location.origin}/anilist/callback` : "/anilist/callback"} +

{t("settings.anilistRedirectUrlHint")}

+
+ + + + setClientId(e.target.value)} + placeholder={t("settings.anilistClientIdPlaceholder")} + /> + + +
+ + + {viewer && ( + + {t("settings.anilistConnected")} {viewer.username} + {" · "} + AniList + + )} + {token && !viewer && ( + {t("settings.anilistTokenPresent")} + )} + {testError && {testError}} +
+
+ + {t("settings.anilistManualToken")} + +
+ + + + setToken(e.target.value)} + placeholder={t("settings.anilistTokenPlaceholder")} + /> + + + + setUserId(e.target.value)} + placeholder={t("settings.anilistUserIdPlaceholder")} + /> + + + +
+
+
+

{t("settings.anilistLocalUserTitle")}

+

{t("settings.anilistLocalUserDesc")}

+
+ + +
+
+
+
+ + + + + + {t("settings.anilistSyncTitle")} + + + +
+
+

{t("settings.anilistSyncDesc")}

+
+ + +
+ {syncReport && ( +
+
+ {t("settings.anilistSynced", { count: String(syncReport.synced) })} + {syncReport.skipped > 0 && {t("settings.anilistSkipped", { count: String(syncReport.skipped) })}} + {syncReport.errors.length > 0 && {t("settings.anilistErrors", { count: String(syncReport.errors.length) })}} +
+ {syncReport.items.length > 0 && ( +
+ {syncReport.items.map((item: AnilistSyncItemDto) => ( +
+ + {item.anilist_title ?? item.series_name} + +
+ {item.status} + {item.progress_volumes > 0 && ( + {item.progress_volumes} vol. + )} +
+
+ ))} +
+ )} + {syncReport.errors.map((err: string, i: number) => ( +

{err}

+ ))} +
+ )} +
+
+

{t("settings.anilistPullDesc")}

+ + {pullReport && ( +
+
+ {t("settings.anilistUpdated", { count: String(pullReport.updated) })} + {pullReport.skipped > 0 && {t("settings.anilistSkipped", { count: String(pullReport.skipped) })}} + {pullReport.errors.length > 0 && {t("settings.anilistErrors", { count: String(pullReport.errors.length) })}} +
+ {pullReport.items.length > 0 && ( +
+ {pullReport.items.map((item: AnilistPullItemDto) => ( +
+ + {item.anilist_title ?? item.series_name} + +
+ {item.anilist_status} + {item.books_updated} {t("dashboard.books").toLowerCase()} +
+
+ ))} +
+ )} + {pullReport.errors.map((err: string, i: number) => ( +

{err}

+ ))} +
+ )} +
+
+ {actionError &&

{actionError}

} + {previewItems !== null && ( +
+
+ {t("settings.anilistPreviewTitle", { count: String(previewItems.length) })} + +
+ {previewItems.length === 0 ? ( +

{t("settings.anilistPreviewEmpty")}

+ ) : ( +
+ {previewItems.map((item) => ( +
+
+ + {item.anilist_title ?? item.series_name} + + {item.anilist_title && item.anilist_title !== item.series_name && ( + — {item.series_name} + )} +
+
+ {item.books_read}/{item.book_count} + + {item.status} + +
+
+ ))} +
+ )} +
+ )} +
+
+ + + ); +} diff --git a/apps/backoffice/app/(app)/settings/page.tsx b/apps/backoffice/app/(app)/settings/page.tsx index 4d9527e..e0c2792 100644 --- a/apps/backoffice/app/(app)/settings/page.tsx +++ b/apps/backoffice/app/(app)/settings/page.tsx @@ -3,7 +3,8 @@ import SettingsPage from "./SettingsPage"; export const dynamic = "force-dynamic"; -export default async function SettingsPageWrapper() { +export default async function SettingsPageWrapper({ searchParams }: { searchParams: Promise<{ tab?: string }> }) { + const { tab } = await searchParams; const settings = await getSettings().catch(() => ({ image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 }, cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 }, @@ -25,5 +26,5 @@ export default async function SettingsPageWrapper() { const users = await fetchUsers().catch(() => []); - return ; + return ; } diff --git a/apps/backoffice/app/api/anilist/libraries/[id]/route.ts b/apps/backoffice/app/api/anilist/libraries/[id]/route.ts new file mode 100644 index 0000000..6f2d261 --- /dev/null +++ b/apps/backoffice/app/api/anilist/libraries/[id]/route.ts @@ -0,0 +1,20 @@ +import { NextResponse, NextRequest } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const data = await apiFetch(`/anilist/libraries/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update library AniList setting"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/anilist/links/route.ts b/apps/backoffice/app/api/anilist/links/route.ts new file mode 100644 index 0000000..3761776 --- /dev/null +++ b/apps/backoffice/app/api/anilist/links/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function GET() { + try { + const data = await apiFetch("/anilist/links"); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch AniList links"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/anilist/pull/route.ts b/apps/backoffice/app/api/anilist/pull/route.ts new file mode 100644 index 0000000..d4f62fc --- /dev/null +++ b/apps/backoffice/app/api/anilist/pull/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function POST() { + try { + const data = await apiFetch("/anilist/pull", { method: "POST", body: "{}" }); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to pull from AniList"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/anilist/search/route.ts b/apps/backoffice/app/api/anilist/search/route.ts new file mode 100644 index 0000000..0c4f869 --- /dev/null +++ b/apps/backoffice/app/api/anilist/search/route.ts @@ -0,0 +1,16 @@ +import { NextResponse, NextRequest } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const data = await apiFetch("/anilist/search", { + method: "POST", + body: JSON.stringify(body), + }); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to search AniList"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/anilist/series/[libraryId]/[seriesName]/route.ts b/apps/backoffice/app/api/anilist/series/[libraryId]/[seriesName]/route.ts new file mode 100644 index 0000000..c3180d1 --- /dev/null +++ b/apps/backoffice/app/api/anilist/series/[libraryId]/[seriesName]/route.ts @@ -0,0 +1,46 @@ +import { NextResponse, NextRequest } from "next/server"; +import { apiFetch } from "@/lib/api"; + +type Params = Promise<{ libraryId: string; seriesName: string }>; + +export async function GET(request: NextRequest, { params }: { params: Params }) { + try { + const { libraryId, seriesName } = await params; + const data = await apiFetch( + `/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`, + ); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Not found"; + return NextResponse.json({ error: message }, { status: 404 }); + } +} + +export async function POST(request: NextRequest, { params }: { params: Params }) { + try { + const { libraryId, seriesName } = await params; + const body = await request.json(); + const data = await apiFetch( + `/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}/link`, + { method: "POST", body: JSON.stringify(body) }, + ); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to link series"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest, { params }: { params: Params }) { + try { + const { libraryId, seriesName } = await params; + const data = await apiFetch( + `/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}/unlink`, + { method: "DELETE" }, + ); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to unlink series"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/anilist/status/route.ts b/apps/backoffice/app/api/anilist/status/route.ts new file mode 100644 index 0000000..cb54990 --- /dev/null +++ b/apps/backoffice/app/api/anilist/status/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function GET() { + try { + const data = await apiFetch("/anilist/status"); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get AniList status"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/anilist/sync/preview/route.ts b/apps/backoffice/app/api/anilist/sync/preview/route.ts new file mode 100644 index 0000000..ae38401 --- /dev/null +++ b/apps/backoffice/app/api/anilist/sync/preview/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function GET() { + try { + const data = await apiFetch("/anilist/sync/preview"); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to preview sync"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/anilist/sync/route.ts b/apps/backoffice/app/api/anilist/sync/route.ts new file mode 100644 index 0000000..c39cb52 --- /dev/null +++ b/apps/backoffice/app/api/anilist/sync/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function POST() { + try { + const data = await apiFetch("/anilist/sync", { method: "POST", body: "{}" }); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to sync to AniList"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/anilist/unlinked/route.ts b/apps/backoffice/app/api/anilist/unlinked/route.ts new file mode 100644 index 0000000..8fca6ae --- /dev/null +++ b/apps/backoffice/app/api/anilist/unlinked/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function GET() { + try { + const data = await apiFetch("/anilist/unlinked"); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch unlinked series"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/components/ExternalLinkBadge.tsx b/apps/backoffice/app/components/ExternalLinkBadge.tsx new file mode 100644 index 0000000..5ee5a85 --- /dev/null +++ b/apps/backoffice/app/components/ExternalLinkBadge.tsx @@ -0,0 +1,21 @@ +"use client"; + +interface ExternalLinkBadgeProps { + href: string; + className?: string; + children: React.ReactNode; +} + +export function ExternalLinkBadge({ href, className, children }: ExternalLinkBadgeProps) { + return ( + e.stopPropagation()} + > + {children} + + ); +} diff --git a/apps/backoffice/app/components/LibraryActions.tsx b/apps/backoffice/app/components/LibraryActions.tsx index e781e92..ec79e86 100644 --- a/apps/backoffice/app/components/LibraryActions.tsx +++ b/apps/backoffice/app/components/LibraryActions.tsx @@ -14,6 +14,7 @@ interface LibraryActionsProps { metadataProvider: string | null; fallbackMetadataProvider: string | null; metadataRefreshMode: string; + readingStatusProvider: string | null; onUpdate?: () => void; } @@ -25,6 +26,7 @@ export function LibraryActions({ metadataProvider, fallbackMetadataProvider, metadataRefreshMode, + readingStatusProvider, }: LibraryActionsProps) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); @@ -40,6 +42,7 @@ export function LibraryActions({ const newMetadataProvider = (formData.get("metadata_provider") as string) || null; const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null; const newMetadataRefreshMode = formData.get("metadata_refresh_mode") as string; + const newReadingStatusProvider = (formData.get("reading_status_provider") as string) || null; try { const [response] = await Promise.all([ @@ -58,6 +61,11 @@ export function LibraryActions({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ metadata_provider: newMetadataProvider, fallback_metadata_provider: newFallbackProvider }), }), + fetch(`/api/libraries/${libraryId}/reading-status-provider`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reading_status_provider: newReadingStatusProvider }), + }), ]); if (response.ok) { @@ -255,6 +263,34 @@ export function LibraryActions({ +
+ + {/* Section: État de lecture */} +
+

+ + + + {t("libraryActions.sectionReadingStatus")} +

+
+
+ + +
+

{t("libraryActions.readingStatusProviderDesc")}

+
+
+ {saveError && (

{saveError} diff --git a/apps/backoffice/app/components/MarkSeriesReadButton.tsx b/apps/backoffice/app/components/MarkSeriesReadButton.tsx index fe6f37b..ef8c5cc 100644 --- a/apps/backoffice/app/components/MarkSeriesReadButton.tsx +++ b/apps/backoffice/app/components/MarkSeriesReadButton.tsx @@ -45,27 +45,27 @@ export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }: - {existingLink && existingLink.status === "approved" && ( - - - {providerLabel(existingLink.provider)} - - )} - {modal} ); diff --git a/apps/backoffice/app/components/ReadingStatusModal.tsx b/apps/backoffice/app/components/ReadingStatusModal.tsx new file mode 100644 index 0000000..d5c0b8c --- /dev/null +++ b/apps/backoffice/app/components/ReadingStatusModal.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { useRouter } from "next/navigation"; +import { Button } from "./ui"; +import { useTranslation } from "../../lib/i18n/context"; +import type { AnilistMediaResultDto, AnilistSeriesLinkDto } from "../../lib/api"; + +interface ReadingStatusModalProps { + libraryId: string; + seriesName: string; + readingStatusProvider: string | null; + existingLink: AnilistSeriesLinkDto | null; +} + +type ModalStep = "idle" | "searching" | "results" | "linked"; + +export function ReadingStatusModal({ + libraryId, + seriesName, + readingStatusProvider, + existingLink, +}: ReadingStatusModalProps) { + const { t } = useTranslation(); + const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const [step, setStep] = useState(existingLink ? "linked" : "idle"); + const [query, setQuery] = useState(seriesName); + const [candidates, setCandidates] = useState([]); + const [error, setError] = useState(null); + const [link, setLink] = useState(existingLink); + const [isLinking, setIsLinking] = useState(false); + const [isUnlinking, setIsUnlinking] = useState(false); + + const handleOpen = useCallback(() => { + setIsOpen(true); + setStep(link ? "linked" : "idle"); + setQuery(seriesName); + setCandidates([]); + setError(null); + }, [link, seriesName]); + + const handleClose = useCallback(() => setIsOpen(false), []); + + async function handleSearch() { + setStep("searching"); + setError(null); + try { + const resp = await fetch("/api/anilist/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || "Search failed"); + setCandidates(data); + setStep("results"); + } catch (e) { + setError(e instanceof Error ? e.message : "Search failed"); + setStep("idle"); + } + } + + async function handleLink(candidate: AnilistMediaResultDto) { + setIsLinking(true); + setError(null); + try { + const resp = await fetch( + `/api/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ anilist_id: candidate.id }), + } + ); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || "Link failed"); + setLink(data); + setStep("linked"); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Link failed"); + } finally { + setIsLinking(false); + } + } + + async function handleUnlink() { + setIsUnlinking(true); + setError(null); + try { + const resp = await fetch( + `/api/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`, + { method: "DELETE" } + ); + if (!resp.ok) throw new Error("Unlink failed"); + setLink(null); + setStep("idle"); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Unlink failed"); + } finally { + setIsUnlinking(false); + } + } + + if (!readingStatusProvider) return null; + + const providerLabel = readingStatusProvider === "anilist" ? "AniList" : readingStatusProvider; + + return ( + <> + + + {isOpen && createPortal( + <> +

+
+
+ {/* Header */} +
+
+ + + + {providerLabel} — {seriesName} +
+ +
+ +
+ {/* Linked state */} + {step === "linked" && link && ( +
+
+
+

{link.anilist_title ?? seriesName}

+ {link.anilist_url && ( + + {link.anilist_url} + + )} +

+ ID: {link.anilist_id} · {t(`readingStatus.status.${link.status}` as any) || link.status} +

+
+ + {providerLabel} + +
+
+ + +
+
+ )} + + {/* Search form */} + {(step === "idle" || step === "results") && ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }} + placeholder={t("readingStatus.searchPlaceholder")} + className="flex-1 text-sm border border-border rounded-lg px-3 py-2 bg-background focus:outline-none focus:ring-2 focus:ring-ring" + /> + +
+ + {step === "results" && candidates.length === 0 && ( +

{t("readingStatus.noResults")}

+ )} + + {step === "results" && candidates.length > 0 && ( +
+ {candidates.map((c) => ( +
+
+

{c.title_romaji ?? c.title_english}

+ {c.title_english && c.title_english !== c.title_romaji && ( +

{c.title_english}

+ )} +
+ {c.volumes && {c.volumes} vol.} + {c.status && {c.status}} + +
+
+ +
+ ))} +
+ )} +
+ )} + + {/* Searching spinner */} + {step === "searching" && ( +
+ + + + + {t("readingStatus.searching")} +
+ )} + + {error &&

{error}

} +
+
+
+ , + document.body + )} + + ); +} diff --git a/apps/backoffice/app/components/ui/Icon.tsx b/apps/backoffice/app/components/ui/Icon.tsx index 22bc1c4..9413a42 100644 --- a/apps/backoffice/app/components/ui/Icon.tsx +++ b/apps/backoffice/app/components/ui/Icon.tsx @@ -35,7 +35,9 @@ type IconName = | "tag" | "document" | "authors" - | "bell"; + | "bell" + | "link" + | "eye"; type IconSize = "sm" | "md" | "lg" | "xl"; @@ -90,6 +92,8 @@ const icons: Record = { document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z", bell: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9", + link: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1", + eye: "M15 12a3 3 0 11-6 0 3 3 0 016 0zm-3-9C7.477 3 3.268 6.11 1.5 12c1.768 5.89 5.977 9 10.5 9s8.732-3.11 10.5-9C20.732 6.11 16.523 3 12 3z", }; const colorClasses: Partial> = { diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index f384da5..15b8ad4 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -14,6 +14,7 @@ export type LibraryDto = { next_metadata_refresh_at: string | null; series_count: number; thumbnail_book_ids: string[]; + reading_status_provider: string | null; }; export type IndexJobDto = { @@ -140,6 +141,83 @@ export type SeriesDto = { series_status: string | null; missing_count: number | null; metadata_provider: string | null; + anilist_id: number | null; + anilist_url: string | null; +}; + +export type AnilistStatusDto = { + connected: boolean; + user_id: number; + username: string; + site_url: string; +}; + +export type AnilistMediaResultDto = { + id: number; + title_romaji: string | null; + title_english: string | null; + title_native: string | null; + site_url: string; + status: string | null; + volumes: number | null; +}; + +export type AnilistSeriesLinkDto = { + library_id: string; + series_name: string; + anilist_id: number; + anilist_title: string | null; + anilist_url: string | null; + status: string; + linked_at: string; + synced_at: string | null; +}; + +export type AnilistUnlinkedSeriesDto = { + library_id: string; + library_name: string; + series_name: string; +}; + +export type AnilistSyncPreviewItemDto = { + series_name: string; + anilist_id: number; + anilist_title: string | null; + anilist_url: string | null; + status: "PLANNING" | "CURRENT" | "COMPLETED"; + progress_volumes: number; + books_read: number; + book_count: number; +}; + +export type AnilistSyncItemDto = { + series_name: string; + anilist_title: string | null; + anilist_url: string | null; + status: string; + progress_volumes: number; +}; + +export type AnilistSyncReportDto = { + synced: number; + skipped: number; + errors: string[]; + items: AnilistSyncItemDto[]; +}; + +export type AnilistPullItemDto = { + series_name: string; + anilist_title: string | null; + anilist_url: string | null; + anilist_status: string; + books_updated: number; +}; + +export type AnilistPullReportDto = { + updated: number; + skipped: number; + errors: string[]; + items: AnilistPullItemDto[]; }; export function config() { @@ -919,6 +997,12 @@ export async function getMetadataLink(libraryId: string, seriesName: string) { return apiFetch(`/metadata/links?${params.toString()}`); } +export async function getReadingStatusLink(libraryId: string, seriesName: string) { + return apiFetch( + `/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}` + ); +} + export async function getMissingBooks(linkId: string) { return apiFetch(`/metadata/missing/${linkId}`); } diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 6938782..fe6ddcd 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -195,6 +195,23 @@ const en: Record = { "libraryActions.metadataRefreshSchedule": "Auto-refresh", "libraryActions.metadataRefreshDesc": "Periodically re-fetch metadata for existing series", "libraryActions.saving": "Saving...", + "libraryActions.sectionReadingStatus": "Reading Status", + "libraryActions.readingStatusProvider": "Reading Status Provider", + "libraryActions.readingStatusProviderDesc": "Syncs reading states (read / reading / planned) with an external service", + + // Reading status modal + "readingStatus.button": "Reading status", + "readingStatus.linkTo": "Link to {{provider}}", + "readingStatus.search": "Search", + "readingStatus.searching": "Searching…", + "readingStatus.searchPlaceholder": "Series title…", + "readingStatus.noResults": "No results.", + "readingStatus.link": "Link", + "readingStatus.unlink": "Unlink", + "readingStatus.changeLink": "Change", + "readingStatus.status.linked": "linked", + "readingStatus.status.synced": "synced", + "readingStatus.status.error": "error", // Library sub-page header "libraryHeader.libraries": "Libraries", @@ -602,6 +619,59 @@ const en: Record = { "settings.telegramHelpChat": "Send a message to your bot, then open https://api.telegram.org/bot<TOKEN>/getUpdates in your browser. The chat id is in message.chat.id.", "settings.telegramHelpGroup": "For a group: add the bot to the group, send a message, then check the same URL. Group IDs are negative (e.g. -123456789).", + // Settings - AniList + "settings.anilist": "Reading status", + "settings.anilistTitle": "AniList Sync", + "settings.anilistDesc": "Sync your reading progress with AniList. Get a personal access token at anilist.co/settings/developer.", + "settings.anilistToken": "Personal Access Token", + "settings.anilistTokenPlaceholder": "AniList token...", + "settings.anilistUserId": "AniList User ID", + "settings.anilistUserIdPlaceholder": "Numeric (e.g. 123456)", + "settings.anilistConnected": "Connected as", + "settings.anilistNotConnected": "Not connected", + "settings.anilistTestConnection": "Test connection", + "settings.anilistLibraries": "Libraries", + "settings.anilistLibrariesDesc": "Enable AniList sync per library", + "settings.anilistEnabled": "AniList sync enabled", + "settings.anilistLocalUserTitle": "Local user", + "settings.anilistLocalUserDesc": "Select the local user whose reading progress is synced with this AniList account", + "settings.anilistLocalUserNone": "— Select a user —", + "settings.anilistSyncTitle": "Sync", + "settings.anilistSyncDesc": "Push local reading progress to AniList. Rules: none read → PLANNING · at least 1 read → CURRENT (progress = volumes read) · all published volumes read (total_volumes known) → COMPLETED.", + "settings.anilistSyncButton": "Sync to AniList", + "settings.anilistPullButton": "Pull from AniList", + "settings.anilistPullDesc": "Import your AniList reading list and update local reading progress. Rules: COMPLETED/CURRENT/REPEATING → books marked read up to the progress volume · PLANNING/PAUSED/DROPPED → unread.", + "settings.anilistSyncing": "Syncing...", + "settings.anilistPulling": "Pulling...", + "settings.anilistSynced": "{{count}} series synced", + "settings.anilistUpdated": "{{count}} series updated", + "settings.anilistSkipped": "{{count}} skipped", + "settings.anilistErrors": "{{count}} error(s)", + "settings.anilistLinks": "AniList Links", + "settings.anilistLinksDesc": "Series linked to AniList", + "settings.anilistNoLinks": "No series linked to AniList", + "settings.anilistUnlink": "Unlink", + "settings.anilistSyncStatus": "synced", + "settings.anilistLinkedStatus": "linked", + "settings.anilistErrorStatus": "error", + "settings.anilistUnlinkedTitle": "{{count}} unlinked series", + "settings.anilistUnlinkedDesc": "These series belong to AniList-enabled libraries but have no AniList link yet. Search each one to link it.", + "settings.anilistSearchButton": "Search", + "settings.anilistSearchNoResults": "No AniList results.", + "settings.anilistLinkButton": "Link", + "settings.anilistRedirectUrlLabel": "Redirect URL to configure in your AniList app:", + "settings.anilistRedirectUrlHint": "Paste this URL in the « Redirect URL » field of your application at anilist.co/settings/developer.", + "settings.anilistTokenPresent": "Token present — not verified", + "settings.anilistPreviewButton": "Preview", + "settings.anilistPreviewing": "Loading...", + "settings.anilistPreviewTitle": "{{count}} series to sync", + "settings.anilistPreviewEmpty": "No series to sync (link series to AniList first).", + "settings.anilistClientId": "AniList Client ID", + "settings.anilistClientIdPlaceholder": "E.g. 37777", + "settings.anilistConnectButton": "Connect with AniList", + "settings.anilistConnectDesc": "Use OAuth to connect automatically. Find your Client ID in your AniList apps (anilist.co/settings/developer).", + "settings.anilistManualToken": "Manual token (advanced)", + // Settings - Language "settings.language": "Language", "settings.languageDesc": "Choose the interface language", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index eff0534..0e99751 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -193,6 +193,23 @@ const fr = { "libraryActions.metadataRefreshSchedule": "Rafraîchissement auto", "libraryActions.metadataRefreshDesc": "Re-télécharger périodiquement les métadonnées existantes", "libraryActions.saving": "Enregistrement...", + "libraryActions.sectionReadingStatus": "État de lecture", + "libraryActions.readingStatusProvider": "Provider d'état de lecture", + "libraryActions.readingStatusProviderDesc": "Synchronise les états de lecture (lu / en cours / planifié) avec un service externe", + + // Reading status modal + "readingStatus.button": "État de lecture", + "readingStatus.linkTo": "Lier à {{provider}}", + "readingStatus.search": "Rechercher", + "readingStatus.searching": "Recherche en cours…", + "readingStatus.searchPlaceholder": "Titre de la série…", + "readingStatus.noResults": "Aucun résultat.", + "readingStatus.link": "Lier", + "readingStatus.unlink": "Délier", + "readingStatus.changeLink": "Changer", + "readingStatus.status.linked": "lié", + "readingStatus.status.synced": "synchronisé", + "readingStatus.status.error": "erreur", // Library sub-page header "libraryHeader.libraries": "Bibliothèques", @@ -600,6 +617,59 @@ const fr = { "settings.telegramHelpChat": "Envoyez un message à votre bot, puis ouvrez https://api.telegram.org/bot<TOKEN>/getUpdates dans votre navigateur. Le chat id apparaît dans message.chat.id.", "settings.telegramHelpGroup": "Pour un groupe : ajoutez le bot au groupe, envoyez un message, puis consultez la même URL. Les IDs de groupe sont négatifs (ex: -123456789).", + // Settings - AniList + "settings.anilist": "État de lecture", + "settings.anilistTitle": "Synchronisation AniList", + "settings.anilistDesc": "Synchronisez votre progression de lecture avec AniList. Obtenez un token d'accès personnel sur anilist.co/settings/developer.", + "settings.anilistToken": "Token d'accès personnel", + "settings.anilistTokenPlaceholder": "Token AniList...", + "settings.anilistUserId": "ID utilisateur AniList", + "settings.anilistUserIdPlaceholder": "Numérique (ex: 123456)", + "settings.anilistConnected": "Connecté en tant que", + "settings.anilistNotConnected": "Non connecté", + "settings.anilistTestConnection": "Tester la connexion", + "settings.anilistLibraries": "Bibliothèques", + "settings.anilistLibrariesDesc": "Activer la synchronisation AniList pour chaque bibliothèque", + "settings.anilistEnabled": "Sync AniList activée", + "settings.anilistLocalUserTitle": "Utilisateur local", + "settings.anilistLocalUserDesc": "Choisir l'utilisateur local dont la progression est synchronisée avec ce compte AniList", + "settings.anilistLocalUserNone": "— Sélectionner un utilisateur —", + "settings.anilistSyncTitle": "Synchronisation", + "settings.anilistSyncDesc": "Envoyer la progression locale vers AniList. Règles : aucun lu → PLANNING · au moins 1 lu → CURRENT (progression = nbre de tomes lus) · tous les tomes publiés lus (total_volumes connu) → COMPLETED.", + "settings.anilistSyncButton": "Synchroniser vers AniList", + "settings.anilistPullButton": "Importer depuis AniList", + "settings.anilistPullDesc": "Importer votre liste de lecture AniList et mettre à jour la progression locale. Règles : COMPLETED/CURRENT/REPEATING → livres marqués lus jusqu'au volume de progression · PLANNING/PAUSED/DROPPED → non lus.", + "settings.anilistSyncing": "Synchronisation...", + "settings.anilistPulling": "Import...", + "settings.anilistSynced": "{{count}} série(s) synchronisée(s)", + "settings.anilistUpdated": "{{count}} série(s) mise(s) à jour", + "settings.anilistSkipped": "{{count}} ignorée(s)", + "settings.anilistErrors": "{{count}} erreur(s)", + "settings.anilistLinks": "Liens AniList", + "settings.anilistLinksDesc": "Séries associées à AniList", + "settings.anilistNoLinks": "Aucune série liée à AniList", + "settings.anilistUnlink": "Délier", + "settings.anilistSyncStatus": "synced", + "settings.anilistLinkedStatus": "linked", + "settings.anilistErrorStatus": "error", + "settings.anilistUnlinkedTitle": "{{count}} série(s) non liée(s)", + "settings.anilistUnlinkedDesc": "Ces séries appartiennent à des bibliothèques activées mais n'ont pas encore de lien AniList. Recherchez chacune pour la lier.", + "settings.anilistSearchButton": "Rechercher", + "settings.anilistSearchNoResults": "Aucun résultat AniList.", + "settings.anilistLinkButton": "Lier", + "settings.anilistRedirectUrlLabel": "URL de redirection à configurer dans votre app AniList :", + "settings.anilistRedirectUrlHint": "Collez cette URL dans le champ « Redirect URL » de votre application sur anilist.co/settings/developer.", + "settings.anilistTokenPresent": "Token présent — non vérifié", + "settings.anilistPreviewButton": "Prévisualiser", + "settings.anilistPreviewing": "Chargement...", + "settings.anilistPreviewTitle": "{{count}} série(s) à synchroniser", + "settings.anilistPreviewEmpty": "Aucune série à synchroniser (liez des séries à AniList d'abord).", + "settings.anilistClientId": "Client ID AniList", + "settings.anilistClientIdPlaceholder": "Ex: 37777", + "settings.anilistConnectButton": "Connecter avec AniList", + "settings.anilistConnectDesc": "Utilisez OAuth pour vous connecter automatiquement. Le Client ID se trouve dans vos applications AniList (anilist.co/settings/developer).", + "settings.anilistManualToken": "Token manuel (avancé)", + // Settings - Language "settings.language": "Langue", "settings.languageDesc": "Choisir la langue de l'interface", diff --git a/infra/migrations/0054_fix_series_metadata_columns.sql b/infra/migrations/0054_fix_series_metadata_columns.sql new file mode 100644 index 0000000..78218a1 --- /dev/null +++ b/infra/migrations/0054_fix_series_metadata_columns.sql @@ -0,0 +1,30 @@ +-- Corrective migration: add series_metadata columns that may be missing if migrations +-- 0022-0033 were baselined (marked applied) without actually running their SQL. +-- All statements use IF NOT EXISTS / idempotent patterns so re-running is safe. + +-- From 0022: add authors + publishers arrays, remove old singular publisher column +ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS authors TEXT[] NOT NULL DEFAULT '{}'; +ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS publishers TEXT[] NOT NULL DEFAULT '{}'; + +-- Migrate old singular publisher value if the column still exists +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'series_metadata' AND column_name = 'publisher' + ) THEN + UPDATE series_metadata + SET publishers = ARRAY[publisher] + WHERE publisher IS NOT NULL AND publisher != '' AND cardinality(publishers) = 0; + ALTER TABLE series_metadata DROP COLUMN publisher; + END IF; +END $$; + +-- From 0030: locked_fields +ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS locked_fields JSONB NOT NULL DEFAULT '{}'; + +-- From 0031: total_volumes +ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS total_volumes INTEGER; + +-- From 0033: status +ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS status TEXT;