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]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "1.2.0"
|
version = "1.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1122,7 +1122,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexer"
|
name = "indexer"
|
||||||
version = "1.2.0"
|
version = "1.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1624,7 +1624,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parsers"
|
name = "parsers"
|
||||||
version = "1.2.0"
|
version = "1.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stripstream-core"
|
name = "stripstream-core"
|
||||||
version = "1.2.0"
|
version = "1.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "1.2.0"
|
version = "1.2.2"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
@@ -83,3 +83,9 @@ impl From<std::io::Error> for ApiError {
|
|||||||
Self::internal(format!("IO error: {err}"))
|
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 error;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod index_jobs;
|
mod index_jobs;
|
||||||
|
mod komga;
|
||||||
mod libraries;
|
mod libraries;
|
||||||
mod api_middleware;
|
mod api_middleware;
|
||||||
mod openapi;
|
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", get(tokens::list_tokens).post(tokens::create_token))
|
||||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_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())
|
.merge(settings::settings_routes())
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
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";
|
"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 { 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 {
|
interface SettingsPageProps {
|
||||||
initialSettings: Settings;
|
initialSettings: Settings;
|
||||||
@@ -22,6 +22,29 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
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) {
|
async function handleUpdateSetting(key: string, value: unknown) {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveMessage(null);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -438,6 +521,246 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 }),
|
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",
|
"name": "stripstream-backoffice",
|
||||||
"version": "1.2.0",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 7082",
|
"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