Compare commits
10 Commits
3a25e42a20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e26219989f | |||
| 5d33a35407 | |||
| d53572dc33 | |||
| cf1953d11f | |||
| 6f663eaee7 | |||
| ee65c6263a | |||
| 691b6b22ab | |||
| 11c80a16a3 | |||
| c366b44c54 | |||
| 92f80542e6 |
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "1.24.0"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -1233,7 +1233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexer"
|
||||
version = "1.24.0"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1667,7 +1667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notifications"
|
||||
version = "1.24.0"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest",
|
||||
@@ -1786,7 +1786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parsers"
|
||||
version = "1.24.0"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flate2",
|
||||
@@ -2923,7 +2923,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stripstream-core"
|
||||
version = "1.24.0"
|
||||
version = "1.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
||||
@@ -10,7 +10,7 @@ resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "1.24.0"
|
||||
version = "1.27.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Julien Froidefond
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
12
README.md
12
README.md
@@ -110,6 +110,12 @@ The backoffice will be available at http://localhost:7082
|
||||
- Batch auto-matching and scheduled metadata refresh
|
||||
- Field locking to protect manual edits from sync
|
||||
|
||||
### Notifications
|
||||
- **Telegram**: real-time notifications via Telegram Bot API
|
||||
- 12 granular event toggles (scans, thumbnails, conversions, metadata)
|
||||
- Book thumbnail images included in notifications where applicable
|
||||
- Test connection from settings
|
||||
|
||||
### External Integrations
|
||||
- **Komga**: import reading progress
|
||||
- **Prowlarr**: search for missing volumes
|
||||
@@ -130,9 +136,11 @@ The backoffice will be available at http://localhost:7082
|
||||
- Rate limiting, token expiration and revocation
|
||||
|
||||
### Web UI (Backoffice)
|
||||
- Dashboard with statistics, charts, and reading progress
|
||||
- Dashboard with statistics, interactive charts (recharts), and reading progress
|
||||
- Currently reading & recently read sections
|
||||
- Library, book, series, author management
|
||||
- Live job monitoring, metadata search modals, settings panel
|
||||
- Notification settings with per-event toggle configuration
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -279,4 +287,4 @@ volumes:
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
@@ -631,12 +631,17 @@ pub async fn get_thumbnail(
|
||||
crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
|
||||
};
|
||||
|
||||
let etag_value = format!("\"{}_{:x}\"", book_id, data.len());
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
if let Ok(v) = HeaderValue::from_str(&etag_value) {
|
||||
headers.insert(header::ETAG, v);
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, headers, Body::from(data)))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct StatsQuery {
|
||||
/// Granularity: "day", "week" or "month" (default: "month")
|
||||
pub period: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct StatsOverview {
|
||||
pub total_books: i64,
|
||||
@@ -97,6 +106,15 @@ pub struct MonthlyReading {
|
||||
pub books_read: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct JobTimePoint {
|
||||
pub label: String,
|
||||
pub scan: i64,
|
||||
pub rebuild: i64,
|
||||
pub thumbnail: i64,
|
||||
pub other: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct StatsResponse {
|
||||
pub overview: StatsOverview,
|
||||
@@ -109,6 +127,7 @@ pub struct StatsResponse {
|
||||
pub by_library: Vec<LibraryStats>,
|
||||
pub top_series: Vec<TopSeries>,
|
||||
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||
pub jobs_over_time: Vec<JobTimePoint>,
|
||||
pub metadata: MetadataStats,
|
||||
}
|
||||
|
||||
@@ -117,6 +136,7 @@ pub struct StatsResponse {
|
||||
get,
|
||||
path = "/stats",
|
||||
tag = "stats",
|
||||
params(StatsQuery),
|
||||
responses(
|
||||
(status = 200, body = StatsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
@@ -125,7 +145,9 @@ pub struct StatsResponse {
|
||||
)]
|
||||
pub async fn get_stats(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<StatsQuery>,
|
||||
) -> Result<Json<StatsResponse>, ApiError> {
|
||||
let period = query.period.as_deref().unwrap_or("month");
|
||||
// Overview + reading status in one query
|
||||
let overview_row = sqlx::query(
|
||||
r#"
|
||||
@@ -285,20 +307,74 @@ pub async fn get_stats(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Additions over time (last 12 months)
|
||||
let additions_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
|
||||
COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
// Additions over time (with gap filling)
|
||||
let additions_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT created_at::date AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY created_at::date
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', created_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
||||
.iter()
|
||||
@@ -409,21 +485,77 @@ pub async fn get_stats(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Reading activity over time (last 12 months)
|
||||
let reading_time_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', brp.last_read_at), 'YYYY-MM') AS month,
|
||||
COUNT(*) AS books_read
|
||||
FROM book_reading_progress brp
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
// Reading activity over time (with gap filling)
|
||||
let reading_time_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_read, 0) AS books_read
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
|
||||
FROM book_reading_progress brp
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY brp.last_read_at::date
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_read, 0) AS books_read
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
||||
FROM book_reading_progress brp
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', brp.last_read_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||
COALESCE(cnt.books_read, 0) AS books_read
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
||||
FROM book_reading_progress brp
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
|
||||
.iter()
|
||||
@@ -433,6 +565,125 @@ pub async fn get_stats(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Jobs over time (with gap filling, grouped by type category)
|
||||
let jobs_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
finished_at::date AS dt,
|
||||
CASE
|
||||
WHEN type = 'scan' THEN 'scan'
|
||||
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||
ELSE 'other'
|
||||
END AS cat,
|
||||
COUNT(*) AS c
|
||||
FROM index_jobs
|
||||
WHERE status IN ('success', 'failed')
|
||||
AND finished_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY finished_at::date, cat
|
||||
) cnt ON cnt.dt = d.dt
|
||||
GROUP BY d.dt
|
||||
ORDER BY label ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
DATE_TRUNC('week', finished_at) AS dt,
|
||||
CASE
|
||||
WHEN type = 'scan' THEN 'scan'
|
||||
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||
ELSE 'other'
|
||||
END AS cat,
|
||||
COUNT(*) AS c
|
||||
FROM index_jobs
|
||||
WHERE status IN ('success', 'failed')
|
||||
AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', finished_at), cat
|
||||
) cnt ON cnt.dt = d.dt
|
||||
GROUP BY d.dt
|
||||
ORDER BY label ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS label,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
DATE_TRUNC('month', finished_at) AS dt,
|
||||
CASE
|
||||
WHEN type = 'scan' THEN 'scan'
|
||||
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||
ELSE 'other'
|
||||
END AS cat,
|
||||
COUNT(*) AS c
|
||||
FROM index_jobs
|
||||
WHERE status IN ('success', 'failed')
|
||||
AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', finished_at), cat
|
||||
) cnt ON cnt.dt = d.dt
|
||||
GROUP BY d.dt
|
||||
ORDER BY label ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let jobs_over_time: Vec<JobTimePoint> = jobs_rows
|
||||
.iter()
|
||||
.map(|r| JobTimePoint {
|
||||
label: r.get("label"),
|
||||
scan: r.get("scan"),
|
||||
rebuild: r.get("rebuild"),
|
||||
thumbnail: r.get("thumbnail"),
|
||||
other: r.get("other"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(StatsResponse {
|
||||
overview,
|
||||
reading_status,
|
||||
@@ -444,6 +695,7 @@ pub async fn get_stats(
|
||||
by_library,
|
||||
top_series,
|
||||
additions_over_time,
|
||||
jobs_over_time,
|
||||
metadata,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -28,12 +28,9 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
// Récupérer le content-type et les données
|
||||
const contentType = response.headers.get("content-type") || "image/webp";
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
|
||||
// Retourner l'image avec le bon content-type
|
||||
return new NextResponse(imageBuffer, {
|
||||
return new NextResponse(response.body, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=300",
|
||||
|
||||
@@ -9,10 +9,25 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const { baseUrl, token } = config();
|
||||
const ifNoneMatch = request.headers.get("if-none-match");
|
||||
|
||||
const fetchHeaders: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
if (ifNoneMatch) {
|
||||
fetchHeaders["If-None-Match"] = ifNoneMatch;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: fetchHeaders,
|
||||
next: { revalidate: 86400 },
|
||||
});
|
||||
|
||||
// Forward 304 Not Modified as-is
|
||||
if (response.status === 304) {
|
||||
return new NextResponse(null, { status: 304 });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
||||
status: response.status
|
||||
@@ -20,14 +35,17 @@ export async function GET(
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "image/webp";
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
const etag = response.headers.get("etag");
|
||||
|
||||
return new NextResponse(imageBuffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
};
|
||||
if (etag) {
|
||||
headers["ETag"] = etag;
|
||||
}
|
||||
|
||||
return new NextResponse(response.body, { headers });
|
||||
} catch (error) {
|
||||
console.error("Error fetching thumbnail:", error);
|
||||
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
AreaChart, Area,
|
||||
AreaChart, Area, Line, LineChart,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
@@ -186,3 +186,46 @@ export function RcHorizontalBar({
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-line chart (jobs over time)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcMultiLineChart({
|
||||
data,
|
||||
lines,
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: Record<string, unknown>[];
|
||||
lines: { key: string; label: string; color: string }[];
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0));
|
||||
if (data.length === 0 || !hasData)
|
||||
return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{lines.map((l) => (
|
||||
<Line
|
||||
key={l.key}
|
||||
type="monotone"
|
||||
dataKey={l.key}
|
||||
name={l.label}
|
||||
stroke={l.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: l.color }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
type Period = "day" | "week" | "month";
|
||||
|
||||
export function PeriodToggle({
|
||||
labels,
|
||||
}: {
|
||||
labels: { day: string; week: string; month: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const raw = searchParams.get("period");
|
||||
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
|
||||
|
||||
function setPeriod(period: Period) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (period === "month") {
|
||||
params.delete("period");
|
||||
} else {
|
||||
params.set("period", period);
|
||||
}
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `?${qs}` : "/", { scroll: false });
|
||||
}
|
||||
|
||||
const options: Period[] = ["day", "week", "month"];
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
|
||||
{options.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
current === p
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{labels[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
||||
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar } from "./components/DashboardCharts";
|
||||
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "./components/DashboardCharts";
|
||||
import { PeriodToggle } from "./components/PeriodToggle";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getServerTranslations } from "../lib/i18n/server";
|
||||
@@ -21,6 +22,24 @@ function formatNumber(n: number, locale: string): string {
|
||||
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
|
||||
}
|
||||
|
||||
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
|
||||
const loc = locale === "fr" ? "fr-FR" : "en-US";
|
||||
if (period === "month") {
|
||||
// raw = "YYYY-MM"
|
||||
const [y, m] = raw.split("-");
|
||||
const d = new Date(Number(y), Number(m) - 1, 1);
|
||||
return d.toLocaleDateString(loc, { month: "short" });
|
||||
}
|
||||
if (period === "week") {
|
||||
// raw = "YYYY-MM-DD" (Monday of the week)
|
||||
const d = new Date(raw + "T00:00:00");
|
||||
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
|
||||
}
|
||||
// day: raw = "YYYY-MM-DD"
|
||||
const d = new Date(raw + "T00:00:00");
|
||||
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
|
||||
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
||||
const pct = max > 0 ? (value / max) * 100 : 0;
|
||||
@@ -40,12 +59,19 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const rawPeriod = searchParamsAwaited.period;
|
||||
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
|
||||
const { t, locale } = await getServerTranslations();
|
||||
|
||||
let stats: StatsResponse | null = null;
|
||||
try {
|
||||
stats = await fetchStats();
|
||||
stats = await fetchStats(period);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch stats:", e);
|
||||
}
|
||||
@@ -62,7 +88,7 @@ export default async function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, metadata } = stats;
|
||||
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, jobs_over_time = [], metadata } = stats;
|
||||
|
||||
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
||||
const formatColors = [
|
||||
@@ -110,7 +136,7 @@ export default async function DashboardPage() {
|
||||
{currently_reading.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||
{currently_reading.slice(0, 8).map((book) => {
|
||||
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
|
||||
return (
|
||||
@@ -150,7 +176,7 @@ export default async function DashboardPage() {
|
||||
{recently_read.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||
{recently_read.map((book) => (
|
||||
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||
<Image
|
||||
@@ -175,20 +201,19 @@ export default async function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* Reading activity line chart */}
|
||||
{reading_over_time.length > 0 && (
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RcAreaChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={reading_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_read }))}
|
||||
color="hsl(142 60% 45%)"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card hover={false}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
|
||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RcAreaChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
|
||||
color="hsl(142 60% 45%)"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -351,20 +376,47 @@ export default async function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly additions line chart – full width */}
|
||||
{/* Additions line chart – full width */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RcAreaChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={additions_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_added }))}
|
||||
data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
|
||||
color="hsl(198 78% 37%)"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Jobs over time – multi-line chart */}
|
||||
<Card hover={false}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
|
||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RcMultiLineChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={jobs_over_time.map((j) => ({
|
||||
label: formatChartLabel(j.label, period, locale),
|
||||
scan: j.scan,
|
||||
rebuild: j.rebuild,
|
||||
thumbnail: j.thumbnail,
|
||||
other: j.other,
|
||||
}))}
|
||||
lines={[
|
||||
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
|
||||
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
|
||||
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
|
||||
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick links */}
|
||||
<QuickLinks t={t} />
|
||||
</div>
|
||||
|
||||
@@ -570,6 +570,14 @@ export type MonthlyReading = {
|
||||
books_read: number;
|
||||
};
|
||||
|
||||
export type JobTimePoint = {
|
||||
label: string;
|
||||
scan: number;
|
||||
rebuild: number;
|
||||
thumbnail: number;
|
||||
other: number;
|
||||
};
|
||||
|
||||
export type StatsResponse = {
|
||||
overview: StatsOverview;
|
||||
reading_status: ReadingStatusStats;
|
||||
@@ -581,11 +589,13 @@ export type StatsResponse = {
|
||||
by_library: LibraryStatsItem[];
|
||||
top_series: TopSeriesItem[];
|
||||
additions_over_time: MonthlyAdditions[];
|
||||
jobs_over_time: JobTimePoint[];
|
||||
metadata: MetadataStats;
|
||||
};
|
||||
|
||||
export async function fetchStats() {
|
||||
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } });
|
||||
export async function fetchStats(period?: "day" | "week" | "month") {
|
||||
const params = period && period !== "month" ? `?period=${period}` : "";
|
||||
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -70,7 +70,15 @@ const en: Record<TranslationKey, string> = {
|
||||
"dashboard.readingStatus": "Reading status",
|
||||
"dashboard.byFormat": "By format",
|
||||
"dashboard.byLibrary": "By library",
|
||||
"dashboard.booksAdded": "Books added (last 12 months)",
|
||||
"dashboard.booksAdded": "Books added",
|
||||
"dashboard.jobsOverTime": "Job runs",
|
||||
"dashboard.jobScan": "Scan",
|
||||
"dashboard.jobRebuild": "Rebuild",
|
||||
"dashboard.jobThumbnail": "Thumbnails",
|
||||
"dashboard.jobOther": "Other",
|
||||
"dashboard.periodDay": "Day",
|
||||
"dashboard.periodWeek": "Week",
|
||||
"dashboard.periodMonth": "Month",
|
||||
"dashboard.popularSeries": "Popular series",
|
||||
"dashboard.noSeries": "No series yet",
|
||||
"dashboard.unknown": "Unknown",
|
||||
@@ -84,7 +92,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"dashboard.withIsbn": "With ISBN",
|
||||
"dashboard.currentlyReading": "Currently reading",
|
||||
"dashboard.recentlyRead": "Recently read",
|
||||
"dashboard.readingActivity": "Reading activity (last 12 months)",
|
||||
"dashboard.readingActivity": "Reading activity",
|
||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||
"dashboard.noCurrentlyReading": "No books in progress",
|
||||
"dashboard.noRecentlyRead": "No books read recently",
|
||||
|
||||
@@ -68,7 +68,15 @@ const fr = {
|
||||
"dashboard.readingStatus": "Statut de lecture",
|
||||
"dashboard.byFormat": "Par format",
|
||||
"dashboard.byLibrary": "Par bibliothèque",
|
||||
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)",
|
||||
"dashboard.booksAdded": "Livres ajoutés",
|
||||
"dashboard.jobsOverTime": "Exécutions de jobs",
|
||||
"dashboard.jobScan": "Scan",
|
||||
"dashboard.jobRebuild": "Rebuild",
|
||||
"dashboard.jobThumbnail": "Thumbnails",
|
||||
"dashboard.jobOther": "Autre",
|
||||
"dashboard.periodDay": "Jour",
|
||||
"dashboard.periodWeek": "Semaine",
|
||||
"dashboard.periodMonth": "Mois",
|
||||
"dashboard.popularSeries": "Séries populaires",
|
||||
"dashboard.noSeries": "Aucune série pour le moment",
|
||||
"dashboard.unknown": "Inconnu",
|
||||
@@ -82,7 +90,7 @@ const fr = {
|
||||
"dashboard.withIsbn": "Avec ISBN",
|
||||
"dashboard.currentlyReading": "En cours de lecture",
|
||||
"dashboard.recentlyRead": "Derniers livres lus",
|
||||
"dashboard.readingActivity": "Activité de lecture (12 derniers mois)",
|
||||
"dashboard.readingActivity": "Activité de lecture",
|
||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||
"dashboard.noCurrentlyReading": "Aucun livre en cours",
|
||||
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
|
||||
|
||||
@@ -4,6 +4,7 @@ const nextConfig = {
|
||||
typedRoutes: true,
|
||||
images: {
|
||||
minimumCacheTTL: 86400,
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.24.0",
|
||||
"version": "1.27.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 7082",
|
||||
|
||||
@@ -170,6 +170,34 @@
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
### Telegram
|
||||
- Real-time notifications via Telegram Bot API (`sendMessage` and `sendPhoto`)
|
||||
- Configuration: bot token, chat ID, enable/disable toggle
|
||||
- Test connection button in settings
|
||||
|
||||
### Granular Event Toggles
|
||||
12 individually configurable notification events grouped by category:
|
||||
|
||||
| Category | Events |
|
||||
|----------|--------|
|
||||
| Scans | `scan_completed`, `scan_failed`, `scan_cancelled` |
|
||||
| Thumbnails | `thumbnail_completed`, `thumbnail_failed`, `thumbnail_cancelled` |
|
||||
| Conversion | `conversion_completed`, `conversion_failed`, `conversion_cancelled` |
|
||||
| Metadata | `metadata_approved`, `metadata_batch_completed`, `metadata_refresh_completed` |
|
||||
|
||||
### Thumbnail Images in Notifications
|
||||
- Book cover thumbnails attached to applicable notifications (conversion, metadata approval)
|
||||
- Uses `sendPhoto` multipart upload with fallback to text-only `sendMessage`
|
||||
|
||||
### Implementation
|
||||
- Shared `crates/notifications` crate used by both API and indexer
|
||||
- Fire-and-forget: notification failures are logged but never block the main operation
|
||||
- Messages formatted in HTML with event-specific icons
|
||||
|
||||
---
|
||||
|
||||
## Page Rendering & Caching
|
||||
|
||||
### Page Extraction
|
||||
@@ -238,13 +266,16 @@
|
||||
## Backoffice (Web UI)
|
||||
|
||||
### Dashboard
|
||||
- Statistics cards: books, series, authors, libraries
|
||||
- Donut charts: reading status breakdown, format distribution
|
||||
- Bar charts: books per language
|
||||
- Per-library reading progress bars
|
||||
- Top series by book/page count
|
||||
- Monthly addition timeline
|
||||
- Metadata coverage stats
|
||||
- Statistics cards: books, series, authors, libraries, pages, total size
|
||||
- Interactive charts (recharts): donut, area, stacked bar, horizontal bar
|
||||
- Reading status breakdown, format distribution, library distribution
|
||||
- Currently reading section with progress bars
|
||||
- Recently read section with cover thumbnails
|
||||
- Reading activity over time (area chart)
|
||||
- Books added over time (area chart)
|
||||
- Per-library stacked reading progress
|
||||
- Top series by book count
|
||||
- Metadata coverage and provider breakdown
|
||||
|
||||
### Pages
|
||||
- **Libraries**: list, create, delete, configure monitoring and metadata provider
|
||||
@@ -253,7 +284,7 @@
|
||||
- **Authors**: list with book/series counts, detail with author's books
|
||||
- **Jobs**: history, live progress via SSE, error details
|
||||
- **Tokens**: create, list, revoke API tokens
|
||||
- **Settings**: image processing, cache, thumbnails, external services (Prowlarr, qBittorrent)
|
||||
- **Settings**: image processing, cache, thumbnails, external services (Prowlarr, qBittorrent), notifications (Telegram)
|
||||
|
||||
### Interactive Features
|
||||
- Real-time search with suggestions
|
||||
|
||||
Reference in New Issue
Block a user