Compare commits

...

35 Commits

Author SHA1 Message Date
d977b6b27a chore: bump version to 2.3.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 42s
2026-03-25 09:08:38 +01:00
9eea43ce99 fix: retry once after 10s on AniList 429 before aborting job
On rate limit, wait 10 seconds and retry the same series. If the retry
also returns 429, the job stops. Otherwise it continues normally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:08:27 +01:00
31538fac24 fix: abort reading_status_match job on AniList 429 rate limit
Continuing after a 429 is pointless — all subsequent requests will also
fail. The job now returns Err immediately, which sets status='failed' with
a clear message indicating where it stopped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:06:34 +01:00
5f7f96f25a chore: bump version to 2.3.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 51s
2026-03-25 08:15:04 +01:00
87f5d9b452 chore: bump version to 2.2.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s
2026-03-24 21:20:40 +01:00
e995732504 fix: reduce action button size on tokens page to match jobs page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:20:31 +01:00
ea4b8798a1 fix: add cursor-pointer to Button component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:20:17 +01:00
b2e59d8aa1 fix: refresh jobs list immediately after replay
Add /api/jobs/list endpoint and fetch the updated list right after
a successful replay so the new job appears instantly instead of
waiting for the next SSE poll (up to 15s when idle).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:19:47 +01:00
6a838fb840 feat: add replay button on completed jobs in the jobs table
Shows a "Replay" button on non-active jobs that re-creates a new job
of the same type and library. Supports all replayable job types:
rebuild, full_rebuild, rescan, scan, thumbnail_rebuild,
thumbnail_regenerate, metadata_batch, metadata_refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:18:12 +01:00
2febab2c39 feat: add books/pages metric toggle on reading activity chart
Allow switching between number of books and number of pages on the
dashboard reading activity chart. Adds pages_read to the stats API
response and a MetricToggle component alongside the existing PeriodToggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:12:43 +01:00
4049c94fc0 chore: bump version to 2.1.3
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 43s
2026-03-24 17:54:01 +01:00
cb684ab9ea fix: use correct column name sm.name instead of sm.series_name in series_metadata queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:53:51 +01:00
5e91ecd39d chore: bump version to 2.1.2
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 42s
2026-03-24 17:43:35 +01:00
f2fa4e3ce8 chore: remove unnecessary auto-enable reading_status_provider on link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:41:21 +01:00
b61ab45fb4 fix: use subquery for total_volumes to avoid GROUP BY returning 0 rows
GROUP BY sm.total_volumes caused fetch_one to fail when no books matched,
silently skipping all series. COUNT(*) without GROUP BY always returns 1 row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:40:56 +01:00
fd0f57824d chore: add missing migrations and routes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:35:49 +01:00
4c10702fb7 chore: bump version to 2.1.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 41s
2026-03-24 17:18:11 +01:00
301669332c fix: make AniList user_id optional for preview/sync (only required for pull)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:18:03 +01:00
f57cc0cae0 chore: bump version to 2.1.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 48s
2026-03-24 17:09:08 +01:00
e94a4a0b13 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>
2026-03-24 17:08:11 +01:00
2a7881ac6e chore: bump version to 2.0.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m7s
2026-03-24 12:56:40 +01:00
0950018b38 fix: add autoComplete=off on password fields to suppress WebKit autofill error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:49:02 +01:00
bc796f4ee5 feat: multi-user reading progress & backoffice impersonation
- Scope all reading progress (books, series, stats) by user via
  Option<Extension<AuthUser>> — admin sees aggregate, read token sees own data
- Fix duplicate book rows when admin views lists (IS NOT NULL guard on JOIN)
- Add X-As-User header support: admin can impersonate any user from backoffice
- UserSwitcher dropdown in nav header (persisted via as_user_id cookie)
- Per-user filter pills on "Currently reading" and "Recently read" dashboard sections
- Inline username editing (UsernameEdit component with optimistic update)
- PATCH /admin/users/:id endpoint to rename a user
- Unassigned read tokens row in users table
- Komga sync now requires a user_id — reading progress attributed to selected user
- Migration 0051: add user_id column to komga_sync_reports
- Nav breakpoints: icons-only from md, labels from xl, hamburger until md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:47:58 +01:00
232ecdda41 feat: add backoffice authentication with login page
- Add login page with logo background, glassmorphism card
- Add session management via JWT (jose) with httpOnly cookie
- Add Next.js proxy middleware to protect all routes
- Add logout button in nav
- Restructure app into (app) route group to isolate login layout
- Add ADMIN_USERNAME, ADMIN_PASSWORD, SESSION_SECRET env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:48:01 +01:00
32d13984a1 chore: bump version to 1.28.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 53s
2026-03-23 19:00:30 +01:00
eab7f2e21b feat: filter metadata refresh to ongoing series & improve job action buttons
- Metadata refresh now skips series with ended/cancelled status
- Add xs size to Button component
- Unify view/cancel button sizes (h-7) with icons (eye & cross)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:59:33 +01:00
b6422fbf3e feat: enhance jobs list stats with tooltips, icons, and refresh count
- Add Tooltip UI component for styled hover tooltips
- Replace native title attributes with Tooltip on all job stats
- Add refresh icon (green) showing actual refreshed count for metadata refresh
- Add icon+tooltip to scanned files stat
- Add icon prop to StatBox component
- Add refreshed field to stats_json types
- Distinct tooltip labels for total links vs refreshed count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:56:42 +01:00
6dbd0c80e6 feat: improve Telegram notification UI with better formatting
Add visual separators, contextual emojis, bold labels, structured
result sections, and conditional error lines for cleaner messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:46:25 +01:00
0c42a9ed04 fix: add API job poller to process scheduler-created metadata jobs
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m12s
The scheduler (indexer) created metadata_refresh/metadata_batch jobs in DB,
but the indexer excluded them (API_ONLY_JOB_TYPES) and the API only processed
jobs created via its REST endpoints. Scheduler-created jobs stayed pending forever.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:05:42 +01:00
95a6e54d06 chore: bump version to 1.27.1 2026-03-22 21:05:23 +01:00
e26219989f feat: add job runs chart and scrollable reading lists on dashboard
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s
- Add multi-line chart showing job runs over time by type (scan,
  rebuild, thumbnails, other) with the same day/week/month toggle
- Limit currently reading and recently read lists to 3 visible items
  with a scrollbar for overflow
- Fix NUMERIC→BIGINT cast for SUM/COALESCE in jobs SQL queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:43:45 +01:00
5d33a35407 chore: bump version to 1.27.0 2026-03-22 10:43:25 +01:00
d53572dc33 chore: bump version to 1.26.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-03-22 10:27:59 +01:00
cf1953d11f feat: add day/week/month period toggle for dashboard line charts
Add a period selector (day, week, month) to the reading activity and
books added charts. The API now accepts a ?period= query param and
returns gap-filled data using generate_series so all time slots appear
even with zero values. Labels are locale-aware (short month, weekday).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:27:24 +01:00
6f663eaee7 docs: add MIT license
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:08:15 +01:00
92 changed files with 6791 additions and 796 deletions

View File

@@ -13,6 +13,12 @@
# Use this token for the first API calls before creating proper API tokens
API_BOOTSTRAP_TOKEN=change-me-in-production
# Backoffice admin credentials (required)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-in-production
# Secret for signing session JWTs (min 32 chars, required)
SESSION_SECRET=change-me-in-production-use-32-chars-min
# =============================================================================
# Service Configuration
# =============================================================================

10
Cargo.lock generated
View File

@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "api"
version = "1.25.0"
version = "2.3.1"
dependencies = [
"anyhow",
"argon2",
@@ -1233,7 +1233,7 @@ dependencies = [
[[package]]
name = "indexer"
version = "1.25.0"
version = "2.3.1"
dependencies = [
"anyhow",
"axum",
@@ -1667,7 +1667,7 @@ dependencies = [
[[package]]
name = "notifications"
version = "1.25.0"
version = "2.3.1"
dependencies = [
"anyhow",
"reqwest",
@@ -1786,7 +1786,7 @@ dependencies = [
[[package]]
name = "parsers"
version = "1.25.0"
version = "2.3.1"
dependencies = [
"anyhow",
"flate2",
@@ -2923,7 +2923,7 @@ dependencies = [
[[package]]
name = "stripstream-core"
version = "1.25.0"
version = "2.3.1"
dependencies = [
"anyhow",
"serde",

View File

@@ -10,7 +10,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "1.25.0"
version = "2.3.1"
license = "MIT"
[workspace.dependencies]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Julien Froidefond
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -287,4 +287,4 @@ volumes:
## License
[Your License Here]
This project is licensed under the [MIT License](LICENSE).

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

@@ -0,0 +1,968 @@
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";
pub(crate) 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)
pub(crate) async fn load_anilist_settings(pool: &sqlx::PgPool) -> Result<(String, Option<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();
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,
(SELECT sm.total_volumes FROM series_metadata sm WHERE sm.library_id = $1 AND sm.name = $2 LIMIT 1) as total_volumes
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
WHERE b.library_id = $1 AND COALESCE(NULLIF(b.series, ''), 'unclassified') = $2
"#,
)
.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,
(SELECT sm.total_volumes FROM series_metadata sm WHERE sm.library_id = $1 AND sm.name = $2 LIMIT 1) as total_volumes
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
WHERE b.library_id = $1 AND COALESCE(NULLIF(b.series, ''), 'unclassified') = $2
"#,
)
.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 user_id = user_id
.ok_or_else(|| ApiError::bad_request("AniList user_id not configured — please test the connection in settings"))?;
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

@@ -10,10 +10,15 @@ use sqlx::Row;
use crate::{error::ApiError, state::AppState};
#[derive(Clone, Debug)]
pub struct AuthUser {
pub user_id: uuid::Uuid,
}
#[derive(Clone, Debug)]
pub enum Scope {
Admin,
Read,
Read { user_id: uuid::Uuid },
}
pub async fn require_admin(
@@ -40,6 +45,20 @@ pub async fn require_read(
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
let scope = authenticate(&state, token).await?;
if let Scope::Read { user_id } = &scope {
req.extensions_mut().insert(AuthUser { user_id: *user_id });
} else if matches!(scope, Scope::Admin) {
// Admin peut s'impersonifier via le header X-As-User
if let Some(as_user_id) = req
.headers()
.get("X-As-User")
.and_then(|v| v.to_str().ok())
.and_then(|v| uuid::Uuid::parse_str(v).ok())
{
req.extensions_mut().insert(AuthUser { user_id: as_user_id });
}
}
req.extensions_mut().insert(scope);
Ok(next.run(req).await)
}
@@ -60,8 +79,7 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
let maybe_row = sqlx::query(
r#"
SELECT id, token_hash, scope
FROM api_tokens
SELECT id, token_hash, scope, user_id FROM api_tokens
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
"#,
)
@@ -88,7 +106,12 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
match scope.as_str() {
"admin" => Ok(Scope::Admin),
"read" => Ok(Scope::Read),
"read" => {
let user_id: uuid::Uuid = row
.try_get("user_id")
.map_err(|_| ApiError::unauthorized("read token missing user_id"))?;
Ok(Scope::Read { user_id })
}
_ => Err(ApiError::unauthorized("invalid token scope")),
}
}

View File

@@ -1,11 +1,11 @@
use axum::{extract::{Path, Query, State}, Json};
use axum::{extract::{Extension, Path, Query, State}, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, index_jobs::IndexJobResponse, state::AppState};
use crate::{auth::AuthUser, error::ApiError, index_jobs::IndexJobResponse, state::AppState};
#[derive(Deserialize, ToSchema)]
pub struct ListBooksQuery {
@@ -122,7 +122,9 @@ pub struct BookDetails {
pub async fn list_books(
State(state): State<AppState>,
Query(query): Query<ListBooksQuery>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<BooksPage>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
@@ -151,6 +153,8 @@ pub async fn list_books(
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
None => String::new(),
};
p += 1;
let uid_p = p;
let metadata_links_cte = r#"
metadata_links AS (
@@ -164,7 +168,7 @@ pub async fn list_books(
let count_sql = format!(
r#"WITH {metadata_links_cte}
SELECT COUNT(*) FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2)
@@ -192,7 +196,7 @@ pub async fn list_books(
brp.current_page AS reading_current_page,
brp.last_read_at AS reading_last_read_at
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2)
@@ -235,8 +239,8 @@ pub async fn list_books(
data_builder = data_builder.bind(mp.clone());
}
}
data_builder = data_builder.bind(limit).bind(offset);
count_builder = count_builder.bind(user_id);
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
@@ -295,7 +299,9 @@ pub async fn list_books(
pub async fn get_book(
State(state): State<AppState>,
Path(id): Path<Uuid>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<BookDetails>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let row = sqlx::query(
r#"
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
@@ -311,11 +317,12 @@ pub async fn get_book(
ORDER BY updated_at DESC
LIMIT 1
) bf ON TRUE
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
WHERE b.id = $1
"#,
)
.bind(id)
.bind(user_id)
.fetch_optional(&state.pool)
.await?;
@@ -521,9 +528,9 @@ pub async fn update_book(
WHERE id = $1
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
summary, isbn, publish_date,
COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status,
(SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page,
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at
'unread' AS reading_status,
NULL::integer AS reading_current_page,
NULL::timestamptz AS reading_last_read_at
"#,
)
.bind(id)

134
apps/api/src/job_poller.rs Normal file
View File

@@ -0,0 +1,134 @@
use std::time::Duration;
use sqlx::{PgPool, Row};
use tracing::{error, info, trace};
use uuid::Uuid;
use crate::{metadata_batch, metadata_refresh};
/// Poll for pending API-only jobs (`metadata_batch`, `metadata_refresh`) and process them.
/// This mirrors the indexer's worker loop but for job types handled by the API.
pub async fn run_job_poller(pool: PgPool, interval_seconds: u64) {
let wait = Duration::from_secs(interval_seconds.max(1));
loop {
match claim_next_api_job(&pool).await {
Ok(Some((job_id, job_type, library_id))) => {
info!("[JOB_POLLER] Claimed {job_type} job {job_id} library={library_id}");
let pool_clone = pool.clone();
let library_name: Option<String> =
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&pool)
.await
.ok()
.flatten();
tokio::spawn(async move {
let result = match job_type.as_str() {
"metadata_refresh" => {
metadata_refresh::process_metadata_refresh(
&pool_clone,
job_id,
library_id,
)
.await
}
"metadata_batch" => {
metadata_batch::process_metadata_batch(
&pool_clone,
job_id,
library_id,
)
.await
}
_ => Err(format!("Unknown API job type: {job_type}")),
};
if let Err(e) = result {
error!("[JOB_POLLER] {job_type} job {job_id} failed: {e}");
let _ = sqlx::query(
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
)
.bind(job_id)
.bind(e.to_string())
.execute(&pool_clone)
.await;
match job_type.as_str() {
"metadata_refresh" => {
notifications::notify(
pool_clone,
notifications::NotificationEvent::MetadataRefreshFailed {
library_name,
error: e.to_string(),
},
);
}
"metadata_batch" => {
notifications::notify(
pool_clone,
notifications::NotificationEvent::MetadataBatchFailed {
library_name,
error: e.to_string(),
},
);
}
_ => {}
}
}
});
}
Ok(None) => {
trace!("[JOB_POLLER] No pending API jobs, waiting...");
tokio::time::sleep(wait).await;
}
Err(err) => {
error!("[JOB_POLLER] Error claiming job: {err}");
tokio::time::sleep(wait).await;
}
}
}
}
const API_JOB_TYPES: &[&str] = &["metadata_batch", "metadata_refresh"];
async fn claim_next_api_job(pool: &PgPool) -> Result<Option<(Uuid, String, Uuid)>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row = sqlx::query(
r#"
SELECT id, type, library_id
FROM index_jobs
WHERE status = 'pending'
AND type = ANY($1)
AND library_id IS NOT NULL
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
"#,
)
.bind(API_JOB_TYPES)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.commit().await?;
return Ok(None);
};
let id: Uuid = row.get("id");
let job_type: String = row.get("type");
let library_id: Uuid = row.get("library_id");
sqlx::query(
"UPDATE index_jobs SET status = 'running', started_at = NOW(), error_opt = NULL WHERE id = $1",
)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((id, job_type, library_id)))
}

View File

@@ -38,6 +38,8 @@ pub struct KomgaSyncRequest {
pub url: String,
pub username: String,
pub password: String,
#[schema(value_type = String)]
pub user_id: Uuid,
}
#[derive(Serialize, ToSchema)]
@@ -45,6 +47,8 @@ pub struct KomgaSyncResponse {
#[schema(value_type = String)]
pub id: Uuid,
pub komga_url: String,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
pub total_komga_read: i64,
pub matched: i64,
pub already_read: i64,
@@ -61,6 +65,8 @@ pub struct KomgaSyncReportSummary {
#[schema(value_type = String)]
pub id: Uuid,
pub komga_url: String,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
pub total_komga_read: i64,
pub matched: i64,
pub already_read: i64,
@@ -215,11 +221,12 @@ pub async fn sync_komga_read_books(
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
if !matched_ids.is_empty() {
// Get already-read book IDs
// Get already-read book IDs for this user
let ar_rows = sqlx::query(
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND status = 'read'",
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND user_id = $2 AND status = 'read'",
)
.bind(&matched_ids)
.bind(body.user_id)
.fetch_all(&state.pool)
.await?;
@@ -228,12 +235,12 @@ pub async fn sync_komga_read_books(
}
already_read = already_read_ids.len() as i64;
// Bulk upsert all matched books as read
// Bulk upsert all matched books as read for this user
sqlx::query(
r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
SELECT unnest($1::uuid[]), 'read', NULL, NOW(), NOW()
ON CONFLICT (book_id) DO UPDATE
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
SELECT unnest($1::uuid[]), $2, 'read', NULL, NOW(), NOW()
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = 'read',
current_page = NULL,
last_read_at = NOW(),
@@ -242,6 +249,7 @@ pub async fn sync_komga_read_books(
"#,
)
.bind(&matched_ids)
.bind(body.user_id)
.execute(&state.pool)
.await?;
}
@@ -273,12 +281,13 @@ pub async fn sync_komga_read_books(
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
let report_row = sqlx::query(
r#"
INSERT INTO komga_sync_reports (komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO komga_sync_reports (komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at
"#,
)
.bind(&url)
.bind(body.user_id)
.bind(total_komga_read)
.bind(matched)
.bind(already_read)
@@ -292,6 +301,7 @@ pub async fn sync_komga_read_books(
Ok(Json(KomgaSyncResponse {
id: report_row.get("id"),
komga_url: url,
user_id: Some(body.user_id),
total_komga_read,
matched,
already_read,
@@ -319,7 +329,7 @@ pub async fn list_sync_reports(
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
let rows = sqlx::query(
r#"
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked,
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked,
jsonb_array_length(unmatched) as unmatched_count, created_at
FROM komga_sync_reports
ORDER BY created_at DESC
@@ -334,6 +344,7 @@ pub async fn list_sync_reports(
.map(|row| KomgaSyncReportSummary {
id: row.get("id"),
komga_url: row.get("komga_url"),
user_id: row.get("user_id"),
total_komga_read: row.get("total_komga_read"),
matched: row.get("matched"),
already_read: row.get("already_read"),
@@ -365,7 +376,7 @@ pub async fn get_sync_report(
) -> Result<Json<KomgaSyncResponse>, ApiError> {
let row = sqlx::query(
r#"
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
FROM komga_sync_reports
WHERE id = $1
"#,
@@ -386,6 +397,7 @@ pub async fn get_sync_report(
Ok(Json(KomgaSyncResponse {
id: row.get("id"),
komga_url: row.get("komga_url"),
user_id: row.get("user_id"),
total_komga_read: row.get("total_komga_read"),
matched: row.get("matched"),
already_read: row.get("already_read"),

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,9 +1,11 @@
mod anilist;
mod auth;
mod authors;
mod books;
mod error;
mod handlers;
mod index_jobs;
mod job_poller;
mod komga;
mod libraries;
mod metadata;
@@ -16,6 +18,7 @@ mod pages;
mod prowlarr;
mod qbittorrent;
mod reading_progress;
mod reading_status_match;
mod search;
mod series;
mod settings;
@@ -24,6 +27,7 @@ mod stats;
mod telegram;
mod thumbnails;
mod tokens;
mod users;
use std::sync::Arc;
use std::time::Instant;
@@ -92,6 +96,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))
@@ -105,8 +110,10 @@ async fn main() -> anyhow::Result<()> {
.route("/index/jobs/:id/errors", get(index_jobs::get_job_errors))
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
.route("/folders", get(index_jobs::list_folders))
.route("/admin/users", get(users::list_users).post(users::create_user))
.route("/admin/users/:id", delete(users::delete_user).patch(users::update_user))
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
.route("/admin/tokens/:id", delete(tokens::revoke_token))
.route("/admin/tokens/:id", delete(tokens::revoke_token).patch(tokens::update_token))
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
@@ -116,6 +123,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))
@@ -128,6 +146,9 @@ async fn main() -> anyhow::Result<()> {
.route("/metadata/batch/:id/results", get(metadata_batch::get_batch_results))
.route("/metadata/refresh", axum::routing::post(metadata_refresh::start_refresh))
.route("/metadata/refresh/:id/report", get(metadata_refresh::get_refresh_report))
.route("/reading-status/match", axum::routing::post(reading_status_match::start_match))
.route("/reading-status/match/:id/report", get(reading_status_match::get_match_report))
.route("/reading-status/match/:id/results", get(reading_status_match::get_match_results))
.merge(settings::settings_routes())
.route_layer(middleware::from_fn_with_state(
state.clone(),
@@ -159,6 +180,9 @@ async fn main() -> anyhow::Result<()> {
auth::require_read,
));
// Clone pool before state is moved into the router
let poller_pool = state.pool.clone();
let app = Router::new()
.route("/health", get(handlers::health))
.route("/ready", get(handlers::ready))
@@ -170,6 +194,11 @@ async fn main() -> anyhow::Result<()> {
.layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter))
.with_state(state);
// Start background poller for API-only jobs (metadata_batch, metadata_refresh)
tokio::spawn(async move {
job_poller::run_job_poller(poller_pool, 5).await;
});
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
info!(addr = %config.listen_addr, "api listening");
axum::serve(listener, app).await?;

View File

@@ -115,14 +115,14 @@ pub async fn start_batch(
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_batch', 'pending')",
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_batch', 'running', NOW())",
)
.bind(job_id)
.bind(library_id)
.execute(&state.pool)
.await?;
// Spawn the background processing task
// Spawn the background processing task (status already 'running' to avoid poller race)
let pool = state.pool.clone();
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
@@ -313,7 +313,7 @@ pub async fn get_batch_results(
// Background processing
// ---------------------------------------------------------------------------
async fn process_metadata_batch(
pub(crate) async fn process_metadata_batch(
pool: &PgPool,
job_id: Uuid,
library_id: Uuid,

View File

@@ -110,9 +110,16 @@ pub async fn start_refresh(
})));
}
// Check there are approved links to refresh
// Check there are approved links to refresh (only ongoing series)
let link_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM external_metadata_links WHERE library_id = $1 AND status = 'approved'",
r#"
SELECT COUNT(*) FROM external_metadata_links eml
LEFT JOIN series_metadata sm
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
WHERE eml.library_id = $1
AND eml.status = 'approved'
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
"#,
)
.bind(library_id)
.fetch_one(&state.pool)
@@ -124,14 +131,14 @@ pub async fn start_refresh(
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_refresh', 'pending')",
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_refresh', 'running', NOW())",
)
.bind(job_id)
.bind(library_id)
.execute(&state.pool)
.await?;
// Spawn the background processing task
// Spawn the background processing task (status already 'running' to avoid poller race)
let pool = state.pool.clone();
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
@@ -222,7 +229,7 @@ pub async fn get_refresh_report(
// Background processing
// ---------------------------------------------------------------------------
async fn process_metadata_refresh(
pub(crate) async fn process_metadata_refresh(
pool: &PgPool,
job_id: Uuid,
library_id: Uuid,
@@ -234,13 +241,17 @@ async fn process_metadata_refresh(
.await
.map_err(|e| e.to_string())?;
// Get all approved links for this library
// Get approved links for this library, only for ongoing series (not ended/cancelled)
let links: Vec<(Uuid, String, String, String)> = sqlx::query_as(
r#"
SELECT id, series_name, provider, external_id
FROM external_metadata_links
WHERE library_id = $1 AND status = 'approved'
ORDER BY series_name
SELECT eml.id, eml.series_name, eml.provider, eml.external_id
FROM external_metadata_links eml
LEFT JOIN series_metadata sm
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
WHERE eml.library_id = $1
AND eml.status = 'approved'
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
ORDER BY eml.series_name
"#,
)
.bind(library_id)

View File

@@ -1,11 +1,11 @@
use axum::{extract::{Path, State}, Json};
use axum::{extract::{Extension, Path, State}, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
use crate::{auth::AuthUser, error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct ReadingProgressResponse {
@@ -42,8 +42,10 @@ pub struct UpdateReadingProgressRequest {
)]
pub async fn get_reading_progress(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Path(id): Path<Uuid>,
) -> Result<Json<ReadingProgressResponse>, ApiError> {
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
// Verify book exists
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
.bind(id)
@@ -55,9 +57,10 @@ pub async fn get_reading_progress(
}
let row = sqlx::query(
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1",
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1 AND user_id = $2",
)
.bind(id)
.bind(auth_user.user_id)
.fetch_optional(&state.pool)
.await?;
@@ -96,9 +99,11 @@ pub async fn get_reading_progress(
)]
pub async fn update_reading_progress(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateReadingProgressRequest>,
) -> Result<Json<ReadingProgressResponse>, ApiError> {
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
// Validate status value
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
return Err(ApiError::bad_request(format!(
@@ -143,9 +148,9 @@ pub async fn update_reading_progress(
let row = sqlx::query(
r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (book_id) DO UPDATE
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = EXCLUDED.status,
current_page = EXCLUDED.current_page,
last_read_at = NOW(),
@@ -154,6 +159,7 @@ pub async fn update_reading_progress(
"#,
)
.bind(id)
.bind(auth_user.user_id)
.bind(&body.status)
.bind(current_page)
.fetch_one(&state.pool)
@@ -194,8 +200,10 @@ pub struct MarkSeriesReadResponse {
)]
pub async fn mark_series_read(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Json(body): Json<MarkSeriesReadRequest>,
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
if !["read", "unread"].contains(&body.status.as_str()) {
return Err(ApiError::bad_request(
"status must be 'read' or 'unread'",
@@ -209,24 +217,50 @@ pub async fn mark_series_read(
};
let sql = if body.status == "unread" {
// Delete progress records to reset to unread
// Delete progress records to reset to unread (scoped to this user)
if body.series == "unclassified" {
format!(
r#"
WITH target_books AS (
SELECT id FROM books WHERE {series_filter}
)
DELETE FROM book_reading_progress
WHERE book_id IN (SELECT id FROM target_books)
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $1
"#
)
} else {
format!(
r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
SELECT id, 'read', NULL, NOW(), NOW()
WITH target_books AS (
SELECT id FROM books WHERE {series_filter}
)
DELETE FROM book_reading_progress
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $2
"#
)
}
} else if body.series == "unclassified" {
format!(
r#"
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
SELECT id, $1, 'read', NULL, NOW(), NOW()
FROM books
WHERE {series_filter}
ON CONFLICT (book_id) DO UPDATE
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = 'read',
current_page = NULL,
last_read_at = NOW(),
updated_at = NOW()
"#
)
} else {
format!(
r#"
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
SELECT id, $2, 'read', NULL, NOW(), NOW()
FROM books
WHERE {series_filter}
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = 'read',
current_page = NULL,
last_read_at = NOW(),
@@ -236,9 +270,18 @@ pub async fn mark_series_read(
};
let result = if body.series == "unclassified" {
sqlx::query(&sql).execute(&state.pool).await?
// $1 = user_id (no series bind needed)
sqlx::query(&sql)
.bind(auth_user.user_id)
.execute(&state.pool)
.await?
} else {
sqlx::query(&sql).bind(&body.series).execute(&state.pool).await?
// $1 = series, $2 = user_id
sqlx::query(&sql)
.bind(&body.series)
.bind(auth_user.user_id)
.execute(&state.pool)
.await?
};
Ok(Json(MarkSeriesReadResponse {

View File

@@ -0,0 +1,614 @@
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use std::time::Duration;
use tracing::{info, warn};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::{anilist, error::ApiError, state::AppState};
// ---------------------------------------------------------------------------
// DTOs
// ---------------------------------------------------------------------------
#[derive(Deserialize, ToSchema)]
pub struct ReadingStatusMatchRequest {
pub library_id: String,
}
#[derive(Serialize, ToSchema)]
pub struct ReadingStatusMatchReportDto {
#[schema(value_type = String)]
pub job_id: Uuid,
pub status: String,
pub total_series: i64,
pub linked: i64,
pub already_linked: i64,
pub no_results: i64,
pub ambiguous: i64,
pub errors: i64,
}
#[derive(Serialize, ToSchema)]
pub struct ReadingStatusMatchResultDto {
#[schema(value_type = String)]
pub id: Uuid,
pub series_name: String,
/// 'linked' | 'already_linked' | 'no_results' | 'ambiguous' | 'error'
pub status: String,
pub anilist_id: Option<i32>,
pub anilist_title: Option<String>,
pub anilist_url: Option<String>,
pub error_message: Option<String>,
}
// ---------------------------------------------------------------------------
// POST /reading-status/match — Trigger a reading status match job
// ---------------------------------------------------------------------------
#[utoipa::path(
post,
path = "/reading-status/match",
tag = "reading_status",
request_body = ReadingStatusMatchRequest,
responses(
(status = 200, description = "Job created"),
(status = 400, description = "Bad request"),
),
security(("Bearer" = []))
)]
pub async fn start_match(
State(state): State<AppState>,
Json(body): Json<ReadingStatusMatchRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let library_id: Uuid = body
.library_id
.parse()
.map_err(|_| ApiError::bad_request("invalid library_id"))?;
// Verify library exists and has a reading_status_provider configured
let lib_row = sqlx::query("SELECT reading_status_provider FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::not_found("library not found"))?;
let provider: Option<String> = lib_row.get("reading_status_provider");
if provider.is_none() {
return Err(ApiError::bad_request(
"This library has no reading status provider configured",
));
}
// Check AniList is configured globally
anilist::load_anilist_settings(&state.pool).await?;
// Check no existing running job for this library
let existing: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM index_jobs WHERE library_id = $1 AND type = 'reading_status_match' AND status IN ('pending', 'running') LIMIT 1",
)
.bind(library_id)
.fetch_optional(&state.pool)
.await?;
if let Some(existing_id) = existing {
return Ok(Json(serde_json::json!({
"id": existing_id.to_string(),
"status": "already_running",
})));
}
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'reading_status_match', 'running', NOW())",
)
.bind(job_id)
.bind(library_id)
.execute(&state.pool)
.await?;
let pool = state.pool.clone();
let library_name: Option<String> =
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
tokio::spawn(async move {
if let Err(e) = process_reading_status_match(&pool, job_id, library_id).await {
warn!("[READING_STATUS_MATCH] job {job_id} failed: {e}");
let _ = sqlx::query(
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
)
.bind(job_id)
.bind(e.to_string())
.execute(&pool)
.await;
notifications::notify(
pool.clone(),
notifications::NotificationEvent::ReadingStatusMatchFailed {
library_name,
error: e.to_string(),
},
);
}
});
Ok(Json(serde_json::json!({
"id": job_id.to_string(),
"status": "running",
})))
}
// ---------------------------------------------------------------------------
// GET /reading-status/match/:id/report
// ---------------------------------------------------------------------------
#[utoipa::path(
get,
path = "/reading-status/match/{id}/report",
tag = "reading_status",
params(("id" = String, Path, description = "Job UUID")),
responses(
(status = 200, body = ReadingStatusMatchReportDto),
(status = 404, description = "Job not found"),
),
security(("Bearer" = []))
)]
pub async fn get_match_report(
State(state): State<AppState>,
axum::extract::Path(job_id): axum::extract::Path<Uuid>,
) -> Result<Json<ReadingStatusMatchReportDto>, ApiError> {
let row = sqlx::query(
"SELECT status, total_files FROM index_jobs WHERE id = $1 AND type = 'reading_status_match'",
)
.bind(job_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::not_found("job not found"))?;
let job_status: String = row.get("status");
let total_files: Option<i32> = row.get("total_files");
let counts = sqlx::query(
"SELECT status, COUNT(*) as cnt FROM reading_status_match_results WHERE job_id = $1 GROUP BY status",
)
.bind(job_id)
.fetch_all(&state.pool)
.await?;
let mut linked = 0i64;
let mut already_linked = 0i64;
let mut no_results = 0i64;
let mut ambiguous = 0i64;
let mut errors = 0i64;
for r in &counts {
let status: String = r.get("status");
let cnt: i64 = r.get("cnt");
match status.as_str() {
"linked" => linked = cnt,
"already_linked" => already_linked = cnt,
"no_results" => no_results = cnt,
"ambiguous" => ambiguous = cnt,
"error" => errors = cnt,
_ => {}
}
}
Ok(Json(ReadingStatusMatchReportDto {
job_id,
status: job_status,
total_series: total_files.unwrap_or(0) as i64,
linked,
already_linked,
no_results,
ambiguous,
errors,
}))
}
// ---------------------------------------------------------------------------
// GET /reading-status/match/:id/results
// ---------------------------------------------------------------------------
#[utoipa::path(
get,
path = "/reading-status/match/{id}/results",
tag = "reading_status",
params(
("id" = String, Path, description = "Job UUID"),
("status" = Option<String>, Query, description = "Filter by status"),
),
responses(
(status = 200, body = Vec<ReadingStatusMatchResultDto>),
),
security(("Bearer" = []))
)]
pub async fn get_match_results(
State(state): State<AppState>,
axum::extract::Path(job_id): axum::extract::Path<Uuid>,
axum::extract::Query(query): axum::extract::Query<ResultsQuery>,
) -> Result<Json<Vec<ReadingStatusMatchResultDto>>, ApiError> {
let rows = if let Some(status_filter) = &query.status {
sqlx::query(
"SELECT id, series_name, status, anilist_id, anilist_title, anilist_url, error_message
FROM reading_status_match_results
WHERE job_id = $1 AND status = $2
ORDER BY series_name",
)
.bind(job_id)
.bind(status_filter)
.fetch_all(&state.pool)
.await?
} else {
sqlx::query(
"SELECT id, series_name, status, anilist_id, anilist_title, anilist_url, error_message
FROM reading_status_match_results
WHERE job_id = $1
ORDER BY status, series_name",
)
.bind(job_id)
.fetch_all(&state.pool)
.await?
};
let results = rows
.iter()
.map(|row| ReadingStatusMatchResultDto {
id: row.get("id"),
series_name: row.get("series_name"),
status: row.get("status"),
anilist_id: row.get("anilist_id"),
anilist_title: row.get("anilist_title"),
anilist_url: row.get("anilist_url"),
error_message: row.get("error_message"),
})
.collect();
Ok(Json(results))
}
#[derive(Deserialize)]
pub struct ResultsQuery {
pub status: Option<String>,
}
// ---------------------------------------------------------------------------
// Background processing
// ---------------------------------------------------------------------------
pub(crate) async fn process_reading_status_match(
pool: &PgPool,
job_id: Uuid,
library_id: Uuid,
) -> Result<(), String> {
let (token, _, _) = anilist::load_anilist_settings(pool)
.await
.map_err(|e| e.message)?;
let series_names: Vec<String> = sqlx::query_scalar(
r#"
SELECT DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')
FROM books
WHERE library_id = $1
ORDER BY 1
"#,
)
.bind(library_id)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let total = series_names.len() as i32;
sqlx::query("UPDATE index_jobs SET total_files = $2 WHERE id = $1")
.bind(job_id)
.bind(total)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
let already_linked: std::collections::HashSet<String> = sqlx::query_scalar(
"SELECT series_name FROM anilist_series_links WHERE library_id = $1",
)
.bind(library_id)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?
.into_iter()
.collect();
let mut processed = 0i32;
for series_name in &series_names {
if is_job_cancelled(pool, job_id).await {
sqlx::query(
"UPDATE index_jobs SET status = 'cancelled', finished_at = NOW() WHERE id = $1",
)
.bind(job_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
return Ok(());
}
processed += 1;
let progress = (processed * 100 / total.max(1)).min(100);
sqlx::query(
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3, current_file = $4 WHERE id = $1",
)
.bind(job_id)
.bind(processed)
.bind(progress)
.bind(series_name)
.execute(pool)
.await
.ok();
if series_name == "unclassified" {
insert_result(pool, job_id, library_id, series_name, "already_linked", None, None, None, None).await;
continue;
}
if already_linked.contains(series_name) {
insert_result(pool, job_id, library_id, series_name, "already_linked", None, None, None, None).await;
continue;
}
match search_and_link(pool, library_id, series_name, &token).await {
Ok(Outcome::Linked { anilist_id, anilist_title, anilist_url }) => {
insert_result(pool, job_id, library_id, series_name, "linked", Some(anilist_id), anilist_title.as_deref(), anilist_url.as_deref(), None).await;
}
Ok(Outcome::NoResults) => {
insert_result(pool, job_id, library_id, series_name, "no_results", None, None, None, None).await;
}
Ok(Outcome::Ambiguous) => {
insert_result(pool, job_id, library_id, series_name, "ambiguous", None, None, None, None).await;
}
Err(e) if e.contains("429") || e.contains("Too Many Requests") => {
warn!("[READING_STATUS_MATCH] rate limit hit for '{series_name}', waiting 10s before retry");
tokio::time::sleep(Duration::from_secs(10)).await;
match search_and_link(pool, library_id, series_name, &token).await {
Ok(Outcome::Linked { anilist_id, anilist_title, anilist_url }) => {
insert_result(pool, job_id, library_id, series_name, "linked", Some(anilist_id), anilist_title.as_deref(), anilist_url.as_deref(), None).await;
}
Ok(Outcome::NoResults) => {
insert_result(pool, job_id, library_id, series_name, "no_results", None, None, None, None).await;
}
Ok(Outcome::Ambiguous) => {
insert_result(pool, job_id, library_id, series_name, "ambiguous", None, None, None, None).await;
}
Err(e2) => {
return Err(format!(
"AniList rate limit exceeded (429) — job stopped after {processed}/{total} series: {e2}"
));
}
}
}
Err(e) => {
warn!("[READING_STATUS_MATCH] series '{series_name}': {e}");
insert_result(pool, job_id, library_id, series_name, "error", None, None, None, Some(&e)).await;
}
}
// Respect AniList rate limit (~90 req/min)
tokio::time::sleep(Duration::from_millis(700)).await;
}
// Build stats from results table
let counts = sqlx::query(
"SELECT status, COUNT(*) as cnt FROM reading_status_match_results WHERE job_id = $1 GROUP BY status",
)
.bind(job_id)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let mut count_linked = 0i64;
let mut count_already_linked = 0i64;
let mut count_no_results = 0i64;
let mut count_ambiguous = 0i64;
let mut count_errors = 0i64;
for row in &counts {
let s: String = row.get("status");
let c: i64 = row.get("cnt");
match s.as_str() {
"linked" => count_linked = c,
"already_linked" => count_already_linked = c,
"no_results" => count_no_results = c,
"ambiguous" => count_ambiguous = c,
"error" => count_errors = c,
_ => {}
}
}
let stats = serde_json::json!({
"total_series": total as i64,
"linked": count_linked,
"already_linked": count_already_linked,
"no_results": count_no_results,
"ambiguous": count_ambiguous,
"errors": count_errors,
});
sqlx::query(
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), stats_json = $2, progress_percent = 100 WHERE id = $1",
)
.bind(job_id)
.bind(&stats)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
info!(
"[READING_STATUS_MATCH] job={job_id} completed: {}/{} series, linked={count_linked}, ambiguous={count_ambiguous}, no_results={count_no_results}, errors={count_errors}",
processed, total
);
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
notifications::notify(
pool.clone(),
notifications::NotificationEvent::ReadingStatusMatchCompleted {
library_name,
total_series: total,
linked: count_linked as i32,
},
);
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
async fn insert_result(
pool: &PgPool,
job_id: Uuid,
library_id: Uuid,
series_name: &str,
status: &str,
anilist_id: Option<i32>,
anilist_title: Option<&str>,
anilist_url: Option<&str>,
error_message: Option<&str>,
) {
let _ = sqlx::query(
r#"
INSERT INTO reading_status_match_results
(job_id, library_id, series_name, status, anilist_id, anilist_title, anilist_url, error_message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
)
.bind(job_id)
.bind(library_id)
.bind(series_name)
.bind(status)
.bind(anilist_id)
.bind(anilist_title)
.bind(anilist_url)
.bind(error_message)
.execute(pool)
.await;
}
enum Outcome {
Linked {
anilist_id: i32,
anilist_title: Option<String>,
anilist_url: Option<String>,
},
NoResults,
Ambiguous,
}
async fn search_and_link(
pool: &PgPool,
library_id: Uuid,
series_name: &str,
token: &str,
) -> Result<Outcome, String> {
let gql = r#"
query SearchManga($search: String) {
Page(perPage: 10) {
media(search: $search, type: MANGA, sort: [SEARCH_MATCH]) {
id
title { romaji english native }
siteUrl
}
}
}
"#;
let data = anilist::anilist_graphql(token, gql, serde_json::json!({ "search": series_name }))
.await
.map_err(|e| e.message)?;
let media: Vec<serde_json::Value> = match data["Page"]["media"].as_array() {
Some(arr) => arr.clone(),
None => return Ok(Outcome::NoResults),
};
if media.is_empty() {
return Ok(Outcome::NoResults);
}
let normalized_query = normalize_title(series_name);
let exact_matches: Vec<_> = media
.iter()
.filter(|m| {
let romaji = m["title"]["romaji"].as_str().map(normalize_title);
let english = m["title"]["english"].as_str().map(normalize_title);
let native = m["title"]["native"].as_str().map(normalize_title);
romaji.as_deref() == Some(&normalized_query)
|| english.as_deref() == Some(&normalized_query)
|| native.as_deref() == Some(&normalized_query)
})
.collect();
let candidate = if exact_matches.len() == 1 {
exact_matches[0]
} else if exact_matches.is_empty() && media.len() == 1 {
&media[0]
} else {
return Ok(Outcome::Ambiguous);
};
let anilist_id = candidate["id"].as_i64().unwrap_or(0) as i32;
let anilist_title = candidate["title"]["english"]
.as_str()
.or_else(|| candidate["title"]["romaji"].as_str())
.map(String::from);
let anilist_url = candidate["siteUrl"].as_str().map(String::from);
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 NOTHING
"#,
)
.bind(library_id)
.bind(series_name)
.bind(anilist_id)
.bind(&anilist_title)
.bind(&anilist_url)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Ok(Outcome::Linked {
anilist_id,
anilist_title,
anilist_url,
})
}
fn normalize_title(s: &str) -> String {
s.to_lowercase()
.replace([':', '!', '?', '.', ',', '\'', '"', '-', '_'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
async fn is_job_cancelled(pool: &PgPool, job_id: Uuid) -> bool {
sqlx::query_scalar::<_, String>("SELECT status FROM index_jobs WHERE id = $1")
.bind(job_id)
.fetch_optional(pool)
.await
.ok()
.flatten()
.as_deref()
== Some("cancelled")
}

View File

@@ -1,10 +1,11 @@
use axum::extract::Extension;
use axum::{extract::{Path, Query, State}, Json};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{books::BookItem, error::ApiError, state::AppState};
use crate::{auth::AuthUser, books::BookItem, error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct SeriesItem {
@@ -18,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)]
@@ -70,9 +73,11 @@ pub struct ListSeriesQuery {
)]
pub async fn list_series(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Path(library_id): Path<Uuid>,
Query(query): Query<ListSeriesQuery>,
) -> Result<Json<SeriesPage>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
@@ -115,6 +120,10 @@ pub async fn list_series(
None => String::new(),
};
let user_id_p = p + 1;
let limit_p = p + 2;
let offset_p = p + 3;
let missing_cte = r#"
missing_counts AS (
SELECT eml.series_name,
@@ -147,7 +156,7 @@ pub async fn list_series(
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
GROUP BY sb.name
),
{missing_cte},
@@ -160,9 +169,6 @@ pub async fn list_series(
"#
);
let limit_p = p + 1;
let offset_p = p + 2;
let data_sql = format!(
r#"
WITH sorted_books AS (
@@ -186,7 +192,7 @@ pub async fn list_series(
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
GROUP BY sb.name
),
{missing_cte},
@@ -198,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}
@@ -245,7 +254,8 @@ pub async fn list_series(
}
}
data_builder = data_builder.bind(limit).bind(offset);
count_builder = count_builder.bind(user_id);
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
@@ -264,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();
@@ -327,8 +339,10 @@ pub struct ListAllSeriesQuery {
)]
pub async fn list_all_series(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Query(query): Query<ListAllSeriesQuery>,
) -> Result<Json<SeriesPage>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
@@ -415,6 +429,10 @@ pub async fn list_all_series(
)
"#;
let user_id_p = p + 1;
let limit_p = p + 2;
let offset_p = p + 3;
let count_sql = format!(
r#"
WITH sorted_books AS (
@@ -426,7 +444,7 @@ pub async fn list_all_series(
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
GROUP BY sb.name, sb.library_id
),
{missing_cte},
@@ -445,9 +463,6 @@ pub async fn list_all_series(
"REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''), COALESCE((REGEXP_MATCH(LOWER(sc.name), '\\d+'))[1]::int, 0), sc.name ASC".to_string()
};
let limit_p = p + 1;
let offset_p = p + 2;
let data_sql = format!(
r#"
WITH sorted_books AS (
@@ -475,7 +490,7 @@ pub async fn list_all_series(
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
MAX(sb.updated_at) as latest_updated_at
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
GROUP BY sb.name, sb.library_id
),
{missing_cte},
@@ -488,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}
@@ -538,7 +556,8 @@ pub async fn list_all_series(
data_builder = data_builder.bind(author.clone());
}
data_builder = data_builder.bind(limit).bind(offset);
count_builder = count_builder.bind(user_id);
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
@@ -557,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();
@@ -642,8 +663,10 @@ pub struct OngoingQuery {
)]
pub async fn ongoing_series(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Query(query): Query<OngoingQuery>,
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(10).clamp(1, 50);
let rows = sqlx::query(
@@ -655,7 +678,7 @@ pub async fn ongoing_series(
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read_count,
MAX(brp.last_read_at) AS last_read_at
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
HAVING (
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
@@ -685,6 +708,7 @@ pub async fn ongoing_series(
"#,
)
.bind(limit)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
@@ -699,6 +723,8 @@ pub async fn ongoing_series(
series_status: None,
missing_count: None,
metadata_provider: None,
anilist_id: None,
anilist_url: None,
})
.collect();
@@ -721,8 +747,10 @@ pub async fn ongoing_series(
)]
pub async fn ongoing_books(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Query(query): Query<OngoingQuery>,
) -> Result<Json<Vec<BookItem>>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(10).clamp(1, 50);
let rows = sqlx::query(
@@ -732,7 +760,7 @@ pub async fn ongoing_books(
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
MAX(brp.last_read_at) AS series_last_read_at
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
HAVING (
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
@@ -753,7 +781,7 @@ pub async fn ongoing_books(
) AS rn
FROM books b
JOIN ongoing_series os ON COALESCE(NULLIF(b.series, ''), 'unclassified') = os.name
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
WHERE COALESCE(brp.status, 'unread') != 'read'
)
SELECT id, library_id, kind, format, title, author, authors, series, volume, language, page_count,
@@ -765,6 +793,7 @@ pub async fn ongoing_books(
"#,
)
.bind(limit)
.bind(user_id)
.fetch_all(&state.pool)
.await?;

View File

@@ -1,9 +1,18 @@
use axum::{extract::State, Json};
use serde::Serialize;
use axum::{
extract::{Extension, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
use crate::{error::ApiError, state::AppState};
use crate::{auth::AuthUser, error::ApiError, state::AppState};
#[derive(Deserialize, IntoParams)]
pub struct StatsQuery {
/// Granularity: "day", "week" or "month" (default: "month")
pub period: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct StatsOverview {
@@ -81,6 +90,7 @@ pub struct CurrentlyReadingItem {
pub series: Option<String>,
pub current_page: i32,
pub page_count: i32,
pub username: Option<String>,
}
#[derive(Serialize, ToSchema)]
@@ -89,12 +99,31 @@ pub struct RecentlyReadItem {
pub title: String,
pub series: Option<String>,
pub last_read_at: String,
pub username: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct MonthlyReading {
pub month: String,
pub books_read: i64,
pub pages_read: i64,
}
#[derive(Serialize, ToSchema)]
pub struct UserMonthlyReading {
pub month: String,
pub username: String,
pub books_read: i64,
pub pages_read: i64,
}
#[derive(Serialize, ToSchema)]
pub struct JobTimePoint {
pub label: String,
pub scan: i64,
pub rebuild: i64,
pub thumbnail: i64,
pub other: i64,
}
#[derive(Serialize, ToSchema)]
@@ -109,7 +138,9 @@ pub struct StatsResponse {
pub by_library: Vec<LibraryStats>,
pub top_series: Vec<TopSeries>,
pub additions_over_time: Vec<MonthlyAdditions>,
pub jobs_over_time: Vec<JobTimePoint>,
pub metadata: MetadataStats,
pub users_reading_over_time: Vec<UserMonthlyReading>,
}
/// Get collection statistics for the dashboard
@@ -117,6 +148,7 @@ pub struct StatsResponse {
get,
path = "/stats",
tag = "stats",
params(StatsQuery),
responses(
(status = 200, body = StatsResponse),
(status = 401, description = "Unauthorized"),
@@ -125,7 +157,11 @@ pub struct StatsResponse {
)]
pub async fn get_stats(
State(state): State<AppState>,
Query(query): Query<StatsQuery>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<StatsResponse>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let period = query.period.as_deref().unwrap_or("month");
// Overview + reading status in one query
let overview_row = sqlx::query(
r#"
@@ -143,9 +179,10 @@ pub async fn get_stats(
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
"#,
)
.bind(user_id)
.fetch_one(&state.pool)
.await?;
@@ -233,7 +270,7 @@ pub async fn get_stats(
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
FROM libraries l
LEFT JOIN books b ON b.library_id = l.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
LEFT JOIN LATERAL (
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
) bf ON TRUE
@@ -241,6 +278,7 @@ pub async fn get_stats(
ORDER BY book_count DESC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
@@ -265,13 +303,14 @@ pub async fn get_stats(
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
WHERE b.series IS NOT NULL AND b.series != ''
GROUP BY b.series
ORDER BY book_count DESC
LIMIT 10
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
@@ -285,20 +324,74 @@ pub async fn get_stats(
})
.collect();
// Additions over time (last 12 months)
let additions_rows = sqlx::query(
// Additions over time (with gap filling)
let additions_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
COUNT(*) AS books_added
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT created_at::date AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', created_at)
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY created_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?;
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
.iter()
@@ -356,14 +449,17 @@ pub async fn get_stats(
// Currently reading books
let reading_rows = sqlx::query(
r#"
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count, u.username
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
LEFT JOIN users u ON u.id = brp.user_id
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
AND ($1::uuid IS NULL OR brp.user_id = $1)
ORDER BY brp.updated_at DESC
LIMIT 20
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
@@ -377,6 +473,7 @@ pub async fn get_stats(
series: r.get("series"),
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
username: r.get("username"),
}
})
.collect();
@@ -385,14 +482,18 @@ pub async fn get_stats(
let recent_rows = sqlx::query(
r#"
SELECT b.id AS book_id, b.title, b.series,
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at,
u.username
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
LEFT JOIN users u ON u.id = brp.user_id
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
AND ($1::uuid IS NULL OR brp.user_id = $1)
ORDER BY brp.last_read_at DESC
LIMIT 10
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
@@ -405,31 +506,320 @@ pub async fn get_stats(
title: r.get("title"),
series: r.get("series"),
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
username: r.get("username"),
}
})
.collect();
// Reading activity over time (last 12 months)
let reading_time_rows = sqlx::query(
// Reading activity over time (with gap filling)
let reading_time_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(DATE_TRUNC('month', brp.last_read_at), 'YYYY-MM') AS month,
COUNT(*) AS books_read
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_read, 0) AS books_read,
COALESCE(cnt.pages_read, 0) AS pages_read
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read,
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', brp.last_read_at)
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY brp.last_read_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_read, 0) AS books_read,
COALESCE(cnt.pages_read, 0) AS pages_read
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read,
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('week', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_read, 0) AS books_read,
COALESCE(cnt.pages_read, 0) AS pages_read
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read,
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('month', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
};
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
.iter()
.map(|r| MonthlyReading {
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
books_read: r.get("books_read"),
pages_read: r.get("pages_read"),
})
.collect();
// Per-user reading over time (admin view — always all users, no user_id filter)
let users_reading_time_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read,
COALESCE(cnt.pages_read, 0) AS pages_read
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT brp.last_read_at::date AS dt, brp.user_id, COUNT(*) AS books_read,
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
WHERE brp.status = 'read'
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY brp.last_read_at::date, brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read,
COALESCE(cnt.pages_read, 0) AS pages_read
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read,
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', brp.last_read_at), brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read,
COALESCE(cnt.pages_read, 0) AS pages_read
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read,
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', brp.last_read_at), brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let users_reading_over_time: Vec<UserMonthlyReading> = users_reading_time_rows
.iter()
.map(|r| UserMonthlyReading {
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
username: r.get("username"),
books_read: r.get("books_read"),
pages_read: r.get("pages_read"),
})
.collect();
// Jobs over time (with gap filling, grouped by type category)
let jobs_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT
finished_at::date AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY finished_at::date, cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT
DATE_TRUNC('week', finished_at) AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', finished_at), cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT
DATE_TRUNC('month', finished_at) AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', finished_at), cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let jobs_over_time: Vec<JobTimePoint> = jobs_rows
.iter()
.map(|r| JobTimePoint {
label: r.get("label"),
scan: r.get("scan"),
rebuild: r.get("rebuild"),
thumbnail: r.get("thumbnail"),
other: r.get("other"),
})
.collect();
@@ -444,6 +834,8 @@ pub async fn get_stats(
by_library,
top_series,
additions_over_time,
jobs_over_time,
metadata,
users_reading_over_time,
}))
}

View File

@@ -16,6 +16,8 @@ pub struct CreateTokenRequest {
pub name: String,
#[schema(value_type = Option<String>, example = "read")]
pub scope: Option<String>,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
}
#[derive(Serialize, ToSchema)]
@@ -26,6 +28,9 @@ pub struct TokenResponse {
pub scope: String,
pub prefix: String,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
pub username: Option<String>,
#[schema(value_type = Option<String>)]
pub last_used_at: Option<DateTime<Utc>>,
#[schema(value_type = Option<String>)]
pub revoked_at: Option<DateTime<Utc>>,
@@ -71,6 +76,10 @@ pub async fn create_token(
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
};
if scope == "read" && input.user_id.is_none() {
return Err(ApiError::bad_request("user_id is required for read-scoped tokens"));
}
let mut random = [0u8; 24];
OsRng.fill_bytes(&mut random);
let secret = URL_SAFE_NO_PAD.encode(random);
@@ -85,13 +94,14 @@ pub async fn create_token(
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope, user_id) VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(id)
.bind(input.name.trim())
.bind(&prefix)
.bind(token_hash)
.bind(scope)
.bind(input.user_id)
.execute(&state.pool)
.await?;
@@ -118,7 +128,13 @@ pub async fn create_token(
)]
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC",
r#"
SELECT t.id, t.name, t.scope, t.prefix, t.user_id, u.username,
t.last_used_at, t.revoked_at, t.created_at
FROM api_tokens t
LEFT JOIN users u ON u.id = t.user_id
ORDER BY t.created_at DESC
"#,
)
.fetch_all(&state.pool)
.await?;
@@ -130,6 +146,8 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
name: row.get("name"),
scope: row.get("scope"),
prefix: row.get("prefix"),
user_id: row.get("user_id"),
username: row.get("username"),
last_used_at: row.get("last_used_at"),
revoked_at: row.get("revoked_at"),
created_at: row.get("created_at"),
@@ -171,6 +189,47 @@ pub async fn revoke_token(
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateTokenRequest {
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
}
/// Update a token's assigned user
#[utoipa::path(
patch,
path = "/admin/tokens/{id}",
tag = "tokens",
params(
("id" = String, Path, description = "Token UUID"),
),
request_body = UpdateTokenRequest,
responses(
(status = 200, description = "Token updated"),
(status = 404, description = "Token not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_token(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTokenRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
.bind(input.user_id)
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("token not found"));
}
Ok(Json(serde_json::json!({"updated": true, "id": id})))
}
/// Permanently delete a revoked API token
#[utoipa::path(
post,

195
apps/api/src/users.rs Normal file
View File

@@ -0,0 +1,195 @@
use axum::{extract::{Path, State}, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct UserResponse {
#[schema(value_type = String)]
pub id: Uuid,
pub username: String,
pub token_count: i64,
pub books_read: i64,
pub books_reading: i64,
#[schema(value_type = String)]
pub created_at: DateTime<Utc>,
}
#[derive(Deserialize, ToSchema)]
pub struct CreateUserRequest {
pub username: String,
}
/// List all reader users with their associated token count
#[utoipa::path(
get,
path = "/admin/users",
tag = "users",
responses(
(status = 200, body = Vec<UserResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn list_users(State(state): State<AppState>) -> Result<Json<Vec<UserResponse>>, ApiError> {
let rows = sqlx::query(
r#"
SELECT u.id, u.username, u.created_at,
COUNT(DISTINCT t.id) AS token_count,
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read,
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'reading') AS books_reading
FROM users u
LEFT JOIN api_tokens t ON t.user_id = u.id AND t.revoked_at IS NULL
LEFT JOIN book_reading_progress brp ON brp.user_id = u.id
GROUP BY u.id, u.username, u.created_at
ORDER BY u.created_at DESC
"#,
)
.fetch_all(&state.pool)
.await?;
let items = rows
.into_iter()
.map(|row| UserResponse {
id: row.get("id"),
username: row.get("username"),
token_count: row.get("token_count"),
books_read: row.get("books_read"),
books_reading: row.get("books_reading"),
created_at: row.get("created_at"),
})
.collect();
Ok(Json(items))
}
/// Create a new reader user
#[utoipa::path(
post,
path = "/admin/users",
tag = "users",
request_body = CreateUserRequest,
responses(
(status = 200, body = UserResponse, description = "User created"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn create_user(
State(state): State<AppState>,
Json(input): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>, ApiError> {
if input.username.trim().is_empty() {
return Err(ApiError::bad_request("username is required"));
}
let id = Uuid::new_v4();
let row = sqlx::query(
"INSERT INTO users (id, username) VALUES ($1, $2) RETURNING id, username, created_at",
)
.bind(id)
.bind(input.username.trim())
.fetch_one(&state.pool)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.constraint() == Some("users_username_key") {
return ApiError::bad_request("username already exists");
}
}
ApiError::from(e)
})?;
Ok(Json(UserResponse {
id: row.get("id"),
username: row.get("username"),
token_count: 0,
books_read: 0,
books_reading: 0,
created_at: row.get("created_at"),
}))
}
/// Update a reader user's username
#[utoipa::path(
patch,
path = "/admin/users/{id}",
tag = "users",
request_body = CreateUserRequest,
responses(
(status = 200, body = UserResponse, description = "User updated"),
(status = 400, description = "Invalid input"),
(status = 404, description = "User not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<CreateUserRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
if input.username.trim().is_empty() {
return Err(ApiError::bad_request("username is required"));
}
let result = sqlx::query("UPDATE users SET username = $1 WHERE id = $2")
.bind(input.username.trim())
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.constraint() == Some("users_username_key") {
return ApiError::bad_request("username already exists");
}
}
ApiError::from(e)
})?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("user not found"));
}
Ok(Json(serde_json::json!({"updated": true, "id": id})))
}
/// Delete a reader user (cascades on tokens and reading progress)
#[utoipa::path(
delete,
path = "/admin/users/{id}",
tag = "users",
params(
("id" = String, Path, description = "User UUID"),
),
responses(
(status = 200, description = "User deleted"),
(status = 404, description = "User not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn delete_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("user not found"));
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
}

View File

@@ -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<string, unknown>) =>
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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4 p-8">
{status === "loading" && (
<>
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-muted-foreground">Connexion AniList en cours</p>
</>
)}
{status === "success" && (
<>
<div className="w-12 h-12 rounded-full bg-success/15 flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-success font-medium">{message}</p>
<p className="text-sm text-muted-foreground">Redirection vers les paramètres</p>
</>
)}
{status === "error" && (
<>
<div className="w-12 h-12 rounded-full bg-destructive/15 flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-destructive font-medium">{message}</p>
<a href="/settings" className="text-sm text-primary hover:underline">
Retour aux paramètres
</a>
</>
)}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api";
import { getServerTranslations } from "../../../lib/i18n/server";
import { BooksGrid } from "../../components/BookCard";
import { OffsetPagination } from "../../components/ui";
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import { BooksGrid } from "@/app/components/BookCard";
import { OffsetPagination } from "@/app/components/ui";
import Image from "next/image";
import Link from "next/link";

View File

@@ -1,7 +1,7 @@
import { fetchAuthors, AuthorsPageDto } from "../../lib/api";
import { getServerTranslations } from "../../lib/i18n/server";
import { LiveSearchForm } from "../components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui";
import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link";
export const dynamic = "force-dynamic";

View File

@@ -1,15 +1,15 @@
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
import { BookPreview } from "../../components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton";
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "@/lib/api";
import { BookPreview } from "@/app/components/BookPreview";
import { ConvertButton } from "@/app/components/ConvertButton";
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import nextDynamic from "next/dynamic";
import { SafeHtml } from "../../components/SafeHtml";
import { getServerTranslations } from "../../../lib/i18n/server";
import { SafeHtml } from "@/app/components/SafeHtml";
import { getServerTranslations } from "@/lib/i18n/server";
import Image from "next/image";
import Link from "next/link";
const EditBookForm = nextDynamic(
() => import("../../components/EditBookForm").then(m => m.EditBookForm)
() => import("@/app/components/EditBookForm").then(m => m.EditBookForm)
);
import { notFound } from "next/navigation";

View File

@@ -1,10 +1,10 @@
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api";
import { BooksGrid, EmptyState } from "../components/BookCard";
import { LiveSearchForm } from "../components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui";
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "@/lib/api";
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link";
import Image from "next/image";
import { getServerTranslations } from "../../lib/i18n/server";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";

View File

@@ -2,13 +2,13 @@ export const dynamic = "force-dynamic";
import { notFound } from "next/navigation";
import Link from "next/link";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, getReadingStatusMatchReport, getReadingStatusMatchResults, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto, ReadingStatusMatchReportDto, ReadingStatusMatchResultDto } from "@/lib/api";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
StatusBadge, JobTypeBadge, StatBox, ProgressBar
} from "../../components/ui";
import { JobDetailLive } from "../../components/JobDetailLive";
import { getServerTranslations } from "../../../lib/i18n/server";
} from "@/app/components/ui";
import { JobDetailLive } from "@/app/components/JobDetailLive";
import { getServerTranslations } from "@/lib/i18n/server";
interface JobDetailPageProps {
params: Promise<{ id: string }>;
@@ -132,10 +132,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
description: t("jobType.metadata_refreshDesc"),
isThumbnailOnly: false,
},
reading_status_match: {
label: t("jobType.reading_status_matchLabel"),
description: t("jobType.reading_status_matchDesc"),
isThumbnailOnly: false,
},
};
const isMetadataBatch = job.type === "metadata_batch";
const isMetadataRefresh = job.type === "metadata_refresh";
const isReadingStatusMatch = job.type === "reading_status_match";
// Fetch batch report & results for metadata_batch jobs
let batchReport: MetadataBatchReportDto | null = null;
@@ -153,6 +159,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
refreshReport = await getMetadataRefreshReport(id).catch(() => null);
}
// Fetch reading status match report & results
let readingStatusReport: ReadingStatusMatchReportDto | null = null;
let readingStatusResults: ReadingStatusMatchResultDto[] = [];
if (isReadingStatusMatch) {
[readingStatusReport, readingStatusResults] = await Promise.all([
getReadingStatusMatchReport(id).catch(() => null),
getReadingStatusMatchResults(id).catch(() => []),
]);
}
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
label: job.type,
description: null,
@@ -177,6 +193,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
? t("jobDetail.metadataSearch")
: isMetadataRefresh
? t("jobDetail.metadataRefresh")
: isReadingStatusMatch
? t("jobDetail.readingStatusMatch")
: isThumbnailOnly
? t("jobType.thumbnail_rebuild")
: isExtractingPages
@@ -189,6 +207,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
? t("jobDetail.metadataSearchDesc")
: isMetadataRefresh
? t("jobDetail.metadataRefreshDesc")
: isReadingStatusMatch
? t("jobDetail.readingStatusMatchDesc")
: isThumbnailOnly
? undefined
: isExtractingPages
@@ -240,7 +260,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
{refreshReport.refreshed} {t("jobDetail.refreshed").toLowerCase()}, {refreshReport.unchanged} {t("jobDetail.unchanged").toLowerCase()}, {refreshReport.errors} {t("jobDetail.errors").toLowerCase()}
</span>
)}
{!isMetadataBatch && !isMetadataRefresh && job.stats_json && (
{isReadingStatusMatch && readingStatusReport && (
<span className="ml-2 text-success/80">
{readingStatusReport.linked} {t("jobDetail.linked").toLowerCase()}, {readingStatusReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {readingStatusReport.ambiguous} {t("jobDetail.ambiguous").toLowerCase()}, {readingStatusReport.errors} {t("jobDetail.errors").toLowerCase()}
</span>
)}
{!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && job.stats_json && (
<span className="ml-2 text-success/80">
{job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
@@ -249,7 +274,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`}
</span>
)}
{!isMetadataBatch && !isMetadataRefresh && !job.stats_json && isThumbnailOnly && job.total_files != null && (
{!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !job.stats_json && isThumbnailOnly && job.total_files != null && (
<span className="ml-2 text-success/80">
{job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()}
</span>
@@ -514,7 +539,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
)}
{/* Index Statistics — index jobs only */}
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && (
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.indexStats")}</CardTitle>
@@ -587,7 +612,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatBox value={refreshReport.refreshed} label={t("jobDetail.refreshed")} variant="success" />
<StatBox
value={refreshReport.refreshed}
label={t("jobDetail.refreshed")}
variant="success"
icon={
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
/>
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
@@ -704,6 +738,95 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
</Card>
)}
{/* Reading status match — summary report */}
{isReadingStatusMatch && readingStatusReport && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.readingStatusMatchReport")}</CardTitle>
<CardDescription>{t("jobDetail.seriesAnalyzed", { count: String(readingStatusReport.total_series) })}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<StatBox value={readingStatusReport.linked} label={t("jobDetail.linked")} variant="success" />
<StatBox value={readingStatusReport.already_linked} label={t("jobDetail.alreadyLinked")} variant="primary" />
<StatBox value={readingStatusReport.no_results} label={t("jobDetail.noResults")} />
<StatBox value={readingStatusReport.ambiguous} label={t("jobDetail.ambiguous")} variant="warning" />
<StatBox value={readingStatusReport.errors} label={t("jobDetail.errors")} variant={readingStatusReport.errors > 0 ? "error" : "default"} />
</div>
</CardContent>
</Card>
)}
{/* Reading status match — per-series detail */}
{isReadingStatusMatch && readingStatusResults.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>{t("jobDetail.resultsBySeries")}</CardTitle>
<CardDescription>{t("jobDetail.seriesProcessed", { count: String(readingStatusResults.length) })}</CardDescription>
</CardHeader>
<CardContent className="space-y-2 max-h-[600px] overflow-y-auto">
{readingStatusResults.map((r) => (
<div
key={r.id}
className={`p-3 rounded-lg border ${
r.status === "linked" ? "bg-success/10 border-success/20" :
r.status === "already_linked" ? "bg-primary/10 border-primary/20" :
r.status === "error" ? "bg-destructive/10 border-destructive/20" :
r.status === "ambiguous" ? "bg-amber-500/10 border-amber-500/20" :
"bg-muted/50 border-border/60"
}`}
>
<div className="flex items-center justify-between gap-2">
{job.library_id ? (
<Link
href={`/libraries/${job.library_id}/series/${encodeURIComponent(r.series_name)}`}
className="font-medium text-sm text-primary hover:underline truncate"
>
{r.series_name}
</Link>
) : (
<span className="font-medium text-sm text-foreground truncate">{r.series_name}</span>
)}
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap ${
r.status === "linked" ? "bg-success/20 text-success" :
r.status === "already_linked" ? "bg-primary/20 text-primary" :
r.status === "no_results" ? "bg-muted text-muted-foreground" :
r.status === "ambiguous" ? "bg-amber-500/15 text-amber-600" :
r.status === "error" ? "bg-destructive/20 text-destructive" :
"bg-muted text-muted-foreground"
}`}>
{r.status === "linked" ? t("jobDetail.linked") :
r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
r.status === "no_results" ? t("jobDetail.noResults") :
r.status === "ambiguous" ? t("jobDetail.ambiguous") :
r.status === "error" ? t("common.error") :
r.status}
</span>
</div>
{r.status === "linked" && r.anilist_title && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<svg className="w-3 h-3 text-success shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
{r.anilist_url ? (
<a href={r.anilist_url} target="_blank" rel="noopener noreferrer" className="text-success hover:underline">
{r.anilist_title}
</a>
) : (
<span className="text-success">{r.anilist_title}</span>
)}
{r.anilist_id && <span className="text-muted-foreground/60">#{r.anilist_id}</span>}
</div>
)}
{r.error_message && (
<p className="text-xs text-destructive/80 mt-1">{r.error_message}</p>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* Metadata batch results */}
{isMetadataBatch && batchResults.length > 0 && (
<Card className="lg:col-span-2">

View File

@@ -1,9 +1,9 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "../../lib/api";
import { JobsList } from "../components/JobsList";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "../components/ui";
import { getServerTranslations } from "../../lib/i18n/server";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, startReadingStatusMatch, IndexJobDto, LibraryDto } from "@/lib/api";
import { JobsList } from "@/app/components/JobsList";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "@/app/components/ui";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
@@ -16,6 +16,7 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
]);
const libraryMap = new Map(libraries.map(l => [l.id, l.name]));
const readingStatusLibraries = libraries.filter(l => l.reading_status_provider);
async function triggerRebuild(formData: FormData) {
"use server";
@@ -118,6 +119,36 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
}
}
async function triggerReadingStatusMatch(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
if (libraryId) {
let result;
try {
result = await startReadingStatusMatch(libraryId);
} catch {
return;
}
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
} else {
// All libraries — only those with reading_status_provider configured
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
let lastId: string | undefined;
for (const lib of allLibraries) {
if (!lib.reading_status_provider) continue;
try {
const result = await startReadingStatusMatch(lib.id);
if (result.status !== "already_running") lastId = result.id;
} catch {
// Skip libraries with errors
}
}
revalidatePath("/jobs");
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
}
}
return (
<>
<div className="mb-6">
@@ -254,6 +285,30 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
</div>
</div>
{/* Reading status group — only shown if at least one library has a provider configured */}
{readingStatusLibraries.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t("jobs.groupReadingStatus")}
</div>
<div className="space-y-2">
<button type="submit" formAction={triggerReadingStatusMatch}
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
<span className="font-medium text-sm text-foreground">{t("jobs.matchReadingStatus")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.matchReadingStatusShort")}</p>
</button>
</div>
</div>
)}
</div>
</form>
</CardContent>

View File

@@ -0,0 +1,127 @@
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { ThemeToggle } from "@/app/theme-toggle";
import { JobsIndicator } from "@/app/components/JobsIndicator";
import { NavIcon, Icon } from "@/app/components/ui";
import { LogoutButton } from "@/app/components/LogoutButton";
import { MobileNav } from "@/app/components/MobileNav";
import { UserSwitcher } from "@/app/components/UserSwitcher";
import { fetchUsers } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslationKey } from "@/lib/i18n/fr";
type NavItem = {
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
labelKey: TranslationKey;
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
};
const navItems: NavItem[] = [
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
{ href: "/books", labelKey: "nav.books", icon: "books" },
{ href: "/series", labelKey: "nav.series", icon: "series" },
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
];
export default async function AppLayout({ children }: { children: ReactNode }) {
const { t } = await getServerTranslations();
const cookieStore = await cookies();
const activeUserId = cookieStore.get("as_user_id")?.value || null;
const users = await fetchUsers().catch(() => []);
async function setActiveUserAction(formData: FormData) {
"use server";
const userId = formData.get("user_id") as string;
const store = await cookies();
if (userId) {
store.set("as_user_id", userId, { path: "/", httpOnly: false, sameSite: "lax" });
} else {
store.delete("as_user_id");
}
revalidatePath("/", "layout");
}
return (
<>
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
>
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
<span className="text-sm text-muted-foreground font-medium hidden xl:inline">
{t("common.backoffice")}
</span>
</div>
</Link>
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} />
<span className="ml-2 hidden xl:inline">{t(item.labelKey)}</span>
</NavLink>
))}
</div>
<UserSwitcher
users={users}
activeUserId={activeUserId}
setActiveUserAction={setActiveUserAction}
/>
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<Link
href="/settings"
className="hidden xl:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t("nav.settings")}
>
<Icon name="settings" size="md" />
</Link>
<ThemeToggle />
<LogoutButton />
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
</div>
</div>
</nav>
</header>
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
{children}
</main>
</>
);
}
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
return (
<Link
href={href}
title={title}
className="
flex items-center
px-2 lg:px-3 py-2
rounded-lg
text-sm font-medium
text-muted-foreground
hover:text-foreground
hover:bg-accent
transition-colors duration-200
active:scale-[0.98]
"
>
{children}
</Link>
);
}

View File

@@ -1,9 +1,9 @@
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../components/BookCard";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
import { OffsetPagination } from "../../../components/ui";
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "@/lib/api";
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
import { OffsetPagination } from "@/app/components/ui";
import { notFound } from "next/navigation";
import { getServerTranslations } from "../../../../lib/i18n/server";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";

View File

@@ -1,24 +1,28 @@
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
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 "../../../../components/ui";
import { SafeHtml } from "../../../../components/SafeHtml";
import { OffsetPagination } from "@/app/components/ui";
import { SafeHtml } from "@/app/components/SafeHtml";
import Image from "next/image";
import Link from "next/link";
const EditSeriesForm = nextDynamic(
() => import("../../../../components/EditSeriesForm").then(m => m.EditSeriesForm)
() => import("@/app/components/EditSeriesForm").then(m => m.EditSeriesForm)
);
const MetadataSearchModal = nextDynamic(
() => import("../../../../components/MetadataSearchModal").then(m => m.MetadataSearchModal)
() => import("@/app/components/MetadataSearchModal").then(m => m.MetadataSearchModal)
);
const ReadingStatusModal = nextDynamic(
() => import("@/app/components/ReadingStatusModal").then(m => m.ReadingStatusModal)
);
const ProwlarrSearchModal = nextDynamic(
() => import("../../../../components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
() => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
);
import { notFound } from "next/navigation";
import { getServerTranslations } from "../../../../../lib/i18n/server";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
@@ -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}
</span>
)}
{existingLink?.status === "approved" && (
existingLink.external_url ? (
<a
href={existingLink.external_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30 hover:bg-primary/20 transition-colors"
>
<ProviderIcon provider={existingLink.provider} size={12} />
{providerLabel(existingLink.provider)}
</a>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
<ProviderIcon provider={existingLink.provider} size={12} />
{providerLabel(existingLink.provider)}
</span>
)
)}
{readingStatusLink && (
<a
href={readingStatusLink.anilist_url ?? `https://anilist.co/manga/${readingStatusLink.anilist_id}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-600 text-xs border border-cyan-500/30 hover:bg-cyan-500/20 transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.361 2.943 0 21.056h4.942l1.077-3.133H11.4l1.077 3.133H17.5L11.128 2.943H6.361zm1.58 11.152 1.84-5.354 1.84 5.354H7.941zM17.358 2.943v18.113h4.284V2.943h-4.284z"/>
</svg>
AniList
</a>
)}
</div>
{seriesMeta?.description && (
@@ -206,6 +242,12 @@ export default async function SeriesDetailPage({
existingLink={existingLink}
initialMissing={missingData}
/>
<ReadingStatusModal
libraryId={id}
seriesName={seriesName}
readingStatusProvider={library.reading_status_provider ?? null}
existingLink={readingStatusLink}
/>
</div>
</div>
</div>

View File

@@ -1,12 +1,12 @@
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
import { OffsetPagination } from "../../../components/ui";
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
import { SeriesFilters } from "../../../components/SeriesFilters";
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "@/lib/api";
import { OffsetPagination } from "@/app/components/ui";
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { SeriesFilters } from "@/app/components/SeriesFilters";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
import { getServerTranslations } from "../../../../lib/i18n/server";
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";

View File

@@ -1,16 +1,16 @@
import { revalidatePath } from "next/cache";
import Image from "next/image";
import Link from "next/link";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api";
import type { TranslationKey } from "../../lib/i18n/fr";
import { getServerTranslations } from "../../lib/i18n/server";
import { LibraryActions } from "../components/LibraryActions";
import { LibraryForm } from "../components/LibraryForm";
import { ProviderIcon } from "../components/ProviderIcon";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "@/lib/api";
import type { TranslationKey } from "@/lib/i18n/fr";
import { getServerTranslations } from "@/lib/i18n/server";
import { LibraryActions } from "@/app/components/LibraryActions";
import { LibraryForm } from "@/app/components/LibraryForm";
import { ProviderIcon } from "@/app/components/ProviderIcon";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge
} from "../components/ui";
} from "@/app/components/ui";
export const dynamic = "force-dynamic";
@@ -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}
/>
<form>
<input type="hidden" name="id" value={lib.id} />

View File

@@ -1,11 +1,13 @@
import React from "react";
import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar } from "./components/DashboardCharts";
import Image from "next/image";
import { fetchStats, fetchUsers, StatsResponse, UserDto } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
import { PeriodToggle } from "@/app/components/PeriodToggle";
import { MetricToggle } from "@/app/components/MetricToggle";
import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
import Link from "next/link";
import { getServerTranslations } from "../lib/i18n/server";
import type { TranslateFunction } from "../lib/i18n/dictionaries";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
export const dynamic = "force-dynamic";
@@ -21,6 +23,24 @@ function formatNumber(n: number, locale: string): string {
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
}
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
const loc = locale === "fr" ? "fr-FR" : "en-US";
if (period === "month") {
// raw = "YYYY-MM"
const [y, m] = raw.split("-");
const d = new Date(Number(y), Number(m) - 1, 1);
return d.toLocaleDateString(loc, { month: "short" });
}
if (period === "week") {
// raw = "YYYY-MM-DD" (Monday of the week)
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
}
// day: raw = "YYYY-MM-DD"
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
}
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
const pct = max > 0 ? (value / max) * 100 : 0;
@@ -40,12 +60,24 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
);
}
export default async function DashboardPage() {
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParamsAwaited = await searchParams;
const rawPeriod = searchParamsAwaited.period;
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
const metric = searchParamsAwaited.metric === "pages" ? "pages" as const : "books" as const;
const { t, locale } = await getServerTranslations();
let stats: StatsResponse | null = null;
let users: UserDto[] = [];
try {
stats = await fetchStats();
[stats, users] = await Promise.all([
fetchStats(period),
fetchUsers().catch(() => []),
]);
} catch (e) {
console.error("Failed to fetch stats:", e);
}
@@ -62,7 +94,20 @@ export default async function DashboardPage() {
);
}
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, metadata } = stats;
const {
overview,
reading_status,
currently_reading = [],
recently_read = [],
reading_over_time = [],
users_reading_over_time = [],
by_format,
by_library,
top_series,
additions_over_time,
jobs_over_time = [],
metadata = { total_series: 0, series_linked: 0, series_unlinked: 0, books_with_summary: 0, books_with_isbn: 0, by_provider: [] },
} = stats;
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
const formatColors = [
@@ -107,37 +152,12 @@ export default async function DashboardPage() {
<CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle>
</CardHeader>
<CardContent>
{currently_reading.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
) : (
<div className="space-y-3">
{currently_reading.slice(0, 8).map((book) => {
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
return (
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
<CurrentlyReadingList
items={currently_reading}
allLabel={t("dashboard.allUsers")}
emptyLabel={t("dashboard.noCurrentlyReading")}
pageProgressTemplate={t("dashboard.pageProgress")}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">{t("dashboard.pageProgress", { current: book.current_page, total: book.page_count })}</p>
</div>
</Link>
);
})}
</div>
)}
</CardContent>
</Card>
@@ -147,57 +167,69 @@ export default async function DashboardPage() {
<CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle>
</CardHeader>
<CardContent>
{recently_read.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
) : (
<div className="space-y-3">
{recently_read.map((book) => (
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
<RecentlyReadList
items={recently_read}
allLabel={t("dashboard.allUsers")}
emptyLabel={t("dashboard.noRecentlyRead")}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
</div>
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Reading activity line chart */}
{reading_over_time.length > 0 && (
<Card hover={false}>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
<div className="flex gap-2">
<MetricToggle labels={{ books: t("dashboard.metricBooks"), pages: t("dashboard.metricPages") }} />
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</div>
</CardHeader>
<CardContent>
{(() => {
const userColors = [
"hsl(142 60% 45%)", "hsl(198 78% 37%)", "hsl(45 93% 47%)",
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
];
const dataKey = metric === "pages" ? "pages_read" : "books_read";
const usernames = [...new Set(users_reading_over_time.map(r => r.username))];
if (usernames.length === 0) {
return (
<RcAreaChart
noDataLabel={noDataLabel}
data={reading_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_read }))}
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m[dataKey] }))}
color="hsl(142 60% 45%)"
/>
);
}
// Pivot: { label, username1: n, username2: n, ... }
const byMonth = new Map<string, Record<string, unknown>>();
for (const row of users_reading_over_time) {
const label = formatChartLabel(row.month, period, locale);
if (!byMonth.has(row.month)) byMonth.set(row.month, { label });
byMonth.get(row.month)![row.username] = row[dataKey];
}
const chartData = [...byMonth.values()];
const lines = usernames.map((u, i) => ({
key: u,
label: u,
color: userColors[i % userColors.length],
}));
return <RcMultiLineChart data={chartData} lines={lines} noDataLabel={noDataLabel} />;
})()}
</CardContent>
</Card>
)}
{/* Charts row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Reading status donut */}
{/* Reading status par lecteur */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
</CardHeader>
<CardContent>
{users.length === 0 ? (
<RcDonutChart
noDataLabel={noDataLabel}
data={[
@@ -206,6 +238,34 @@ export default async function DashboardPage() {
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] },
]}
/>
) : (
<div className="space-y-3">
{users.map((user) => {
const total = overview.total_books;
const read = user.books_read;
const reading = user.books_reading;
const unread = Math.max(0, total - read - reading);
const readPct = total > 0 ? (read / total) * 100 : 0;
const readingPct = total > 0 ? (reading / total) * 100 : 0;
return (
<div key={user.id} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-foreground truncate">{user.username}</span>
<span className="text-xs text-muted-foreground shrink-0 ml-2">
<span className="text-success font-medium">{read}</span>
{reading > 0 && <span className="text-amber-500 font-medium"> · {reading}</span>}
<span className="text-muted-foreground/60"> / {total}</span>
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
<div className="h-full bg-success transition-all duration-500" style={{ width: `${readPct}%` }} />
<div className="h-full bg-amber-500 transition-all duration-500" style={{ width: `${readingPct}%` }} />
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
@@ -351,20 +411,47 @@ export default async function DashboardPage() {
</Card>
</div>
{/* Monthly additions line chart full width */}
{/* Additions line chart full width */}
<Card hover={false}>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcAreaChart
noDataLabel={noDataLabel}
data={additions_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_added }))}
data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
color="hsl(198 78% 37%)"
/>
</CardContent>
</Card>
{/* Jobs over time multi-line chart */}
<Card hover={false}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcMultiLineChart
noDataLabel={noDataLabel}
data={jobs_over_time.map((j) => ({
label: formatChartLabel(j.label, period, locale),
scan: j.scan,
rebuild: j.rebuild,
thumbnail: j.thumbnail,
other: j.other,
}))}
lines={[
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
]}
/>
</CardContent>
</Card>
{/* Quick links */}
<QuickLinks t={t} />
</div>

View File

@@ -1,11 +1,12 @@
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
import { getServerTranslations } from "../../lib/i18n/server";
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
import { LiveSearchForm } from "../components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui";
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Image from "next/image";
import Link from "next/link";
import { ProviderIcon } from "../components/ProviderIcon";
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({
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{series.map((s) => (
<Link
key={s.name}
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
className="group"
>
<div key={s.name} className="group relative">
<div
className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200 ${
className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden group-hover:shadow-md group-hover:-translate-y-1 transition-all duration-200 ${
s.books_read_count >= s.book_count ? "opacity-50" : ""
}`}
>
@@ -149,13 +146,15 @@ export default async function SeriesPage({
<p className="text-xs text-muted-foreground">
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
</p>
<div className="relative z-20">
<MarkSeriesReadButton
seriesName={s.name}
bookCount={s.book_count}
booksReadCount={s.books_read_count}
/>
</div>
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
</div>
<div className="relative z-20 flex items-center gap-1 mt-1.5 flex-wrap">
{s.series_status && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
@@ -177,10 +176,24 @@ export default async function SeriesPage({
<ProviderIcon provider={s.metadata_provider} size={10} />
</span>
)}
{s.anilist_id && (
<ExternalLinkBadge
href={s.anilist_url ?? `https://anilist.co/manga/${s.anilist_id}`}
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-cyan-500/15 text-cyan-600 hover:bg-cyan-500/25"
>
AL
</ExternalLinkBadge>
)}
</div>
</div>
</div>
</Link>
{/* Link overlay covering the full card — below interactive elements */}
<Link
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
className="absolute inset-0 z-10 rounded-xl"
aria-label={s.name === "unclassified" ? t("books.unclassified") : s.name}
/>
</div>
))}
</div>

View File

@@ -1,19 +1,21 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
import { ProviderIcon } from "../components/ProviderIcon";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "../../lib/api";
import { useTranslation } from "../../lib/i18n/context";
import type { Locale } from "../../lib/i18n/types";
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, AnilistStatusDto, AnilistSyncReportDto, AnilistPullReportDto, AnilistSyncPreviewItemDto, AnilistSyncItemDto, AnilistPullItemDto } from "@/lib/api";
import { useTranslation } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/types";
interface SettingsPageProps {
initialSettings: Settings;
initialCacheStats: CacheStats;
initialThumbnailStats: ThumbnailStats;
users: UserDto[];
initialTab?: string;
}
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) {
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab }: SettingsPageProps) {
const { t, locale, setLocale } = useTranslation();
const [settings, setSettings] = useState<Settings>({
...initialSettings,
@@ -29,6 +31,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
const [komgaUrl, setKomgaUrl] = useState("");
const [komgaUsername, setKomgaUsername] = useState("");
const [komgaPassword, setKomgaPassword] = useState("");
const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? "");
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
@@ -104,6 +107,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
if (data) {
if (data.url) setKomgaUrl(data.url);
if (data.username) setKomgaUsername(data.username);
if (data.user_id) setKomgaUserId(data.user_id);
}
}).catch(() => {});
}, [fetchReports]);
@@ -128,7 +132,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
const response = await fetch("/api/komga/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword }),
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword, user_id: komgaUserId }),
});
const data = await response.json();
if (!response.ok) {
@@ -140,7 +144,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
fetch("/api/settings/komga", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername } }),
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername, user_id: komgaUserId } }),
}).catch(() => {});
}
} catch {
@@ -150,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 },
];
@@ -620,16 +627,29 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.password")}</label>
<FormInput
type="password"
type="password" autoComplete="off"
value={komgaPassword}
onChange={(e) => setKomgaPassword(e.target.value)}
/>
</FormField>
</FormRow>
{users.length > 0 && (
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("users.title")}</label>
<FormSelect value={komgaUserId} onChange={(e) => setKomgaUserId(e.target.value)}>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</FormSelect>
</FormField>
</FormRow>
)}
<Button
onClick={handleKomgaSync}
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword}
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword || !komgaUserId}
>
{isSyncing ? (
<>
@@ -832,6 +852,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
{/* Telegram Notifications */}
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
</>)}
{activeTab === "anilist" && (
<AnilistTab handleUpdateSetting={handleUpdateSetting} users={users} />
)}
</>
);
}
@@ -955,7 +979,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
{t("settings.googleBooksKey")}
</label>
<FormInput
type="password"
type="password" autoComplete="off"
placeholder={t("settings.googleBooksPlaceholder")}
value={apiKeys.google_books || ""}
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
@@ -970,7 +994,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
{t("settings.comicvineKey")}
</label>
<FormInput
type="password"
type="password" autoComplete="off"
placeholder={t("settings.comicvinePlaceholder")}
value={apiKeys.comicvine || ""}
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
@@ -1312,7 +1336,7 @@ function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label>
<FormInput
type="password"
type="password" autoComplete="off"
placeholder={t("settings.prowlarrApiKeyPlaceholder")}
value={prowlarrApiKey}
onChange={(e) => setProwlarrApiKey(e.target.value)}
@@ -1450,7 +1474,7 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentPassword")}</label>
<FormInput
type="password"
type="password" autoComplete="off"
value={qbPassword}
onChange={(e) => setQbPassword(e.target.value)}
onBlur={() => saveQbittorrent()}
@@ -1616,7 +1640,7 @@ function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.botToken")}</label>
<FormInput
type="password"
type="password" autoComplete="off"
placeholder={t("settings.botTokenPlaceholder")}
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
@@ -1737,3 +1761,407 @@ function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
</Card>
);
}
// ---------------------------------------------------------------------------
// AniList sub-component
// ---------------------------------------------------------------------------
function AnilistTab({
handleUpdateSetting,
users,
}: {
handleUpdateSetting: (key: string, value: unknown) => Promise<void>;
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<AnilistStatusDto | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncReport, setSyncReport] = useState<AnilistSyncReportDto | null>(null);
const [isPulling, setIsPulling] = useState(false);
const [pullReport, setPullReport] = useState<AnilistPullReportDto | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
const [previewItems, setPreviewItems] = useState<AnilistSyncPreviewItemDto[] | null>(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 (
<>
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="link" size="md" />
{t("settings.anilistTitle")}
</CardTitle>
<CardDescription>{t("settings.anilistDesc")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{t("settings.anilistConnectDesc")}</p>
{/* Redirect URL info */}
<div className="rounded-md bg-muted/50 border px-3 py-2 text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">{t("settings.anilistRedirectUrlLabel")}</p>
<code className="select-all font-mono">{typeof window !== "undefined" ? `${window.location.origin}/anilist/callback` : "/anilist/callback"}</code>
<p>{t("settings.anilistRedirectUrlHint")}</p>
</div>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.anilistClientId")}</label>
<FormInput
type="text"
autoComplete="off"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={t("settings.anilistClientIdPlaceholder")}
/>
</FormField>
</FormRow>
<div className="flex items-center gap-3 flex-wrap">
<Button onClick={handleConnect} disabled={!clientId}>
<Icon name="link" size="sm" className="mr-2" />
{t("settings.anilistConnectButton")}
</Button>
<Button onClick={handleTestConnection} disabled={isTesting || !token} variant="secondary">
{isTesting ? (
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.testing")}</>
) : (
<><Icon name="refresh" size="sm" className="mr-2" />{t("settings.anilistTestConnection")}</>
)}
</Button>
{viewer && (
<span className="text-sm text-success font-medium">
{t("settings.anilistConnected")} <strong>{viewer.username}</strong>
{" · "}
<a href={viewer.site_url} target="_blank" rel="noopener noreferrer" className="underline">AniList</a>
</span>
)}
{token && !viewer && (
<span className="text-sm text-muted-foreground">{t("settings.anilistTokenPresent")}</span>
)}
{testError && <span className="text-sm text-destructive">{testError}</span>}
</div>
<details className="group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground select-none">
{t("settings.anilistManualToken")}
</summary>
<div className="mt-3 space-y-3">
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.anilistToken")}</label>
<FormInput
type="password"
autoComplete="off"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={t("settings.anilistTokenPlaceholder")}
/>
</FormField>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.anilistUserId")}</label>
<FormInput
type="text"
autoComplete="off"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder={t("settings.anilistUserIdPlaceholder")}
/>
</FormField>
</FormRow>
<Button onClick={handleSaveToken} disabled={!token}>
{t("common.save")}
</Button>
</div>
</details>
<div className="border-t border-border/50 pt-4 mt-2">
<p className="text-sm font-medium text-foreground mb-1">{t("settings.anilistLocalUserTitle")}</p>
<p className="text-xs text-muted-foreground mb-3">{t("settings.anilistLocalUserDesc")}</p>
<div className="flex items-center gap-3">
<select
value={localUserId}
onChange={(e) => setLocalUserId(e.target.value)}
autoComplete="off"
className="flex-1 text-sm border border-border rounded-lg px-3 py-2.5 bg-background focus:outline-none focus:ring-2 focus:ring-ring h-10"
>
<option value="">{t("settings.anilistLocalUserNone")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
<Button onClick={() => handleUpdateSetting("anilist", buildAnilistSettings())} disabled={!localUserId}>
{t("common.save")}
</Button>
</div>
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="refresh" size="md" />
{t("settings.anilistSyncTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("settings.anilistSyncDesc")}</p>
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={handlePreview} disabled={isPreviewing} variant="secondary">
{isPreviewing ? (
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.anilistPreviewing")}</>
) : (
<><Icon name="eye" size="sm" className="mr-2" />{t("settings.anilistPreviewButton")}</>
)}
</Button>
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing ? (
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.anilistSyncing")}</>
) : (
<><Icon name="refresh" size="sm" className="mr-2" />{t("settings.anilistSyncButton")}</>
)}
</Button>
</div>
{syncReport && (
<div className="mt-2 border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-muted/50 flex items-center gap-3">
<span className="text-sm text-success font-medium">{t("settings.anilistSynced", { count: String(syncReport.synced) })}</span>
{syncReport.skipped > 0 && <span className="text-sm text-muted-foreground">{t("settings.anilistSkipped", { count: String(syncReport.skipped) })}</span>}
{syncReport.errors.length > 0 && <span className="text-sm text-destructive">{t("settings.anilistErrors", { count: String(syncReport.errors.length) })}</span>}
</div>
{syncReport.items.length > 0 && (
<div className="divide-y max-h-60 overflow-y-auto">
{syncReport.items.map((item: AnilistSyncItemDto) => (
<div key={item.series_name} className="flex items-center justify-between px-4 py-2 text-sm">
<a
href={item.anilist_url ?? `https://anilist.co/manga/`}
target="_blank"
rel="noopener noreferrer"
className="truncate font-medium hover:underline min-w-0 mr-3"
>
{item.anilist_title ?? item.series_name}
</a>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
item.status === "COMPLETED" ? "bg-green-500/15 text-green-600" :
item.status === "CURRENT" ? "bg-blue-500/15 text-blue-600" :
"bg-muted text-muted-foreground"
}`}>{item.status}</span>
{item.progress_volumes > 0 && (
<span className="text-xs text-muted-foreground">{item.progress_volumes} vol.</span>
)}
</div>
</div>
))}
</div>
)}
{syncReport.errors.map((err: string, i: number) => (
<p key={i} className="text-xs text-destructive px-4 py-1 border-t">{err}</p>
))}
</div>
)}
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("settings.anilistPullDesc")}</p>
<Button onClick={handlePull} disabled={isPulling}>
{isPulling ? (
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.anilistPulling")}</>
) : (
<><Icon name="refresh" size="sm" className="mr-2" />{t("settings.anilistPullButton")}</>
)}
</Button>
{pullReport && (
<div className="mt-2 border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-muted/50 flex items-center gap-3">
<span className="text-sm text-success font-medium">{t("settings.anilistUpdated", { count: String(pullReport.updated) })}</span>
{pullReport.skipped > 0 && <span className="text-sm text-muted-foreground">{t("settings.anilistSkipped", { count: String(pullReport.skipped) })}</span>}
{pullReport.errors.length > 0 && <span className="text-sm text-destructive">{t("settings.anilistErrors", { count: String(pullReport.errors.length) })}</span>}
</div>
{pullReport.items.length > 0 && (
<div className="divide-y max-h-60 overflow-y-auto">
{pullReport.items.map((item: AnilistPullItemDto) => (
<div key={item.series_name} className="flex items-center justify-between px-4 py-2 text-sm">
<a
href={item.anilist_url ?? `https://anilist.co/manga/`}
target="_blank"
rel="noopener noreferrer"
className="truncate font-medium hover:underline min-w-0 mr-3"
>
{item.anilist_title ?? item.series_name}
</a>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
item.anilist_status === "COMPLETED" ? "bg-green-500/15 text-green-600" :
item.anilist_status === "CURRENT" ? "bg-blue-500/15 text-blue-600" :
item.anilist_status === "PLANNING" ? "bg-amber-500/15 text-amber-600" :
"bg-muted text-muted-foreground"
}`}>{item.anilist_status}</span>
<span className="text-xs text-muted-foreground">{item.books_updated} {t("dashboard.books").toLowerCase()}</span>
</div>
</div>
))}
</div>
)}
{pullReport.errors.map((err: string, i: number) => (
<p key={i} className="text-xs text-destructive px-4 py-1 border-t">{err}</p>
))}
</div>
)}
</div>
</div>
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{previewItems !== null && (
<div className="mt-2 border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between">
<span className="text-sm font-medium">{t("settings.anilistPreviewTitle", { count: String(previewItems.length) })}</span>
<button onClick={() => setPreviewItems(null)} className="text-xs text-muted-foreground hover:text-foreground"></button>
</div>
{previewItems.length === 0 ? (
<p className="text-sm text-muted-foreground px-4 py-3">{t("settings.anilistPreviewEmpty")}</p>
) : (
<div className="divide-y">
{previewItems.map((item) => (
<div key={`${item.anilist_id}-${item.series_name}`} className="flex items-center justify-between px-4 py-2 text-sm">
<div className="flex items-center gap-2 min-w-0">
<a
href={item.anilist_url ?? `https://anilist.co/manga/${item.anilist_id}`}
target="_blank"
rel="noopener noreferrer"
className="truncate font-medium hover:underline"
>
{item.anilist_title ?? item.series_name}
</a>
{item.anilist_title && item.anilist_title !== item.series_name && (
<span className="text-muted-foreground truncate hidden sm:inline"> {item.series_name}</span>
)}
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
<span className="text-xs text-muted-foreground">{item.books_read}/{item.book_count}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
item.status === "COMPLETED" ? "bg-success/15 text-success" :
item.status === "CURRENT" ? "bg-blue-500/15 text-blue-600" :
"bg-muted text-muted-foreground"
}`}>
{item.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
</>
);
}

View File

@@ -1,9 +1,10 @@
import { getSettings, getCacheStats, getThumbnailStats } from "../../lib/api";
import { getSettings, getCacheStats, getThumbnailStats, fetchUsers } from "@/lib/api";
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 },
@@ -23,5 +24,7 @@ export default async function SettingsPageWrapper() {
directory: "/data/thumbnails"
}));
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} />;
const users = await fetchUsers().catch(() => []);
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} initialTab={tab} />;
}

View File

@@ -0,0 +1,316 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listTokens, createToken, revokeToken, deleteToken, updateToken, fetchUsers, createUser, deleteUser, updateUser, TokenDto, UserDto } from "@/lib/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
import { TokenUserSelect } from "@/app/components/TokenUserSelect";
import { UsernameEdit } from "@/app/components/UsernameEdit";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
export default async function TokensPage({
searchParams
}: {
searchParams: Promise<{ created?: string }>;
}) {
const { t } = await getServerTranslations();
const params = await searchParams;
const tokens = await listTokens().catch(() => [] as TokenDto[]);
const users = await fetchUsers().catch(() => [] as UserDto[]);
async function createTokenAction(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const scope = formData.get("scope") as string;
const userId = (formData.get("user_id") as string) || undefined;
if (name) {
const result = await createToken(name, scope, userId);
revalidatePath("/tokens");
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
}
}
async function revokeTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await revokeToken(id);
revalidatePath("/tokens");
}
async function deleteTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteToken(id);
revalidatePath("/tokens");
}
async function createUserAction(formData: FormData) {
"use server";
const username = formData.get("username") as string;
if (username) {
await createUser(username);
revalidatePath("/tokens");
}
}
async function deleteUserAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteUser(id);
revalidatePath("/tokens");
}
async function renameUserAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
const username = formData.get("username") as string;
if (username?.trim()) {
await updateUser(id, username.trim());
revalidatePath("/tokens");
}
}
async function reassignTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
const userId = (formData.get("user_id") as string) || null;
await updateToken(id, userId);
revalidatePath("/tokens");
}
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{t("tokens.title")}
</h1>
</div>
{/* ── Lecteurs ─────────────────────────────────────────── */}
<div className="mb-2">
<h2 className="text-xl font-semibold text-foreground">{t("users.title")}</h2>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("users.createNew")}</CardTitle>
<CardDescription>{t("users.createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form action={createUserAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="username" placeholder={t("users.username")} required autoComplete="off" />
</FormField>
<Button type="submit">{t("users.createButton")}</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden mb-10">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.tokenCount")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.read")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.reading")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.createdAt")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{/* Ligne admin synthétique */}
<tr className="hover:bg-accent/50 transition-colors bg-destructive/5">
<td className="px-4 py-3 text-sm font-medium text-foreground flex items-center gap-2">
{process.env.ADMIN_USERNAME ?? "admin"}
<Badge variant="destructive">{t("tokens.scopeAdmin")}</Badge>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{tokens.filter(tok => tok.scope === "admin" && !tok.revoked_at).length}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
</tr>
{/* Ligne tokens read non assignés */}
{(() => {
const unassigned = tokens.filter(tok => tok.scope === "read" && !tok.user_id && !tok.revoked_at);
if (unassigned.length === 0) return null;
return (
<tr className="hover:bg-accent/50 transition-colors bg-warning/5">
<td className="px-4 py-3 text-sm font-medium text-muted-foreground italic">
{t("tokens.noUser")}
</td>
<td className="px-4 py-3 text-sm text-warning font-medium">{unassigned.length}</td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
</tr>
);
})()}
{users.map((user) => (
<tr key={user.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3">
<UsernameEdit userId={user.id} currentUsername={user.username} action={renameUserAction} />
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{user.token_count}</td>
<td className="px-4 py-3 text-sm">
{user.books_read > 0
? <span className="font-medium text-success">{user.books_read}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
<td className="px-4 py-3 text-sm">
{user.books_reading > 0
? <span className="font-medium text-amber-500">{user.books_reading}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<form action={deleteUserAction}>
<input type="hidden" name="id" value={user.id} />
<Button type="submit" variant="destructive" size="xs">
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t("common.delete")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* ── Tokens API ───────────────────────────────────────── */}
<div className="mb-2">
<h2 className="text-xl font-semibold text-foreground">{t("tokens.apiTokens")}</h2>
</div>
{params.created ? (
<Card className="mb-6 border-success/50 bg-success/5">
<CardHeader>
<CardTitle className="text-success">{t("tokens.created")}</CardTitle>
<CardDescription>{t("tokens.createdDescription")}</CardDescription>
</CardHeader>
<CardContent>
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
</CardContent>
</Card>
) : null}
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("tokens.createNew")}</CardTitle>
<CardDescription>{t("tokens.createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form action={createTokenAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder={t("tokens.tokenName")} required autoComplete="off" />
</FormField>
<FormField className="w-32">
<FormSelect name="scope" defaultValue="read">
<option value="read">{t("tokens.scopeRead")}</option>
<option value="admin">{t("tokens.scopeAdmin")}</option>
</FormSelect>
</FormField>
<FormField className="w-48">
<FormSelect name="user_id" defaultValue="">
<option value="">{t("tokens.noUser")}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.username}</option>
))}
</FormSelect>
</FormField>
<Button type="submit">{t("tokens.createButton")}</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.user")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{tokens.map((token) => (
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
<td className="px-4 py-3 text-sm">
<TokenUserSelect
tokenId={token.id}
currentUserId={token.user_id}
users={users}
action={reassignTokenAction}
noUserLabel={t("tokens.noUser")}
/>
</td>
<td className="px-4 py-3 text-sm">
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
{token.scope}
</Badge>
</td>
<td className="px-4 py-3 text-sm">
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
</td>
<td className="px-4 py-3 text-sm">
{token.revoked_at ? (
<Badge variant="error">{t("tokens.revoked")}</Badge>
) : (
<Badge variant="success">{t("tokens.active")}</Badge>
)}
</td>
<td className="px-4 py-3">
{!token.revoked_at ? (
<form action={revokeTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="xs">
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t("tokens.revoke")}
</Button>
</form>
) : (
<form action={deleteTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="xs">
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t("common.delete")}
</Button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { createSessionToken, SESSION_COOKIE } from "@/lib/session";
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
if (!body || typeof body.username !== "string" || typeof body.password !== "string") {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const expectedUsername = process.env.ADMIN_USERNAME || "admin";
const expectedPassword = process.env.ADMIN_PASSWORD;
if (!expectedPassword) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
if (body.username !== expectedUsername || body.password !== expectedPassword) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await createSessionToken();
const response = NextResponse.json({ success: true });
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60,
path: "/",
});
return response;
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/lib/session";
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.delete(SESSION_COOKIE);
return response;
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch, IndexJobDto, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, startReadingStatusMatch } from "@/lib/api";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const job = await apiFetch<IndexJobDto>(`/index/jobs/${id}`);
const libraryId = job.library_id ?? undefined;
switch (job.type) {
case "rebuild":
return NextResponse.json(await rebuildIndex(libraryId));
case "full_rebuild":
return NextResponse.json(await rebuildIndex(libraryId, true));
case "rescan":
return NextResponse.json(await rebuildIndex(libraryId, false, true));
case "scan":
return NextResponse.json(await rebuildIndex(libraryId));
case "thumbnail_rebuild":
return NextResponse.json(await rebuildThumbnails(libraryId));
case "thumbnail_regenerate":
return NextResponse.json(await regenerateThumbnails(libraryId));
case "metadata_batch":
if (!libraryId) return NextResponse.json({ error: "Library ID required for metadata batch" }, { status: 400 });
return NextResponse.json(await startMetadataBatch(libraryId));
case "metadata_refresh":
if (!libraryId) return NextResponse.json({ error: "Library ID required for metadata refresh" }, { status: 400 });
return NextResponse.json(await startMetadataRefresh(libraryId));
case "reading_status_match":
if (!libraryId) return NextResponse.json({ error: "Library ID required for reading status match" }, { status: 400 });
return NextResponse.json(await startReadingStatusMatch(libraryId));
default:
return NextResponse.json({ error: `Cannot replay job type: ${job.type}` }, { status: 400 });
}
} catch (error) {
return NextResponse.json({ error: "Failed to replay job" }, { status: 500 });
}
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { listJobs } from "@/lib/api";
export async function GET() {
try {
const data = await listJobs();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: "Failed to fetch jobs" }, { status: 500 });
}
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const body = await request.json();
const data = await apiFetch(`/libraries/${id}/reading-status-provider`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update reading status provider";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -3,7 +3,7 @@
import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
AreaChart, Area,
AreaChart, Area, Line, LineChart,
Legend,
} from "recharts";
@@ -186,3 +186,46 @@ export function RcHorizontalBar({
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Multi-line chart (jobs over time)
// ---------------------------------------------------------------------------
export function RcMultiLineChart({
data,
lines,
noDataLabel,
}: {
data: Record<string, unknown>[];
lines: { key: string; label: string; color: string }[];
noDataLabel?: string;
}) {
const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0));
if (data.length === 0 || !hasData)
return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
return (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
{lines.map((l) => (
<Line
key={l.key}
type="monotone"
dataKey={l.key}
name={l.label}
stroke={l.color}
strokeWidth={2}
dot={{ r: 3, fill: l.color }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
interface ExternalLinkBadgeProps {
href: string;
className?: string;
children: React.ReactNode;
}
export function ExternalLinkBadge({ href, className, children }: ExternalLinkBadgeProps) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={className}
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
);
}

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { useTranslation } from "../../lib/i18n/context";
import { JobProgress } from "./JobProgress";
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon } from "./ui";
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon, Tooltip } from "./ui";
interface JobRowProps {
job: {
@@ -21,6 +21,7 @@ interface JobRowProps {
indexed_files: number;
removed_files: number;
errors: number;
refreshed?: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
@@ -29,11 +30,14 @@ interface JobRowProps {
libraryName: string | undefined;
highlighted?: boolean;
onCancel: (id: string) => void;
onReplay: (id: string) => void;
formatDate: (date: string) => string;
formatDuration: (start: string, end: string | null) => string;
}
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
const REPLAYABLE_TYPES = new Set(["rebuild", "full_rebuild", "rescan", "scan", "thumbnail_rebuild", "thumbnail_regenerate", "metadata_batch", "metadata_refresh", "reading_status_match"]);
export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, formatDate, formatDuration }: JobRowProps) {
const { t } = useTranslation();
const isActive = job.status === "running" || job.status === "pending" || job.status === "extracting_pages" || job.status === "generating_thumbnails";
const [showProgress, setShowProgress] = useState(highlighted || isActive);
@@ -117,49 +121,74 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
<div className="flex items-center gap-3 text-xs">
{/* Files: indexed count */}
{indexed > 0 && (
<span className="inline-flex items-center gap-1 text-success" title={t("jobRow.filesIndexed", { count: indexed })}>
<Tooltip label={t("jobRow.filesIndexed", { count: indexed })}>
<span className="inline-flex items-center gap-1 text-success">
<Icon name="document" size="sm" />
{indexed}
</span>
</Tooltip>
)}
{/* Removed files */}
{removed > 0 && (
<span className="inline-flex items-center gap-1 text-warning" title={t("jobRow.filesRemoved", { count: removed })}>
<Tooltip label={t("jobRow.filesRemoved", { count: removed })}>
<span className="inline-flex items-center gap-1 text-warning">
<Icon name="trash" size="sm" />
{removed}
</span>
</Tooltip>
)}
{/* Thumbnails */}
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
<span className="inline-flex items-center gap-1 text-primary" title={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
<Tooltip label={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
<span className="inline-flex items-center gap-1 text-primary">
<Icon name="image" size="sm" />
{job.total_files}
</span>
</Tooltip>
)}
{/* Metadata batch: series processed */}
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataProcessed", { count: job.total_files })}>
<Tooltip label={t("jobRow.metadataProcessed", { count: job.total_files })}>
<span className="inline-flex items-center gap-1 text-info">
<Icon name="tag" size="sm" />
{job.total_files}
</span>
</Tooltip>
)}
{/* Metadata refresh: links refreshed */}
{/* Metadata refresh: total links + refreshed count */}
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataRefreshed", { count: job.total_files })}>
<Tooltip label={t("jobRow.metadataLinks", { count: job.total_files })}>
<span className="inline-flex items-center gap-1 text-info">
<Icon name="tag" size="sm" />
{job.total_files}
</span>
</Tooltip>
)}
{isMetadataRefresh && job.stats_json?.refreshed != null && job.stats_json.refreshed > 0 && (
<Tooltip label={t("jobRow.metadataRefreshed", { count: job.stats_json.refreshed })}>
<span className="inline-flex items-center gap-1 text-success">
<Icon name="refresh" size="sm" />
{job.stats_json.refreshed}
</span>
</Tooltip>
)}
{/* Errors */}
{errors > 0 && (
<span className="inline-flex items-center gap-1 text-error" title={t("jobRow.errors", { count: errors })}>
<Tooltip label={t("jobRow.errors", { count: errors })}>
<span className="inline-flex items-center gap-1 text-error">
<Icon name="warning" size="sm" />
{errors}
</span>
</Tooltip>
)}
{/* Scanned only (no other stats) */}
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
<span className="text-sm text-muted-foreground">{t("jobRow.scanned", { count: scanned })}</span>
<Tooltip label={t("jobRow.scanned", { count: scanned })}>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Icon name="search" size="sm" />
{scanned}
</span>
</Tooltip>
)}
{/* Nothing to show */}
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
@@ -179,19 +208,38 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
<div className="flex items-center gap-2">
<Link
href={`/jobs/${job.id}`}
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
className="inline-flex items-center justify-center gap-1.5 h-7 px-2.5 text-xs font-medium rounded-md bg-primary text-white hover:bg-primary/90 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{t("jobRow.view")}
</Link>
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
{isActive && (
<Button
variant="danger"
size="sm"
size="xs"
onClick={() => onCancel(job.id)}
>
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
{t("common.cancel")}
</Button>
)}
{!isActive && REPLAYABLE_TYPES.has(job.type) && (
<Button
variant="secondary"
size="xs"
onClick={() => onReplay(job.id)}
>
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{t("jobRow.replay")}
</Button>
)}
</div>
</td>
</tr>

View File

@@ -18,6 +18,7 @@ interface Job {
indexed_files: number;
removed_files: number;
errors: number;
refreshed?: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
@@ -94,6 +95,26 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
}
};
const refreshJobs = async () => {
try {
const res = await fetch("/api/jobs/list");
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) setJobs(data);
}
} catch { /* SSE will catch up */ }
};
const handleReplay = async (id: string) => {
const response = await fetch(`/api/jobs/${id}/replay`, { method: "POST" });
if (response.ok) {
await refreshJobs();
} else {
const data = await response.json().catch(() => ({}));
console.error("Failed to replay job:", data?.error ?? response.status);
}
};
return (
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden">
<div className="overflow-x-auto">
@@ -118,6 +139,7 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
highlighted={job.id === highlightJobId}
onCancel={handleCancel}
onReplay={handleReplay}
formatDate={formatDate}
formatDuration={formatDuration}
/>

View File

@@ -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({
</div>
</div>
<hr className="border-border/40" />
{/* Section: État de lecture */}
<div className="space-y-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t("libraryActions.sectionReadingStatus")}
</h3>
<div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm font-medium text-foreground">
{t("libraryActions.readingStatusProvider")}
</label>
<select
name="reading_status_provider"
defaultValue={readingStatusProvider || ""}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
>
<option value="">{t("libraryActions.none")}</option>
<option value="anilist">AniList</option>
</select>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.readingStatusProviderDesc")}</p>
</div>
</div>
{saveError && (
<p className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-lg break-all">
{saveError}

View File

@@ -0,0 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<button
onClick={handleLogout}
title="Se déconnecter"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
);
}

View File

@@ -45,27 +45,27 @@ export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }:
<button
onClick={handleClick}
disabled={loading}
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full font-medium transition-colors ${
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors disabled:opacity-50 ${
allRead
? "bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/25"
: "bg-muted/50 text-muted-foreground hover:bg-primary/10 hover:text-primary"
} disabled:opacity-50`}
? "border-green-500/30 bg-green-500/10 text-green-600 hover:bg-green-500/20"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary"
}`}
>
{loading ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : allRead ? (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
</svg>
{label}
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
</svg>
{label}

View File

@@ -683,13 +683,6 @@ export function MetadataSearchModal({
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
</button>
{existingLink && existingLink.status === "approved" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
<ProviderIcon provider={existingLink.provider} size={12} />
<span>{providerLabel(existingLink.provider)}</span>
</span>
)}
{modal}
</>
);

View File

@@ -0,0 +1,47 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
type Metric = "books" | "pages";
export function MetricToggle({
labels,
}: {
labels: { books: string; pages: string };
}) {
const router = useRouter();
const searchParams = useSearchParams();
const raw = searchParams.get("metric");
const current: Metric = raw === "pages" ? "pages" : "books";
function setMetric(metric: Metric) {
const params = new URLSearchParams(searchParams.toString());
if (metric === "books") {
params.delete("metric");
} else {
params.set("metric", metric);
}
const qs = params.toString();
router.push(qs ? `?${qs}` : "/", { scroll: false });
}
const options: Metric[] = ["books", "pages"];
return (
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
{options.map((m) => (
<button
key={m}
onClick={() => setMetric(m)}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
current === m
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{labels[m]}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
type Period = "day" | "week" | "month";
export function PeriodToggle({
labels,
}: {
labels: { day: string; week: string; month: string };
}) {
const router = useRouter();
const searchParams = useSearchParams();
const raw = searchParams.get("period");
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
function setPeriod(period: Period) {
const params = new URLSearchParams(searchParams.toString());
if (period === "month") {
params.delete("period");
} else {
params.set("period", period);
}
const qs = params.toString();
router.push(qs ? `?${qs}` : "/", { scroll: false });
}
const options: Period[] = ["day", "week", "month"];
return (
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
{options.map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
current === p
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{labels[p]}
</button>
))}
</div>
);
}

View File

@@ -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<ModalStep>(existingLink ? "linked" : "idle");
const [query, setQuery] = useState(seriesName);
const [candidates, setCandidates] = useState<AnilistMediaResultDto[]>([]);
const [error, setError] = useState<string | null>(null);
const [link, setLink] = useState<AnilistSeriesLinkDto | null>(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 (
<>
<button
type="button"
onClick={handleOpen}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
{t("readingStatus.button")}
</button>
{isOpen && createPortal(
<>
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50" onClick={handleClose} />
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
<div className="flex items-center gap-2.5">
<svg className="w-5 h-5 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
<span className="font-semibold text-lg">{providerLabel} {seriesName}</span>
</div>
<button type="button" onClick={handleClose} className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Linked state */}
{step === "linked" && link && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/40">
<div className="flex-1 min-w-0">
<p className="font-medium">{link.anilist_title ?? seriesName}</p>
{link.anilist_url && (
<a href={link.anilist_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">
{link.anilist_url}
</a>
)}
<p className="text-xs text-muted-foreground mt-1">
ID: {link.anilist_id} · {t(`readingStatus.status.${link.status}` as any) || link.status}
</p>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-cyan-500/15 text-cyan-600 shrink-0">
{providerLabel}
</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setStep("idle")}>
{t("readingStatus.changeLink")}
</Button>
<Button variant="ghost" size="sm" onClick={handleUnlink} disabled={isUnlinking} className="text-destructive hover:text-destructive">
{isUnlinking ? t("common.loading") : t("readingStatus.unlink")}
</Button>
</div>
</div>
)}
{/* Search form */}
{(step === "idle" || step === "results") && (
<div className="space-y-3">
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => 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"
/>
<Button onClick={handleSearch} size="sm">
{t("readingStatus.search")}
</Button>
</div>
{step === "results" && candidates.length === 0 && (
<p className="text-sm text-muted-foreground">{t("readingStatus.noResults")}</p>
)}
{step === "results" && candidates.length > 0 && (
<div className="space-y-2">
{candidates.map((c) => (
<div key={c.id} className="flex items-center gap-3 p-3 rounded-lg border border-border/60 hover:bg-muted/30 transition-colors">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{c.title_romaji ?? c.title_english}</p>
{c.title_english && c.title_english !== c.title_romaji && (
<p className="text-xs text-muted-foreground truncate">{c.title_english}</p>
)}
<div className="flex items-center gap-2 mt-0.5">
{c.volumes && <span className="text-xs text-muted-foreground">{c.volumes} vol.</span>}
{c.status && <span className="text-xs text-muted-foreground">{c.status}</span>}
<a href={c.site_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline"></a>
</div>
</div>
<Button size="sm" onClick={() => handleLink(c)} disabled={isLinking} className="shrink-0">
{t("readingStatus.link")}
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* Searching spinner */}
{step === "searching" && (
<div className="flex items-center gap-2 py-4 text-muted-foreground">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">{t("readingStatus.searching")}</span>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
</div>
</>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import type { CurrentlyReadingItem, RecentlyReadItem } from "@/lib/api";
import { getBookCoverUrl } from "@/lib/api";
function FilterPills({ usernames, selected, allLabel, onSelect }: {
usernames: string[];
selected: string | null;
allLabel: string;
onSelect: (u: string | null) => void;
}) {
if (usernames.length <= 1) return null;
return (
<div className="flex flex-wrap gap-1.5 mb-3">
<button
onClick={() => onSelect(null)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
selected === null
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{allLabel}
</button>
{usernames.map((u) => (
<button
key={u}
onClick={() => onSelect(u === selected ? null : u)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
selected === u
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{u}
</button>
))}
</div>
);
}
export function CurrentlyReadingList({
items,
allLabel,
emptyLabel,
pageProgressTemplate,
}: {
items: CurrentlyReadingItem[];
allLabel: string;
emptyLabel: string;
/** Template with {{current}} and {{total}} placeholders */
pageProgressTemplate: string;
}) {
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
const [selected, setSelected] = useState<string | null>(null);
const filtered = selected ? items.filter((i) => i.username === selected) : items;
return (
<div>
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{filtered.slice(0, 8).map((book) => {
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
return (
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
{book.username && usernames.length > 1 && (
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">{pageProgressTemplate.replace("{{current}}", String(book.current_page)).replace("{{total}}", String(book.page_count))}</p>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
export function RecentlyReadList({
items,
allLabel,
emptyLabel,
}: {
items: RecentlyReadItem[];
allLabel: string;
emptyLabel: string;
}) {
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
const [selected, setSelected] = useState<string | null>(null);
const filtered = selected ? items.filter((i) => i.username === selected) : items;
return (
<div>
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{filtered.map((book) => (
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
{book.username && usernames.length > 1 && (
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useOptimistic, useTransition } from "react";
interface TokenUserSelectProps {
tokenId: string;
currentUserId?: string;
users: { id: string; username: string }[];
action: (formData: FormData) => Promise<void>;
noUserLabel: string;
}
export function TokenUserSelect({ tokenId, currentUserId, users, action, noUserLabel }: TokenUserSelectProps) {
const [optimisticValue, setOptimisticValue] = useOptimistic(currentUserId ?? "");
const [, startTransition] = useTransition();
return (
<select
value={optimisticValue}
onChange={(e) => {
const newValue = e.target.value;
startTransition(async () => {
setOptimisticValue(newValue);
const fd = new FormData();
fd.append("id", tokenId);
fd.append("user_id", newValue);
await action(fd);
});
}}
className="flex h-8 rounded-md border border-input bg-background px-2 py-0 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">{noUserLabel}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import type { UserDto } from "@/lib/api";
export function UserSwitcher({
users,
activeUserId,
setActiveUserAction,
}: {
users: UserDto[];
activeUserId: string | null;
setActiveUserAction: (formData: FormData) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [, startTransition] = useTransition();
const ref = useRef<HTMLDivElement>(null);
const activeUser = users.find((u) => u.id === activeUserId) ?? null;
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function select(userId: string | null) {
setOpen(false);
startTransition(async () => {
const fd = new FormData();
fd.append("user_id", userId ?? "");
await setActiveUserAction(fd);
});
}
if (users.length === 0) return null;
const isImpersonating = activeUserId !== null;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
isImpersonating
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/15"
: "border-border/60 bg-muted/40 text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{isImpersonating ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
)}
<span className="max-w-[80px] truncate hidden sm:inline">
{activeUser ? activeUser.username : "Admin"}
</span>
<svg className="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-lg border border-border/60 bg-popover shadow-lg z-50 overflow-hidden py-1">
<button
onClick={() => select(null)}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
!isImpersonating
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Admin
{!isImpersonating && (
<svg className="w-3.5 h-3.5 ml-auto text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<div className="h-px bg-border/60 my-1" />
{users.map((user) => (
<button
key={user.id}
onClick={() => select(user.id)}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
activeUserId === user.id
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="truncate">{user.username}</span>
{activeUserId === user.id && (
<svg className="w-3.5 h-3.5 ml-auto text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useOptimistic, useTransition, useRef, useState } from "react";
export function UsernameEdit({
userId,
currentUsername,
action,
}: {
userId: string;
currentUsername: string;
action: (formData: FormData) => Promise<void>;
}) {
const [optimisticUsername, setOptimisticUsername] = useOptimistic(currentUsername);
const [editing, setEditing] = useState(false);
const [, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
function startEdit() {
setEditing(true);
setTimeout(() => inputRef.current?.select(), 0);
}
function submit(value: string) {
const trimmed = value.trim();
if (!trimmed || trimmed === currentUsername) {
setEditing(false);
return;
}
setEditing(false);
startTransition(async () => {
setOptimisticUsername(trimmed);
const fd = new FormData();
fd.append("id", userId);
fd.append("username", trimmed);
await action(fd);
});
}
if (editing) {
return (
<input
ref={inputRef}
defaultValue={optimisticUsername}
className="text-sm font-medium text-foreground bg-background border border-border rounded px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary w-36"
autoFocus
onBlur={(e) => submit(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit((e.target as HTMLInputElement).value);
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<button
onClick={startEdit}
className="flex items-center gap-1.5 group/edit text-left"
title="Modifier"
>
<span className="text-sm font-medium text-foreground">{optimisticUsername}</span>
<svg
className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover/edit:opacity-100 transition-opacity shrink-0"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
);
}

View File

@@ -117,6 +117,7 @@ export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
cbr_to_cbz: t("jobType.cbr_to_cbz"),
metadata_batch: t("jobType.metadata_batch"),
metadata_refresh: t("jobType.metadata_refresh"),
reading_status_match: t("jobType.reading_status_match"),
};
const label = jobTypeLabels[key] ?? type;
return <Badge variant={variant} className={className}>{label}</Badge>;

View File

@@ -14,7 +14,7 @@ type ButtonVariant =
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: ButtonVariant;
size?: "sm" | "md" | "lg";
size?: "xs" | "sm" | "md" | "lg";
}
const variantStyles: Record<ButtonVariant, string> = {
@@ -33,6 +33,7 @@ const variantStyles: Record<ButtonVariant, string> = {
};
const sizeStyles: Record<string, string> = {
xs: "h-7 px-2.5 text-xs rounded-md",
sm: "h-9 px-3 text-xs rounded-md",
md: "h-10 px-4 py-2 text-sm rounded-md",
lg: "h-11 px-8 text-base rounded-md",
@@ -50,7 +51,7 @@ export function Button({
<button
className={`
inline-flex items-center justify-center
font-medium
font-medium cursor-pointer
transition-all duration-200 ease-out
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50

View File

@@ -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<IconName, string> = {
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<Record<IconName, string>> = {

View File

@@ -4,6 +4,7 @@ interface StatBoxProps {
value: ReactNode;
label: string;
variant?: "default" | "primary" | "success" | "warning" | "error";
icon?: ReactNode;
className?: string;
}
@@ -23,10 +24,13 @@ const valueVariantStyles: Record<string, string> = {
error: "text-destructive",
};
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
export function StatBox({ value, label, variant = "default", icon, className = "" }: StatBoxProps) {
return (
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
<div className={`flex items-center justify-center gap-1.5 ${valueVariantStyles[variant]}`}>
{icon && <span className="text-xl">{icon}</span>}
<span className="text-3xl font-bold">{value}</span>
</div>
<span className={`text-xs text-muted-foreground`}>{label}</span>
</div>
);

View File

@@ -0,0 +1,18 @@
import { ReactNode } from "react";
interface TooltipProps {
label: string;
children: ReactNode;
className?: string;
}
export function Tooltip({ label, children, className = "" }: TooltipProps) {
return (
<span className={`relative group/tooltip inline-flex ${className}`}>
{children}
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 text-xs text-popover-foreground bg-popover border border-border rounded-lg shadow-lg whitespace-nowrap opacity-0 scale-95 transition-all duration-150 group-hover/tooltip:opacity-100 group-hover/tooltip:scale-100 z-50">
{label}
</span>
</span>
);
}

View File

@@ -19,3 +19,4 @@ export {
} from "./Form";
export { PageIcon, NavIcon, Icon } from "./Icon";
export { CursorPagination, OffsetPagination } from "./Pagination";
export { Tooltip } from "./Tooltip";

View File

@@ -1,130 +1,27 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import "./globals.css";
import { ThemeProvider } from "./theme-provider";
import { ThemeToggle } from "./theme-toggle";
import { JobsIndicator } from "./components/JobsIndicator";
import { NavIcon, Icon } from "./components/ui";
import { MobileNav } from "./components/MobileNav";
import { LocaleProvider } from "../lib/i18n/context";
import { getServerLocale, getServerTranslations } from "../lib/i18n/server";
import type { TranslationKey } from "../lib/i18n/fr";
import { LocaleProvider } from "@/lib/i18n/context";
import { getServerLocale } from "@/lib/i18n/server";
export const metadata: Metadata = {
title: "StripStream Backoffice",
description: "Administration backoffice pour StripStream Librarian"
};
type NavItem = {
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
labelKey: TranslationKey;
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
};
const navItems: NavItem[] = [
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
{ href: "/books", labelKey: "nav.books", icon: "books" },
{ href: "/series", labelKey: "nav.series", icon: "series" },
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
];
export default async function RootLayout({ children }: { children: ReactNode }) {
const locale = await getServerLocale();
const { t } = await getServerTranslations();
return (
<html lang={locale} suppressHydrationWarning>
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
<ThemeProvider>
<LocaleProvider initialLocale={locale}>
{/* Header avec effet glassmorphism */}
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
{/* Brand */}
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
>
<Image
src="/logo.png"
alt="StripStream"
width={36}
height={36}
className="rounded-lg"
/>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">
StripStream
</span>
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
{t("common.backoffice")}
</span>
</div>
</Link>
{/* Navigation Links */}
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} />
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
</NavLink>
))}
</div>
{/* Actions */}
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<Link
href="/settings"
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t("nav.settings")}
>
<Icon name="settings" size="md" />
</Link>
<ThemeToggle />
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
</div>
</div>
</nav>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
{children}
</main>
</LocaleProvider>
</ThemeProvider>
</body>
</html>
);
}
// Navigation Link Component
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
return (
<Link
href={href}
title={title}
className="
flex items-center
px-2 lg:px-3 py-2
rounded-lg
text-sm font-medium
text-muted-foreground
hover:text-foreground
hover:bg-accent
transition-colors duration-200
active:scale-[0.98]
"
>
{children}
</Link>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { useState, Suspense } from "react";
function LoginForm() {
const searchParams = useSearchParams();
const from = searchParams.get("from") || "/";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
window.location.href = from;
} else {
const data = await res.json().catch(() => ({}));
setError(data.error || "Identifiants invalides");
}
} catch {
setError("Erreur réseau");
} finally {
setLoading(false);
}
}
return (
<div className="relative min-h-screen flex flex-col items-center justify-center px-4 py-16 overflow-hidden">
{/* Background logo */}
<Image
src="/logo.png"
alt=""
fill
className="object-cover opacity-20"
priority
aria-hidden
/>
{/* Hero */}
<div className="relative flex flex-col items-center mb-10">
<h1 className="text-4xl font-bold tracking-tight text-foreground">
StripStream{" "}
<span className="text-primary font-light">: Librarian</span>
</h1>
<p className="text-sm text-muted-foreground mt-1.5 tracking-wide uppercase font-medium">
Administration
</p>
</div>
{/* Form card */}
<div
className="relative w-full max-w-sm rounded-2xl border border-white/20 backdrop-blur-sm p-8"
style={{ boxShadow: "0 24px 48px -12px rgb(0 0 0 / 0.18)" }}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1.5">
Identifiant
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
required
disabled={loading}
placeholder="admin"
className="
flex w-full h-11 px-4
rounded-xl border border-input bg-background/60
text-sm text-foreground
placeholder:text-muted-foreground/40
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
disabled:opacity-50
transition-all duration-200
"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
Mot de passe
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
disabled={loading}
placeholder="••••••••"
className="
flex w-full h-11 px-4
rounded-xl border border-input bg-background/60
text-sm text-foreground
placeholder:text-muted-foreground/40
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
disabled:opacity-50
transition-all duration-200
"
/>
</div>
{error && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-destructive/10 border border-destructive/20 text-sm text-destructive">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="
w-full h-11 mt-2
inline-flex items-center justify-center gap-2
rounded-xl font-medium text-sm
bg-primary text-primary-foreground
hover:bg-primary/90
transition-all duration-200 ease-out
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
active:scale-[0.98]
"
style={{ boxShadow: "0 4px 16px -4px hsl(198 78% 37% / 0.5)" }}
>
{loading ? (
<>
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Connexion
</>
) : "Se connecter"}
</button>
</form>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}

View File

@@ -1,151 +0,0 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "../../lib/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
import { getServerTranslations } from "../../lib/i18n/server";
export const dynamic = "force-dynamic";
export default async function TokensPage({
searchParams
}: {
searchParams: Promise<{ created?: string }>;
}) {
const { t } = await getServerTranslations();
const params = await searchParams;
const tokens = await listTokens().catch(() => [] as TokenDto[]);
async function createTokenAction(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const scope = formData.get("scope") as string;
if (name) {
const result = await createToken(name, scope);
revalidatePath("/tokens");
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
}
}
async function revokeTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await revokeToken(id);
revalidatePath("/tokens");
}
async function deleteTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteToken(id);
revalidatePath("/tokens");
}
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{t("tokens.title")}
</h1>
</div>
{params.created ? (
<Card className="mb-6 border-success/50 bg-success/5">
<CardHeader>
<CardTitle className="text-success">{t("tokens.created")}</CardTitle>
<CardDescription>{t("tokens.createdDescription")}</CardDescription>
</CardHeader>
<CardContent>
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
</CardContent>
</Card>
) : null}
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("tokens.createNew")}</CardTitle>
<CardDescription>{t("tokens.createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form action={createTokenAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder={t("tokens.tokenName")} required />
</FormField>
<FormField className="w-32">
<FormSelect name="scope" defaultValue="read">
<option value="read">{t("tokens.scopeRead")}</option>
<option value="admin">{t("tokens.scopeAdmin")}</option>
</FormSelect>
</FormField>
<Button type="submit">{t("tokens.createButton")}</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{tokens.map((token) => (
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
<td className="px-4 py-3 text-sm">
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
{token.scope}
</Badge>
</td>
<td className="px-4 py-3 text-sm">
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
</td>
<td className="px-4 py-3 text-sm">
{token.revoked_at ? (
<Badge variant="error">{t("tokens.revoked")}</Badge>
) : (
<Badge variant="success">{t("tokens.active")}</Badge>
)}
</td>
<td className="px-4 py-3">
{!token.revoked_at ? (
<form action={revokeTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t("tokens.revoke")}
</Button>
</form>
) : (
<form action={deleteTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t("common.delete")}
</Button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}

View File

@@ -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 = {
@@ -32,6 +33,7 @@ export type IndexJobDto = {
removed_files: number;
errors: number;
warnings: number;
refreshed?: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
@@ -44,6 +46,17 @@ export type TokenDto = {
scope: string;
prefix: string;
revoked_at: string | null;
user_id?: string;
username?: string;
};
export type UserDto = {
id: string;
username: string;
token_count: number;
books_read: number;
books_reading: number;
created_at: string;
};
export type FolderItem = {
@@ -128,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() {
@@ -150,6 +240,16 @@ export async function apiFetch<T>(
headers.set("Content-Type", "application/json");
}
// Impersonation : injecte X-As-User si un user est sélectionné dans le backoffice
try {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const asUserId = cookieStore.get("as_user_id")?.value;
if (asUserId) headers.set("X-As-User", asUserId);
} catch {
// Hors contexte Next.js (tests, etc.)
}
const { next: nextOptions, ...restInit } = init ?? {};
const res = await fetch(`${baseUrl}${path}`, {
@@ -267,10 +367,32 @@ export async function listTokens() {
return apiFetch<TokenDto[]>("/admin/tokens");
}
export async function createToken(name: string, scope: string) {
export async function createToken(name: string, scope: string, userId?: string) {
return apiFetch<{ token: string }>("/admin/tokens", {
method: "POST",
body: JSON.stringify({ name, scope }),
body: JSON.stringify({ name, scope, ...(userId ? { user_id: userId } : {}) }),
});
}
export async function fetchUsers(): Promise<UserDto[]> {
return apiFetch<UserDto[]>("/admin/users");
}
export async function createUser(username: string): Promise<UserDto> {
return apiFetch<UserDto>("/admin/users", {
method: "POST",
body: JSON.stringify({ username }),
});
}
export async function deleteUser(id: string): Promise<void> {
return apiFetch<void>(`/admin/users/${id}`, { method: "DELETE" });
}
export async function updateUser(id: string, username: string): Promise<void> {
return apiFetch<void>(`/admin/users/${id}`, {
method: "PATCH",
body: JSON.stringify({ username }),
});
}
@@ -282,6 +404,13 @@ export async function deleteToken(id: string) {
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
}
export async function updateToken(id: string, userId: string | null) {
return apiFetch<void>(`/admin/tokens/${id}`, {
method: "PATCH",
body: JSON.stringify({ user_id: userId || null }),
});
}
export async function fetchBooks(
libraryId?: string,
series?: string,
@@ -556,6 +685,7 @@ export type CurrentlyReadingItem = {
series: string | null;
current_page: number;
page_count: number;
username?: string;
};
export type RecentlyReadItem = {
@@ -563,11 +693,28 @@ export type RecentlyReadItem = {
title: string;
series: string | null;
last_read_at: string;
username?: string;
};
export type MonthlyReading = {
month: string;
books_read: number;
pages_read: number;
};
export type UserMonthlyReading = {
month: string;
username: string;
books_read: number;
pages_read: number;
};
export type JobTimePoint = {
label: string;
scan: number;
rebuild: number;
thumbnail: number;
other: number;
};
export type StatsResponse = {
@@ -576,16 +723,19 @@ export type StatsResponse = {
currently_reading: CurrentlyReadingItem[];
recently_read: RecentlyReadItem[];
reading_over_time: MonthlyReading[];
users_reading_over_time: UserMonthlyReading[];
by_format: FormatCount[];
by_language: LanguageCount[];
by_library: LibraryStatsItem[];
top_series: TopSeriesItem[];
additions_over_time: MonthlyAdditions[];
jobs_over_time: JobTimePoint[];
metadata: MetadataStats;
};
export async function fetchStats() {
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } });
export async function fetchStats(period?: "day" | "week" | "month") {
const params = period && period !== "month" ? `?period=${period}` : "";
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
}
// ---------------------------------------------------------------------------
@@ -688,11 +838,13 @@ export type KomgaSyncRequest = {
url: string;
username: string;
password: string;
user_id: string;
};
export type KomgaSyncResponse = {
id: string;
komga_url: string;
user_id?: string;
total_komga_read: number;
matched: number;
already_read: number;
@@ -706,6 +858,7 @@ export type KomgaSyncResponse = {
export type KomgaSyncReportSummary = {
id: string;
komga_url: string;
user_id?: string;
total_komga_read: number;
matched: number;
already_read: number;
@@ -846,6 +999,12 @@ export async function getMetadataLink(libraryId: string, seriesName: string) {
return apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
}
export async function getReadingStatusLink(libraryId: string, seriesName: string) {
return apiFetch<AnilistSeriesLinkDto>(
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`
);
}
export async function getMissingBooks(linkId: string) {
return apiFetch<MissingBooksDto>(`/metadata/missing/${linkId}`);
}
@@ -907,6 +1066,42 @@ export async function startMetadataRefresh(libraryId: string) {
});
}
export async function startReadingStatusMatch(libraryId: string) {
return apiFetch<{ id: string; status: string }>("/reading-status/match", {
method: "POST",
body: JSON.stringify({ library_id: libraryId }),
});
}
export type ReadingStatusMatchReportDto = {
job_id: string;
status: string;
total_series: number;
linked: number;
already_linked: number;
no_results: number;
ambiguous: number;
errors: number;
};
export type ReadingStatusMatchResultDto = {
id: string;
series_name: string;
status: "linked" | "already_linked" | "no_results" | "ambiguous" | "error";
anilist_id: number | null;
anilist_title: string | null;
anilist_url: string | null;
error_message: string | null;
};
export async function getReadingStatusMatchReport(jobId: string) {
return apiFetch<ReadingStatusMatchReportDto>(`/reading-status/match/${jobId}/report`);
}
export async function getReadingStatusMatchResults(jobId: string) {
return apiFetch<ReadingStatusMatchResultDto[]>(`/reading-status/match/${jobId}/results`);
}
export type RefreshFieldDiff = {
field: string;
old?: unknown;

View File

@@ -8,6 +8,7 @@ const en: Record<TranslationKey, string> = {
"nav.libraries": "Libraries",
"nav.jobs": "Jobs",
"nav.tokens": "Tokens",
"nav.users": "Users",
"nav.settings": "Settings",
"nav.navigation": "Navigation",
"nav.closeMenu": "Close menu",
@@ -70,7 +71,17 @@ const en: Record<TranslationKey, string> = {
"dashboard.readingStatus": "Reading status",
"dashboard.byFormat": "By format",
"dashboard.byLibrary": "By library",
"dashboard.booksAdded": "Books added (last 12 months)",
"dashboard.booksAdded": "Books added",
"dashboard.jobsOverTime": "Job runs",
"dashboard.jobScan": "Scan",
"dashboard.jobRebuild": "Rebuild",
"dashboard.jobThumbnail": "Thumbnails",
"dashboard.jobOther": "Other",
"dashboard.periodDay": "Day",
"dashboard.periodWeek": "Week",
"dashboard.periodMonth": "Month",
"dashboard.metricBooks": "Books",
"dashboard.metricPages": "Pages",
"dashboard.popularSeries": "Popular series",
"dashboard.noSeries": "No series yet",
"dashboard.unknown": "Unknown",
@@ -84,10 +95,11 @@ const en: Record<TranslationKey, string> = {
"dashboard.withIsbn": "With ISBN",
"dashboard.currentlyReading": "Currently reading",
"dashboard.recentlyRead": "Recently read",
"dashboard.readingActivity": "Reading activity (last 12 months)",
"dashboard.readingActivity": "Reading activity",
"dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "No books in progress",
"dashboard.noRecentlyRead": "No books read recently",
"dashboard.allUsers": "All",
// Books page
"books.title": "Books",
@@ -185,6 +197,23 @@ const en: Record<TranslationKey, string> = {
"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",
@@ -230,6 +259,9 @@ const en: Record<TranslationKey, string> = {
"jobs.generateThumbnailsDescription": "Generates thumbnails only for books that don't have one yet. Existing thumbnails are not affected. Useful after an import or if some thumbnails are missing.",
"jobs.regenerateThumbnailsDescription": "Regenerates all thumbnails from scratch, replacing existing ones. Useful if thumbnail quality or size has changed in the configuration, or if thumbnails are corrupted.",
"jobs.batchMetadataDescription": "Automatically searches metadata for each series in the library from the configured provider (with fallback if configured). Only results with a unique 100% confidence match are applied automatically. Already linked series are skipped. A detailed per-series report is available at the end of the job. <strong>Requires a specific library</strong> (does not work on \"All libraries\").",
"jobs.groupReadingStatus": "Reading status",
"jobs.matchReadingStatus": "Match series",
"jobs.matchReadingStatusShort": "Auto-link unmatched series to the reading status provider",
// Jobs list
"jobsList.id": "ID",
@@ -250,8 +282,10 @@ const en: Record<TranslationKey, string> = {
"jobRow.thumbnailsGenerated": "{{count}} thumbnails generated",
"jobRow.metadataProcessed": "{{count}} series processed",
"jobRow.metadataRefreshed": "{{count}} series refreshed",
"jobRow.metadataLinks": "{{count}} links analyzed",
"jobRow.errors": "{{count}} errors",
"jobRow.view": "View",
"jobRow.replay": "Replay",
// Job progress
"jobProgress.loadingProgress": "Loading progress...",
@@ -329,6 +363,11 @@ const en: Record<TranslationKey, string> = {
"jobDetail.match": "Match: {{title}}",
"jobDetail.fileErrors": "File errors ({{count}})",
"jobDetail.fileErrorsDesc": "Errors encountered while processing files",
"jobDetail.readingStatusMatch": "Series matching",
"jobDetail.readingStatusMatchDesc": "Searching each series against the reading status provider",
"jobDetail.readingStatusMatchReport": "Match report",
"jobDetail.linked": "Linked",
"jobDetail.ambiguous": "Ambiguous",
// Job types
"jobType.rebuild": "Indexing",
@@ -355,6 +394,9 @@ const en: Record<TranslationKey, string> = {
"jobType.metadata_batchDesc": "Searches external metadata providers for all series in the library and automatically applies 100% confidence matches.",
"jobType.metadata_refreshLabel": "Metadata refresh",
"jobType.metadata_refreshDesc": "Re-downloads and updates metadata for all series already linked to an external provider.",
"jobType.reading_status_match": "Reading status match",
"jobType.reading_status_matchLabel": "Series matching (reading status)",
"jobType.reading_status_matchDesc": "Automatically searches each series in the library against the configured reading status provider (e.g. AniList) and creates links for unambiguously identified series.",
// Status badges
"statusBadge.extracting_pages": "Extracting pages",
@@ -396,6 +438,21 @@ const en: Record<TranslationKey, string> = {
"tokens.revoked": "Revoked",
"tokens.active": "Active",
"tokens.revoke": "Revoke",
"tokens.user": "User",
"tokens.noUser": "None (admin)",
"tokens.apiTokens": "API Tokens",
// Users page
"users.title": "Users",
"users.createNew": "Create a user",
"users.createDescription": "Create a user account for read access",
"users.username": "Username",
"users.createButton": "Create",
"users.name": "Username",
"users.tokenCount": "Tokens",
"users.createdAt": "Created",
"users.actions": "Actions",
"users.noUsers": "No users",
// Settings page
"settings.title": "Settings",
@@ -576,6 +633,59 @@ const en: Record<TranslationKey, string> = {
"settings.telegramHelpChat": "Send a message to your bot, then open <code>https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code> in your browser. The <b>chat id</b> is in <code>message.chat.id</code>.",
"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. <code>-123456789</code>).",
// 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",

View File

@@ -6,6 +6,7 @@ const fr = {
"nav.libraries": "Bibliothèques",
"nav.jobs": "Tâches",
"nav.tokens": "Jetons",
"nav.users": "Utilisateurs",
"nav.settings": "Paramètres",
"nav.navigation": "Navigation",
"nav.closeMenu": "Fermer le menu",
@@ -68,7 +69,17 @@ const fr = {
"dashboard.readingStatus": "Statut de lecture",
"dashboard.byFormat": "Par format",
"dashboard.byLibrary": "Par bibliothèque",
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)",
"dashboard.booksAdded": "Livres ajoutés",
"dashboard.jobsOverTime": "Exécutions de jobs",
"dashboard.jobScan": "Scan",
"dashboard.jobRebuild": "Rebuild",
"dashboard.jobThumbnail": "Thumbnails",
"dashboard.jobOther": "Autre",
"dashboard.periodDay": "Jour",
"dashboard.periodWeek": "Semaine",
"dashboard.periodMonth": "Mois",
"dashboard.metricBooks": "Livres",
"dashboard.metricPages": "Pages",
"dashboard.popularSeries": "Séries populaires",
"dashboard.noSeries": "Aucune série pour le moment",
"dashboard.unknown": "Inconnu",
@@ -82,10 +93,11 @@ const fr = {
"dashboard.withIsbn": "Avec ISBN",
"dashboard.currentlyReading": "En cours de lecture",
"dashboard.recentlyRead": "Derniers livres lus",
"dashboard.readingActivity": "Activité de lecture (12 derniers mois)",
"dashboard.readingActivity": "Activité de lecture",
"dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "Aucun livre en cours",
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
"dashboard.allUsers": "Tous",
// Books page
"books.title": "Livres",
@@ -183,6 +195,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",
@@ -228,6 +257,9 @@ const fr = {
"jobs.generateThumbnailsDescription": "Génère les miniatures uniquement pour les livres qui n'en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.",
"jobs.regenerateThumbnailsDescription": "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.",
"jobs.batchMetadataDescription": "Recherche automatiquement les métadonnées de chaque série de la bibliothèque auprès du provider configuré (avec fallback si configuré). Seuls les résultats avec un match unique à 100% de confiance sont appliqués automatiquement. Les séries déjà liées sont ignorées. Un rapport détaillé par série est disponible à la fin du job. <strong>Requiert une bibliothèque spécifique</strong> (ne fonctionne pas sur \u00ab Toutes les bibliothèques \u00bb).",
"jobs.groupReadingStatus": "Statut de lecture",
"jobs.matchReadingStatus": "Correspondance des séries",
"jobs.matchReadingStatusShort": "Lier automatiquement les séries non associées au provider",
// Jobs list
"jobsList.id": "ID",
@@ -248,8 +280,10 @@ const fr = {
"jobRow.thumbnailsGenerated": "{{count}} miniatures générées",
"jobRow.metadataProcessed": "{{count}} séries traitées",
"jobRow.metadataRefreshed": "{{count}} séries rafraîchies",
"jobRow.metadataLinks": "{{count}} liens analysés",
"jobRow.errors": "{{count}} erreurs",
"jobRow.view": "Voir",
"jobRow.replay": "Rejouer",
// Job progress
"jobProgress.loadingProgress": "Chargement de la progression...",
@@ -327,6 +361,11 @@ const fr = {
"jobDetail.match": "Correspondance : {{title}}",
"jobDetail.fileErrors": "Erreurs de fichiers ({{count}})",
"jobDetail.fileErrorsDesc": "Erreurs rencontrées lors du traitement des fichiers",
"jobDetail.readingStatusMatch": "Correspondance des séries",
"jobDetail.readingStatusMatchDesc": "Recherche de chaque série sur le provider de statut de lecture",
"jobDetail.readingStatusMatchReport": "Rapport de correspondance",
"jobDetail.linked": "Liées",
"jobDetail.ambiguous": "Ambiguës",
// Job types
"jobType.rebuild": "Indexation",
@@ -353,6 +392,9 @@ const fr = {
"jobType.metadata_batchDesc": "Recherche les métadonnées auprès des fournisseurs externes pour toutes les séries de la bibliothèque et applique automatiquement les correspondances à 100% de confiance.",
"jobType.metadata_refreshLabel": "Rafraîchissement métadonnées",
"jobType.metadata_refreshDesc": "Re-télécharge et met à jour les métadonnées pour toutes les séries déjà liées à un fournisseur externe.",
"jobType.reading_status_match": "Correspondance statut lecture",
"jobType.reading_status_matchLabel": "Correspondance des séries (statut lecture)",
"jobType.reading_status_matchDesc": "Recherche automatiquement chaque série de la bibliothèque sur le provider de statut de lecture configuré (ex. AniList) et crée les liens pour les séries identifiées sans ambiguïté.",
// Status badges
"statusBadge.extracting_pages": "Extraction des pages",
@@ -394,6 +436,21 @@ const fr = {
"tokens.revoked": "Révoqué",
"tokens.active": "Actif",
"tokens.revoke": "Révoquer",
"tokens.user": "Utilisateur",
"tokens.noUser": "Aucun (admin)",
"tokens.apiTokens": "Tokens API",
// Users page
"users.title": "Utilisateurs",
"users.createNew": "Créer un utilisateur",
"users.createDescription": "Créer un compte utilisateur pour accès lecture",
"users.username": "Nom d'utilisateur",
"users.createButton": "Créer",
"users.name": "Nom d'utilisateur",
"users.tokenCount": "Nb de jetons",
"users.createdAt": "Créé le",
"users.actions": "Actions",
"users.noUsers": "Aucun utilisateur",
// Settings page
"settings.title": "Paramètres",
@@ -574,6 +631,59 @@ const fr = {
"settings.telegramHelpChat": "Envoyez un message à votre bot, puis ouvrez <code>https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code> dans votre navigateur. Le <b>chat id</b> apparaît dans <code>message.chat.id</code>.",
"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: <code>-123456789</code>).",
// 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",

View File

@@ -0,0 +1,33 @@
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
export const SESSION_COOKIE = "sl_session";
function getSecret(): Uint8Array {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error("SESSION_SECRET env var is required");
return new TextEncoder().encode(secret);
}
export async function createSessionToken(): Promise<string> {
return new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.sign(getSecret());
}
export async function verifySessionToken(token: string): Promise<boolean> {
try {
await jwtVerify(token, getSecret());
return true;
} catch {
return false;
}
}
export async function getSession(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return false;
return verifySessionToken(token);
}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,13 +1,14 @@
{
"name": "stripstream-backoffice",
"version": "1.23.0",
"version": "1.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stripstream-backoffice",
"version": "1.23.0",
"version": "1.28.0",
"dependencies": {
"jose": "^6.2.2",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"react": "19.0.0",
@@ -143,9 +144,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -162,9 +160,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -181,9 +176,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -200,9 +192,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -219,9 +208,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -238,9 +224,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -257,9 +240,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -276,9 +256,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -295,9 +272,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -320,9 +294,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -345,9 +316,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -370,9 +338,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -395,9 +360,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -420,9 +382,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -445,9 +404,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -470,9 +426,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -659,9 +612,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -678,9 +628,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -697,9 +644,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -716,9 +660,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -950,9 +891,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -970,9 +908,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -990,9 +925,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1010,9 +942,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1179,6 +1108,7 @@
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1311,6 +1241,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1717,6 +1648,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -1860,9 +1800,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1884,9 +1821,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1908,9 +1842,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1932,9 +1863,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2147,6 +2075,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2168,6 +2097,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2177,6 +2107,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.25.0"
},
@@ -2196,6 +2127,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -2248,7 +2180,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "stripstream-backoffice",
"version": "1.25.0",
"version": "2.3.1",
"private": true,
"scripts": {
"dev": "next dev -p 7082",
@@ -8,6 +8,7 @@
"start": "next start -p 7082"
},
"dependencies": {
"jose": "^6.2.2",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"react": "19.0.0",

38
apps/backoffice/proxy.ts Normal file
View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
import { SESSION_COOKIE } from "./lib/session";
function getSecret(): Uint8Array {
const secret = process.env.SESSION_SECRET;
if (!secret) return new TextEncoder().encode("dev-insecure-secret");
return new TextEncoder().encode(secret);
}
export async function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
// Skip auth for login page and auth API routes
if (pathname.startsWith("/login") || pathname.startsWith("/api/auth")) {
return NextResponse.next();
}
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (token) {
try {
await jwtVerify(token, getSecret());
return NextResponse.next();
} catch {
// Token invalid or expired
}
}
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon\\.ico|logo\\.png|.*\\.svg).*)",
],
};

View File

@@ -43,6 +43,10 @@ pub struct EventToggles {
pub metadata_refresh_completed: bool,
#[serde(default = "default_true")]
pub metadata_refresh_failed: bool,
#[serde(default = "default_true")]
pub reading_status_match_completed: bool,
#[serde(default = "default_true")]
pub reading_status_match_failed: bool,
}
fn default_true() -> bool {
@@ -63,6 +67,8 @@ fn default_events() -> EventToggles {
metadata_batch_failed: true,
metadata_refresh_completed: true,
metadata_refresh_failed: true,
reading_status_match_completed: true,
reading_status_match_failed: true,
}
}
@@ -161,7 +167,13 @@ async fn send_telegram_photo(config: &TelegramConfig, caption: &str, photo_path:
/// Send a test message. Returns the result directly (not fire-and-forget).
pub async fn send_test_message(config: &TelegramConfig) -> Result<()> {
send_telegram(config, "🔔 <b>Stripstream Librarian</b>\nTest notification — connection OK!").await
send_telegram(
config,
"🔔 <b>Stripstream Librarian</b>\n\
━━━━━━━━━━━━━━━━━━━━\n\
✅ Test notification — connection OK!",
)
.await
}
// ---------------------------------------------------------------------------
@@ -243,6 +255,16 @@ pub enum NotificationEvent {
library_name: Option<String>,
error: String,
},
// Reading status match (auto-link series to provider)
ReadingStatusMatchCompleted {
library_name: Option<String>,
total_series: i32,
linked: i32,
},
ReadingStatusMatchFailed {
library_name: Option<String>,
error: String,
},
}
/// Classify an indexer job_type string into the right event constructor category.
@@ -265,22 +287,23 @@ fn format_event(event: &NotificationEvent) -> String {
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let duration = format_duration(*duration_seconds);
format!(
"📚 <b>Scan completed</b>\n\
Library: {lib}\n\
Type: {job_type}\n\
New books: {}\n\
New series: {}\n\
Files scanned: {}\n\
Removed: {}\n\
Errors: {}\n\
Duration: {duration}",
stats.indexed_files,
stats.new_series,
stats.scanned_files,
stats.removed_files,
stats.errors,
)
let mut lines = vec![
format!(" <b>Scan completed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
format!("🏷 <b>Type:</b> {job_type}"),
format!("⏱ <b>Duration:</b> {duration}"),
String::new(),
format!("📊 <b>Results</b>"),
format!(" 📗 New books: <b>{}</b>", stats.indexed_files),
format!(" 📚 New series: <b>{}</b>", stats.new_series),
format!(" 🔎 Files scanned: <b>{}</b>", stats.scanned_files),
format!(" 🗑 Removed: <b>{}</b>", stats.removed_files),
];
if stats.errors > 0 {
lines.push(format!(" ⚠️ Errors: <b>{}</b>", stats.errors));
}
lines.join("\n")
}
NotificationEvent::ScanFailed {
job_type,
@@ -289,23 +312,28 @@ fn format_event(event: &NotificationEvent) -> String {
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
format!(
" <b>Scan failed</b>\n\
Library: {lib}\n\
Type: {job_type}\n\
Error: {err}"
)
[
format!("🚨 <b>Scan failed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
format!("🏷 <b>Type:</b> {job_type}"),
String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
}
NotificationEvent::ScanCancelled {
job_type,
library_name,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
format!(
"⏹ <b>Scan cancelled</b>\n\
Library: {lib}\n\
Type: {job_type}"
)
[
format!("⏹ <b>Scan cancelled</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
format!("🏷 <b>Type:</b> {job_type}"),
]
.join("\n")
}
NotificationEvent::ThumbnailCompleted {
job_type,
@@ -314,12 +342,14 @@ fn format_event(event: &NotificationEvent) -> String {
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let duration = format_duration(*duration_seconds);
format!(
"🖼 <b>Thumbnails completed</b>\n\
Library: {lib}\n\
Type: {job_type}\n\
Duration: {duration}"
)
[
format!(" <b>Thumbnails completed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
format!("🏷 <b>Type:</b> {job_type}"),
format!("⏱ <b>Duration:</b> {duration}"),
]
.join("\n")
}
NotificationEvent::ThumbnailFailed {
job_type,
@@ -328,12 +358,15 @@ fn format_event(event: &NotificationEvent) -> String {
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
format!(
" <b>Thumbnails failed</b>\n\
Library: {lib}\n\
Type: {job_type}\n\
Error: {err}"
)
[
format!("🚨 <b>Thumbnails failed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
format!("🏷 <b>Type:</b> {job_type}"),
String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
}
NotificationEvent::ConversionCompleted {
library_name,
@@ -342,11 +375,13 @@ fn format_event(event: &NotificationEvent) -> String {
} => {
let lib = library_name.as_deref().unwrap_or("Unknown");
let title = book_title.as_deref().unwrap_or("Unknown");
format!(
"🔄 <b>CBRCBZ conversion completed</b>\n\
Library: {lib}\n\
Book: {title}"
)
[
format!(" <b>CBRCBZ conversion completed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
format!("📖 <b>Book:</b> {title}"),
]
.join("\n")
}
NotificationEvent::ConversionFailed {
library_name,
@@ -357,23 +392,28 @@ fn format_event(event: &NotificationEvent) -> String {
let lib = library_name.as_deref().unwrap_or("Unknown");
let title = book_title.as_deref().unwrap_or("Unknown");
let err = truncate(error, 200);
format!(
" <b>CBRCBZ conversion failed</b>\n\
Library: {lib}\n\
Book: {title}\n\
Error: {err}"
)
[
format!("🚨 <b>CBRCBZ conversion failed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
format!("📖 <b>Book:</b> {title}"),
String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
}
NotificationEvent::MetadataApproved {
series_name,
provider,
..
} => {
format!(
"🔗 <b>Metadata linked</b>\n\
Series: {series_name}\n\
Provider: {provider}"
)
[
format!(" <b>Metadata linked</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📚 <b>Series:</b> {series_name}"),
format!("🔗 <b>Provider:</b> {provider}"),
]
.join("\n")
}
NotificationEvent::MetadataBatchCompleted {
library_name,
@@ -381,11 +421,13 @@ fn format_event(event: &NotificationEvent) -> String {
processed,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
format!(
"🔍 <b>Metadata batch completed</b>\n\
Library: {lib}\n\
Series processed: {processed}/{total_series}"
)
[
format!(" <b>Metadata batch completed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
format!("📊 <b>Processed:</b> {processed}/{total_series} series"),
]
.join("\n")
}
NotificationEvent::MetadataBatchFailed {
library_name,
@@ -393,11 +435,14 @@ fn format_event(event: &NotificationEvent) -> String {
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
format!(
" <b>Metadata batch failed</b>\n\
Library: {lib}\n\
Error: {err}"
)
[
format!("🚨 <b>Metadata batch failed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
}
NotificationEvent::MetadataRefreshCompleted {
library_name,
@@ -406,13 +451,19 @@ fn format_event(event: &NotificationEvent) -> String {
errors,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
format!(
"🔄 <b>Metadata refresh completed</b>\n\
Library: {lib}\n\
Updated: {refreshed}\n\
Unchanged: {unchanged}\n\
Errors: {errors}"
)
let mut lines = vec![
format!(" <b>Metadata refresh completed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
String::new(),
format!("📊 <b>Results</b>"),
format!(" 🔄 Updated: <b>{refreshed}</b>"),
format!(" ▪️ Unchanged: <b>{unchanged}</b>"),
];
if *errors > 0 {
lines.push(format!(" ⚠️ Errors: <b>{errors}</b>"));
}
lines.join("\n")
}
NotificationEvent::MetadataRefreshFailed {
library_name,
@@ -420,11 +471,45 @@ fn format_event(event: &NotificationEvent) -> String {
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
format!(
" <b>Metadata refresh failed</b>\n\
Library: {lib}\n\
Error: {err}"
)
[
format!("🚨 <b>Metadata refresh failed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
}
NotificationEvent::ReadingStatusMatchCompleted {
library_name,
total_series,
linked,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
[
format!("✅ <b>Reading status match completed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
String::new(),
format!("📊 <b>Results</b>"),
format!(" 🔗 Linked: <b>{linked}</b> / <b>{total_series}</b> series"),
]
.join("\n")
}
NotificationEvent::ReadingStatusMatchFailed {
library_name,
error,
} => {
let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200);
[
format!("🚨 <b>Reading status match failed</b>"),
format!("━━━━━━━━━━━━━━━━━━━━"),
format!("📂 <b>Library:</b> {lib}"),
String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
}
}
}
@@ -466,6 +551,8 @@ fn is_event_enabled(config: &TelegramConfig, event: &NotificationEvent) -> bool
NotificationEvent::MetadataBatchFailed { .. } => config.events.metadata_batch_failed,
NotificationEvent::MetadataRefreshCompleted { .. } => config.events.metadata_refresh_completed,
NotificationEvent::MetadataRefreshFailed { .. } => config.events.metadata_refresh_failed,
NotificationEvent::ReadingStatusMatchCompleted { .. } => config.events.reading_status_match_completed,
NotificationEvent::ReadingStatusMatchFailed { .. } => config.events.reading_status_match_failed,
}
}

View File

@@ -0,0 +1,36 @@
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Les tokens read ont un user_id obligatoire, les tokens admin NULL
ALTER TABLE api_tokens ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE CASCADE;
-- Rendre book_reading_progress par user
ALTER TABLE book_reading_progress DROP CONSTRAINT book_reading_progress_pkey;
ALTER TABLE book_reading_progress ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE CASCADE;
-- Créer un user par défaut si des données existantes doivent être migrées
INSERT INTO users (id, username)
SELECT '00000000-0000-0000-0000-000000000001', 'default'
WHERE EXISTS (
SELECT 1 FROM book_reading_progress WHERE user_id IS NULL
UNION ALL
SELECT 1 FROM api_tokens WHERE scope = 'read' AND user_id IS NULL
);
-- Rattacher les anciennes progressions de lecture au user default
UPDATE book_reading_progress
SET user_id = '00000000-0000-0000-0000-000000000001'
WHERE user_id IS NULL;
-- Rattacher les anciens tokens read au user default
UPDATE api_tokens
SET user_id = '00000000-0000-0000-0000-000000000001'
WHERE scope = 'read' AND user_id IS NULL;
ALTER TABLE book_reading_progress ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE book_reading_progress ADD PRIMARY KEY (book_id, user_id);
DROP INDEX IF EXISTS idx_book_reading_progress_status;
CREATE INDEX idx_book_reading_progress_status ON book_reading_progress(status, user_id);

View File

@@ -0,0 +1 @@
ALTER TABLE komga_sync_reports ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,16 @@
-- Add AniList sync support
ALTER TABLE libraries ADD COLUMN anilist_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE anilist_series_links (
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
series_name TEXT NOT NULL,
anilist_id INTEGER NOT NULL,
anilist_title TEXT,
anilist_url TEXT,
status TEXT NOT NULL DEFAULT 'linked', -- 'linked' | 'synced' | 'error'
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
synced_at TIMESTAMPTZ,
PRIMARY KEY (library_id, series_name)
);
CREATE INDEX idx_anilist_series_links_library ON anilist_series_links(library_id);

View File

@@ -0,0 +1,10 @@
-- Replace anilist_enabled boolean with generic reading_status_provider
ALTER TABLE libraries ADD COLUMN reading_status_provider TEXT;
UPDATE libraries SET reading_status_provider = 'anilist' WHERE anilist_enabled = TRUE;
ALTER TABLE libraries DROP COLUMN anilist_enabled;
-- Add provider column to anilist_series_links for future multi-provider support
ALTER TABLE anilist_series_links ADD COLUMN provider TEXT NOT NULL DEFAULT 'anilist';
-- Update the primary key to include provider
ALTER TABLE anilist_series_links DROP CONSTRAINT anilist_series_links_pkey;
ALTER TABLE anilist_series_links ADD PRIMARY KEY (library_id, series_name, provider);

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
-- Add reading_status_match job type: auto-matches library series against the
-- configured reading status provider (e.g. AniList) and creates links.
ALTER TABLE index_jobs
DROP CONSTRAINT IF EXISTS index_jobs_type_check,
ADD CONSTRAINT index_jobs_type_check
CHECK (type IN ('scan', 'rebuild', 'full_rebuild', 'rescan', 'thumbnail_rebuild', 'thumbnail_regenerate', 'cbr_to_cbz', 'metadata_batch', 'metadata_refresh', 'reading_status_match'));

View File

@@ -0,0 +1,16 @@
-- Table to store per-series results for reading_status_match jobs
CREATE TABLE reading_status_match_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES index_jobs(id) ON DELETE CASCADE,
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
series_name TEXT NOT NULL,
status TEXT NOT NULL, -- 'linked', 'already_linked', 'no_results', 'ambiguous', 'error'
anilist_id INTEGER,
anilist_title TEXT,
anilist_url TEXT,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_rsmr_job_id ON reading_status_match_results(job_id);
CREATE INDEX idx_rsmr_status ON reading_status_match_results(status);

383
infra/perf.sh Executable file
View File

@@ -0,0 +1,383 @@
#!/usr/bin/env bash
# perf.sh — Performance benchmarks for Stripstream Librarian
#
# Measures:
# - Indexer: full rebuild phase durations (discovery / extracting_pages / generating_thumbnails)
# - Indexer: incremental rebuild speed (should skip unchanged dirs via mtime cache)
# - Indexer: thumbnail rebuild (generate missing) and regenerate (force all)
# - API: page render latency (cold + warm/cached), thumbnail fetch, books list, search
#
# Usage:
# BASE_API=http://localhost:7080 API_TOKEN=my-token bash infra/perf.sh
#
# Optional env:
# JOB_TIMEOUT seconds to wait for a job to complete (default 600)
# BENCH_N number of API requests per endpoint for latency measurement (default 10)
# LIBRARY_ID restrict rebuild jobs to a specific library UUID
set -euo pipefail
BASE_API="${BASE_API:-http://127.0.0.1:7080}"
TOKEN="${API_TOKEN:-stripstream-dev-bootstrap-token}"
JOB_TIMEOUT="${JOB_TIMEOUT:-600}"
BENCH_N="${BENCH_N:-10}"
LIBRARY_ID="${LIBRARY_ID:-}"
export BASE_API TOKEN
# ─── colours ────────────────────────────────────────────────────────────────
BOLD="\033[1m"; RESET="\033[0m"; GREEN="\033[32m"; YELLOW="\033[33m"; CYAN="\033[36m"; RED="\033[31m"
header() { echo -e "\n${BOLD}${CYAN}$*${RESET}"; }
ok() { echo -e " ${GREEN}${RESET} $*"; }
warn() { echo -e " ${YELLOW}${RESET} $*"; }
fail() { echo -e " ${RED}${RESET} $*"; }
row() { printf " %-40s %s\n" "$1" "$2"; }
# ─── helpers ────────────────────────────────────────────────────────────────
auth() { curl -fsS -H "Authorization: Bearer $TOKEN" "$@"; }
# Wait for job to finish; print a dot every 2s.
wait_job() {
local job_id="$1" label="${2:-job}" waited=0 status
printf " waiting for %s ." "$label"
while true; do
status="$(auth "$BASE_API/index/jobs/$job_id" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))")"
case "$status" in
success) echo " done"; return 0 ;;
failed) echo " FAILED"; fail "$label failed"; return 1 ;;
cancelled) echo " cancelled"; fail "$label was cancelled"; return 1 ;;
esac
if [ "$waited" -ge "$JOB_TIMEOUT" ]; then
echo " timeout"; fail "$label timed out after ${JOB_TIMEOUT}s (last: $status)"; return 1
fi
printf "."; sleep 2; waited=$((waited + 2))
done
}
# Fetch /index/jobs/:id/details and pretty-print phase durations + throughput.
report_job() {
local job_id="$1" label="$2"
local details
details="$(auth "$BASE_API/index/jobs/$job_id")"
export PERF_DETAILS="$details" PERF_LABEL="$label"
python3 - <<'PY'
import json, os
from datetime import datetime, timezone
def parse(s):
if not s: return None
# Handle both with and without microseconds
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%f+00:00", "%Y-%m-%dT%H:%M:%S+00:00"):
try: return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
except ValueError: pass
return None
d = json.loads(os.environ["PERF_DETAILS"])
label = os.environ["PERF_LABEL"]
started = parse(d.get("started_at"))
phase2 = parse(d.get("phase2_started_at"))
thumbs = parse(d.get("generating_thumbnails_started_at"))
finished = parse(d.get("finished_at"))
stats = d.get("stats_json") or {}
total_files = d.get("total_files") or 0
def secs(a, b):
if a and b: return (b - a).total_seconds()
return None
def fmt(s):
if s is None: return "n/a"
if s < 1: return f"{s*1000:.0f}ms"
return f"{s:.1f}s"
def tps(n, s):
if n and s and s > 0: return f"{n/s:.1f}/s"
return "n/a"
t_total = secs(started, finished)
t_discover = secs(started, phase2)
t_extract = secs(phase2, thumbs)
t_thumbs = secs(thumbs, finished)
indexed = stats.get("indexed_files", 0)
print(f" {'Total':38s} {fmt(t_total)}")
if t_discover is not None:
print(f" {' Phase 1 discovery':38s} {fmt(t_discover)} ({tps(indexed, t_discover)} books indexed)")
if t_extract is not None:
print(f" {' Phase 2A extracting_pages':38s} {fmt(t_extract)} ({tps(total_files, t_extract)} books/s)")
if t_thumbs is not None:
print(f" {' Phase 2B generating_thumbnails':38s} {fmt(t_thumbs)} ({tps(total_files, t_thumbs)} thumbs/s)")
print(f" {' Files indexed':38s} {indexed} / {total_files}")
if stats.get("errors"):
print(f" {' Errors':38s} {stats['errors']}")
PY
}
# Measure avg latency of a GET endpoint over N requests.
measure_latency() {
local label="$1" url="$2" n="${3:-$BENCH_N}"
local total=0 i
for i in $(seq 1 "$n"); do
local t
t=$(curl -s -o /dev/null -w '%{time_total}' -H "Authorization: Bearer $TOKEN" "$url")
total=$(python3 -c "print($total + $t)")
done
local avg_ms
avg_ms=$(python3 -c "print(round(($total / $n)*1000, 1))")
row "$label" "${avg_ms}ms (n=$n)"
}
# Build optional library_id JSON fragment
lib_json() {
if [ -n "$LIBRARY_ID" ]; then echo "\"library_id\":\"$LIBRARY_ID\","; else echo ""; fi
}
enqueue_rebuild() {
local full="${1:-false}"
auth -X POST -H "Content-Type: application/json" \
-d "{$(lib_json)\"full\":$full}" \
"$BASE_API/index/rebuild" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])"
}
enqueue_thumb_rebuild() {
auth -X POST -H "Content-Type: application/json" \
-d "{$(lib_json | sed 's/,$//')}" \
"$BASE_API/index/thumbnails/rebuild" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])"
}
enqueue_thumb_regen() {
auth -X POST -H "Content-Type: application/json" \
-d "{$(lib_json | sed 's/,$//')}" \
"$BASE_API/index/thumbnails/regenerate" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])"
}
# ─── health check ────────────────────────────────────────────────────────────
header "Health"
curl -fsS "$BASE_API/health" >/dev/null && ok "API healthy"
BOOKS_JSON="$(auth "$BASE_API/books")"
BOOK_COUNT="$(echo "$BOOKS_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('total',0))")"
FIRST_BOOK_ID="$(echo "$BOOKS_JSON" | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(items[0]['id'] if items else '')")"
ok "Books in index: $BOOK_COUNT"
if [ -n "$LIBRARY_ID" ]; then ok "Scoped to library: $LIBRARY_ID"; fi
# ─── 1. full rebuild ─────────────────────────────────────────────────────────
header "1 / Full Rebuild"
JOB_FULL="$(enqueue_rebuild true)"
ok "job $JOB_FULL"
wait_job "$JOB_FULL" "full rebuild"
report_job "$JOB_FULL" "full rebuild"
# ─── 2. incremental rebuild (dirs unchanged → mtime skip) ───────────────────
header "2 / Incremental Rebuild (should be fast — mtime cache)"
JOB_INCR="$(enqueue_rebuild false)"
ok "job $JOB_INCR"
wait_job "$JOB_INCR" "incremental rebuild"
report_job "$JOB_INCR" "incremental rebuild"
python3 - <<'PY'
import json, os
from datetime import datetime, timezone
def parse(s):
if not s: return None
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%f+00:00", "%Y-%m-%dT%H:%M:%S+00:00"):
try: return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
except ValueError: pass
return None
full_id = os.environ.get("PERF_FULL_ID", "")
incr_id = os.environ.get("PERF_INCR_ID", "")
if not full_id or not incr_id:
exit(0)
PY
# Speedup ratio via env export
export PERF_FULL_ID="$JOB_FULL" PERF_INCR_ID="$JOB_INCR"
python3 - <<'PY'
import json, os, subprocess
from datetime import datetime, timezone
def parse(s):
if not s: return None
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%f+00:00", "%Y-%m-%dT%H:%M:%S+00:00"):
try: return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
except ValueError: pass
return None
base = os.environ.get("BASE_API", "http://127.0.0.1:7080")
token = os.environ.get("TOKEN", "")
import urllib.request
def fetch(url):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def duration(job_id):
d = fetch(f"{base}/index/jobs/{job_id}")
s = parse(d.get("started_at"))
f = parse(d.get("finished_at"))
if s and f: return (f - s).total_seconds()
return None
t_full = duration(os.environ["PERF_FULL_ID"])
t_incr = duration(os.environ["PERF_INCR_ID"])
if t_full and t_incr:
ratio = t_full / t_incr if t_incr > 0 else 0
print(f" {'Speedup (full vs incremental)':38s} {ratio:.1f}x ({t_full:.1f}s → {t_incr:.1f}s)")
PY
# ─── 3. thumbnail rebuild (generate missing) ─────────────────────────────────
header "3 / Thumbnail Rebuild (generate missing only)"
JOB_TREB="$(enqueue_thumb_rebuild)"
ok "job $JOB_TREB"
wait_job "$JOB_TREB" "thumbnail rebuild"
report_job "$JOB_TREB" "thumbnail rebuild"
# ─── 4. thumbnail regenerate (force all) ─────────────────────────────────────
header "4 / Thumbnail Regenerate (force all)"
JOB_TREG="$(enqueue_thumb_regen)"
ok "job $JOB_TREG"
wait_job "$JOB_TREG" "thumbnail regenerate"
report_job "$JOB_TREG" "thumbnail regenerate"
# ─── 5. API latency ──────────────────────────────────────────────────────────
header "5 / API Latency (n=$BENCH_N requests each)"
measure_latency "books list" "$BASE_API/books"
measure_latency "search (query)" "$BASE_API/search?q=marvel"
if [ -n "$FIRST_BOOK_ID" ]; then
# Cold page render: clear cache between runs by using different params
measure_latency "page render (width=1080, webp)" \
"$BASE_API/books/$FIRST_BOOK_ID/pages/1?format=webp&quality=80&width=1080"
# Warm render: same URL repeated → should hit LRU cache
measure_latency "page render (warm/cached)" \
"$BASE_API/books/$FIRST_BOOK_ID/pages/1?format=webp&quality=80&width=1080"
measure_latency "thumbnail fetch" \
"$BASE_API/books/$FIRST_BOOK_ID/thumbnail"
else
warn "No books found — skipping page/thumbnail latency tests"
fi
# ─── 6. Page render deep-dive ────────────────────────────────────────────────
#
# Tests what the refactoring touches: archive reading for each format.
# Uses width-cycling to bypass disk cache and measure real decode cost.
# Tests: per-format cold render, sequential pages, concurrent throughput.
header "6 / Page Render Deep-Dive"
if [ -z "$FIRST_BOOK_ID" ]; then
warn "No books found — skipping deep-dive"
else
# Resolve one book per format (API may not support ?format= filter; graceful fallback)
resolve_book_by_format() {
local fmt="$1"
local id
id=$(auth "$BASE_API/books?format=$fmt&limit=1" 2>/dev/null \
| python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null || echo "")
echo "$id"
}
BOOK_CBZ=$(resolve_book_by_format cbz)
BOOK_CBR=$(resolve_book_by_format cbr)
BOOK_PDF=$(resolve_book_by_format pdf)
# Cold render: cycle widths (480..487) across N requests so each misses disk cache
measure_latency_cold() {
local label="$1" book_id="$2" n="${3:-$BENCH_N}"
local total=0 i
for i in $(seq 1 "$n"); do
local w=$((480 + i)) # unique width → unique cache key
local t
t=$(curl -s -o /dev/null -w '%{time_total}' \
-H "Authorization: Bearer $TOKEN" \
"$BASE_API/books/$book_id/pages/1?format=webp&quality=80&width=$w")
total=$(python3 -c "print($total + $t)")
done
local avg_ms
avg_ms=$(python3 -c "print(round(($total / $n)*1000, 1))")
row "$label" "${avg_ms}ms (cold, n=$n)"
}
echo ""
echo " Cold render latency by format (cache-busted widths):"
[ -n "$BOOK_CBZ" ] && measure_latency_cold "CBZ page 1 (cold)" "$BOOK_CBZ" \
|| warn "No CBZ book found"
[ -n "$BOOK_CBR" ] && measure_latency_cold "CBR page 1 (cold)" "$BOOK_CBR" \
|| warn "No CBR book found"
[ -n "$BOOK_PDF" ] && measure_latency_cold "PDF page 1 (cold)" "$BOOK_PDF" \
|| warn "No PDF book found"
# Warm render: same URL repeated → LRU / disk cache
echo ""
echo " Warm render (disk cache, same URL):"
# One cold request first, then N warm
curl -s -o /dev/null -H "Authorization: Bearer $TOKEN" \
"$BASE_API/books/$FIRST_BOOK_ID/pages/1?format=webp&quality=80&width=600" >/dev/null
measure_latency "page render (warm/disk-cached)" \
"$BASE_API/books/$FIRST_BOOK_ID/pages/1?format=webp&quality=80&width=600"
# Sequential pages: measures archive open+close overhead across consecutive pages
echo ""
echo " Sequential pages (pages 110, same book, cold widths):"
SEQ_TOTAL=0
for PAGE in $(seq 1 10); do
local_t=$(curl -s -o /dev/null -w '%{time_total}' \
-H "Authorization: Bearer $TOKEN" \
"$BASE_API/books/$FIRST_BOOK_ID/pages/$PAGE?format=webp&quality=80&width=$((500 + PAGE))")
local_ms=$(python3 -c "print(round($local_t*1000, 1))")
SEQ_TOTAL=$(python3 -c "print($SEQ_TOTAL + $local_t)")
row " page $PAGE" "${local_ms}ms"
done
SEQ_AVG=$(python3 -c "print(round($SEQ_TOTAL / 10 * 1000, 1))")
row " avg (10 pages)" "${SEQ_AVG}ms"
# Concurrent throughput: N requests in parallel → measures semaphore + CPU saturation
CONC_N="${CONC_N:-10}"
echo ""
echo " Concurrent rendering ($CONC_N simultaneous requests, cold widths):"
CONC_START=$(date +%s%3N)
PIDS=()
for i in $(seq 1 "$CONC_N"); do
curl -s -o /dev/null \
-H "Authorization: Bearer $TOKEN" \
"$BASE_API/books/$FIRST_BOOK_ID/pages/$i?format=webp&quality=80&width=$((550 + i))" &
PIDS+=($!)
done
for PID in "${PIDS[@]}"; do wait "$PID" 2>/dev/null || true; done
CONC_END=$(date +%s%3N)
CONC_MS=$((CONC_END - CONC_START))
CONC_PER=$(python3 -c "print(round($CONC_MS / $CONC_N, 1))")
row " wall time (${CONC_N} pages in parallel)" "${CONC_MS}ms (~${CONC_PER}ms/page)"
fi
# ─── summary ─────────────────────────────────────────────────────────────────
header "Summary"
ok "Full rebuild job: $JOB_FULL"
ok "Incremental rebuild job: $JOB_INCR"
ok "Thumbnail rebuild job: $JOB_TREB"
ok "Thumbnail regenerate job: $JOB_TREG"
echo -e "\n${BOLD}perf done${RESET}"