feat: add Prowlarr integration for manual release search
Add Prowlarr indexer integration (step 1: config + manual search). Allows searching for comics/ebooks releases on Prowlarr indexers directly from the series detail page, with download links and per-volume search for missing books. - Backend: new prowlarr module with search and test endpoints - Migration: add prowlarr settings (url, api_key, categories) - Settings UI: Prowlarr config card with test connection button - ProwlarrSearchModal: auto-search on open, missing volumes shortcuts - Fix series.readCount i18n plural parameter on series pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ mod metadata_providers;
|
|||||||
mod api_middleware;
|
mod api_middleware;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod pages;
|
mod pages;
|
||||||
|
mod prowlarr;
|
||||||
mod reading_progress;
|
mod reading_progress;
|
||||||
mod search;
|
mod search;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -104,6 +105,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||||
|
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
||||||
|
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
||||||
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||||
.route("/komga/reports", get(komga::list_sync_reports))
|
.route("/komga/reports", get(komga::list_sync_reports))
|
||||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ use utoipa::OpenApi;
|
|||||||
crate::settings::list_status_mappings,
|
crate::settings::list_status_mappings,
|
||||||
crate::settings::upsert_status_mapping,
|
crate::settings::upsert_status_mapping,
|
||||||
crate::settings::delete_status_mapping,
|
crate::settings::delete_status_mapping,
|
||||||
|
crate::prowlarr::search_prowlarr,
|
||||||
|
crate::prowlarr::test_prowlarr,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -122,6 +124,11 @@ use utoipa::OpenApi;
|
|||||||
crate::metadata::ExternalMetadataLinkDto,
|
crate::metadata::ExternalMetadataLinkDto,
|
||||||
crate::metadata::MissingBooksDto,
|
crate::metadata::MissingBooksDto,
|
||||||
crate::metadata::MissingBookItem,
|
crate::metadata::MissingBookItem,
|
||||||
|
crate::prowlarr::ProwlarrSearchRequest,
|
||||||
|
crate::prowlarr::ProwlarrRelease,
|
||||||
|
crate::prowlarr::ProwlarrCategory,
|
||||||
|
crate::prowlarr::ProwlarrSearchResponse,
|
||||||
|
crate::prowlarr::ProwlarrTestResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -135,6 +142,7 @@ use utoipa::OpenApi;
|
|||||||
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
||||||
(name = "tokens", description = "API token management (Admin only)"),
|
(name = "tokens", description = "API token management (Admin only)"),
|
||||||
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
||||||
|
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon)
|
modifiers(&SecurityAddon)
|
||||||
)]
|
)]
|
||||||
|
|||||||
202
apps/api/src/prowlarr.rs
Normal file
202
apps/api/src/prowlarr.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct ProwlarrSearchRequest {
|
||||||
|
pub series_name: String,
|
||||||
|
pub volume_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProwlarrRelease {
|
||||||
|
pub guid: String,
|
||||||
|
pub title: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub download_url: Option<String>,
|
||||||
|
pub indexer: Option<String>,
|
||||||
|
pub seeders: Option<i32>,
|
||||||
|
pub leechers: Option<i32>,
|
||||||
|
pub publish_date: Option<String>,
|
||||||
|
pub protocol: Option<String>,
|
||||||
|
pub info_url: Option<String>,
|
||||||
|
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProwlarrCategory {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ProwlarrSearchResponse {
|
||||||
|
pub results: Vec<ProwlarrRelease>,
|
||||||
|
pub query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ProwlarrTestResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub indexer_count: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ProwlarrConfig {
|
||||||
|
url: String,
|
||||||
|
api_key: String,
|
||||||
|
categories: Option<Vec<i32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_prowlarr_config(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> Result<(String, String, Vec<i32>), ApiError> {
|
||||||
|
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'prowlarr'")
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let row = row.ok_or_else(|| ApiError::bad_request("Prowlarr is not configured"))?;
|
||||||
|
let value: serde_json::Value = row.get("value");
|
||||||
|
let config: ProwlarrConfig = serde_json::from_value(value)
|
||||||
|
.map_err(|e| ApiError::internal(format!("invalid prowlarr config: {e}")))?;
|
||||||
|
|
||||||
|
if config.url.is_empty() || config.api_key.is_empty() {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"Prowlarr URL and API key must be configured in settings",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = config.url.trim_end_matches('/').to_string();
|
||||||
|
let categories = config.categories.unwrap_or_else(|| vec![7030, 7020]);
|
||||||
|
|
||||||
|
Ok((url, config.api_key, categories))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Search for releases on Prowlarr
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/prowlarr/search",
|
||||||
|
tag = "prowlarr",
|
||||||
|
request_body = ProwlarrSearchRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = ProwlarrSearchResponse),
|
||||||
|
(status = 400, description = "Bad request or Prowlarr not configured"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Prowlarr connection error"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn search_prowlarr(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<ProwlarrSearchRequest>,
|
||||||
|
) -> Result<Json<ProwlarrSearchResponse>, ApiError> {
|
||||||
|
let (url, api_key, categories) = load_prowlarr_config(&state.pool).await?;
|
||||||
|
|
||||||
|
let query = if let Some(vol) = body.volume_number {
|
||||||
|
format!("\"{}\" {}", body.series_name, vol)
|
||||||
|
} else {
|
||||||
|
format!("\"{}\"", body.series_name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||||
|
|
||||||
|
let mut params: Vec<(&str, String)> = vec![
|
||||||
|
("query", query.clone()),
|
||||||
|
("type", "search".to_string()),
|
||||||
|
];
|
||||||
|
for cat in &categories {
|
||||||
|
params.push(("categories", cat.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{url}/api/v1/search"))
|
||||||
|
.query(¶ms)
|
||||||
|
.header("X-Api-Key", &api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Prowlarr request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ApiError::internal(format!(
|
||||||
|
"Prowlarr returned {status}: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let results: Vec<ProwlarrRelease> = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Failed to parse Prowlarr response: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(ProwlarrSearchResponse { results, query }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test connection to Prowlarr
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/prowlarr/test",
|
||||||
|
tag = "prowlarr",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = ProwlarrTestResponse),
|
||||||
|
(status = 400, description = "Prowlarr not configured"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn test_prowlarr(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<ProwlarrTestResponse>, ApiError> {
|
||||||
|
let (url, api_key, _categories) = load_prowlarr_config(&state.pool).await?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{url}/api/v1/indexer"))
|
||||||
|
.header("X-Api-Key", &api_key)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match resp {
|
||||||
|
Ok(r) if r.status().is_success() => {
|
||||||
|
let indexers: Vec<serde_json::Value> = r.json().await.unwrap_or_default();
|
||||||
|
Ok(Json(ProwlarrTestResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Connected successfully ({} indexers)", indexers.len()),
|
||||||
|
indexer_count: Some(indexers.len() as i32),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Ok(r) => {
|
||||||
|
let status = r.status();
|
||||||
|
let text = r.text().await.unwrap_or_default();
|
||||||
|
Ok(Json(ProwlarrTestResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Prowlarr returned {status}: {text}"),
|
||||||
|
indexer_count: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(e) => Ok(Json(ProwlarrTestResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Connection failed: {e}"),
|
||||||
|
indexer_count: None,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/prowlarr/search/route.ts
Normal file
16
apps/backoffice/app/api/prowlarr/search/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch("/prowlarr/search", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to search Prowlarr";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/backoffice/app/api/prowlarr/test/route.ts
Normal file
12
apps/backoffice/app/api/prowlarr/test/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch("/prowlarr/test");
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to test Prowlarr connection";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
266
apps/backoffice/app/components/ProwlarrSearchModal.tsx
Normal file
266
apps/backoffice/app/components/ProwlarrSearchModal.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
import type { ProwlarrRelease, ProwlarrSearchResponse } from "../../lib/api";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
|
interface MissingBookItem {
|
||||||
|
title: string | null;
|
||||||
|
volume_number: number | null;
|
||||||
|
external_book_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProwlarrSearchModalProps {
|
||||||
|
seriesName: string;
|
||||||
|
missingBooks: MissingBookItem[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + " GB";
|
||||||
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB";
|
||||||
|
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + " KB";
|
||||||
|
return bytes + " B";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearchModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isConfigured, setIsConfigured] = useState<boolean | null>(null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [results, setResults] = useState<ProwlarrRelease[]>([]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Check if Prowlarr is configured on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings/prowlarr")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
setIsConfigured(!!(data && data.api_key && data.api_key.trim()));
|
||||||
|
})
|
||||||
|
.catch(() => setIsConfigured(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doSearch = useCallback(async (searchSeriesName: string, volumeNumber?: number) => {
|
||||||
|
setIsSearching(true);
|
||||||
|
setError(null);
|
||||||
|
setResults([]);
|
||||||
|
try {
|
||||||
|
const body: { series_name: string; volume_number?: number } = { series_name: searchSeriesName };
|
||||||
|
if (volumeNumber !== undefined) body.volume_number = volumeNumber;
|
||||||
|
const resp = await fetch("/api/prowlarr/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
} else {
|
||||||
|
const searchResp = data as ProwlarrSearchResponse;
|
||||||
|
setResults(searchResp.results);
|
||||||
|
setQuery(searchResp.query);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t("prowlarr.searchError"));
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
setIsOpen(true);
|
||||||
|
setResults([]);
|
||||||
|
setError(null);
|
||||||
|
setQuery("");
|
||||||
|
// Auto-search the series on open
|
||||||
|
doSearch(seriesName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render button if not configured
|
||||||
|
if (isConfigured === false) return null;
|
||||||
|
if (isConfigured === null) return null;
|
||||||
|
|
||||||
|
const modal = isOpen
|
||||||
|
? createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||||
|
<h3 className="font-semibold text-foreground">{t("prowlarr.modalTitle")}</h3>
|
||||||
|
<button type="button" onClick={handleClose}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* Missing volumes + re-search */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => doSearch(seriesName)}
|
||||||
|
disabled={isSearching}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-primary/50 bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="search" size="sm" />
|
||||||
|
{seriesName}
|
||||||
|
</button>
|
||||||
|
{missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => doSearch(seriesName, book.volume_number ?? undefined)}
|
||||||
|
disabled={isSearching}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border bg-muted/30 hover:bg-muted/50 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="search" size="sm" />
|
||||||
|
{book.title || `Vol. ${book.volume_number}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Searching indicator */}
|
||||||
|
{isSearching && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
|
<Icon name="spinner" size="sm" className="animate-spin" />
|
||||||
|
{t("prowlarr.searching")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{!isSearching && results.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
{t("prowlarr.resultCount", { count: results.length, plural: results.length !== 1 ? "s" : "" })}
|
||||||
|
{query && <span className="ml-1 text-xs opacity-70">({query})</span>}
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 text-left">
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnTitle")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnIndexer")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground text-right">{t("prowlarr.columnSize")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnSeeders")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnLeechers")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnProtocol")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground text-right"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{results.map((release, i) => (
|
||||||
|
<tr key={release.guid || i} className="hover:bg-muted/20 transition-colors">
|
||||||
|
<td className="px-3 py-2 max-w-[400px]">
|
||||||
|
<span className="truncate block" title={release.title}>
|
||||||
|
{release.title}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
||||||
|
{release.indexer || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted-foreground whitespace-nowrap">
|
||||||
|
{release.size > 0 ? formatSize(release.size) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{release.seeders != null ? (
|
||||||
|
<span className={release.seeders > 0 ? "text-green-500 font-medium" : "text-muted-foreground"}>
|
||||||
|
{release.seeders}
|
||||||
|
</span>
|
||||||
|
) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center text-muted-foreground">
|
||||||
|
{release.leechers != null ? release.leechers : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{release.protocol && (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
release.protocol === "torrent"
|
||||||
|
? "bg-blue-500/15 text-blue-600"
|
||||||
|
: "bg-amber-500/15 text-amber-600"
|
||||||
|
}`}>
|
||||||
|
{release.protocol}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{release.downloadUrl && (
|
||||||
|
<a
|
||||||
|
href={release.downloadUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-primary hover:bg-primary/10 transition-colors"
|
||||||
|
title={t("prowlarr.download")}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M8 2v8M4 7l4 4 4-4M2 13h12" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{release.infoUrl && (
|
||||||
|
<a
|
||||||
|
href={release.infoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
title={t("prowlarr.info")}
|
||||||
|
>
|
||||||
|
<Icon name="externalLink" size="sm" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results */}
|
||||||
|
{!isSearching && !error && query && results.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("prowlarr.noResults")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpen}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="search" size="sm" />
|
||||||
|
{t("prowlarr.searchButton")}
|
||||||
|
</button>
|
||||||
|
{modal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButto
|
|||||||
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
||||||
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
||||||
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
|
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
|
||||||
|
import { ProwlarrSearchModal } from "../../../../components/ProwlarrSearchModal";
|
||||||
import { OffsetPagination } from "../../../../components/ui";
|
import { OffsetPagination } from "../../../../components/ui";
|
||||||
import { SafeHtml } from "../../../../components/SafeHtml";
|
import { SafeHtml } from "../../../../components/SafeHtml";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -138,7 +139,7 @@ export default async function SeriesDetailPage({
|
|||||||
</span>
|
</span>
|
||||||
<span className="w-px h-4 bg-border" />
|
<span className="w-px h-4 bg-border" />
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("series.readCount", { read: String(booksReadCount), total: String(booksPage.total) })}
|
{t("series.readCount", { read: String(booksReadCount), total: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
@@ -177,6 +178,10 @@ export default async function SeriesDetailPage({
|
|||||||
existingLink={existingLink}
|
existingLink={existingLink}
|
||||||
initialMissing={missingData}
|
initialMissing={missingData}
|
||||||
/>
|
/>
|
||||||
|
<ProwlarrSearchModal
|
||||||
|
seriesName={seriesName}
|
||||||
|
missingBooks={missingData?.missing_books ?? null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default async function LibrarySeriesPage({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count) })}
|
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||||
</p>
|
</p>
|
||||||
<MarkSeriesReadButton
|
<MarkSeriesReadButton
|
||||||
seriesName={s.name}
|
seriesName={s.name}
|
||||||
|
|||||||
@@ -580,6 +580,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
{/* Status Mappings */}
|
{/* Status Mappings */}
|
||||||
<StatusMappingsCard />
|
<StatusMappingsCard />
|
||||||
|
|
||||||
|
{/* Prowlarr */}
|
||||||
|
<ProwlarrCard handleUpdateSetting={handleUpdateSetting} />
|
||||||
|
|
||||||
{/* Komga Sync */}
|
{/* Komga Sync */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1217,3 +1220,136 @@ function StatusMappingsCard() {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prowlarr sub-component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [prowlarrUrl, setProwlarrUrl] = useState("");
|
||||||
|
const [prowlarrApiKey, setProwlarrApiKey] = useState("");
|
||||||
|
const [prowlarrCategories, setProwlarrCategories] = useState("7030, 7020");
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings/prowlarr")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
if (data.url) setProwlarrUrl(data.url);
|
||||||
|
if (data.api_key) setProwlarrApiKey(data.api_key);
|
||||||
|
if (data.categories) setProwlarrCategories(data.categories.join(", "));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function saveProwlarr(url?: string, apiKey?: string, cats?: string) {
|
||||||
|
const categories = (cats ?? prowlarrCategories)
|
||||||
|
.split(",")
|
||||||
|
.map((s) => parseInt(s.trim()))
|
||||||
|
.filter((n) => !isNaN(n));
|
||||||
|
handleUpdateSetting("prowlarr", {
|
||||||
|
url: url ?? prowlarrUrl,
|
||||||
|
api_key: apiKey ?? prowlarrApiKey,
|
||||||
|
categories,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestConnection() {
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/prowlarr/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="search" size="md" />
|
||||||
|
{t("settings.prowlarr")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("settings.prowlarrDesc")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrUrl")}</label>
|
||||||
|
<FormInput
|
||||||
|
type="url"
|
||||||
|
placeholder={t("settings.prowlarrUrlPlaceholder")}
|
||||||
|
value={prowlarrUrl}
|
||||||
|
onChange={(e) => setProwlarrUrl(e.target.value)}
|
||||||
|
onBlur={() => saveProwlarr()}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label>
|
||||||
|
<FormInput
|
||||||
|
type="password"
|
||||||
|
placeholder={t("settings.prowlarrApiKeyPlaceholder")}
|
||||||
|
value={prowlarrApiKey}
|
||||||
|
onChange={(e) => setProwlarrApiKey(e.target.value)}
|
||||||
|
onBlur={() => saveProwlarr()}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrCategories")}</label>
|
||||||
|
<FormInput
|
||||||
|
type="text"
|
||||||
|
placeholder="7030, 7020"
|
||||||
|
value={prowlarrCategories}
|
||||||
|
onChange={(e) => setProwlarrCategories(e.target.value)}
|
||||||
|
onBlur={() => saveProwlarr()}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{t("settings.prowlarrCategoriesHelp")}</p>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting || !prowlarrUrl || !prowlarrApiKey}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -876,3 +876,37 @@ export async function getMetadataBatchResults(jobId: string, status?: string) {
|
|||||||
const params = status ? `?status=${status}` : "";
|
const params = status ? `?status=${status}` : "";
|
||||||
return apiFetch<MetadataBatchResultDto[]>(`/metadata/batch/${jobId}/results${params}`);
|
return apiFetch<MetadataBatchResultDto[]>(`/metadata/batch/${jobId}/results${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prowlarr
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ProwlarrCategory = {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProwlarrRelease = {
|
||||||
|
guid: string;
|
||||||
|
title: string;
|
||||||
|
size: number;
|
||||||
|
downloadUrl: string | null;
|
||||||
|
indexer: string | null;
|
||||||
|
seeders: number | null;
|
||||||
|
leechers: number | null;
|
||||||
|
publishDate: string | null;
|
||||||
|
protocol: string | null;
|
||||||
|
infoUrl: string | null;
|
||||||
|
categories: ProwlarrCategory[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProwlarrSearchResponse = {
|
||||||
|
results: ProwlarrRelease[];
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProwlarrTestResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
indexer_count: number | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -458,6 +458,40 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"settings.newTargetPlaceholder": "New target status (e.g. hiatus)",
|
"settings.newTargetPlaceholder": "New target status (e.g. hiatus)",
|
||||||
"settings.createTargetStatus": "Create status",
|
"settings.createTargetStatus": "Create status",
|
||||||
|
|
||||||
|
// Settings - Prowlarr
|
||||||
|
"settings.prowlarr": "Prowlarr",
|
||||||
|
"settings.prowlarrDesc": "Configure Prowlarr to search for releases on indexers (torrents/usenet). Only manual search is available for now.",
|
||||||
|
"settings.prowlarrUrl": "Prowlarr URL",
|
||||||
|
"settings.prowlarrUrlPlaceholder": "http://localhost:9696",
|
||||||
|
"settings.prowlarrApiKey": "API Key",
|
||||||
|
"settings.prowlarrApiKeyPlaceholder": "Prowlarr API key",
|
||||||
|
"settings.prowlarrCategories": "Categories",
|
||||||
|
"settings.prowlarrCategoriesHelp": "Comma-separated Newznab category IDs (7030 = Comics, 7020 = Ebooks)",
|
||||||
|
"settings.testConnection": "Test connection",
|
||||||
|
"settings.testing": "Testing...",
|
||||||
|
"settings.testSuccess": "Connection successful",
|
||||||
|
"settings.testFailed": "Connection failed",
|
||||||
|
|
||||||
|
// Prowlarr search modal
|
||||||
|
"prowlarr.searchButton": "Prowlarr",
|
||||||
|
"prowlarr.modalTitle": "Prowlarr Search",
|
||||||
|
"prowlarr.searchSeries": "Search series",
|
||||||
|
"prowlarr.searchVolume": "Search",
|
||||||
|
"prowlarr.searching": "Searching...",
|
||||||
|
"prowlarr.noResults": "No results found",
|
||||||
|
"prowlarr.resultCount": "{{count}} result{{plural}}",
|
||||||
|
"prowlarr.missingVolumes": "Missing volumes",
|
||||||
|
"prowlarr.columnTitle": "Title",
|
||||||
|
"prowlarr.columnIndexer": "Indexer",
|
||||||
|
"prowlarr.columnSize": "Size",
|
||||||
|
"prowlarr.columnSeeders": "Seeds",
|
||||||
|
"prowlarr.columnLeechers": "Peers",
|
||||||
|
"prowlarr.columnProtocol": "Protocol",
|
||||||
|
"prowlarr.searchError": "Search failed",
|
||||||
|
"prowlarr.notConfigured": "Prowlarr is not configured",
|
||||||
|
"prowlarr.download": "Download",
|
||||||
|
"prowlarr.info": "Info",
|
||||||
|
|
||||||
// Settings - Language
|
// Settings - Language
|
||||||
"settings.language": "Language",
|
"settings.language": "Language",
|
||||||
"settings.languageDesc": "Choose the interface language",
|
"settings.languageDesc": "Choose the interface language",
|
||||||
|
|||||||
@@ -456,6 +456,40 @@ const fr = {
|
|||||||
"settings.newTargetPlaceholder": "Nouveau statut cible (ex: hiatus)",
|
"settings.newTargetPlaceholder": "Nouveau statut cible (ex: hiatus)",
|
||||||
"settings.createTargetStatus": "Créer un statut",
|
"settings.createTargetStatus": "Créer un statut",
|
||||||
|
|
||||||
|
// Settings - Prowlarr
|
||||||
|
"settings.prowlarr": "Prowlarr",
|
||||||
|
"settings.prowlarrDesc": "Configurer Prowlarr pour rechercher des releases sur les indexeurs (torrents/usenet). Seule la recherche manuelle est disponible pour le moment.",
|
||||||
|
"settings.prowlarrUrl": "URL Prowlarr",
|
||||||
|
"settings.prowlarrUrlPlaceholder": "http://localhost:9696",
|
||||||
|
"settings.prowlarrApiKey": "Clé API",
|
||||||
|
"settings.prowlarrApiKeyPlaceholder": "Clé API Prowlarr",
|
||||||
|
"settings.prowlarrCategories": "Catégories",
|
||||||
|
"settings.prowlarrCategoriesHelp": "ID de catégories Newznab séparés par des virgules (7030 = Comics, 7020 = Ebooks)",
|
||||||
|
"settings.testConnection": "Tester la connexion",
|
||||||
|
"settings.testing": "Test en cours...",
|
||||||
|
"settings.testSuccess": "Connexion réussie",
|
||||||
|
"settings.testFailed": "Échec de la connexion",
|
||||||
|
|
||||||
|
// Prowlarr search modal
|
||||||
|
"prowlarr.searchButton": "Prowlarr",
|
||||||
|
"prowlarr.modalTitle": "Recherche Prowlarr",
|
||||||
|
"prowlarr.searchSeries": "Rechercher la série",
|
||||||
|
"prowlarr.searchVolume": "Rechercher",
|
||||||
|
"prowlarr.searching": "Recherche en cours...",
|
||||||
|
"prowlarr.noResults": "Aucun résultat trouvé",
|
||||||
|
"prowlarr.resultCount": "{{count}} résultat{{plural}}",
|
||||||
|
"prowlarr.missingVolumes": "Volumes manquants",
|
||||||
|
"prowlarr.columnTitle": "Titre",
|
||||||
|
"prowlarr.columnIndexer": "Indexeur",
|
||||||
|
"prowlarr.columnSize": "Taille",
|
||||||
|
"prowlarr.columnSeeders": "Seeds",
|
||||||
|
"prowlarr.columnLeechers": "Peers",
|
||||||
|
"prowlarr.columnProtocol": "Protocole",
|
||||||
|
"prowlarr.searchError": "Erreur lors de la recherche",
|
||||||
|
"prowlarr.notConfigured": "Prowlarr n'est pas configuré",
|
||||||
|
"prowlarr.download": "Télécharger",
|
||||||
|
"prowlarr.info": "Info",
|
||||||
|
|
||||||
// Settings - Language
|
// Settings - Language
|
||||||
"settings.language": "Langue",
|
"settings.language": "Langue",
|
||||||
"settings.languageDesc": "Choisir la langue de l'interface",
|
"settings.languageDesc": "Choisir la langue de l'interface",
|
||||||
|
|||||||
3
infra/migrations/0043_add_prowlarr_settings.sql
Normal file
3
infra/migrations/0043_add_prowlarr_settings.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
INSERT INTO app_settings (key, value) VALUES
|
||||||
|
('prowlarr', '{"url": "", "api_key": "", "categories": [7030, 7020]}')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
Reference in New Issue
Block a user