feat: add Telegram notification system with granular event toggles
Add notifications crate shared between API and indexer to send Telegram messages on scan/thumbnail/conversion completion/failure, metadata linking, batch and refresh events. Configurable via a new Notifications tab in the backoffice settings with per-event toggle switches grouped by category. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ futures = "0.3"
|
||||
image.workspace = true
|
||||
jpeg-decoder.workspace = true
|
||||
lru.workspace = true
|
||||
notifications = { path = "../../crates/notifications" }
|
||||
stripstream-core = { path = "../../crates/core" }
|
||||
parsers = { path = "../../crates/parsers" }
|
||||
rand.workspace = true
|
||||
|
||||
@@ -6,13 +6,15 @@ COPY Cargo.toml ./
|
||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||
COPY crates/notifications/Cargo.toml crates/notifications/Cargo.toml
|
||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||
|
||||
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/parsers/src && \
|
||||
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/notifications/src crates/parsers/src && \
|
||||
echo "fn main() {}" > apps/api/src/main.rs && \
|
||||
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||
echo "" > apps/indexer/src/lib.rs && \
|
||||
echo "" > crates/core/src/lib.rs && \
|
||||
echo "" > crates/notifications/src/lib.rs && \
|
||||
echo "" > crates/parsers/src/lib.rs
|
||||
|
||||
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
||||
@@ -26,12 +28,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
COPY apps/api/src apps/api/src
|
||||
COPY apps/indexer/src apps/indexer/src
|
||||
COPY crates/core/src crates/core/src
|
||||
COPY crates/notifications/src crates/notifications/src
|
||||
COPY crates/parsers/src crates/parsers/src
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/target \
|
||||
touch apps/api/src/main.rs crates/core/src/lib.rs crates/parsers/src/lib.rs && \
|
||||
touch apps/api/src/main.rs crates/core/src/lib.rs crates/notifications/src/lib.rs crates/parsers/src/lib.rs && \
|
||||
cargo build --release -p api && \
|
||||
cp /app/target/release/api /usr/local/bin/api
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ mod series;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod stats;
|
||||
mod telegram;
|
||||
mod thumbnails;
|
||||
mod tokens;
|
||||
|
||||
@@ -111,6 +112,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
||||
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
|
||||
.route("/qbittorrent/test", get(qbittorrent::test_qbittorrent))
|
||||
.route("/telegram/test", get(telegram::test_telegram))
|
||||
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||
.route("/komga/reports", get(komga::list_sync_reports))
|
||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||
|
||||
@@ -369,6 +369,16 @@ pub async fn approve_metadata(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Notify via Telegram
|
||||
let provider_for_notif: String = row.get("provider");
|
||||
notifications::notify(
|
||||
state.pool.clone(),
|
||||
notifications::NotificationEvent::MetadataApproved {
|
||||
series_name: series_name.clone(),
|
||||
provider: provider_for_notif,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Json(ApproveResponse {
|
||||
status: "approved".to_string(),
|
||||
report,
|
||||
|
||||
@@ -124,6 +124,12 @@ pub async fn start_batch(
|
||||
|
||||
// Spawn the background processing task
|
||||
let pool = state.pool.clone();
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = process_metadata_batch(&pool, job_id, library_id).await {
|
||||
warn!("[METADATA_BATCH] job {job_id} failed: {e}");
|
||||
@@ -134,6 +140,13 @@ pub async fn start_batch(
|
||||
.bind(e.to_string())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::MetadataBatchFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -621,6 +634,21 @@ async fn process_metadata_batch(
|
||||
|
||||
info!("[METADATA_BATCH] job={job_id} completed: {processed}/{total} series processed");
|
||||
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::MetadataBatchCompleted {
|
||||
library_name,
|
||||
total_series: total,
|
||||
processed,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,12 @@ pub async fn start_refresh(
|
||||
|
||||
// Spawn the background processing task
|
||||
let pool = state.pool.clone();
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
|
||||
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
|
||||
@@ -143,6 +149,13 @@ pub async fn start_refresh(
|
||||
.bind(e.to_string())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::MetadataRefreshFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -319,6 +332,22 @@ async fn process_metadata_refresh(
|
||||
|
||||
info!("[METADATA_REFRESH] job={job_id} completed: {refreshed} updated, {unchanged} unchanged, {errors} errors");
|
||||
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::MetadataRefreshCompleted {
|
||||
library_name,
|
||||
refreshed,
|
||||
unchanged,
|
||||
errors,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
46
apps/api/src/telegram.rs
Normal file
46
apps/api/src/telegram.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TelegramTestResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Test Telegram connection by sending a test message
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/telegram/test",
|
||||
tag = "notifications",
|
||||
responses(
|
||||
(status = 200, body = TelegramTestResponse),
|
||||
(status = 400, description = "Telegram not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn test_telegram(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<TelegramTestResponse>, ApiError> {
|
||||
let config = notifications::load_telegram_config(&state.pool)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
ApiError::bad_request(
|
||||
"Telegram is not configured or disabled. Set bot_token, chat_id, and enable it.",
|
||||
)
|
||||
})?;
|
||||
|
||||
match notifications::send_test_message(&config).await {
|
||||
Ok(()) => Ok(Json(TelegramTestResponse {
|
||||
success: true,
|
||||
message: "Test message sent successfully".to_string(),
|
||||
})),
|
||||
Err(e) => Ok(Json(TelegramTestResponse {
|
||||
success: false,
|
||||
message: format!("Failed to send: {e}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ type IconName =
|
||||
| "warning"
|
||||
| "tag"
|
||||
| "document"
|
||||
| "authors";
|
||||
| "authors"
|
||||
| "bell";
|
||||
|
||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
@@ -88,6 +89,7 @@ const icons: Record<IconName, string> = {
|
||||
tag: "M7 7h.01M7 3h5a1.99 1.99 0 011.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
|
||||
document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
|
||||
authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
|
||||
bell: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9",
|
||||
};
|
||||
|
||||
const colorClasses: Partial<Record<IconName, string>> = {
|
||||
|
||||
@@ -150,11 +150,12 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
}
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"general" | "integrations">("general");
|
||||
const [activeTab, setActiveTab] = useState<"general" | "integrations" | "notifications">("general");
|
||||
|
||||
const tabs = [
|
||||
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
|
||||
{ id: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const },
|
||||
{ id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -826,6 +827,11 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>)}
|
||||
|
||||
{activeTab === "notifications" && (<>
|
||||
{/* Telegram Notifications */}
|
||||
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
|
||||
</>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1480,3 +1486,254 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telegram Notifications sub-component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_EVENTS = {
|
||||
scan_completed: true,
|
||||
scan_failed: true,
|
||||
scan_cancelled: true,
|
||||
thumbnail_completed: true,
|
||||
thumbnail_failed: true,
|
||||
conversion_completed: true,
|
||||
conversion_failed: true,
|
||||
metadata_approved: true,
|
||||
metadata_batch_completed: true,
|
||||
metadata_batch_failed: true,
|
||||
metadata_refresh_completed: true,
|
||||
metadata_refresh_failed: true,
|
||||
};
|
||||
|
||||
function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
|
||||
const { t } = useTranslation();
|
||||
const [botToken, setBotToken] = useState("");
|
||||
const [chatId, setChatId] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [events, setEvents] = useState(DEFAULT_EVENTS);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/telegram")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
if (data.bot_token) setBotToken(data.bot_token);
|
||||
if (data.chat_id) setChatId(data.chat_id);
|
||||
if (data.enabled !== undefined) setEnabled(data.enabled);
|
||||
if (data.events) setEvents({ ...DEFAULT_EVENTS, ...data.events });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function saveTelegram(token?: string, chat?: string, en?: boolean, ev?: typeof events) {
|
||||
handleUpdateSetting("telegram", {
|
||||
bot_token: token ?? botToken,
|
||||
chat_id: chat ?? chatId,
|
||||
enabled: en ?? enabled,
|
||||
events: ev ?? events,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const resp = await fetch("/api/telegram/test");
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
setTestResult({ success: false, message: data.error });
|
||||
} else {
|
||||
setTestResult(data);
|
||||
}
|
||||
} catch {
|
||||
setTestResult({ success: false, message: "Failed to connect" });
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="bell" size="md" />
|
||||
{t("settings.telegram")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("settings.telegramDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Setup guide */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="text-sm text-primary hover:text-primary/80 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Icon name={showHelp ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{t("settings.telegramHelp")}
|
||||
</button>
|
||||
{showHelp && (
|
||||
<div className="mt-3 p-4 rounded-lg bg-muted/30 space-y-3 text-sm text-foreground">
|
||||
<div>
|
||||
<p className="font-medium mb-1">1. Bot Token</p>
|
||||
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpBot") }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-1">2. Chat ID</p>
|
||||
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpChat") }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-1">3. Group chat</p>
|
||||
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpGroup") }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => {
|
||||
setEnabled(e.target.checked);
|
||||
saveTelegram(undefined, undefined, e.target.checked);
|
||||
}}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-muted rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||
</label>
|
||||
<span className="text-sm font-medium text-foreground">{t("settings.telegramEnabled")}</span>
|
||||
</div>
|
||||
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.botToken")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
placeholder={t("settings.botTokenPlaceholder")}
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
onBlur={() => saveTelegram()}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.chatId")}</label>
|
||||
<FormInput
|
||||
type="text"
|
||||
placeholder={t("settings.chatIdPlaceholder")}
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
onBlur={() => saveTelegram()}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
|
||||
{/* Event toggles grouped by category */}
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<h4 className="text-sm font-medium text-foreground mb-4">{t("settings.telegramEvents")}</h4>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-5">
|
||||
{([
|
||||
{
|
||||
category: t("settings.eventCategoryScan"),
|
||||
icon: "search" as const,
|
||||
items: [
|
||||
{ key: "scan_completed" as const, label: t("settings.eventCompleted") },
|
||||
{ key: "scan_failed" as const, label: t("settings.eventFailed") },
|
||||
{ key: "scan_cancelled" as const, label: t("settings.eventCancelled") },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: t("settings.eventCategoryThumbnail"),
|
||||
icon: "image" as const,
|
||||
items: [
|
||||
{ key: "thumbnail_completed" as const, label: t("settings.eventCompleted") },
|
||||
{ key: "thumbnail_failed" as const, label: t("settings.eventFailed") },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: t("settings.eventCategoryConversion"),
|
||||
icon: "refresh" as const,
|
||||
items: [
|
||||
{ key: "conversion_completed" as const, label: t("settings.eventCompleted") },
|
||||
{ key: "conversion_failed" as const, label: t("settings.eventFailed") },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: t("settings.eventCategoryMetadata"),
|
||||
icon: "tag" as const,
|
||||
items: [
|
||||
{ key: "metadata_approved" as const, label: t("settings.eventLinked") },
|
||||
{ key: "metadata_batch_completed" as const, label: t("settings.eventBatchCompleted") },
|
||||
{ key: "metadata_batch_failed" as const, label: t("settings.eventBatchFailed") },
|
||||
{ key: "metadata_refresh_completed" as const, label: t("settings.eventRefreshCompleted") },
|
||||
{ key: "metadata_refresh_failed" as const, label: t("settings.eventRefreshFailed") },
|
||||
],
|
||||
},
|
||||
]).map(({ category, icon, items }) => (
|
||||
<div key={category}>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<Icon name={icon} size="sm" className="text-muted-foreground" />
|
||||
{category}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{items.map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center justify-between py-1.5 cursor-pointer group">
|
||||
<span className="text-sm text-foreground group-hover:text-foreground/80">{label}</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={events[key]}
|
||||
onChange={(e) => {
|
||||
const updated = { ...events, [key]: e.target.checked };
|
||||
setEvents(updated);
|
||||
saveTelegram(undefined, undefined, undefined, updated);
|
||||
}}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-muted rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting || !botToken || !chatId || !enabled}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
||||
{t("settings.testing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="refresh" size="sm" className="mr-2" />
|
||||
{t("settings.testConnection")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<span className={`text-sm font-medium ${testResult.success ? "text-success" : "text-destructive"}`}>
|
||||
{testResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -543,6 +543,33 @@ const en: Record<TranslationKey, string> = {
|
||||
"settings.qbittorrentUsername": "Username",
|
||||
"settings.qbittorrentPassword": "Password",
|
||||
|
||||
// Settings - Telegram Notifications
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.telegram": "Telegram",
|
||||
"settings.telegramDesc": "Receive Telegram notifications for scans, errors, and metadata linking.",
|
||||
"settings.botToken": "Bot Token",
|
||||
"settings.botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||
"settings.chatId": "Chat ID",
|
||||
"settings.chatIdPlaceholder": "123456789",
|
||||
"settings.telegramEnabled": "Enable Telegram notifications",
|
||||
"settings.telegramEvents": "Events",
|
||||
"settings.eventCategoryScan": "Scans",
|
||||
"settings.eventCategoryThumbnail": "Thumbnails",
|
||||
"settings.eventCategoryConversion": "CBR → CBZ Conversion",
|
||||
"settings.eventCategoryMetadata": "Metadata",
|
||||
"settings.eventCompleted": "Completed",
|
||||
"settings.eventFailed": "Failed",
|
||||
"settings.eventCancelled": "Cancelled",
|
||||
"settings.eventLinked": "Linked",
|
||||
"settings.eventBatchCompleted": "Batch completed",
|
||||
"settings.eventBatchFailed": "Batch failed",
|
||||
"settings.eventRefreshCompleted": "Refresh completed",
|
||||
"settings.eventRefreshFailed": "Refresh failed",
|
||||
"settings.telegramHelp": "How to get the required information?",
|
||||
"settings.telegramHelpBot": "Open Telegram, search for <b>@BotFather</b>, send <code>/newbot</code> and follow the instructions. Copy the token it gives you.",
|
||||
"settings.telegramHelpChat": "Send a message to your bot, then open <code>https://api.telegram.org/bot<TOKEN>/getUpdates</code> in your browser. The <b>chat id</b> is in <code>message.chat.id</code>.",
|
||||
"settings.telegramHelpGroup": "For a group: add the bot to the group, send a message, then check the same URL. Group IDs are negative (e.g. <code>-123456789</code>).",
|
||||
|
||||
// Settings - Language
|
||||
"settings.language": "Language",
|
||||
"settings.languageDesc": "Choose the interface language",
|
||||
|
||||
@@ -541,6 +541,33 @@ const fr = {
|
||||
"settings.qbittorrentUsername": "Nom d'utilisateur",
|
||||
"settings.qbittorrentPassword": "Mot de passe",
|
||||
|
||||
// Settings - Telegram Notifications
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.telegram": "Telegram",
|
||||
"settings.telegramDesc": "Recevoir des notifications Telegram lors des scans, erreurs et liaisons de métadonnées.",
|
||||
"settings.botToken": "Bot Token",
|
||||
"settings.botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||
"settings.chatId": "Chat ID",
|
||||
"settings.chatIdPlaceholder": "123456789",
|
||||
"settings.telegramEnabled": "Activer les notifications Telegram",
|
||||
"settings.telegramEvents": "Événements",
|
||||
"settings.eventCategoryScan": "Scans",
|
||||
"settings.eventCategoryThumbnail": "Miniatures",
|
||||
"settings.eventCategoryConversion": "Conversion CBR → CBZ",
|
||||
"settings.eventCategoryMetadata": "Métadonnées",
|
||||
"settings.eventCompleted": "Terminé",
|
||||
"settings.eventFailed": "Échoué",
|
||||
"settings.eventCancelled": "Annulé",
|
||||
"settings.eventLinked": "Liée",
|
||||
"settings.eventBatchCompleted": "Batch terminé",
|
||||
"settings.eventBatchFailed": "Batch échoué",
|
||||
"settings.eventRefreshCompleted": "Rafraîchissement terminé",
|
||||
"settings.eventRefreshFailed": "Rafraîchissement échoué",
|
||||
"settings.telegramHelp": "Comment obtenir les informations ?",
|
||||
"settings.telegramHelpBot": "Ouvrez Telegram, recherchez <b>@BotFather</b>, envoyez <code>/newbot</code> et suivez les instructions. Copiez le token fourni.",
|
||||
"settings.telegramHelpChat": "Envoyez un message à votre bot, puis ouvrez <code>https://api.telegram.org/bot<TOKEN>/getUpdates</code> dans votre navigateur. Le <b>chat id</b> apparaît dans <code>message.chat.id</code>.",
|
||||
"settings.telegramHelpGroup": "Pour un groupe : ajoutez le bot au groupe, envoyez un message, puis consultez la même URL. Les IDs de groupe sont négatifs (ex: <code>-123456789</code>).",
|
||||
|
||||
// Settings - Language
|
||||
"settings.language": "Langue",
|
||||
"settings.languageDesc": "Choisir la langue de l'interface",
|
||||
|
||||
@@ -14,6 +14,7 @@ futures = "0.3"
|
||||
image.workspace = true
|
||||
jpeg-decoder.workspace = true
|
||||
num_cpus.workspace = true
|
||||
notifications = { path = "../../crates/notifications" }
|
||||
parsers = { path = "../../crates/parsers" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -6,13 +6,15 @@ COPY Cargo.toml ./
|
||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||
COPY crates/notifications/Cargo.toml crates/notifications/Cargo.toml
|
||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||
|
||||
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/parsers/src && \
|
||||
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/notifications/src crates/parsers/src && \
|
||||
echo "fn main() {}" > apps/api/src/main.rs && \
|
||||
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||
echo "" > apps/indexer/src/lib.rs && \
|
||||
echo "" > crates/core/src/lib.rs && \
|
||||
echo "" > crates/notifications/src/lib.rs && \
|
||||
echo "" > crates/parsers/src/lib.rs
|
||||
|
||||
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
||||
@@ -25,12 +27,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
COPY apps/api/src apps/api/src
|
||||
COPY apps/indexer/src apps/indexer/src
|
||||
COPY crates/core/src crates/core/src
|
||||
COPY crates/notifications/src crates/notifications/src
|
||||
COPY crates/parsers/src crates/parsers/src
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/target \
|
||||
touch apps/indexer/src/main.rs crates/core/src/lib.rs crates/parsers/src/lib.rs && \
|
||||
touch apps/indexer/src/main.rs crates/core/src/lib.rs crates/notifications/src/lib.rs crates/parsers/src/lib.rs && \
|
||||
cargo build --release -p indexer && \
|
||||
cp /app/target/release/indexer /usr/local/bin/indexer
|
||||
|
||||
|
||||
@@ -328,6 +328,7 @@ pub async fn process_job(
|
||||
removed_files: 0,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
new_series: 0,
|
||||
};
|
||||
|
||||
let mut total_processed_count = 0i32;
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::{
|
||||
utils,
|
||||
AppState,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct JobStats {
|
||||
@@ -22,6 +23,7 @@ pub struct JobStats {
|
||||
pub removed_files: usize,
|
||||
pub errors: usize,
|
||||
pub warnings: usize,
|
||||
pub new_series: usize,
|
||||
}
|
||||
|
||||
const BATCH_SIZE: usize = 100;
|
||||
@@ -106,6 +108,18 @@ pub async fn scan_library_discovery(
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// Track existing series names for new_series counting
|
||||
let existing_series: HashSet<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT COALESCE(NULLIF(series, ''), 'unclassified') FROM books WHERE library_id = $1",
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut seen_new_series: HashSet<String> = HashSet::new();
|
||||
|
||||
let mut seen: HashMap<String, bool> = HashMap::new();
|
||||
let mut library_processed_count = 0i32;
|
||||
let mut last_progress_update = std::time::Instant::now();
|
||||
@@ -382,6 +396,12 @@ pub async fn scan_library_discovery(
|
||||
let book_id = Uuid::new_v4();
|
||||
let file_id = Uuid::new_v4();
|
||||
|
||||
// Track new series
|
||||
let series_key = parsed.series.as_deref().unwrap_or("unclassified").to_string();
|
||||
if !existing_series.contains(&series_key) && seen_new_series.insert(series_key) {
|
||||
stats.new_series += 1;
|
||||
}
|
||||
|
||||
books_to_insert.push(BookInsert {
|
||||
book_id,
|
||||
library_id,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::time::Duration;
|
||||
use sqlx::Row;
|
||||
use tracing::{error, info, trace};
|
||||
use uuid::Uuid;
|
||||
use crate::{job, scheduler, watcher, AppState};
|
||||
|
||||
pub async fn run_worker(state: AppState, interval_seconds: u64) {
|
||||
let wait = Duration::from_secs(interval_seconds.max(1));
|
||||
|
||||
|
||||
// Cleanup stale jobs from previous runs
|
||||
if let Err(err) = job::cleanup_stale_jobs(&state.pool).await {
|
||||
error!("[CLEANUP] Failed to cleanup stale jobs: {}", err);
|
||||
@@ -34,21 +36,168 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
|
||||
}
|
||||
});
|
||||
|
||||
async fn load_job_info(
|
||||
pool: &sqlx::PgPool,
|
||||
job_id: Uuid,
|
||||
library_id: Option<Uuid>,
|
||||
) -> (String, Option<String>, Option<String>) {
|
||||
let row = sqlx::query("SELECT type, book_id FROM index_jobs WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let (job_type, book_id): (String, Option<Uuid>) = match row {
|
||||
Some(r) => (r.get("type"), r.get("book_id")),
|
||||
None => ("unknown".to_string(), None),
|
||||
};
|
||||
|
||||
let library_name: Option<String> = if let Some(lib_id) = library_id {
|
||||
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(lib_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let book_title: Option<String> = if let Some(bid) = book_id {
|
||||
sqlx::query_scalar("SELECT title FROM books WHERE id = $1")
|
||||
.bind(bid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(job_type, library_name, book_title)
|
||||
}
|
||||
|
||||
async fn load_scan_stats(pool: &sqlx::PgPool, job_id: Uuid) -> notifications::ScanStats {
|
||||
let row = sqlx::query("SELECT stats_json FROM index_jobs WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some(row) = row {
|
||||
if let Ok(val) = row.try_get::<serde_json::Value, _>("stats_json") {
|
||||
return notifications::ScanStats {
|
||||
scanned_files: val.get("scanned_files").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
indexed_files: val.get("indexed_files").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
removed_files: val.get("removed_files").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
new_series: val.get("new_series").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
errors: val.get("errors").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
notifications::ScanStats {
|
||||
scanned_files: 0,
|
||||
indexed_files: 0,
|
||||
removed_files: 0,
|
||||
new_series: 0,
|
||||
errors: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_completed_event(
|
||||
job_type: &str,
|
||||
library_name: Option<String>,
|
||||
book_title: Option<String>,
|
||||
stats: notifications::ScanStats,
|
||||
duration_seconds: u64,
|
||||
) -> notifications::NotificationEvent {
|
||||
match notifications::job_type_category(job_type) {
|
||||
"thumbnail" => notifications::NotificationEvent::ThumbnailCompleted {
|
||||
job_type: job_type.to_string(),
|
||||
library_name,
|
||||
duration_seconds,
|
||||
},
|
||||
"conversion" => notifications::NotificationEvent::ConversionCompleted {
|
||||
library_name,
|
||||
book_title,
|
||||
},
|
||||
_ => notifications::NotificationEvent::ScanCompleted {
|
||||
job_type: job_type.to_string(),
|
||||
library_name,
|
||||
stats,
|
||||
duration_seconds,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_failed_event(
|
||||
job_type: &str,
|
||||
library_name: Option<String>,
|
||||
book_title: Option<String>,
|
||||
error: String,
|
||||
) -> notifications::NotificationEvent {
|
||||
match notifications::job_type_category(job_type) {
|
||||
"thumbnail" => notifications::NotificationEvent::ThumbnailFailed {
|
||||
job_type: job_type.to_string(),
|
||||
library_name,
|
||||
error,
|
||||
},
|
||||
"conversion" => notifications::NotificationEvent::ConversionFailed {
|
||||
library_name,
|
||||
book_title,
|
||||
error,
|
||||
},
|
||||
_ => notifications::NotificationEvent::ScanFailed {
|
||||
job_type: job_type.to_string(),
|
||||
library_name,
|
||||
error,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
match job::claim_next_job(&state.pool).await {
|
||||
Ok(Some((job_id, library_id))) => {
|
||||
info!("[INDEXER] Starting job {} library={:?}", job_id, library_id);
|
||||
let started_at = std::time::Instant::now();
|
||||
let (job_type, library_name, book_title) =
|
||||
load_job_info(&state.pool, job_id, library_id).await;
|
||||
|
||||
if let Err(err) = job::process_job(&state, job_id, library_id).await {
|
||||
let err_str = err.to_string();
|
||||
if err_str.contains("cancelled") || err_str.contains("Cancelled") {
|
||||
info!("[INDEXER] Job {} was cancelled by user", job_id);
|
||||
// Status is already 'cancelled' in DB, don't change it
|
||||
notifications::notify(
|
||||
state.pool.clone(),
|
||||
notifications::NotificationEvent::ScanCancelled {
|
||||
job_type: job_type.clone(),
|
||||
library_name: library_name.clone(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
error!("[INDEXER] Job {} failed: {}", job_id, err);
|
||||
let _ = job::fail_job(&state.pool, job_id, &err_str).await;
|
||||
notifications::notify(
|
||||
state.pool.clone(),
|
||||
build_failed_event(&job_type, library_name.clone(), book_title.clone(), err_str),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
info!("[INDEXER] Job {} completed", job_id);
|
||||
let stats = load_scan_stats(&state.pool, job_id).await;
|
||||
notifications::notify(
|
||||
state.pool.clone(),
|
||||
build_completed_event(
|
||||
&job_type,
|
||||
library_name.clone(),
|
||||
book_title.clone(),
|
||||
stats,
|
||||
started_at.elapsed().as_secs(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
|
||||
Reference in New Issue
Block a user