Compare commits
3 Commits
b17718df9b
...
127cd8a42c
| Author | SHA1 | Date | |
|---|---|---|---|
| 127cd8a42c | |||
| 1b9f2d3915 | |||
| f095bf050b |
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -51,7 +51,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -1122,7 +1122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexer"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1624,7 +1624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parsers"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flate2",
|
||||
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stripstream-core"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
||||
@@ -9,7 +9,7 @@ resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -83,3 +83,9 @@ impl From<std::io::Error> for ApiError {
|
||||
Self::internal(format!("IO error: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for ApiError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
Self::internal(format!("HTTP client error: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
397
apps/api/src/komga.rs
Normal file
397
apps/api/src/komga.rs
Normal file
@@ -0,0 +1,397 @@
|
||||
use axum::{extract::State, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ─── Komga API types ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KomgaBooksResponse {
|
||||
content: Vec<KomgaBook>,
|
||||
#[serde(rename = "totalPages")]
|
||||
total_pages: i32,
|
||||
number: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KomgaBook {
|
||||
name: String,
|
||||
#[serde(rename = "seriesTitle")]
|
||||
series_title: String,
|
||||
metadata: KomgaBookMetadata,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KomgaBookMetadata {
|
||||
title: String,
|
||||
}
|
||||
|
||||
// ─── Request / Response ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct KomgaSyncRequest {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct KomgaSyncResponse {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub komga_url: String,
|
||||
pub total_komga_read: i64,
|
||||
pub matched: i64,
|
||||
pub already_read: i64,
|
||||
pub newly_marked: i64,
|
||||
pub matched_books: Vec<String>,
|
||||
pub newly_marked_books: Vec<String>,
|
||||
pub unmatched: Vec<String>,
|
||||
#[schema(value_type = String)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct KomgaSyncReportSummary {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub komga_url: String,
|
||||
pub total_komga_read: i64,
|
||||
pub matched: i64,
|
||||
pub already_read: i64,
|
||||
pub newly_marked: i64,
|
||||
pub unmatched_count: i32,
|
||||
#[schema(value_type = String)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Sync read books from a Komga server
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/komga/sync",
|
||||
tag = "komga",
|
||||
request_body = KomgaSyncRequest,
|
||||
responses(
|
||||
(status = 200, body = KomgaSyncResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Komga connection or sync error"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn sync_komga_read_books(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<KomgaSyncRequest>,
|
||||
) -> Result<Json<KomgaSyncResponse>, ApiError> {
|
||||
let url = body.url.trim_end_matches('/').to_string();
|
||||
if url.is_empty() {
|
||||
return Err(ApiError::bad_request("url is required"));
|
||||
}
|
||||
|
||||
// Build HTTP client with basic auth
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||
|
||||
// Paginate through all READ books from Komga
|
||||
let mut komga_books: Vec<(String, String)> = Vec::new(); // (series_title, title)
|
||||
let mut page = 0;
|
||||
let page_size = 100;
|
||||
let max_pages = 500;
|
||||
|
||||
loop {
|
||||
let resp = client
|
||||
.post(format!("{url}/api/v1/books/list?page={page}&size={page_size}"))
|
||||
.basic_auth(&body.username, Some(&body.password))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({ "condition": { "readStatus": { "operator": "is", "value": "READ" } } }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Komga 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!(
|
||||
"Komga returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let data: KomgaBooksResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Failed to parse Komga response: {e}")))?;
|
||||
|
||||
for book in &data.content {
|
||||
let title = if !book.metadata.title.is_empty() {
|
||||
&book.metadata.title
|
||||
} else {
|
||||
&book.name
|
||||
};
|
||||
komga_books.push((book.series_title.clone(), title.clone()));
|
||||
}
|
||||
|
||||
if data.number >= data.total_pages - 1 || page >= max_pages {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
let total_komga_read = komga_books.len() as i64;
|
||||
|
||||
// Build local lookup maps
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, title, COALESCE(series, '') as series, LOWER(title) as title_lower, LOWER(COALESCE(series, '')) as series_lower FROM books",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Primary: (series_lower, title_lower) -> Vec<(Uuid, title, series)>
|
||||
let mut primary_map: HashMap<(String, String), Vec<(Uuid, String, String)>> = HashMap::new();
|
||||
// Secondary: title_lower -> Vec<(Uuid, title, series)>
|
||||
let mut secondary_map: HashMap<String, Vec<(Uuid, String, String)>> = HashMap::new();
|
||||
|
||||
for row in &rows {
|
||||
let id: Uuid = row.get("id");
|
||||
let title: String = row.get("title");
|
||||
let series: String = row.get("series");
|
||||
let title_lower: String = row.get("title_lower");
|
||||
let series_lower: String = row.get("series_lower");
|
||||
let entry = (id, title, series);
|
||||
|
||||
primary_map
|
||||
.entry((series_lower, title_lower.clone()))
|
||||
.or_default()
|
||||
.push(entry.clone());
|
||||
secondary_map.entry(title_lower).or_default().push(entry);
|
||||
}
|
||||
|
||||
// Match Komga books to local books
|
||||
let mut matched_entries: Vec<(Uuid, String)> = Vec::new(); // (id, display_title)
|
||||
let mut unmatched: Vec<String> = Vec::new();
|
||||
|
||||
for (series_title, title) in &komga_books {
|
||||
let title_lower = title.to_lowercase();
|
||||
let series_lower = series_title.to_lowercase();
|
||||
|
||||
let found = if let Some(entries) = primary_map.get(&(series_lower.clone(), title_lower.clone())) {
|
||||
Some(entries)
|
||||
} else {
|
||||
secondary_map.get(&title_lower)
|
||||
};
|
||||
|
||||
if let Some(entries) = found {
|
||||
for (id, local_title, local_series) in entries {
|
||||
let display = if local_series.is_empty() {
|
||||
local_title.clone()
|
||||
} else {
|
||||
format!("{local_series} - {local_title}")
|
||||
};
|
||||
matched_entries.push((*id, display));
|
||||
}
|
||||
} else if series_title.is_empty() {
|
||||
unmatched.push(title.clone());
|
||||
} else {
|
||||
unmatched.push(format!("{series_title} - {title}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by ID
|
||||
matched_entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
matched_entries.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
let matched_ids: Vec<Uuid> = matched_entries.iter().map(|(id, _)| *id).collect();
|
||||
let matched = matched_ids.len() as i64;
|
||||
let mut already_read: i64 = 0;
|
||||
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
||||
|
||||
if !matched_ids.is_empty() {
|
||||
// Get already-read book IDs
|
||||
let ar_rows = sqlx::query(
|
||||
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND status = 'read'",
|
||||
)
|
||||
.bind(&matched_ids)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
for row in &ar_rows {
|
||||
already_read_ids.insert(row.get("book_id"));
|
||||
}
|
||||
already_read = already_read_ids.len() as i64;
|
||||
|
||||
// Bulk upsert all matched books as read
|
||||
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
|
||||
SET status = 'read',
|
||||
current_page = NULL,
|
||||
last_read_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE book_reading_progress.status != 'read'
|
||||
"#,
|
||||
)
|
||||
.bind(&matched_ids)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let newly_marked = matched - already_read;
|
||||
|
||||
// Build matched_books and newly_marked_books lists
|
||||
let mut newly_marked_books: Vec<String> = Vec::new();
|
||||
let mut matched_books: Vec<String> = Vec::new();
|
||||
for (id, title) in &matched_entries {
|
||||
if !already_read_ids.contains(id) {
|
||||
newly_marked_books.push(title.clone());
|
||||
}
|
||||
matched_books.push(title.clone());
|
||||
}
|
||||
// Sort: newly marked first, then alphabetical
|
||||
let newly_marked_set: std::collections::HashSet<&str> =
|
||||
newly_marked_books.iter().map(|s| s.as_str()).collect();
|
||||
matched_books.sort_by(|a, b| {
|
||||
let a_new = newly_marked_set.contains(a.as_str());
|
||||
let b_new = newly_marked_set.contains(b.as_str());
|
||||
b_new.cmp(&a_new).then(a.cmp(b))
|
||||
});
|
||||
newly_marked_books.sort();
|
||||
|
||||
// Save sync report
|
||||
let unmatched_json = serde_json::to_value(&unmatched).unwrap_or_default();
|
||||
let matched_books_json = serde_json::to_value(&matched_books).unwrap_or_default();
|
||||
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)
|
||||
RETURNING id, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(&url)
|
||||
.bind(total_komga_read)
|
||||
.bind(matched)
|
||||
.bind(already_read)
|
||||
.bind(newly_marked)
|
||||
.bind(&matched_books_json)
|
||||
.bind(&newly_marked_books_json)
|
||||
.bind(&unmatched_json)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(KomgaSyncResponse {
|
||||
id: report_row.get("id"),
|
||||
komga_url: url,
|
||||
total_komga_read,
|
||||
matched,
|
||||
already_read,
|
||||
newly_marked,
|
||||
matched_books,
|
||||
newly_marked_books,
|
||||
unmatched,
|
||||
created_at: report_row.get("created_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List Komga sync reports (most recent first)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/komga/reports",
|
||||
tag = "komga",
|
||||
responses(
|
||||
(status = 200, body = Vec<KomgaSyncReportSummary>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_sync_reports(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, komga_url, 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
|
||||
LIMIT 20
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let reports: Vec<KomgaSyncReportSummary> = rows
|
||||
.iter()
|
||||
.map(|row| KomgaSyncReportSummary {
|
||||
id: row.get("id"),
|
||||
komga_url: row.get("komga_url"),
|
||||
total_komga_read: row.get("total_komga_read"),
|
||||
matched: row.get("matched"),
|
||||
already_read: row.get("already_read"),
|
||||
newly_marked: row.get("newly_marked"),
|
||||
unmatched_count: row.get("unmatched_count"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(reports))
|
||||
}
|
||||
|
||||
/// Get a specific sync report with full unmatched list
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/komga/reports/{id}",
|
||||
tag = "komga",
|
||||
params(("id" = String, Path, description = "Report UUID")),
|
||||
responses(
|
||||
(status = 200, body = KomgaSyncResponse),
|
||||
(status = 404, description = "Report not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_sync_report(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(id): axum::extract::Path<Uuid>,
|
||||
) -> 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
|
||||
FROM komga_sync_reports
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or_else(|| ApiError::not_found("report not found"))?;
|
||||
|
||||
let matched_books_json: serde_json::Value = row.try_get("matched_books").unwrap_or(serde_json::Value::Array(vec![]));
|
||||
let matched_books: Vec<String> = serde_json::from_value(matched_books_json).unwrap_or_default();
|
||||
let newly_marked_books_json: serde_json::Value = row.try_get("newly_marked_books").unwrap_or(serde_json::Value::Array(vec![]));
|
||||
let newly_marked_books: Vec<String> = serde_json::from_value(newly_marked_books_json).unwrap_or_default();
|
||||
let unmatched_json: serde_json::Value = row.get("unmatched");
|
||||
let unmatched: Vec<String> = serde_json::from_value(unmatched_json).unwrap_or_default();
|
||||
|
||||
Ok(Json(KomgaSyncResponse {
|
||||
id: row.get("id"),
|
||||
komga_url: row.get("komga_url"),
|
||||
total_komga_read: row.get("total_komga_read"),
|
||||
matched: row.get("matched"),
|
||||
already_read: row.get("already_read"),
|
||||
newly_marked: row.get("newly_marked"),
|
||||
matched_books,
|
||||
newly_marked_books,
|
||||
unmatched,
|
||||
created_at: row.get("created_at"),
|
||||
}))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod books;
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod index_jobs;
|
||||
mod komga;
|
||||
mod libraries;
|
||||
mod api_middleware;
|
||||
mod openapi;
|
||||
@@ -100,6 +101,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||
.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))
|
||||
.merge(settings::settings_routes())
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
|
||||
16
apps/backoffice/app/api/komga/reports/[id]/route.ts
Normal file
16
apps/backoffice/app/api/komga/reports/[id]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { getKomgaReport } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await getKomgaReport(id);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch report";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/komga/reports/route.ts
Normal file
12
apps/backoffice/app/api/komga/reports/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listKomgaReports } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await listKomgaReports();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch reports";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/komga/sync/route.ts
Normal file
16
apps/backoffice/app/api/komga/sync/route.ts
Normal 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("/komga/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to sync with Komga";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats } from "../../lib/api";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary } from "../../lib/api";
|
||||
|
||||
interface SettingsPageProps {
|
||||
initialSettings: Settings;
|
||||
@@ -22,6 +22,29 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
|
||||
// Komga sync state — URL and username are persisted in settings
|
||||
const [komgaUrl, setKomgaUrl] = useState("");
|
||||
const [komgaUsername, setKomgaUsername] = useState("");
|
||||
const [komgaPassword, setKomgaPassword] = useState("");
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const [showUnmatched, setShowUnmatched] = useState(false);
|
||||
const [reports, setReports] = useState<KomgaSyncReportSummary[]>([]);
|
||||
const [selectedReport, setSelectedReport] = useState<KomgaSyncResponse | null>(null);
|
||||
const [showReportUnmatched, setShowReportUnmatched] = useState(false);
|
||||
const [showMatchedBooks, setShowMatchedBooks] = useState(false);
|
||||
const [showReportMatchedBooks, setShowReportMatchedBooks] = useState(false);
|
||||
|
||||
const syncNewlyMarkedSet = useMemo(
|
||||
() => new Set(syncResult?.newly_marked_books ?? []),
|
||||
[syncResult?.newly_marked_books],
|
||||
);
|
||||
const reportNewlyMarkedSet = useMemo(
|
||||
() => new Set(selectedReport?.newly_marked_books ?? []),
|
||||
[selectedReport?.newly_marked_books],
|
||||
);
|
||||
|
||||
async function handleUpdateSetting(key: string, value: unknown) {
|
||||
setIsSaving(true);
|
||||
setSaveMessage(null);
|
||||
@@ -64,6 +87,66 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
}
|
||||
}
|
||||
|
||||
const fetchReports = useCallback(async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/komga/reports");
|
||||
if (resp.ok) setReports(await resp.json());
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// Load saved Komga credentials (URL + username only)
|
||||
fetch("/api/settings/komga").then(r => r.ok ? r.json() : null).then(data => {
|
||||
if (data) {
|
||||
if (data.url) setKomgaUrl(data.url);
|
||||
if (data.username) setKomgaUsername(data.username);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [fetchReports]);
|
||||
|
||||
async function handleViewReport(id: string) {
|
||||
setSelectedReport(null);
|
||||
setShowReportUnmatched(false);
|
||||
setShowReportMatchedBooks(false);
|
||||
try {
|
||||
const resp = await fetch(`/api/komga/reports/${id}`);
|
||||
if (resp.ok) setSelectedReport(await resp.json());
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleKomgaSync() {
|
||||
setIsSyncing(true);
|
||||
setSyncResult(null);
|
||||
setSyncError(null);
|
||||
setShowUnmatched(false);
|
||||
setShowMatchedBooks(false);
|
||||
try {
|
||||
const response = await fetch("/api/komga/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setSyncError(data.error || "Sync failed");
|
||||
} else {
|
||||
setSyncResult(data);
|
||||
fetchReports();
|
||||
// Persist URL and username (not password)
|
||||
fetch("/api/settings/komga", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername } }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
setSyncError("Failed to connect to sync endpoint");
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
@@ -438,6 +521,246 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Komga Sync */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="refresh" size="md" />
|
||||
Komga Sync
|
||||
</CardTitle>
|
||||
<CardDescription>Import read status from a Komga server. Books are matched by title (case-insensitive). Credentials are not stored.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Komga URL</label>
|
||||
<FormInput
|
||||
type="url"
|
||||
placeholder="https://komga.example.com"
|
||||
value={komgaUrl}
|
||||
onChange={(e) => setKomgaUrl(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Username</label>
|
||||
<FormInput
|
||||
value={komgaUsername}
|
||||
onChange={(e) => setKomgaUsername(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Password</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
value={komgaPassword}
|
||||
onChange={(e) => setKomgaPassword(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
|
||||
<Button
|
||||
onClick={handleKomgaSync}
|
||||
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
||||
Syncing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="refresh" size="sm" className="mr-2" />
|
||||
Sync Read Books
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{syncError && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive">
|
||||
{syncError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Komga read</p>
|
||||
<p className="text-2xl font-semibold">{syncResult.total_komga_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Matched</p>
|
||||
<p className="text-2xl font-semibold">{syncResult.matched}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Already read</p>
|
||||
<p className="text-2xl font-semibold">{syncResult.already_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Newly marked</p>
|
||||
<p className="text-2xl font-semibold text-success">{syncResult.newly_marked}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncResult.matched_books.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMatchedBooks(!showMatchedBooks)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showMatchedBooks ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{syncResult.matched_books.length} matched book{syncResult.matched_books.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showMatchedBooks && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-success/5 rounded-lg text-sm space-y-1">
|
||||
{syncResult.matched_books.map((title, i) => (
|
||||
<p key={i} className="text-foreground truncate flex items-center gap-1.5" title={title}>
|
||||
{syncNewlyMarkedSet.has(title) && (
|
||||
<Icon name="check" size="sm" className="text-success shrink-0" />
|
||||
)}
|
||||
{title}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncResult.unmatched.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUnmatched(!showUnmatched)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showUnmatched ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{syncResult.unmatched.length} unmatched book{syncResult.unmatched.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showUnmatched && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
{syncResult.unmatched.map((title, i) => (
|
||||
<p key={i} className="text-muted-foreground truncate" title={title}>{title}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Past reports */}
|
||||
{reports.length > 0 && (
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">Sync History</h3>
|
||||
<div className="space-y-2">
|
||||
{reports.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => handleViewReport(r.id)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
selectedReport?.id === r.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/60 bg-muted/20 hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate ml-2" title={r.komga_url}>
|
||||
{r.komga_url}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-1 text-xs text-muted-foreground">
|
||||
<span>{r.total_komga_read} read</span>
|
||||
<span>{r.matched} matched</span>
|
||||
<span className="text-success">{r.newly_marked} new</span>
|
||||
{r.unmatched_count > 0 && (
|
||||
<span className="text-warning">{r.unmatched_count} unmatched</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selected report detail */}
|
||||
{selectedReport && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Komga read</p>
|
||||
<p className="text-2xl font-semibold">{selectedReport.total_komga_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Matched</p>
|
||||
<p className="text-2xl font-semibold">{selectedReport.matched}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Already read</p>
|
||||
<p className="text-2xl font-semibold">{selectedReport.already_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Newly marked</p>
|
||||
<p className="text-2xl font-semibold text-success">{selectedReport.newly_marked}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedReport.matched_books && selectedReport.matched_books.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReportMatchedBooks(!showReportMatchedBooks)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showReportMatchedBooks ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{selectedReport.matched_books.length} matched book{selectedReport.matched_books.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showReportMatchedBooks && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-success/5 rounded-lg text-sm space-y-1">
|
||||
{selectedReport.matched_books.map((title, i) => (
|
||||
<p key={i} className="text-foreground truncate flex items-center gap-1.5" title={title}>
|
||||
{reportNewlyMarkedSet.has(title) && (
|
||||
<Icon name="check" size="sm" className="text-success shrink-0" />
|
||||
)}
|
||||
{title}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedReport.unmatched.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReportUnmatched(!showReportUnmatched)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showReportUnmatched ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{selectedReport.unmatched.length} unmatched book{selectedReport.unmatched.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showReportUnmatched && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
{selectedReport.unmatched.map((title, i) => (
|
||||
<p key={i} className="text-muted-foreground truncate" title={title}>{title}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -539,3 +539,48 @@ export async function markSeriesRead(seriesName: string, status: "read" | "unrea
|
||||
body: JSON.stringify({ series: seriesName, status }),
|
||||
});
|
||||
}
|
||||
|
||||
export type KomgaSyncRequest = {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type KomgaSyncResponse = {
|
||||
id: string;
|
||||
komga_url: string;
|
||||
total_komga_read: number;
|
||||
matched: number;
|
||||
already_read: number;
|
||||
newly_marked: number;
|
||||
matched_books: string[];
|
||||
newly_marked_books: string[];
|
||||
unmatched: string[];
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type KomgaSyncReportSummary = {
|
||||
id: string;
|
||||
komga_url: string;
|
||||
total_komga_read: number;
|
||||
matched: number;
|
||||
already_read: number;
|
||||
newly_marked: number;
|
||||
unmatched_count: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function syncKomga(req: KomgaSyncRequest) {
|
||||
return apiFetch<KomgaSyncResponse>("/komga/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listKomgaReports() {
|
||||
return apiFetch<KomgaSyncReportSummary[]>("/komga/reports");
|
||||
}
|
||||
|
||||
export async function getKomgaReport(id: string) {
|
||||
return apiFetch<KomgaSyncResponse>(`/komga/reports/${id}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 7082",
|
||||
|
||||
10
infra/migrations/0024_add_komga_sync_reports.sql
Normal file
10
infra/migrations/0024_add_komga_sync_reports.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS komga_sync_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
komga_url TEXT NOT NULL,
|
||||
total_komga_read BIGINT NOT NULL DEFAULT 0,
|
||||
matched BIGINT NOT NULL DEFAULT 0,
|
||||
already_read BIGINT NOT NULL DEFAULT 0,
|
||||
newly_marked BIGINT NOT NULL DEFAULT 0,
|
||||
unmatched JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE komga_sync_reports ADD COLUMN IF NOT EXISTS matched_books JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE komga_sync_reports ADD COLUMN IF NOT EXISTS newly_marked_books JSONB NOT NULL DEFAULT '[]';
|
||||
Reference in New Issue
Block a user