feat: table series avec UUID PK — migration complète backend + frontend
Migration DB (0070 + 0071): - Backup automatique de book_reading_progress avant migration - Crée table series (fusion de series_metadata) avec UUID PK - Ajoute series_id FK à books, external_metadata_links, anilist_series_links, available_downloads, download_detection_results - Supprime les colonnes TEXT legacy et la table series_metadata Backend API + Indexer: - Toutes les queries SQL migrées vers series_id FK + JOIN series - Routes /series/:name → /series/:series_id (UUID) - Nouvel endpoint GET /series/by-name/:name pour lookup par nom - match_title_volumes() factorisé entre prowlarr.rs et download_detection.rs - Fix scheduler.rs: settings → app_settings - OpenAPI mis à jour avec les nouveaux endpoints Frontend: - Routes /libraries/[id]/series/[name] → /series/[seriesId] - Tous les composants (Edit, Delete, MarkRead, Prowlarr, Metadata, ReadingStatus) utilisent seriesId - compressVolumes() pour afficher T1→3 au lieu de T1 T2 T3 - Titre release en entier (plus de truncate) dans available downloads Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -287,11 +287,11 @@ pub async fn search_manga(
|
|||||||
/// Get AniList link for a specific series
|
/// Get AniList link for a specific series
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/anilist/series/{library_id}/{series_name}",
|
path = "/anilist/series/{library_id}/{series_id}",
|
||||||
tag = "anilist",
|
tag = "anilist",
|
||||||
params(
|
params(
|
||||||
("library_id" = String, Path, description = "Library UUID"),
|
("library_id" = String, Path, description = "Library UUID"),
|
||||||
("series_name" = String, Path, description = "Series name"),
|
("series_id" = String, Path, description = "Series UUID"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = AnilistSeriesLinkResponse),
|
(status = 200, body = AnilistSeriesLinkResponse),
|
||||||
@@ -302,15 +302,16 @@ pub async fn search_manga(
|
|||||||
)]
|
)]
|
||||||
pub async fn get_series_link(
|
pub async fn get_series_link(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((library_id, series_name)): Path<(Uuid, String)>,
|
Path((library_id, series_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<AnilistSeriesLinkResponse>, ApiError> {
|
) -> Result<Json<AnilistSeriesLinkResponse>, ApiError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT library_id, series_name, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
|
"SELECT asl.library_id, s.name AS series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url, asl.status, asl.linked_at, asl.synced_at
|
||||||
FROM anilist_series_links
|
FROM anilist_series_links asl
|
||||||
WHERE library_id = $1 AND series_name = $2",
|
JOIN series s ON s.id = asl.series_id
|
||||||
|
WHERE asl.library_id = $1 AND asl.series_id = $2",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(&series_name)
|
.bind(series_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -331,11 +332,11 @@ pub async fn get_series_link(
|
|||||||
/// Link a series to an AniList media ID
|
/// Link a series to an AniList media ID
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/anilist/series/{library_id}/{series_name}/link",
|
path = "/anilist/series/{library_id}/{series_id}/link",
|
||||||
tag = "anilist",
|
tag = "anilist",
|
||||||
params(
|
params(
|
||||||
("library_id" = String, Path, description = "Library UUID"),
|
("library_id" = String, Path, description = "Library UUID"),
|
||||||
("series_name" = String, Path, description = "Series name"),
|
("series_id" = String, Path, description = "Series UUID"),
|
||||||
),
|
),
|
||||||
request_body = AnilistLinkRequest,
|
request_body = AnilistLinkRequest,
|
||||||
responses(
|
responses(
|
||||||
@@ -346,7 +347,7 @@ pub async fn get_series_link(
|
|||||||
)]
|
)]
|
||||||
pub async fn link_series(
|
pub async fn link_series(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((library_id, series_name)): Path<(Uuid, String)>,
|
Path((library_id, series_id)): Path<(Uuid, Uuid)>,
|
||||||
Json(body): Json<AnilistLinkRequest>,
|
Json(body): Json<AnilistLinkRequest>,
|
||||||
) -> Result<Json<AnilistSeriesLinkResponse>, ApiError> {
|
) -> Result<Json<AnilistSeriesLinkResponse>, ApiError> {
|
||||||
// Try to fetch title/url from AniList if not provided
|
// Try to fetch title/url from AniList if not provided
|
||||||
@@ -382,29 +383,36 @@ pub async fn link_series(
|
|||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO anilist_series_links (library_id, series_name, provider, anilist_id, anilist_title, anilist_url, status, linked_at)
|
INSERT INTO anilist_series_links (library_id, series_id, provider, anilist_id, anilist_title, anilist_url, status, linked_at)
|
||||||
VALUES ($1, $2, 'anilist', $3, $4, $5, 'linked', NOW())
|
VALUES ($1, $2, 'anilist', $3, $4, $5, 'linked', NOW())
|
||||||
ON CONFLICT (library_id, series_name, provider) DO UPDATE
|
ON CONFLICT (series_id, provider) DO UPDATE
|
||||||
SET anilist_id = EXCLUDED.anilist_id,
|
SET anilist_id = EXCLUDED.anilist_id,
|
||||||
anilist_title = EXCLUDED.anilist_title,
|
anilist_title = EXCLUDED.anilist_title,
|
||||||
anilist_url = EXCLUDED.anilist_url,
|
anilist_url = EXCLUDED.anilist_url,
|
||||||
status = 'linked',
|
status = 'linked',
|
||||||
linked_at = NOW(),
|
linked_at = NOW(),
|
||||||
synced_at = NULL
|
synced_at = NULL
|
||||||
RETURNING library_id, series_name, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
|
RETURNING library_id, series_id, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(&series_name)
|
.bind(series_id)
|
||||||
.bind(body.anilist_id)
|
.bind(body.anilist_id)
|
||||||
.bind(&anilist_title)
|
.bind(&anilist_title)
|
||||||
.bind(&anilist_url)
|
.bind(&anilist_url)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Fetch series name for the response
|
||||||
|
let series_name: String = sqlx::query_scalar("SELECT name FROM series WHERE id = $1")
|
||||||
|
.bind(series_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "unknown".to_string());
|
||||||
|
|
||||||
Ok(Json(AnilistSeriesLinkResponse {
|
Ok(Json(AnilistSeriesLinkResponse {
|
||||||
library_id: row.get("library_id"),
|
library_id: row.get("library_id"),
|
||||||
series_name: row.get("series_name"),
|
series_name,
|
||||||
anilist_id: row.get("anilist_id"),
|
anilist_id: row.get("anilist_id"),
|
||||||
anilist_title: row.get("anilist_title"),
|
anilist_title: row.get("anilist_title"),
|
||||||
anilist_url: row.get("anilist_url"),
|
anilist_url: row.get("anilist_url"),
|
||||||
@@ -417,11 +425,11 @@ pub async fn link_series(
|
|||||||
/// Remove the AniList link for a series
|
/// Remove the AniList link for a series
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
delete,
|
||||||
path = "/anilist/series/{library_id}/{series_name}/unlink",
|
path = "/anilist/series/{library_id}/{series_id}/unlink",
|
||||||
tag = "anilist",
|
tag = "anilist",
|
||||||
params(
|
params(
|
||||||
("library_id" = String, Path, description = "Library UUID"),
|
("library_id" = String, Path, description = "Library UUID"),
|
||||||
("series_name" = String, Path, description = "Series name"),
|
("series_id" = String, Path, description = "Series UUID"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Unlinked"),
|
(status = 200, description = "Unlinked"),
|
||||||
@@ -432,13 +440,13 @@ pub async fn link_series(
|
|||||||
)]
|
)]
|
||||||
pub async fn unlink_series(
|
pub async fn unlink_series(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((library_id, series_name)): Path<(Uuid, String)>,
|
Path((library_id, series_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<crate::responses::UnlinkedResponse>, ApiError> {
|
) -> Result<Json<crate::responses::UnlinkedResponse>, ApiError> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"DELETE FROM anilist_series_links WHERE library_id = $1 AND series_name = $2",
|
"DELETE FROM anilist_series_links WHERE library_id = $1 AND series_id = $2",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(&series_name)
|
.bind(series_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -506,10 +514,10 @@ pub async fn list_unlinked(
|
|||||||
JOIN libraries l ON l.id = b.library_id
|
JOIN libraries l ON l.id = b.library_id
|
||||||
LEFT JOIN series s ON s.id = b.series_id
|
LEFT JOIN series s ON s.id = b.series_id
|
||||||
LEFT JOIN anilist_series_links asl
|
LEFT JOIN anilist_series_links asl
|
||||||
ON asl.library_id = b.library_id
|
ON asl.series_id = b.series_id
|
||||||
AND asl.series_name = COALESCE(s.name, 'unclassified')
|
|
||||||
WHERE l.reading_status_provider = 'anilist'
|
WHERE l.reading_status_provider = 'anilist'
|
||||||
AND asl.library_id IS NULL
|
AND asl.series_id IS NULL
|
||||||
|
AND b.series_id IS NOT NULL
|
||||||
GROUP BY l.id, l.name, COALESCE(s.name, 'unclassified')
|
GROUP BY l.id, l.name, COALESCE(s.name, 'unclassified')
|
||||||
ORDER BY l.name, series_name
|
ORDER BY l.name, series_name
|
||||||
"#,
|
"#,
|
||||||
@@ -553,11 +561,12 @@ pub async fn preview_sync(
|
|||||||
|
|
||||||
let links = sqlx::query(
|
let links = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT asl.library_id, asl.series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
|
SELECT asl.library_id, asl.series_id, s.name AS series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
|
||||||
FROM anilist_series_links asl
|
FROM anilist_series_links asl
|
||||||
|
JOIN series s ON s.id = asl.series_id
|
||||||
JOIN libraries l ON l.id = asl.library_id
|
JOIN libraries l ON l.id = asl.library_id
|
||||||
WHERE l.reading_status_provider = 'anilist'
|
WHERE l.reading_status_provider = 'anilist'
|
||||||
ORDER BY l.name, asl.series_name
|
ORDER BY l.name, s.name
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
@@ -566,7 +575,7 @@ pub async fn preview_sync(
|
|||||||
let mut items: Vec<AnilistSyncPreviewItem> = Vec::new();
|
let mut items: Vec<AnilistSyncPreviewItem> = Vec::new();
|
||||||
|
|
||||||
for link in &links {
|
for link in &links {
|
||||||
let library_id: Uuid = link.get("library_id");
|
let series_id: Uuid = link.get("series_id");
|
||||||
let series_name: String = link.get("series_name");
|
let series_name: String = link.get("series_name");
|
||||||
let anilist_id: i32 = link.get("anilist_id");
|
let anilist_id: i32 = link.get("anilist_id");
|
||||||
let anilist_title: Option<String> = link.get("anilist_title");
|
let anilist_title: Option<String> = link.get("anilist_title");
|
||||||
@@ -577,15 +586,13 @@ pub async fn preview_sync(
|
|||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as book_count,
|
COUNT(*) as book_count,
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read,
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read,
|
||||||
(SELECT sm.total_volumes FROM series sm WHERE sm.library_id = $1 AND sm.name = $2 LIMIT 1) as total_volumes
|
(SELECT sm.total_volumes FROM series sm WHERE sm.id = $1 LIMIT 1) as total_volumes
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN series s ON s.id = b.series_id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $2
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
|
WHERE b.series_id = $1
|
||||||
WHERE b.library_id = $1 AND COALESCE(s.name, 'unclassified') = $2
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(series_id)
|
||||||
.bind(&series_name)
|
|
||||||
.bind(local_user_id)
|
.bind(local_user_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
@@ -649,8 +656,9 @@ pub async fn sync_to_anilist(
|
|||||||
// Get all series that have AniList links in enabled libraries
|
// Get all series that have AniList links in enabled libraries
|
||||||
let links = sqlx::query(
|
let links = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT asl.library_id, asl.series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
|
SELECT asl.library_id, asl.series_id, s.name AS series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
|
||||||
FROM anilist_series_links asl
|
FROM anilist_series_links asl
|
||||||
|
JOIN series s ON s.id = asl.series_id
|
||||||
JOIN libraries l ON l.id = asl.library_id
|
JOIN libraries l ON l.id = asl.library_id
|
||||||
WHERE l.reading_status_provider = 'anilist'
|
WHERE l.reading_status_provider = 'anilist'
|
||||||
"#,
|
"#,
|
||||||
@@ -674,7 +682,7 @@ pub async fn sync_to_anilist(
|
|||||||
"#;
|
"#;
|
||||||
|
|
||||||
for link in &links {
|
for link in &links {
|
||||||
let library_id: Uuid = link.get("library_id");
|
let series_id: Uuid = link.get("series_id");
|
||||||
let series_name: String = link.get("series_name");
|
let series_name: String = link.get("series_name");
|
||||||
let anilist_id: i32 = link.get("anilist_id");
|
let anilist_id: i32 = link.get("anilist_id");
|
||||||
let anilist_title: Option<String> = link.get("anilist_title");
|
let anilist_title: Option<String> = link.get("anilist_title");
|
||||||
@@ -686,15 +694,13 @@ pub async fn sync_to_anilist(
|
|||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as book_count,
|
COUNT(*) as book_count,
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read,
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read,
|
||||||
(SELECT sm.total_volumes FROM series sm WHERE sm.library_id = $1 AND sm.name = $2 LIMIT 1) as total_volumes
|
(SELECT sm.total_volumes FROM series sm WHERE sm.id = $1 LIMIT 1) as total_volumes
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN series s ON s.id = b.series_id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $2
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
|
WHERE b.series_id = $1
|
||||||
WHERE b.library_id = $1 AND COALESCE(s.name, 'unclassified') = $2
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(series_id)
|
||||||
.bind(&series_name)
|
|
||||||
.bind(local_user_id)
|
.bind(local_user_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
@@ -735,10 +741,10 @@ pub async fn sync_to_anilist(
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Update synced_at
|
// Update synced_at
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE anilist_series_links SET status = 'synced', synced_at = NOW() WHERE library_id = $1 AND series_name = $2",
|
"UPDATE anilist_series_links SET status = 'synced', synced_at = NOW() WHERE library_id = $1 AND series_id = $2",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(link.get::<Uuid, _>("library_id"))
|
||||||
.bind(&series_name)
|
.bind(series_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
items.push(AnilistSyncItem {
|
items.push(AnilistSyncItem {
|
||||||
@@ -752,10 +758,10 @@ pub async fn sync_to_anilist(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE anilist_series_links SET status = 'error' WHERE library_id = $1 AND series_name = $2",
|
"UPDATE anilist_series_links SET status = 'error' WHERE library_id = $1 AND series_id = $2",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(link.get::<Uuid, _>("library_id"))
|
||||||
.bind(&series_name)
|
.bind(series_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
errors.push(format!("{series_name}: {}", e.message));
|
errors.push(format!("{series_name}: {}", e.message));
|
||||||
@@ -824,8 +830,9 @@ pub async fn pull_from_anilist(
|
|||||||
// Find local series linked to these anilist IDs (in enabled libraries)
|
// Find local series linked to these anilist IDs (in enabled libraries)
|
||||||
let link_rows = sqlx::query(
|
let link_rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT asl.library_id, asl.series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
|
SELECT asl.library_id, asl.series_id, s.name AS series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
|
||||||
FROM anilist_series_links asl
|
FROM anilist_series_links asl
|
||||||
|
JOIN series s ON s.id = asl.series_id
|
||||||
JOIN libraries l ON l.id = asl.library_id
|
JOIN libraries l ON l.id = asl.library_id
|
||||||
WHERE l.reading_status_provider = 'anilist'
|
WHERE l.reading_status_provider = 'anilist'
|
||||||
"#,
|
"#,
|
||||||
@@ -833,16 +840,16 @@ pub async fn pull_from_anilist(
|
|||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Build map: anilist_id → (library_id, series_name, anilist_title, anilist_url)
|
// Build map: anilist_id → (series_id, series_name, anilist_title, anilist_url)
|
||||||
let mut link_map: std::collections::HashMap<i32, (Uuid, String, Option<String>, Option<String>)> =
|
let mut link_map: std::collections::HashMap<i32, (Uuid, String, Option<String>, Option<String>)> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
for row in &link_rows {
|
for row in &link_rows {
|
||||||
let aid: i32 = row.get("anilist_id");
|
let aid: i32 = row.get("anilist_id");
|
||||||
let lib: Uuid = row.get("library_id");
|
let sid: Uuid = row.get("series_id");
|
||||||
let name: String = row.get("series_name");
|
let name: String = row.get("series_name");
|
||||||
let title: Option<String> = row.get("anilist_title");
|
let title: Option<String> = row.get("anilist_title");
|
||||||
let url: Option<String> = row.get("anilist_url");
|
let url: Option<String> = row.get("anilist_url");
|
||||||
link_map.insert(aid, (lib, name, title, url));
|
link_map.insert(aid, (sid, name, title, url));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut updated = 0i32;
|
let mut updated = 0i32;
|
||||||
@@ -851,7 +858,7 @@ pub async fn pull_from_anilist(
|
|||||||
let mut items: Vec<AnilistPullItem> = Vec::new();
|
let mut items: Vec<AnilistPullItem> = Vec::new();
|
||||||
|
|
||||||
for (anilist_id, anilist_status, progress_volumes) in &entries {
|
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 {
|
let Some((series_id, series_name, anilist_title, anilist_url)) = link_map.get(anilist_id) else {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -869,10 +876,9 @@ pub async fn pull_from_anilist(
|
|||||||
|
|
||||||
// Get all book IDs for this series, ordered by volume
|
// Get all book IDs for this series, ordered by volume
|
||||||
let book_rows = sqlx::query(
|
let book_rows = sqlx::query(
|
||||||
"SELECT b.id, b.volume FROM books b LEFT JOIN series s ON s.id = b.series_id WHERE b.library_id = $1 AND COALESCE(s.name, 'unclassified') = $2 ORDER BY b.volume NULLS LAST",
|
"SELECT b.id, b.volume FROM books b WHERE b.series_id = $1 ORDER BY b.volume NULLS LAST",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(series_id)
|
||||||
.bind(series_name)
|
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -946,9 +952,10 @@ pub async fn list_links(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<AnilistSeriesLinkResponse>>, ApiError> {
|
) -> Result<Json<Vec<AnilistSeriesLinkResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT library_id, series_name, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
|
"SELECT asl.library_id, s.name AS series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url, asl.status, asl.linked_at, asl.synced_at
|
||||||
FROM anilist_series_links
|
FROM anilist_series_links asl
|
||||||
ORDER BY linked_at DESC",
|
JOIN series s ON s.id = asl.series_id
|
||||||
|
ORDER BY asl.linked_at DESC",
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -77,14 +77,14 @@ pub async fn list_authors(
|
|||||||
NULLIF(authors, '{{}}'),
|
NULLIF(authors, '{{}}'),
|
||||||
CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END
|
CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END
|
||||||
)
|
)
|
||||||
) AS author_name, id AS book_id, library_id, series
|
) AS author_name, id AS book_id, series_id
|
||||||
FROM books
|
FROM books
|
||||||
),
|
),
|
||||||
author_agg AS (
|
author_agg AS (
|
||||||
SELECT
|
SELECT
|
||||||
author_name AS name,
|
author_name AS name,
|
||||||
COUNT(DISTINCT book_id) AS book_count,
|
COUNT(DISTINCT book_id) AS book_count,
|
||||||
COUNT(DISTINCT (library_id, series)) AS series_count
|
COUNT(DISTINCT series_id) AS series_count
|
||||||
FROM author_books
|
FROM author_books
|
||||||
WHERE ($1::text IS NULL OR author_name ILIKE $1)
|
WHERE ($1::text IS NULL OR author_name ILIKE $1)
|
||||||
GROUP BY author_name
|
GROUP BY author_name
|
||||||
|
|||||||
@@ -303,10 +303,11 @@ pub async fn get_detection_results(
|
|||||||
) -> Result<Json<Vec<DownloadDetectionResultDto>>, ApiError> {
|
) -> Result<Json<Vec<DownloadDetectionResultDto>>, ApiError> {
|
||||||
let rows = if let Some(status_filter) = &query.status {
|
let rows = if let Some(status_filter) = &query.status {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"SELECT id, series_name, status, missing_count, available_releases, error_message
|
"SELECT ddr.id, COALESCE(s.name, 'unknown') AS series_name, ddr.status, ddr.missing_count, ddr.available_releases, ddr.error_message
|
||||||
FROM download_detection_results
|
FROM download_detection_results ddr
|
||||||
WHERE job_id = $1 AND status = $2
|
LEFT JOIN series s ON s.id = ddr.series_id
|
||||||
ORDER BY series_name",
|
WHERE ddr.job_id = $1 AND ddr.status = $2
|
||||||
|
ORDER BY s.name",
|
||||||
)
|
)
|
||||||
.bind(job_id)
|
.bind(job_id)
|
||||||
.bind(status_filter)
|
.bind(status_filter)
|
||||||
@@ -314,10 +315,11 @@ pub async fn get_detection_results(
|
|||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"SELECT id, series_name, status, missing_count, available_releases, error_message
|
"SELECT ddr.id, COALESCE(s.name, 'unknown') AS series_name, ddr.status, ddr.missing_count, ddr.available_releases, ddr.error_message
|
||||||
FROM download_detection_results
|
FROM download_detection_results ddr
|
||||||
WHERE job_id = $1
|
LEFT JOIN series s ON s.id = ddr.series_id
|
||||||
ORDER BY status, series_name",
|
WHERE ddr.job_id = $1
|
||||||
|
ORDER BY ddr.status, s.name",
|
||||||
)
|
)
|
||||||
.bind(job_id)
|
.bind(job_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
@@ -381,11 +383,12 @@ pub async fn get_latest_found(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<LatestFoundPerLibraryDto>>, ApiError> {
|
) -> Result<Json<Vec<LatestFoundPerLibraryDto>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT ad.id, ad.library_id, ad.series_name, ad.missing_count, ad.available_releases, ad.updated_at, \
|
"SELECT ad.id, ad.library_id, s.name AS series_name, ad.series_id, ad.missing_count, ad.available_releases, ad.updated_at, \
|
||||||
l.name as library_name \
|
l.name as library_name \
|
||||||
FROM available_downloads ad \
|
FROM available_downloads ad \
|
||||||
JOIN libraries l ON l.id = ad.library_id \
|
JOIN libraries l ON l.id = ad.library_id \
|
||||||
ORDER BY l.name, ad.series_name",
|
JOIN series s ON s.id = ad.series_id \
|
||||||
|
ORDER BY l.name, s.name",
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -509,9 +512,9 @@ pub(crate) async fn process_download_detection(
|
|||||||
.map_err(|e| e.message)?;
|
.map_err(|e| e.message)?;
|
||||||
|
|
||||||
// Fetch all series with their metadata link status
|
// Fetch all series with their metadata link status
|
||||||
let all_series: Vec<String> = sqlx::query_scalar(
|
let all_series_rows: Vec<(String, Option<uuid::Uuid>)> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT DISTINCT COALESCE(s.name, 'unclassified')
|
SELECT DISTINCT COALESCE(s.name, 'unclassified') AS name, b.series_id
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN series s ON s.id = b.series_id
|
LEFT JOIN series s ON s.id = b.series_id
|
||||||
WHERE b.library_id = $1
|
WHERE b.library_id = $1
|
||||||
@@ -528,11 +531,10 @@ pub(crate) async fn process_download_detection(
|
|||||||
r#"
|
r#"
|
||||||
DELETE FROM available_downloads
|
DELETE FROM available_downloads
|
||||||
WHERE library_id = $1
|
WHERE library_id = $1
|
||||||
AND series_name NOT IN (
|
AND series_id NOT IN (
|
||||||
SELECT DISTINCT COALESCE(s.name, 'unclassified')
|
SELECT DISTINCT b.series_id
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN series s ON s.id = b.series_id
|
WHERE b.library_id = $1 AND b.series_id IS NOT NULL
|
||||||
WHERE b.library_id = $1
|
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -541,6 +543,10 @@ pub(crate) async fn process_download_detection(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let all_series: Vec<String> = all_series_rows.iter().map(|(name, _)| name.clone()).collect();
|
||||||
|
let series_id_map: std::collections::HashMap<String, Uuid> = all_series_rows.iter()
|
||||||
|
.filter_map(|(name, id)| id.map(|id| (name.clone(), id)))
|
||||||
|
.collect();
|
||||||
let total = all_series.len() as i32;
|
let total = all_series.len() as i32;
|
||||||
sqlx::query("UPDATE index_jobs SET total_files = $2 WHERE id = $1")
|
sqlx::query("UPDATE index_jobs SET total_files = $2 WHERE id = $1")
|
||||||
.bind(job_id)
|
.bind(job_id)
|
||||||
@@ -551,7 +557,9 @@ pub(crate) async fn process_download_detection(
|
|||||||
|
|
||||||
// Fetch approved metadata links for this library (series_name -> link_id)
|
// Fetch approved metadata links for this library (series_name -> link_id)
|
||||||
let links: Vec<(String, Uuid)> = sqlx::query(
|
let links: Vec<(String, Uuid)> = sqlx::query(
|
||||||
"SELECT series_name, id FROM external_metadata_links WHERE library_id = $1 AND status = 'approved'",
|
"SELECT s.name AS series_name, eml.id FROM external_metadata_links eml \
|
||||||
|
JOIN series s ON s.id = eml.series_id \
|
||||||
|
WHERE eml.library_id = $1 AND eml.status = 'approved'",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -602,7 +610,7 @@ pub(crate) async fn process_download_detection(
|
|||||||
|
|
||||||
// Skip unclassified
|
// Skip unclassified
|
||||||
if series_name == "unclassified" {
|
if series_name == "unclassified" {
|
||||||
insert_result(pool, job_id, library_id, series_name, "no_metadata", 0, None, None).await;
|
insert_result(pool, job_id, library_id, series_id_map.get(series_name).copied(), "no_metadata", 0, None, None).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +618,7 @@ pub(crate) async fn process_download_detection(
|
|||||||
let link_id = match link_map.get(series_name) {
|
let link_id = match link_map.get(series_name) {
|
||||||
Some(id) => *id,
|
Some(id) => *id,
|
||||||
None => {
|
None => {
|
||||||
insert_result(pool, job_id, library_id, series_name, "no_metadata", 0, None, None).await;
|
insert_result(pool, job_id, library_id, series_id_map.get(series_name).copied(), "no_metadata", 0, None, None).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -625,10 +633,12 @@ pub(crate) async fn process_download_detection(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
if missing_rows.is_empty() {
|
if missing_rows.is_empty() {
|
||||||
insert_result(pool, job_id, library_id, series_name, "no_missing", 0, None, None).await;
|
insert_result(pool, job_id, library_id, series_id_map.get(series_name).copied(), "no_missing", 0, None, None).await;
|
||||||
// Series is complete, remove from available_downloads
|
// Series is complete, remove from available_downloads
|
||||||
let _ = sqlx::query("DELETE FROM available_downloads WHERE library_id = $1 AND series_name = $2")
|
if let Some(&sid) = series_id_map.get(series_name) {
|
||||||
.bind(library_id).bind(series_name).execute(pool).await;
|
let _ = sqlx::query("DELETE FROM available_downloads WHERE series_id = $1")
|
||||||
|
.bind(sid).execute(pool).await;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,7 +665,7 @@ pub(crate) async fn process_download_detection(
|
|||||||
pool,
|
pool,
|
||||||
job_id,
|
job_id,
|
||||||
library_id,
|
library_id,
|
||||||
series_name,
|
series_id_map.get(series_name).copied(),
|
||||||
"found",
|
"found",
|
||||||
missing_count,
|
missing_count,
|
||||||
releases_json.clone(),
|
releases_json.clone(),
|
||||||
@@ -663,17 +673,17 @@ pub(crate) async fn process_download_detection(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// UPSERT into available_downloads
|
// UPSERT into available_downloads
|
||||||
if let Some(ref rj) = releases_json {
|
if let (Some(ref rj), Some(&sid)) = (&releases_json, series_id_map.get(series_name)) {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"INSERT INTO available_downloads (library_id, series_name, missing_count, available_releases, updated_at) \
|
"INSERT INTO available_downloads (library_id, series_id, missing_count, available_releases, updated_at) \
|
||||||
VALUES ($1, $2, $3, $4, NOW()) \
|
VALUES ($1, $2, $3, $4, NOW()) \
|
||||||
ON CONFLICT (library_id, series_name) DO UPDATE SET \
|
ON CONFLICT (series_id) DO UPDATE SET \
|
||||||
missing_count = EXCLUDED.missing_count, \
|
missing_count = EXCLUDED.missing_count, \
|
||||||
available_releases = EXCLUDED.available_releases, \
|
available_releases = EXCLUDED.available_releases, \
|
||||||
updated_at = NOW()",
|
updated_at = NOW()",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(series_name)
|
.bind(sid)
|
||||||
.bind(missing_count)
|
.bind(missing_count)
|
||||||
.bind(rj)
|
.bind(rj)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -681,19 +691,20 @@ pub(crate) async fn process_download_detection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
insert_result(pool, job_id, library_id, series_name, "not_found", missing_count, None, None).await;
|
insert_result(pool, job_id, library_id, series_id_map.get(series_name).copied(), "not_found", missing_count, None, None).await;
|
||||||
// Remove from available_downloads if previously found
|
// Remove from available_downloads if previously found
|
||||||
|
if let Some(&sid) = series_id_map.get(series_name) {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"DELETE FROM available_downloads WHERE library_id = $1 AND series_name = $2",
|
"DELETE FROM available_downloads WHERE series_id = $1",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(sid)
|
||||||
.bind(series_name)
|
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("[DOWNLOAD_DETECTION] series '{series_name}': {e}");
|
warn!("[DOWNLOAD_DETECTION] series '{series_name}': {e}");
|
||||||
insert_result(pool, job_id, library_id, series_name, "error", missing_count, None, Some(&e)).await;
|
insert_result(pool, job_id, library_id, series_id_map.get(series_name).copied(), "error", missing_count, None, Some(&e)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -810,20 +821,7 @@ async fn search_prowlarr_for_series(
|
|||||||
let matched: Vec<AvailableReleaseDto> = raw_releases
|
let matched: Vec<AvailableReleaseDto> = raw_releases
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|r| {
|
.filter_map(|r| {
|
||||||
let title_volumes = prowlarr::extract_volumes_from_title_pub(&r.title);
|
let (matched_vols, all_volumes) = prowlarr::match_title_volumes(&r.title, missing_volumes);
|
||||||
|
|
||||||
// "Intégrale" / "Complet" releases match ALL missing volumes
|
|
||||||
let is_integral = prowlarr::is_integral_release(&r.title);
|
|
||||||
|
|
||||||
let matched_vols: Vec<i32> = if is_integral && !missing_volumes.is_empty() {
|
|
||||||
missing_volumes.to_vec()
|
|
||||||
} else {
|
|
||||||
title_volumes
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|v| missing_volumes.contains(v))
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
if matched_vols.is_empty() {
|
if matched_vols.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -835,7 +833,7 @@ async fn search_prowlarr_for_series(
|
|||||||
indexer: r.indexer,
|
indexer: r.indexer,
|
||||||
seeders: r.seeders,
|
seeders: r.seeders,
|
||||||
matched_missing_volumes: matched_vols,
|
matched_missing_volumes: matched_vols,
|
||||||
all_volumes: if is_integral { vec![] } else { title_volumes },
|
all_volumes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -849,7 +847,7 @@ async fn insert_result(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
library_id: Uuid,
|
library_id: Uuid,
|
||||||
series_name: &str,
|
series_id: Option<Uuid>,
|
||||||
status: &str,
|
status: &str,
|
||||||
missing_count: i32,
|
missing_count: i32,
|
||||||
available_releases: Option<serde_json::Value>,
|
available_releases: Option<serde_json::Value>,
|
||||||
@@ -858,13 +856,13 @@ async fn insert_result(
|
|||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO download_detection_results
|
INSERT INTO download_detection_results
|
||||||
(job_id, library_id, series_name, status, missing_count, available_releases, error_message)
|
(job_id, library_id, series_id, status, missing_count, available_releases, error_message)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(job_id)
|
.bind(job_id)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(series_name)
|
.bind(series_id)
|
||||||
.bind(status)
|
.bind(status)
|
||||||
.bind(missing_count)
|
.bind(missing_count)
|
||||||
.bind(&available_releases)
|
.bind(&available_releases)
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
.route("/books/:id/pages/:n", get(pages::get_page))
|
||||||
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
|
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
|
||||||
.route("/libraries/:library_id/series", get(series::list_series))
|
.route("/libraries/:library_id/series", get(series::list_series))
|
||||||
|
.route("/libraries/:library_id/series/by-name/:name", get(series::get_series_by_name))
|
||||||
.route("/libraries/:library_id/series/:series_id/metadata", get(series::get_series_metadata))
|
.route("/libraries/:library_id/series/:series_id/metadata", get(series::get_series_metadata))
|
||||||
.route("/series", get(series::list_all_series))
|
.route("/series", get(series::list_all_series))
|
||||||
.route("/series/ongoing", get(series::ongoing_series))
|
.route("/series/ongoing", get(series::ongoing_series))
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ pub struct MissingBookItem {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct MetadataLinkQuery {
|
pub struct MetadataLinkQuery {
|
||||||
pub library_id: Option<String>,
|
pub library_id: Option<String>,
|
||||||
pub series_name: Option<String>,
|
pub series_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -234,12 +234,14 @@ pub async fn create_metadata_match(
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| ApiError::bad_request("invalid library_id"))?;
|
.map_err(|_| ApiError::bad_request("invalid library_id"))?;
|
||||||
|
|
||||||
|
let series_id = crate::series::get_or_create_series(&state.pool, library_id, &body.series_name).await?;
|
||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO external_metadata_links
|
INSERT INTO external_metadata_links
|
||||||
(library_id, series_name, provider, external_id, external_url, status, confidence, metadata_json, total_volumes_external)
|
(library_id, series_id, provider, external_id, external_url, status, confidence, metadata_json, total_volumes_external)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8)
|
||||||
ON CONFLICT (library_id, series_name, provider)
|
ON CONFLICT (series_id, provider)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
external_id = EXCLUDED.external_id,
|
external_id = EXCLUDED.external_id,
|
||||||
external_url = EXCLUDED.external_url,
|
external_url = EXCLUDED.external_url,
|
||||||
@@ -251,12 +253,11 @@ pub async fn create_metadata_match(
|
|||||||
updated_at = NOW(),
|
updated_at = NOW(),
|
||||||
approved_at = NULL,
|
approved_at = NULL,
|
||||||
synced_at = NULL
|
synced_at = NULL
|
||||||
RETURNING id, library_id, series_name, provider, external_id, external_url, status, confidence,
|
RETURNING id
|
||||||
metadata_json, total_volumes_external, matched_at, approved_at, synced_at
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(&body.series_name)
|
.bind(series_id)
|
||||||
.bind(&body.provider)
|
.bind(&body.provider)
|
||||||
.bind(&body.external_id)
|
.bind(&body.external_id)
|
||||||
.bind(&body.external_url)
|
.bind(&body.external_url)
|
||||||
@@ -266,7 +267,22 @@ pub async fn create_metadata_match(
|
|||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(row_to_link_dto(&row)))
|
let link_id: Uuid = row.get("id");
|
||||||
|
// Re-fetch with JOIN to get series_name for the DTO
|
||||||
|
let full_row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT eml.id, eml.library_id, s.name AS series_name, eml.series_id, eml.provider, eml.external_id, eml.external_url, eml.status, eml.confidence,
|
||||||
|
eml.metadata_json, eml.total_volumes_external, eml.matched_at, eml.approved_at, eml.synced_at
|
||||||
|
FROM external_metadata_links eml
|
||||||
|
JOIN series s ON s.id = eml.series_id
|
||||||
|
WHERE eml.id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(link_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(row_to_link_dto(&full_row)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -296,7 +312,7 @@ pub async fn approve_metadata(
|
|||||||
UPDATE external_metadata_links
|
UPDATE external_metadata_links
|
||||||
SET status = 'approved', approved_at = NOW(), updated_at = NOW()
|
SET status = 'approved', approved_at = NOW(), updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING library_id, series_name, provider, external_id, metadata_json, total_volumes_external
|
RETURNING library_id, series_id, provider, external_id, metadata_json, total_volumes_external
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -306,7 +322,9 @@ pub async fn approve_metadata(
|
|||||||
let row = result.ok_or_else(|| ApiError::not_found("link not found"))?;
|
let row = result.ok_or_else(|| ApiError::not_found("link not found"))?;
|
||||||
|
|
||||||
let library_id: Uuid = row.get("library_id");
|
let library_id: Uuid = row.get("library_id");
|
||||||
let series_name: String = row.get("series_name");
|
let series_id: Uuid = row.get("series_id");
|
||||||
|
let series_name: String = sqlx::query_scalar("SELECT name FROM series WHERE id = $1")
|
||||||
|
.bind(series_id).fetch_one(&state.pool).await?;
|
||||||
|
|
||||||
// Reject any other approved links for the same series (only one active link per series)
|
// Reject any other approved links for the same series (only one active link per series)
|
||||||
// Also clean up their external_book_metadata
|
// Also clean up their external_book_metadata
|
||||||
@@ -314,12 +332,11 @@ pub async fn approve_metadata(
|
|||||||
r#"
|
r#"
|
||||||
UPDATE external_metadata_links
|
UPDATE external_metadata_links
|
||||||
SET status = 'rejected', updated_at = NOW()
|
SET status = 'rejected', updated_at = NOW()
|
||||||
WHERE library_id = $1 AND series_name = $2 AND id != $3 AND status = 'approved'
|
WHERE series_id = $1 AND id != $2 AND status = 'approved'
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(series_id)
|
||||||
.bind(&series_name)
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -438,7 +455,7 @@ pub async fn reject_metadata(
|
|||||||
tag = "metadata",
|
tag = "metadata",
|
||||||
params(
|
params(
|
||||||
("library_id" = Option<String>, Query, description = "Library UUID"),
|
("library_id" = Option<String>, Query, description = "Library UUID"),
|
||||||
("series_name" = Option<String>, Query, description = "Series name"),
|
("series_id" = Option<String>, Query, description = "Series UUID"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = Vec<ExternalMetadataLinkDto>),
|
(status = 200, body = Vec<ExternalMetadataLinkDto>),
|
||||||
@@ -454,18 +471,21 @@ pub async fn get_metadata_links(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|s| s.parse().ok());
|
.and_then(|s| s.parse().ok());
|
||||||
|
|
||||||
|
let series_id: Option<Uuid> = query.series_id.as_deref().and_then(|s| s.parse().ok());
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, library_id, series_name, provider, external_id, external_url, status, confidence,
|
SELECT eml.id, eml.library_id, s.name AS series_name, eml.series_id, eml.provider, eml.external_id, eml.external_url, eml.status, eml.confidence,
|
||||||
metadata_json, total_volumes_external, matched_at, approved_at, synced_at
|
eml.metadata_json, eml.total_volumes_external, eml.matched_at, eml.approved_at, eml.synced_at
|
||||||
FROM external_metadata_links
|
FROM external_metadata_links eml
|
||||||
WHERE ($1::uuid IS NULL OR library_id = $1)
|
JOIN series s ON s.id = eml.series_id
|
||||||
AND ($2::text IS NULL OR series_name = $2)
|
WHERE ($1::uuid IS NULL OR eml.library_id = $1)
|
||||||
ORDER BY updated_at DESC
|
AND ($2::uuid IS NULL OR eml.series_id = $2)
|
||||||
|
ORDER BY eml.updated_at DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(query.series_name.as_deref())
|
.bind(series_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -495,7 +515,10 @@ pub async fn get_missing_books(
|
|||||||
) -> Result<Json<MissingBooksDto>, ApiError> {
|
) -> Result<Json<MissingBooksDto>, ApiError> {
|
||||||
// Verify link exists
|
// Verify link exists
|
||||||
let link = sqlx::query(
|
let link = sqlx::query(
|
||||||
"SELECT library_id, series_name FROM external_metadata_links WHERE id = $1",
|
"SELECT eml.library_id, eml.series_id, s.name AS series_name \
|
||||||
|
FROM external_metadata_links eml \
|
||||||
|
JOIN series s ON s.id = eml.series_id \
|
||||||
|
WHERE eml.id = $1",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
@@ -503,6 +526,7 @@ pub async fn get_missing_books(
|
|||||||
.ok_or_else(|| ApiError::not_found("link not found"))?;
|
.ok_or_else(|| ApiError::not_found("link not found"))?;
|
||||||
|
|
||||||
let library_id: Uuid = link.get("library_id");
|
let library_id: Uuid = link.get("library_id");
|
||||||
|
let series_id: Uuid = link.get("series_id");
|
||||||
let series_name: String = link.get("series_name");
|
let series_name: String = link.get("series_name");
|
||||||
|
|
||||||
// Count external books
|
// Count external books
|
||||||
@@ -514,10 +538,9 @@ pub async fn get_missing_books(
|
|||||||
|
|
||||||
// Count local books
|
// Count local books
|
||||||
let total_local: i64 = sqlx::query_scalar(
|
let total_local: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM books b LEFT JOIN series s ON s.id = b.series_id WHERE b.library_id = $1 AND COALESCE(s.name, 'unclassified') = $2",
|
"SELECT COUNT(*) FROM books WHERE series_id = $1",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(series_id)
|
||||||
.bind(&series_name)
|
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ pub(crate) async fn process_metadata_batch(
|
|||||||
|
|
||||||
// Get series that already have an approved link (skip them)
|
// Get series that already have an approved link (skip them)
|
||||||
let already_linked: std::collections::HashSet<String> = sqlx::query_scalar(
|
let already_linked: std::collections::HashSet<String> = sqlx::query_scalar(
|
||||||
"SELECT series_name FROM external_metadata_links WHERE library_id = $1 AND status = 'approved'",
|
"SELECT s.name FROM external_metadata_links eml JOIN series s ON s.id = eml.series_id WHERE eml.library_id = $1 AND eml.status = 'approved'",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -797,14 +797,25 @@ async fn auto_apply(
|
|||||||
provider_name: &str,
|
provider_name: &str,
|
||||||
candidate: &metadata_providers::SeriesCandidate,
|
candidate: &metadata_providers::SeriesCandidate,
|
||||||
) -> Result<Uuid, String> {
|
) -> Result<Uuid, String> {
|
||||||
|
// Resolve series_id from series name
|
||||||
|
let series_id: Uuid = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM series WHERE library_id = $1 AND name = $2",
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.bind(series_name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("Series '{}' not found in library", series_name))?;
|
||||||
|
|
||||||
// Create the external_metadata_link
|
// Create the external_metadata_link
|
||||||
let metadata_json = &candidate.metadata_json;
|
let metadata_json = &candidate.metadata_json;
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO external_metadata_links
|
INSERT INTO external_metadata_links
|
||||||
(library_id, series_name, provider, external_id, external_url, status, confidence, metadata_json, total_volumes_external)
|
(library_id, series_id, provider, external_id, external_url, status, confidence, metadata_json, total_volumes_external)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'approved', $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, 'approved', $6, $7, $8)
|
||||||
ON CONFLICT (library_id, series_name, provider)
|
ON CONFLICT (series_id, provider)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
external_id = EXCLUDED.external_id,
|
external_id = EXCLUDED.external_id,
|
||||||
external_url = EXCLUDED.external_url,
|
external_url = EXCLUDED.external_url,
|
||||||
@@ -819,7 +830,7 @@ async fn auto_apply(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(series_name)
|
.bind(series_id)
|
||||||
.bind(provider_name)
|
.bind(provider_name)
|
||||||
.bind(&candidate.external_id)
|
.bind(&candidate.external_id)
|
||||||
.bind(&candidate.external_url)
|
.bind(&candidate.external_url)
|
||||||
|
|||||||
@@ -309,8 +309,10 @@ pub async fn refresh_single_link(
|
|||||||
AxumPath(link_id): AxumPath<Uuid>,
|
AxumPath(link_id): AxumPath<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT library_id, series_name, provider, external_id, status \
|
"SELECT eml.library_id, s.name AS series_name, eml.provider, eml.external_id, eml.status \
|
||||||
FROM external_metadata_links WHERE id = $1",
|
FROM external_metadata_links eml \
|
||||||
|
JOIN series s ON s.id = eml.series_id \
|
||||||
|
WHERE eml.id = $1",
|
||||||
)
|
)
|
||||||
.bind(link_id)
|
.bind(link_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ use utoipa::OpenApi;
|
|||||||
crate::books::convert_book,
|
crate::books::convert_book,
|
||||||
crate::books::update_book,
|
crate::books::update_book,
|
||||||
crate::series::get_series_metadata,
|
crate::series::get_series_metadata,
|
||||||
|
crate::series::get_series_by_name,
|
||||||
crate::series::update_series,
|
crate::series::update_series,
|
||||||
|
crate::series::delete_series,
|
||||||
crate::pages::get_page,
|
crate::pages::get_page,
|
||||||
crate::search::search_books,
|
crate::search::search_books,
|
||||||
crate::index_jobs::enqueue_rebuild,
|
crate::index_jobs::enqueue_rebuild,
|
||||||
@@ -112,6 +114,7 @@ use utoipa::OpenApi;
|
|||||||
crate::series::OngoingQuery,
|
crate::series::OngoingQuery,
|
||||||
crate::books::UpdateBookRequest,
|
crate::books::UpdateBookRequest,
|
||||||
crate::series::SeriesMetadata,
|
crate::series::SeriesMetadata,
|
||||||
|
crate::series::SeriesLookup,
|
||||||
crate::series::UpdateSeriesRequest,
|
crate::series::UpdateSeriesRequest,
|
||||||
crate::series::UpdateSeriesResponse,
|
crate::series::UpdateSeriesResponse,
|
||||||
crate::pages::PageQuery,
|
crate::pages::PageQuery,
|
||||||
|
|||||||
@@ -104,6 +104,27 @@ pub(crate) fn extract_volumes_from_title_pub(title: &str) -> Vec<i32> {
|
|||||||
|
|
||||||
/// Returns true if the title indicates a complete/integral edition
|
/// Returns true if the title indicates a complete/integral edition
|
||||||
/// (e.g., "intégrale", "complet", "complete", "integral").
|
/// (e.g., "intégrale", "complet", "complete", "integral").
|
||||||
|
/// Match a release title against a list of missing volumes.
|
||||||
|
/// Returns (matched_volumes, all_volumes_in_title).
|
||||||
|
/// For integral releases, matched_volumes = all missing volumes, all_volumes = empty.
|
||||||
|
pub(crate) fn match_title_volumes(title: &str, missing_volumes: &[i32]) -> (Vec<i32>, Vec<i32>) {
|
||||||
|
let title_volumes = extract_volumes_from_title(title);
|
||||||
|
let is_integral = is_integral_release(title);
|
||||||
|
|
||||||
|
let matched = if is_integral && !missing_volumes.is_empty() {
|
||||||
|
missing_volumes.to_vec()
|
||||||
|
} else {
|
||||||
|
title_volumes
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|v| missing_volumes.contains(v))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let all = if is_integral { vec![] } else { title_volumes };
|
||||||
|
(matched, all)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn is_integral_release(title: &str) -> bool {
|
pub(crate) fn is_integral_release(title: &str) -> bool {
|
||||||
let lower = title.to_lowercase();
|
let lower = title.to_lowercase();
|
||||||
// Strip accents for matching: "intégrale" → "integrale"
|
// Strip accents for matching: "intégrale" → "integrale"
|
||||||
@@ -403,21 +424,8 @@ fn match_missing_volumes(
|
|||||||
releases
|
releases
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
let title_volumes = extract_volumes_from_title(&r.title);
|
let (matched_vols, all_volumes) = match_title_volumes(&r.title, &missing_numbers);
|
||||||
let matched = if missing_numbers.is_empty() {
|
let matched = if matched_vols.is_empty() { None } else { Some(matched_vols) };
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let matched: Vec<i32> = title_volumes
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|v| missing_numbers.contains(v))
|
|
||||||
.collect();
|
|
||||||
if matched.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(matched)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ProwlarrRelease {
|
ProwlarrRelease {
|
||||||
guid: r.guid,
|
guid: r.guid,
|
||||||
@@ -432,7 +440,7 @@ fn match_missing_volumes(
|
|||||||
info_url: r.info_url,
|
info_url: r.info_url,
|
||||||
categories: r.categories,
|
categories: r.categories,
|
||||||
matched_missing_volumes: matched,
|
matched_missing_volumes: matched,
|
||||||
all_volumes: title_volumes,
|
all_volumes,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ pub(crate) async fn process_reading_status_match(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let already_linked: std::collections::HashSet<String> = sqlx::query_scalar(
|
let already_linked: std::collections::HashSet<String> = sqlx::query_scalar(
|
||||||
"SELECT series_name FROM anilist_series_links WHERE library_id = $1",
|
"SELECT s.name FROM anilist_series_links asl JOIN series s ON s.id = asl.series_id WHERE asl.library_id = $1",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -636,15 +636,24 @@ async fn search_and_link(
|
|||||||
.map(String::from);
|
.map(String::from);
|
||||||
let anilist_url = candidate["siteUrl"].as_str().map(String::from);
|
let anilist_url = candidate["siteUrl"].as_str().map(String::from);
|
||||||
|
|
||||||
sqlx::query(
|
let series_id: Uuid = sqlx::query_scalar(
|
||||||
r#"
|
"SELECT id FROM series WHERE library_id = $1 AND name = $2",
|
||||||
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(library_id)
|
||||||
.bind(series_name)
|
.bind(series_name)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("series lookup failed for '{}': {}", series_name, e))?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO anilist_series_links (library_id, series_id, provider, anilist_id, anilist_title, anilist_url, status, linked_at)
|
||||||
|
VALUES ($1, $2, 'anilist', $3, $4, $5, 'linked', NOW())
|
||||||
|
ON CONFLICT (series_id, provider) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.bind(series_id)
|
||||||
.bind(anilist_id)
|
.bind(anilist_id)
|
||||||
.bind(&anilist_title)
|
.bind(&anilist_title)
|
||||||
.bind(&anilist_url)
|
.bind(&anilist_url)
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ pub async fn get_push_results(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
struct SeriesInfo {
|
struct SeriesInfo {
|
||||||
|
series_id: Uuid,
|
||||||
series_name: String,
|
series_name: String,
|
||||||
anilist_id: i32,
|
anilist_id: i32,
|
||||||
anilist_title: Option<String>,
|
anilist_title: Option<String>,
|
||||||
@@ -379,11 +380,13 @@ pub async fn process_reading_status_push(
|
|||||||
let series_to_push: Vec<SeriesInfo> = sqlx::query(
|
let series_to_push: Vec<SeriesInfo> = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
asl.series_name,
|
asl.series_id,
|
||||||
|
s.name AS series_name,
|
||||||
asl.anilist_id,
|
asl.anilist_id,
|
||||||
asl.anilist_title,
|
asl.anilist_title,
|
||||||
asl.anilist_url
|
asl.anilist_url
|
||||||
FROM anilist_series_links asl
|
FROM anilist_series_links asl
|
||||||
|
JOIN series s ON s.id = asl.series_id
|
||||||
WHERE asl.library_id = $1
|
WHERE asl.library_id = $1
|
||||||
AND asl.anilist_id IS NOT NULL
|
AND asl.anilist_id IS NOT NULL
|
||||||
AND (
|
AND (
|
||||||
@@ -392,22 +395,18 @@ pub async fn process_reading_status_push(
|
|||||||
SELECT 1
|
SELECT 1
|
||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
JOIN books b2 ON b2.id = brp.book_id
|
JOIN books b2 ON b2.id = brp.book_id
|
||||||
LEFT JOIN series s2 ON s2.id = b2.series_id
|
WHERE b2.series_id = asl.series_id
|
||||||
WHERE b2.library_id = asl.library_id
|
|
||||||
AND COALESCE(s2.name, 'unclassified') = asl.series_name
|
|
||||||
AND brp.user_id = $2
|
AND brp.user_id = $2
|
||||||
AND brp.updated_at > asl.synced_at
|
AND brp.updated_at > asl.synced_at
|
||||||
)
|
)
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM books b2
|
FROM books b2
|
||||||
LEFT JOIN series s2 ON s2.id = b2.series_id
|
WHERE b2.series_id = asl.series_id
|
||||||
WHERE b2.library_id = asl.library_id
|
|
||||||
AND COALESCE(s2.name, 'unclassified') = asl.series_name
|
|
||||||
AND b2.created_at > asl.synced_at
|
AND b2.created_at > asl.synced_at
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ORDER BY asl.series_name
|
ORDER BY s.name
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
@@ -417,6 +416,7 @@ pub async fn process_reading_status_push(
|
|||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| SeriesInfo {
|
.map(|row| SeriesInfo {
|
||||||
|
series_id: row.get("series_id"),
|
||||||
series_name: row.get("series_name"),
|
series_name: row.get("series_name"),
|
||||||
anilist_id: row.get("anilist_id"),
|
anilist_id: row.get("anilist_id"),
|
||||||
anilist_title: row.get("anilist_title"),
|
anilist_title: row.get("anilist_title"),
|
||||||
@@ -466,15 +466,12 @@ pub async fn process_reading_status_push(
|
|||||||
COUNT(b.id) AS total_books,
|
COUNT(b.id) AS total_books,
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN series s ON s.id = b.series_id
|
|
||||||
LEFT JOIN book_reading_progress brp
|
LEFT JOIN book_reading_progress brp
|
||||||
ON brp.book_id = b.id AND brp.user_id = $3
|
ON brp.book_id = b.id AND brp.user_id = $2
|
||||||
WHERE b.library_id = $1
|
WHERE b.series_id = $1
|
||||||
AND COALESCE(s.name, 'unclassified') = $2
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(series.series_id)
|
||||||
.bind(&series.series_name)
|
|
||||||
.bind(local_user_id)
|
.bind(local_user_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
@@ -513,10 +510,9 @@ pub async fn process_reading_status_push(
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Update synced_at
|
// Update synced_at
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE anilist_series_links SET synced_at = NOW() WHERE library_id = $1 AND series_name = $2",
|
"UPDATE anilist_series_links SET synced_at = NOW() WHERE series_id = $1",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(series.series_id)
|
||||||
.bind(&series.series_name)
|
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -532,10 +528,9 @@ pub async fn process_reading_status_push(
|
|||||||
match push_to_anilist(&token, series.anilist_id, anilist_status, progress_volumes).await {
|
match push_to_anilist(&token, series.anilist_id, anilist_status, progress_volumes).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE anilist_series_links SET synced_at = NOW() WHERE library_id = $1 AND series_name = $2",
|
"UPDATE anilist_series_links SET synced_at = NOW() WHERE series_id = $1",
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(series.series_id)
|
||||||
.bind(&series.series_name)
|
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,53 @@ pub(crate) async fn get_or_create_series(
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Lookup by name ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct SeriesLookup {
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub id: Uuid,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub library_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a series by name within a library. Returns its UUID and name.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/libraries/{library_id}/series/by-name/{name}",
|
||||||
|
tag = "series",
|
||||||
|
params(
|
||||||
|
("library_id" = String, Path, description = "Library UUID"),
|
||||||
|
("name" = String, Path, description = "Series name (URL-encoded)"),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, body = SeriesLookup),
|
||||||
|
(status = 404, description = "Series not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn get_series_by_name(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((library_id, name)): Path<(Uuid, String)>,
|
||||||
|
) -> Result<Json<SeriesLookup>, ApiError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT id, library_id, name FROM series WHERE library_id = $1 AND LOWER(name) = LOWER($2)"
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.bind(&name)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::not_found(format!("series '{}' not found", name)))?;
|
||||||
|
|
||||||
|
Ok(Json(SeriesLookup {
|
||||||
|
id: row.get("id"),
|
||||||
|
library_id: row.get("library_id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Structs ─────────────────────────────────────────────────────────────────
|
// ─── Structs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -906,6 +953,8 @@ pub async fn ongoing_books(
|
|||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct SeriesMetadata {
|
pub struct SeriesMetadata {
|
||||||
|
/// Name of the series
|
||||||
|
pub series_name: String,
|
||||||
/// Authors of the series (series-level metadata, distinct from per-book author field)
|
/// Authors of the series (series-level metadata, distinct from per-book author field)
|
||||||
pub authors: Vec<String>,
|
pub authors: Vec<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
@@ -943,7 +992,7 @@ pub async fn get_series_metadata(
|
|||||||
) -> Result<Json<SeriesMetadata>, ApiError> {
|
) -> Result<Json<SeriesMetadata>, ApiError> {
|
||||||
// Fetch series row (contains metadata directly)
|
// Fetch series row (contains metadata directly)
|
||||||
let series_row = sqlx::query(
|
let series_row = sqlx::query(
|
||||||
"SELECT authors, description, publishers, start_year, total_volumes, status, locked_fields, book_author, book_language \
|
"SELECT name, authors, description, publishers, start_year, total_volumes, status, locked_fields, book_author, book_language \
|
||||||
FROM series WHERE id = $1 AND library_id = $2"
|
FROM series WHERE id = $1 AND library_id = $2"
|
||||||
)
|
)
|
||||||
.bind(series_id)
|
.bind(series_id)
|
||||||
@@ -958,6 +1007,7 @@ pub async fn get_series_metadata(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(SeriesMetadata {
|
Ok(Json(SeriesMetadata {
|
||||||
|
series_name: series_row.as_ref().map(|r| r.get::<String, _>("name")).unwrap_or_default(),
|
||||||
authors: series_row.as_ref().map(|r| r.get::<Vec<String>, _>("authors")).unwrap_or_default(),
|
authors: series_row.as_ref().map(|r| r.get::<Vec<String>, _>("authors")).unwrap_or_default(),
|
||||||
description: series_row.as_ref().and_then(|r| r.get("description")),
|
description: series_row.as_ref().and_then(|r| r.get("description")),
|
||||||
publishers: series_row.as_ref().map(|r| r.get::<Vec<String>, _>("publishers")).unwrap_or_default(),
|
publishers: series_row.as_ref().map(|r| r.get::<Vec<String>, _>("publishers")).unwrap_or_default(),
|
||||||
@@ -1267,6 +1317,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn series_metadata_serializes() {
|
fn series_metadata_serializes() {
|
||||||
let meta = SeriesMetadata {
|
let meta = SeriesMetadata {
|
||||||
|
series_name: "Naruto".to_string(),
|
||||||
description: Some("A ninja story".to_string()),
|
description: Some("A ninja story".to_string()),
|
||||||
authors: vec!["Kishimoto".to_string()],
|
authors: vec!["Kishimoto".to_string()],
|
||||||
publishers: vec![],
|
publishers: vec![],
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ export default async function AuthorDetailPage({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||||
{authorSeries.map((s) => (
|
{authorSeries.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${s.library_id}-${s.name}`}
|
key={`${s.library_id}-${s.series_id}`}
|
||||||
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
href={`/libraries/${s.library_id}/series/${s.series_id}`}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<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">
|
<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">
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ export default async function BookDetailPage({
|
|||||||
<span className="text-muted-foreground">/</span>
|
<span className="text-muted-foreground">/</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{book.series && (
|
{book.series && book.series_id && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
|
href={`/libraries/${book.library_id}/series/${book.series_id}`}
|
||||||
className="text-muted-foreground hover:text-primary transition-colors"
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{book.series}
|
{book.series}
|
||||||
@@ -116,9 +116,9 @@ export default async function BookDetailPage({
|
|||||||
{book.author && (
|
{book.author && (
|
||||||
<p className="text-base text-muted-foreground">{book.author}</p>
|
<p className="text-base text-muted-foreground">{book.author}</p>
|
||||||
)}
|
)}
|
||||||
{book.series && (
|
{book.series && book.series_id && (
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
|
href={`/libraries/${book.library_id}/series/${book.series_id}`}
|
||||||
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 font-medium"
|
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 font-medium"
|
||||||
>
|
>
|
||||||
{book.series}
|
{book.series}
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ export default async function BooksPage({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
{seriesHits.map((s) => (
|
{seriesHits.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${s.library_id}-${s.name}`}
|
key={`${s.library_id}-${s.series_id}`}
|
||||||
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
href={`/libraries/${s.library_id}/series/${s.series_id}`}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { TorrentDownloadDto, LatestFoundPerLibraryDto } from "@/lib/api";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, Button, Icon } from "@/app/components/ui";
|
import { Card, CardContent, CardHeader, CardTitle, Button, Icon } from "@/app/components/ui";
|
||||||
import { QbittorrentProvider, QbittorrentDownloadButton } from "@/app/components/QbittorrentDownloadButton";
|
import { QbittorrentProvider, QbittorrentDownloadButton } from "@/app/components/QbittorrentDownloadButton";
|
||||||
import { useTranslation } from "@/lib/i18n/context";
|
import { useTranslation } from "@/lib/i18n/context";
|
||||||
|
import { compressVolumes } from "@/lib/volumeRanges";
|
||||||
import type { TranslationKey } from "@/lib/i18n/fr";
|
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||||
|
|
||||||
type TFunction = (key: TranslationKey, vars?: Record<string, string | number>) => string;
|
type TFunction = (key: TranslationKey, vars?: Record<string, string | number>) => string;
|
||||||
@@ -260,7 +261,7 @@ function DownloadRow({ dl, onDeleted }: { dl: TorrentDownloadDto; onDeleted: ()
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Desktop: single row */}
|
{/* Desktop: single row */}
|
||||||
<div className="hidden sm:flex items-center gap-2">
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
<Link href={`/libraries/${dl.library_id}/series/${encodeURIComponent(dl.series_name)}`} className="text-sm font-medium text-primary hover:underline truncate">{dl.series_name}</Link>
|
<Link href={`/libraries/${dl.library_id}/series/${dl.series_id ?? encodeURIComponent(dl.series_name)}`} className="text-sm font-medium text-primary hover:underline truncate">{dl.series_name}</Link>
|
||||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${statusClass(dl.status)}`}>
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${statusClass(dl.status)}`}>
|
||||||
{statusLabel(dl.status, t)}
|
{statusLabel(dl.status, t)}
|
||||||
</span>
|
</span>
|
||||||
@@ -275,7 +276,7 @@ function DownloadRow({ dl, onDeleted }: { dl: TorrentDownloadDto; onDeleted: ()
|
|||||||
{/* Mobile: stacked */}
|
{/* Mobile: stacked */}
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Link href={`/libraries/${dl.library_id}/series/${encodeURIComponent(dl.series_name)}`} className="text-sm font-medium text-primary hover:underline truncate">{dl.series_name}</Link>
|
<Link href={`/libraries/${dl.library_id}/series/${dl.series_id ?? encodeURIComponent(dl.series_name)}`} className="text-sm font-medium text-primary hover:underline truncate">{dl.series_name}</Link>
|
||||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0 ${statusClass(dl.status)}`}>
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0 ${statusClass(dl.status)}`}>
|
||||||
{statusLabel(dl.status, t)}
|
{statusLabel(dl.status, t)}
|
||||||
</span>
|
</span>
|
||||||
@@ -389,7 +390,7 @@ function AvailableLibraryCard({ lib, onDeleted }: { lib: LatestFoundPerLibraryDt
|
|||||||
<div key={r.id} className="rounded-lg border border-border/40 bg-background/60 p-2 sm:p-3">
|
<div key={r.id} className="rounded-lg border border-border/40 bg-background/60 p-2 sm:p-3">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${lib.library_id}/series/${encodeURIComponent(r.series_name)}`}
|
href={`/libraries/${lib.library_id}/series/${r.series_id ?? encodeURIComponent(r.series_name)}`}
|
||||||
className="font-semibold text-xs sm:text-sm text-primary hover:underline truncate"
|
className="font-semibold text-xs sm:text-sm text-primary hover:underline truncate"
|
||||||
>
|
>
|
||||||
{r.series_name}
|
{r.series_name}
|
||||||
@@ -403,11 +404,11 @@ function AvailableLibraryCard({ lib, onDeleted }: { lib: LatestFoundPerLibraryDt
|
|||||||
{groupReleasesByTitle(r.available_releases).map((group) => (
|
{groupReleasesByTitle(r.available_releases).map((group) => (
|
||||||
<div key={group.title} className="rounded bg-muted/30 overflow-hidden">
|
<div key={group.title} className="rounded bg-muted/30 overflow-hidden">
|
||||||
{/* Title + matched volumes (shown once) */}
|
{/* Title + matched volumes (shown once) */}
|
||||||
<div className="flex items-center gap-2 py-1 px-2">
|
<div className="py-1 px-2">
|
||||||
<p className="text-[11px] sm:text-xs font-mono text-foreground truncate flex-1" title={group.title}>{group.title}</p>
|
<p className="text-[11px] sm:text-xs font-mono text-foreground break-all">{group.title}</p>
|
||||||
<div className="flex flex-wrap items-center gap-1 shrink-0">
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
{group.items[0].matched_missing_volumes.map(vol => (
|
{compressVolumes(group.items[0].matched_missing_volumes).map(range => (
|
||||||
<span key={vol} className="text-[10px] px-1 py-0.5 rounded-full bg-success/20 text-success font-medium">T{vol}</span>
|
<span key={range} className="text-[10px] px-1 py-0.5 rounded-full bg-success/20 text-success font-medium">{range}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent, StatBox } fr
|
|||||||
import { QbittorrentProvider, QbittorrentDownloadButton } from "@/app/components/QbittorrentDownloadButton";
|
import { QbittorrentProvider, QbittorrentDownloadButton } from "@/app/components/QbittorrentDownloadButton";
|
||||||
import type { DownloadDetectionReportDto, DownloadDetectionResultDto } from "@/lib/api";
|
import type { DownloadDetectionReportDto, DownloadDetectionResultDto } from "@/lib/api";
|
||||||
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
|
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
|
||||||
|
import { compressVolumes } from "@/lib/volumeRanges";
|
||||||
|
|
||||||
export function DownloadDetectionReportCard({ report, t }: { report: DownloadDetectionReportDto; t: TranslateFunction }) {
|
export function DownloadDetectionReportCard({ report, t }: { report: DownloadDetectionReportDto; t: TranslateFunction }) {
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +70,7 @@ export function DownloadDetectionResultsCard({ results, libraryId, qbConfigured,
|
|||||||
<div className="flex items-center justify-between gap-2 mb-2">
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
{libraryId ? (
|
{libraryId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${libraryId}/series/${encodeURIComponent(r.series_name)}`}
|
href={`/libraries/${libraryId}/series/${r.series_id ?? encodeURIComponent(r.series_name)}`}
|
||||||
className="font-semibold text-sm text-primary hover:underline truncate"
|
className="font-semibold text-sm text-primary hover:underline truncate"
|
||||||
>
|
>
|
||||||
{r.series_name}
|
{r.series_name}
|
||||||
@@ -97,10 +98,10 @@ export function DownloadDetectionResultsCard({ results, libraryId, qbConfigured,
|
|||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{(release.size / 1024 / 1024).toFixed(0)} MB
|
{(release.size / 1024 / 1024).toFixed(0)} MB
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{release.matched_missing_volumes.map((vol) => (
|
{compressVolumes(release.matched_missing_volumes).map((range) => (
|
||||||
<span key={vol} className="text-[10px] px-1.5 py-0.5 rounded-full bg-success/20 text-success font-medium">
|
<span key={range} className="text-[10px] px-1.5 py-0.5 rounded-full bg-success/20 text-success font-medium">
|
||||||
T.{vol}
|
{range}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function MetadataBatchResultsCard({ results, libraryId, t }: {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{libraryId ? (
|
{libraryId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${libraryId}/series/${encodeURIComponent(r.series_name)}`}
|
href={`/libraries/${libraryId}/series/${r.series_id ?? encodeURIComponent(r.series_name)}`}
|
||||||
className="font-medium text-sm text-primary hover:underline truncate"
|
className="font-medium text-sm text-primary hover:underline truncate"
|
||||||
>
|
>
|
||||||
{r.series_name}
|
{r.series_name}
|
||||||
@@ -157,7 +157,7 @@ export function MetadataRefreshChangesCard({ report, libraryId, t }: {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{libraryId ? (
|
{libraryId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${libraryId}/series/${encodeURIComponent(r.series_name)}`}
|
href={`/libraries/${libraryId}/series/${r.series_id ?? encodeURIComponent(r.series_name)}`}
|
||||||
className="font-medium text-sm text-primary hover:underline truncate"
|
className="font-medium text-sm text-primary hover:underline truncate"
|
||||||
>
|
>
|
||||||
{r.series_name}
|
{r.series_name}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function ReadingStatusMatchResultsCard({ results, libraryId, t }: {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{libraryId ? (
|
{libraryId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${libraryId}/series/${encodeURIComponent(r.series_name)}`}
|
href={`/libraries/${libraryId}/series/${r.series_id ?? encodeURIComponent(r.series_name)}`}
|
||||||
className="font-medium text-sm text-primary hover:underline truncate"
|
className="font-medium text-sm text-primary hover:underline truncate"
|
||||||
>
|
>
|
||||||
{r.series_name}
|
{r.series_name}
|
||||||
@@ -146,7 +146,7 @@ export function ReadingStatusPushResultsCard({ results, libraryId, t }: {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{libraryId ? (
|
{libraryId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${libraryId}/series/${encodeURIComponent(r.series_name)}`}
|
href={`/libraries/${libraryId}/series/${r.series_id ?? encodeURIComponent(r.series_name)}`}
|
||||||
className="font-medium text-sm text-primary hover:underline truncate"
|
className="font-medium text-sm text-primary hover:underline truncate"
|
||||||
>
|
>
|
||||||
{r.series_name}
|
{r.series_name}
|
||||||
|
|||||||
@@ -33,28 +33,20 @@ export default async function SeriesDetailPage({
|
|||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string; name: string }>;
|
params: Promise<{ id: string; seriesId: string }>;
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const { id, name } = await params;
|
const { id, seriesId } = await params;
|
||||||
const { t } = await getServerTranslations();
|
const { t } = await getServerTranslations();
|
||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 50;
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 50;
|
||||||
|
|
||||||
const seriesName = decodeURIComponent(name);
|
const [library, seriesMeta, metadataLinks, readingStatusLink, prowlarrConfigured, qbConfigured, metadataProviders] = await Promise.all([
|
||||||
|
|
||||||
const [library, booksPage, seriesMeta, metadataLinks, readingStatusLink, prowlarrConfigured, qbConfigured, metadataProviders] = await Promise.all([
|
|
||||||
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
||||||
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
fetchSeriesMetadata(id, seriesId).catch(() => null as SeriesMetadataDto | null),
|
||||||
items: [] as BookDto[],
|
getMetadataLink(id, seriesId).catch(() => [] as ExternalMetadataLinkDto[]),
|
||||||
total: 0,
|
getReadingStatusLink(id, seriesId).catch(() => null as AnilistSeriesLinkDto | null),
|
||||||
page: 1,
|
|
||||||
limit,
|
|
||||||
})),
|
|
||||||
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
|
||||||
getMetadataLink(id, seriesName).catch(() => [] as ExternalMetadataLinkDto[]),
|
|
||||||
getReadingStatusLink(id, seriesName).catch(() => null as AnilistSeriesLinkDto | null),
|
|
||||||
apiFetch<{ api_key?: string }>("/settings/prowlarr")
|
apiFetch<{ api_key?: string }>("/settings/prowlarr")
|
||||||
.then(d => !!(d?.api_key?.trim()))
|
.then(d => !!(d?.api_key?.trim()))
|
||||||
.catch(() => false),
|
.catch(() => false),
|
||||||
@@ -64,6 +56,17 @@ export default async function SeriesDetailPage({
|
|||||||
apiFetch<{ comicvine?: { api_key?: string } }>("/settings/metadata_providers").catch(() => null),
|
apiFetch<{ comicvine?: { api_key?: string } }>("/settings/metadata_providers").catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Get series name from metadata for display
|
||||||
|
const seriesName = seriesMeta?.series_name ?? "";
|
||||||
|
|
||||||
|
// Fetch books using seriesId for the filter query
|
||||||
|
const booksPage = await fetchBooks(id, seriesId, page, limit).catch(() => ({
|
||||||
|
items: [] as BookDto[],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit,
|
||||||
|
}));
|
||||||
|
|
||||||
const hiddenProviders: string[] = [];
|
const hiddenProviders: string[] = [];
|
||||||
if (!metadataProviders?.comicvine?.api_key) hiddenProviders.push("comicvine");
|
if (!metadataProviders?.comicvine?.api_key) hiddenProviders.push("comicvine");
|
||||||
|
|
||||||
@@ -228,12 +231,14 @@ export default async function SeriesDetailPage({
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<MarkSeriesReadButton
|
<MarkSeriesReadButton
|
||||||
|
seriesId={seriesId}
|
||||||
seriesName={seriesName}
|
seriesName={seriesName}
|
||||||
bookCount={booksPage.total}
|
bookCount={booksPage.total}
|
||||||
booksReadCount={booksReadCount}
|
booksReadCount={booksReadCount}
|
||||||
/>
|
/>
|
||||||
<EditSeriesForm
|
<EditSeriesForm
|
||||||
libraryId={id}
|
libraryId={id}
|
||||||
|
seriesId={seriesId}
|
||||||
seriesName={seriesName}
|
seriesName={seriesName}
|
||||||
currentAuthors={seriesMeta?.authors ?? []}
|
currentAuthors={seriesMeta?.authors ?? []}
|
||||||
currentPublishers={seriesMeta?.publishers ?? []}
|
currentPublishers={seriesMeta?.publishers ?? []}
|
||||||
@@ -261,13 +266,14 @@ export default async function SeriesDetailPage({
|
|||||||
/>
|
/>
|
||||||
<ReadingStatusModal
|
<ReadingStatusModal
|
||||||
libraryId={id}
|
libraryId={id}
|
||||||
|
seriesId={seriesId}
|
||||||
seriesName={seriesName}
|
seriesName={seriesName}
|
||||||
readingStatusProvider={library.reading_status_provider ?? null}
|
readingStatusProvider={library.reading_status_provider ?? null}
|
||||||
existingLink={readingStatusLink}
|
existingLink={readingStatusLink}
|
||||||
/>
|
/>
|
||||||
<DeleteSeriesButton
|
<DeleteSeriesButton
|
||||||
libraryId={id}
|
libraryId={id}
|
||||||
seriesName={seriesName}
|
seriesId={seriesId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,8 +75,8 @@ export default async function LibrarySeriesPage({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||||
{series.map((s) => (
|
{series.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
key={s.name}
|
key={s.series_id}
|
||||||
href={`/libraries/${id}/series/${encodeURIComponent(s.name)}`}
|
href={`/libraries/${id}/series/${s.series_id}`}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<div className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200 ${s.books_read_count >= s.book_count ? "opacity-50" : ""}`}>
|
<div className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200 ${s.books_read_count >= s.book_count ? "opacity-50" : ""}`}>
|
||||||
@@ -98,6 +98,7 @@ export default async function LibrarySeriesPage({
|
|||||||
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||||
</p>
|
</p>
|
||||||
<MarkSeriesReadButton
|
<MarkSeriesReadButton
|
||||||
|
seriesId={s.series_id}
|
||||||
seriesName={s.name}
|
seriesName={s.name}
|
||||||
bookCount={s.book_count}
|
bookCount={s.book_count}
|
||||||
booksReadCount={s.books_read_count}
|
booksReadCount={s.books_read_count}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ 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">
|
<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) => (
|
{series.map((s) => (
|
||||||
<div key={s.name} className="group relative">
|
<div key={s.series_id} className="group relative">
|
||||||
<div
|
<div
|
||||||
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 ${
|
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" : ""
|
s.books_read_count >= s.book_count ? "opacity-50" : ""
|
||||||
@@ -149,6 +149,7 @@ export default async function SeriesPage({
|
|||||||
</p>
|
</p>
|
||||||
<div className="relative z-20">
|
<div className="relative z-20">
|
||||||
<MarkSeriesReadButton
|
<MarkSeriesReadButton
|
||||||
|
seriesId={s.series_id}
|
||||||
seriesName={s.name}
|
seriesName={s.name}
|
||||||
bookCount={s.book_count}
|
bookCount={s.book_count}
|
||||||
booksReadCount={s.books_read_count}
|
booksReadCount={s.books_read_count}
|
||||||
@@ -190,7 +191,7 @@ export default async function SeriesPage({
|
|||||||
</div>
|
</div>
|
||||||
{/* Link overlay covering the full card — below interactive elements */}
|
{/* Link overlay covering the full card — below interactive elements */}
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
href={`/libraries/${s.library_id}/series/${s.series_id}`}
|
||||||
className="absolute inset-0 z-10 rounded-xl"
|
className="absolute inset-0 z-10 rounded-xl"
|
||||||
aria-label={s.name === "unclassified" ? t("books.unclassified") : s.name}
|
aria-label={s.name === "unclassified" ? t("books.unclassified") : s.name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { NextResponse, NextRequest } from "next/server";
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
type Params = Promise<{ libraryId: string; seriesName: string }>;
|
type Params = Promise<{ libraryId: string; seriesId: string }>;
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Params }) {
|
export async function GET(request: NextRequest, { params }: { params: Params }) {
|
||||||
try {
|
try {
|
||||||
const { libraryId, seriesName } = await params;
|
const { libraryId, seriesId } = await params;
|
||||||
const data = await apiFetch(
|
const data = await apiFetch(
|
||||||
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`,
|
`/anilist/series/${libraryId}/${seriesId}`,
|
||||||
);
|
);
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -18,10 +18,10 @@ export async function GET(request: NextRequest, { params }: { params: Params })
|
|||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Params }) {
|
export async function POST(request: NextRequest, { params }: { params: Params }) {
|
||||||
try {
|
try {
|
||||||
const { libraryId, seriesName } = await params;
|
const { libraryId, seriesId } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const data = await apiFetch(
|
const data = await apiFetch(
|
||||||
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}/link`,
|
`/anilist/series/${libraryId}/${seriesId}/link`,
|
||||||
{ method: "POST", body: JSON.stringify(body) },
|
{ method: "POST", body: JSON.stringify(body) },
|
||||||
);
|
);
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
@@ -33,9 +33,9 @@ export async function POST(request: NextRequest, { params }: { params: Params })
|
|||||||
|
|
||||||
export async function DELETE(request: NextRequest, { params }: { params: Params }) {
|
export async function DELETE(request: NextRequest, { params }: { params: Params }) {
|
||||||
try {
|
try {
|
||||||
const { libraryId, seriesName } = await params;
|
const { libraryId, seriesId } = await params;
|
||||||
const data = await apiFetch(
|
const data = await apiFetch(
|
||||||
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}/unlink`,
|
`/anilist/series/${libraryId}/${seriesId}/unlink`,
|
||||||
{ method: "DELETE" },
|
{ method: "DELETE" },
|
||||||
);
|
);
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { updateSeries } from "@/lib/api";
|
|
||||||
|
|
||||||
export async function PATCH(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string; name: string }> }
|
|
||||||
) {
|
|
||||||
const { id, name } = await params;
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const data = await updateSeries(id, name, body);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Failed to update series";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,11 @@ import { fetchSeriesMetadata } from "@/lib/api";
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string; name: string }> }
|
{ params }: { params: Promise<{ id: string; seriesId: string }> }
|
||||||
) {
|
) {
|
||||||
const { id, name } = await params;
|
const { id, seriesId } = await params;
|
||||||
try {
|
try {
|
||||||
const data = await fetchSeriesMetadata(id, name);
|
const data = await fetchSeriesMetadata(id, seriesId);
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to fetch series metadata";
|
const message = error instanceof Error ? error.message : "Failed to fetch series metadata";
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { updateSeries, deleteSeries } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; seriesId: string }> }
|
||||||
|
) {
|
||||||
|
const { id, seriesId } = await params;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await updateSeries(id, seriesId, body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to update series";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; seriesId: string }> }
|
||||||
|
) {
|
||||||
|
const { id, seriesId } = await params;
|
||||||
|
try {
|
||||||
|
await deleteSeries(id, seriesId);
|
||||||
|
return NextResponse.json({ deleted: true });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to delete series";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Button, Icon, Modal } from "./ui";
|
import { Button, Icon, Modal } from "./ui";
|
||||||
import { useTranslation } from "@/lib/i18n/context";
|
import { useTranslation } from "@/lib/i18n/context";
|
||||||
|
|
||||||
export function DeleteSeriesButton({ libraryId, seriesName }: { libraryId: string; seriesName: string }) {
|
export function DeleteSeriesButton({ libraryId, seriesId }: { libraryId: string; seriesId: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
@@ -16,7 +16,7 @@ export function DeleteSeriesButton({ libraryId, seriesName }: { libraryId: strin
|
|||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/api/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}`,
|
`/api/libraries/${libraryId}/series/${seriesId}`,
|
||||||
{ method: "DELETE" }
|
{ method: "DELETE" }
|
||||||
);
|
);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const SERIES_STATUS_VALUES = ["", "ongoing", "ended", "hiatus", "cancelled", "up
|
|||||||
|
|
||||||
interface EditSeriesFormProps {
|
interface EditSeriesFormProps {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
|
seriesId: string;
|
||||||
seriesName: string;
|
seriesName: string;
|
||||||
currentAuthors: string[];
|
currentAuthors: string[];
|
||||||
currentPublishers: string[];
|
currentPublishers: string[];
|
||||||
@@ -58,6 +59,7 @@ interface EditSeriesFormProps {
|
|||||||
|
|
||||||
export function EditSeriesForm({
|
export function EditSeriesForm({
|
||||||
libraryId,
|
libraryId,
|
||||||
|
seriesId,
|
||||||
seriesName,
|
seriesName,
|
||||||
currentAuthors,
|
currentAuthors,
|
||||||
currentPublishers,
|
currentPublishers,
|
||||||
@@ -199,7 +201,7 @@ export function EditSeriesForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}`,
|
`/api/libraries/${libraryId}/series/${seriesId}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -212,12 +214,7 @@ export function EditSeriesForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
||||||
if (effectiveName !== seriesName) {
|
|
||||||
router.push(`/libraries/${libraryId}/series/${encodeURIComponent(effectiveName)}` as any);
|
|
||||||
} else {
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("common.networkError"));
|
setError(t("common.networkError"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface MarkSeriesReadButtonProps {
|
interface MarkSeriesReadButtonProps {
|
||||||
|
seriesId: string;
|
||||||
seriesName: string;
|
seriesName: string;
|
||||||
bookCount: number;
|
bookCount: number;
|
||||||
booksReadCount: number;
|
booksReadCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }: MarkSeriesReadButtonProps) {
|
export function MarkSeriesReadButton({ seriesId, seriesName, bookCount, booksReadCount }: MarkSeriesReadButtonProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -27,7 +28,7 @@ export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }:
|
|||||||
const res = await fetch("/api/series/mark-read", {
|
const res = await fetch("/api/series/mark-read", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ series: seriesName, status: targetStatus }),
|
body: JSON.stringify({ series: seriesId, status: targetStatus }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Icon } from "./ui";
|
|||||||
import type { ProwlarrRelease, ProwlarrSearchResponse } from "../../lib/api";
|
import type { ProwlarrRelease, ProwlarrSearchResponse } from "../../lib/api";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
import { QbittorrentProvider, QbittorrentDownloadButton } from "./QbittorrentDownloadButton";
|
import { QbittorrentProvider, QbittorrentDownloadButton } from "./QbittorrentDownloadButton";
|
||||||
|
import { compressVolumes } from "@/lib/volumeRanges";
|
||||||
|
|
||||||
interface MissingBookItem {
|
interface MissingBookItem {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -251,9 +252,9 @@ export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initi
|
|||||||
</span>
|
</span>
|
||||||
{hasMissing && (
|
{hasMissing && (
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
{first.matchedMissingVolumes!.map((vol) => (
|
{compressVolumes(first.matchedMissingVolumes!).map((range) => (
|
||||||
<span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
|
<span key={range} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
|
||||||
{t("prowlarr.missingVol", { vol })}
|
{range}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { AnilistMediaResultDto, AnilistSeriesLinkDto } from "../../lib/api"
|
|||||||
|
|
||||||
interface ReadingStatusModalProps {
|
interface ReadingStatusModalProps {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
|
seriesId: string;
|
||||||
seriesName: string;
|
seriesName: string;
|
||||||
readingStatusProvider: string | null;
|
readingStatusProvider: string | null;
|
||||||
existingLink: AnilistSeriesLinkDto | null;
|
existingLink: AnilistSeriesLinkDto | null;
|
||||||
@@ -18,6 +19,7 @@ type ModalStep = "idle" | "searching" | "results" | "linked";
|
|||||||
|
|
||||||
export function ReadingStatusModal({
|
export function ReadingStatusModal({
|
||||||
libraryId,
|
libraryId,
|
||||||
|
seriesId,
|
||||||
seriesName,
|
seriesName,
|
||||||
readingStatusProvider,
|
readingStatusProvider,
|
||||||
existingLink,
|
existingLink,
|
||||||
@@ -67,7 +69,7 @@ export function ReadingStatusModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/api/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`,
|
`/api/anilist/series/${libraryId}/${seriesId}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -91,7 +93,7 @@ export function ReadingStatusModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/api/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`,
|
`/api/anilist/series/${libraryId}/${seriesId}`,
|
||||||
{ method: "DELETE" }
|
{ method: "DELETE" }
|
||||||
);
|
);
|
||||||
if (!resp.ok) throw new Error("Unlink failed");
|
if (!resp.ok) throw new Error("Unlink failed");
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export type BookDto = {
|
|||||||
reading_status: ReadingStatus;
|
reading_status: ReadingStatus;
|
||||||
reading_current_page: number | null;
|
reading_current_page: number | null;
|
||||||
reading_last_read_at: string | null;
|
reading_last_read_at: string | null;
|
||||||
|
series_id: string | null;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
isbn: string | null;
|
isbn: string | null;
|
||||||
publish_date: string | null;
|
publish_date: string | null;
|
||||||
@@ -122,6 +123,7 @@ export type SearchHitDto = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SeriesHitDto = {
|
export type SeriesHitDto = {
|
||||||
|
series_id: string;
|
||||||
library_id: string;
|
library_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
book_count: number;
|
book_count: number;
|
||||||
@@ -137,6 +139,7 @@ export type SearchResponseDto = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SeriesDto = {
|
export type SeriesDto = {
|
||||||
|
series_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
book_count: number;
|
book_count: number;
|
||||||
books_read_count: number;
|
books_read_count: number;
|
||||||
@@ -802,6 +805,7 @@ export async function updateBook(bookId: string, data: UpdateBookRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SeriesMetadataDto = {
|
export type SeriesMetadataDto = {
|
||||||
|
series_name: string;
|
||||||
authors: string[];
|
authors: string[];
|
||||||
description: string | null;
|
description: string | null;
|
||||||
publishers: string[];
|
publishers: string[];
|
||||||
@@ -813,9 +817,9 @@ export type SeriesMetadataDto = {
|
|||||||
locked_fields: Record<string, boolean>;
|
locked_fields: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchSeriesMetadata(libraryId: string, seriesName: string) {
|
export async function fetchSeriesMetadata(libraryId: string, seriesId: string) {
|
||||||
return apiFetch<SeriesMetadataDto>(
|
return apiFetch<SeriesMetadataDto>(
|
||||||
`/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}/metadata`
|
`/libraries/${libraryId}/series/${seriesId}/metadata`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,17 +835,23 @@ export type UpdateSeriesRequest = {
|
|||||||
locked_fields?: Record<string, boolean>;
|
locked_fields?: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function updateSeries(libraryId: string, seriesName: string, data: UpdateSeriesRequest) {
|
export async function updateSeries(libraryId: string, seriesId: string, data: UpdateSeriesRequest) {
|
||||||
return apiFetch<{ updated: number }>(`/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}`, {
|
return apiFetch<{ updated: number }>(`/libraries/${libraryId}/series/${seriesId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markSeriesRead(seriesName: string, status: "read" | "unread" = "read") {
|
export async function deleteSeries(libraryId: string, seriesId: string) {
|
||||||
|
return apiFetch<void>(`/libraries/${libraryId}/series/${seriesId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markSeriesRead(seriesId: string, status: "read" | "unread" = "read") {
|
||||||
return apiFetch<{ updated: number }>("/series/mark-read", {
|
return apiFetch<{ updated: number }>("/series/mark-read", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ series: seriesName, status }),
|
body: JSON.stringify({ series: seriesId, status }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,16 +1013,16 @@ export async function rejectMetadataMatch(id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMetadataLink(libraryId: string, seriesName: string) {
|
export async function getMetadataLink(libraryId: string, seriesId: string) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("library_id", libraryId);
|
params.set("library_id", libraryId);
|
||||||
params.set("series_name", seriesName);
|
params.set("series_id", seriesId);
|
||||||
return apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
|
return apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getReadingStatusLink(libraryId: string, seriesName: string) {
|
export async function getReadingStatusLink(libraryId: string, seriesId: string) {
|
||||||
return apiFetch<AnilistSeriesLinkDto>(
|
return apiFetch<AnilistSeriesLinkDto>(
|
||||||
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`
|
`/anilist/series/${libraryId}/${seriesId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,6 +1062,7 @@ export type MetadataBatchReportDto = {
|
|||||||
|
|
||||||
export type MetadataBatchResultDto = {
|
export type MetadataBatchResultDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
series_id?: string;
|
||||||
series_name: string;
|
series_name: string;
|
||||||
status: string;
|
status: string;
|
||||||
provider_used: string | null;
|
provider_used: string | null;
|
||||||
@@ -1097,6 +1108,7 @@ export type ReadingStatusMatchReportDto = {
|
|||||||
|
|
||||||
export type ReadingStatusMatchResultDto = {
|
export type ReadingStatusMatchResultDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
series_id?: string;
|
||||||
series_name: string;
|
series_name: string;
|
||||||
status: "linked" | "already_linked" | "no_results" | "ambiguous" | "error";
|
status: "linked" | "already_linked" | "no_results" | "ambiguous" | "error";
|
||||||
anilist_id: number | null;
|
anilist_id: number | null;
|
||||||
@@ -1132,6 +1144,7 @@ export type ReadingStatusPushReportDto = {
|
|||||||
|
|
||||||
export type ReadingStatusPushResultDto = {
|
export type ReadingStatusPushResultDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
series_id?: string;
|
||||||
series_name: string;
|
series_name: string;
|
||||||
status: "pushed" | "skipped" | "no_books" | "error";
|
status: "pushed" | "skipped" | "no_books" | "error";
|
||||||
anilist_id: number | null;
|
anilist_id: number | null;
|
||||||
@@ -1180,6 +1193,7 @@ export type DownloadDetectionReportDto = {
|
|||||||
|
|
||||||
export type DownloadDetectionResultDto = {
|
export type DownloadDetectionResultDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
series_id?: string;
|
||||||
series_name: string;
|
series_name: string;
|
||||||
status: "found" | "not_found" | "no_missing" | "no_metadata" | "error";
|
status: "found" | "not_found" | "no_missing" | "no_metadata" | "error";
|
||||||
missing_count: number;
|
missing_count: number;
|
||||||
@@ -1189,6 +1203,7 @@ export type DownloadDetectionResultDto = {
|
|||||||
|
|
||||||
export type AvailableDownloadDto = {
|
export type AvailableDownloadDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
series_id?: string;
|
||||||
series_name: string;
|
series_name: string;
|
||||||
missing_count: number;
|
missing_count: number;
|
||||||
available_releases: AvailableReleaseDto[] | null;
|
available_releases: AvailableReleaseDto[] | null;
|
||||||
@@ -1226,6 +1241,7 @@ export type RefreshBookDiff = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type RefreshSeriesResult = {
|
export type RefreshSeriesResult = {
|
||||||
|
series_id?: string;
|
||||||
series_name: string;
|
series_name: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
status: string; // "updated" | "unchanged" | "error"
|
status: string; // "updated" | "unchanged" | "error"
|
||||||
@@ -1306,6 +1322,7 @@ export type QBittorrentAddResponse = {
|
|||||||
export type TorrentDownloadDto = {
|
export type TorrentDownloadDto = {
|
||||||
id: string;
|
id: string;
|
||||||
library_id: string;
|
library_id: string;
|
||||||
|
series_id?: string;
|
||||||
series_name: string;
|
series_name: string;
|
||||||
expected_volumes: number[];
|
expected_volumes: number[];
|
||||||
qb_hash: string | null;
|
qb_hash: string | null;
|
||||||
|
|||||||
25
apps/backoffice/lib/volumeRanges.ts
Normal file
25
apps/backoffice/lib/volumeRanges.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Compress a sorted list of volume numbers into ranges.
|
||||||
|
* e.g. [1,2,3,5,7,8,9] → ["1→3", "5", "7→9"]
|
||||||
|
*/
|
||||||
|
export function compressVolumes(volumes: number[]): string[] {
|
||||||
|
if (volumes.length === 0) return [];
|
||||||
|
|
||||||
|
const sorted = [...volumes].sort((a, b) => a - b);
|
||||||
|
const ranges: string[] = [];
|
||||||
|
let start = sorted[0];
|
||||||
|
let end = sorted[0];
|
||||||
|
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
if (sorted[i] === end + 1) {
|
||||||
|
end = sorted[i];
|
||||||
|
} else {
|
||||||
|
ranges.push(start === end ? `T${start}` : `T${start}→${end}`);
|
||||||
|
start = sorted[i];
|
||||||
|
end = sorted[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges.push(start === end ? `T${start}` : `T${start}→${end}`);
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
2
apps/backoffice/next-env.d.ts
vendored
2
apps/backoffice/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -17,14 +17,12 @@ async fn rematch_unlinked_books(pool: &PgPool, library_id: Uuid) {
|
|||||||
b.id AS book_id
|
b.id AS book_id
|
||||||
FROM external_book_metadata ebm2
|
FROM external_book_metadata ebm2
|
||||||
JOIN external_metadata_links eml ON eml.id = ebm2.link_id
|
JOIN external_metadata_links eml ON eml.id = ebm2.link_id
|
||||||
JOIN books b ON b.library_id = eml.library_id
|
JOIN books b ON b.series_id = eml.series_id
|
||||||
AND b.volume = ebm2.volume_number
|
AND b.volume = ebm2.volume_number
|
||||||
LEFT JOIN series s ON s.id = b.series_id
|
|
||||||
WHERE eml.library_id = $1
|
WHERE eml.library_id = $1
|
||||||
AND ebm2.book_id IS NULL
|
AND ebm2.book_id IS NULL
|
||||||
AND ebm2.volume_number IS NOT NULL
|
AND ebm2.volume_number IS NOT NULL
|
||||||
AND eml.status = 'approved'
|
AND eml.status = 'approved'
|
||||||
AND LOWER(COALESCE(s.name, 'unclassified')) = LOWER(eml.series_name)
|
|
||||||
) matched
|
) matched
|
||||||
WHERE ebm.id = matched.ebm_id
|
WHERE ebm.id = matched.ebm_id
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ pub async fn check_and_schedule_reading_status_push(pool: &PgPool) -> Result<()>
|
|||||||
pub async fn check_and_schedule_download_detection(pool: &PgPool) -> Result<()> {
|
pub async fn check_and_schedule_download_detection(pool: &PgPool) -> Result<()> {
|
||||||
// Only schedule if Prowlarr is configured
|
// Only schedule if Prowlarr is configured
|
||||||
let prowlarr_configured: bool = sqlx::query_scalar(
|
let prowlarr_configured: bool = sqlx::query_scalar(
|
||||||
"SELECT EXISTS(SELECT 1 FROM settings WHERE key = 'prowlarr' AND value->>'base_url' IS NOT NULL AND value->>'base_url' != '')"
|
"SELECT EXISTS(SELECT 1 FROM app_settings WHERE key = 'prowlarr' AND value->>'base_url' IS NOT NULL AND value->>'base_url' != '')"
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
-- Fuses series_metadata into series. All related tables get series_id FK.
|
-- Fuses series_metadata into series. All related tables get series_id FK.
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- 0. Safety: backup reading progress before any schema changes
|
||||||
|
CREATE TABLE IF NOT EXISTS _backup_book_reading_progress AS
|
||||||
|
SELECT * FROM book_reading_progress;
|
||||||
|
|
||||||
-- 1. Create the series table (fusion of series_metadata)
|
-- 1. Create the series table (fusion of series_metadata)
|
||||||
CREATE TABLE IF NOT EXISTS series (
|
CREATE TABLE IF NOT EXISTS series (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
Reference in New Issue
Block a user