diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index c400f9b..416307e 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -12,6 +12,7 @@ mod metadata_providers; mod api_middleware; mod openapi; mod pages; +mod prowlarr; mod reading_progress; mod search; 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/:id", delete(tokens::revoke_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/reports", get(komga::list_sync_reports)) .route("/komga/reports/:id", get(komga::get_sync_report)) diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index 9335d19..26fd1f4 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -58,6 +58,8 @@ use utoipa::OpenApi; crate::settings::list_status_mappings, crate::settings::upsert_status_mapping, crate::settings::delete_status_mapping, + crate::prowlarr::search_prowlarr, + crate::prowlarr::test_prowlarr, ), components( schemas( @@ -122,6 +124,11 @@ use utoipa::OpenApi; crate::metadata::ExternalMetadataLinkDto, crate::metadata::MissingBooksDto, crate::metadata::MissingBookItem, + crate::prowlarr::ProwlarrSearchRequest, + crate::prowlarr::ProwlarrRelease, + crate::prowlarr::ProwlarrCategory, + crate::prowlarr::ProwlarrSearchResponse, + crate::prowlarr::ProwlarrTestResponse, ErrorResponse, ) ), @@ -135,6 +142,7 @@ use utoipa::OpenApi; (name = "indexing", description = "Search index management and job control (Admin only)"), (name = "tokens", description = "API token management (Admin only)"), (name = "settings", description = "Application settings and cache management (Admin only)"), + (name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"), ), modifiers(&SecurityAddon) )] diff --git a/apps/api/src/prowlarr.rs b/apps/api/src/prowlarr.rs new file mode 100644 index 0000000..ba1dcee --- /dev/null +++ b/apps/api/src/prowlarr.rs @@ -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, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProwlarrRelease { + pub guid: String, + pub title: String, + pub size: i64, + pub download_url: Option, + pub indexer: Option, + pub seeders: Option, + pub leechers: Option, + pub publish_date: Option, + pub protocol: Option, + pub info_url: Option, + pub categories: Option>, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProwlarrCategory { + pub id: i32, + pub name: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ProwlarrSearchResponse { + pub results: Vec, + pub query: String, +} + +#[derive(Serialize, ToSchema)] +pub struct ProwlarrTestResponse { + pub success: bool, + pub message: String, + pub indexer_count: Option, +} + +// ─── Config helper ────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct ProwlarrConfig { + url: String, + api_key: String, + categories: Option>, +} + +async fn load_prowlarr_config( + pool: &sqlx::PgPool, +) -> Result<(String, String, Vec), 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, + Json(body): Json, +) -> Result, 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 = 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, +) -> Result, 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 = 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, + })), + } +} diff --git a/apps/backoffice/app/api/prowlarr/search/route.ts b/apps/backoffice/app/api/prowlarr/search/route.ts new file mode 100644 index 0000000..0d514fe --- /dev/null +++ b/apps/backoffice/app/api/prowlarr/search/route.ts @@ -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 }); + } +} diff --git a/apps/backoffice/app/api/prowlarr/test/route.ts b/apps/backoffice/app/api/prowlarr/test/route.ts new file mode 100644 index 0000000..dd1559c --- /dev/null +++ b/apps/backoffice/app/api/prowlarr/test/route.ts @@ -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 }); + } +} diff --git a/apps/backoffice/app/components/ProwlarrSearchModal.tsx b/apps/backoffice/app/components/ProwlarrSearchModal.tsx new file mode 100644 index 0000000..273ea16 --- /dev/null +++ b/apps/backoffice/app/components/ProwlarrSearchModal.tsx @@ -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(null); + const [isSearching, setIsSearching] = useState(false); + const [results, setResults] = useState([]); + const [query, setQuery] = useState(""); + const [error, setError] = useState(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( + <> +
+
+
+ {/* Header */} +
+

{t("prowlarr.modalTitle")}

+ +
+ +
+ {/* Missing volumes + re-search */} +
+ + {missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => ( + + ))} +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Searching indicator */} + {isSearching && ( +
+ + {t("prowlarr.searching")} +
+ )} + + {/* Results */} + {!isSearching && results.length > 0 && ( +
+

+ {t("prowlarr.resultCount", { count: results.length, plural: results.length !== 1 ? "s" : "" })} + {query && ({query})} +

+
+ + + + + + + + + + + + + + {results.map((release, i) => ( + + + + + + + + + + ))} + +
{t("prowlarr.columnTitle")}{t("prowlarr.columnIndexer")}{t("prowlarr.columnSize")}{t("prowlarr.columnSeeders")}{t("prowlarr.columnLeechers")}{t("prowlarr.columnProtocol")}
+ + {release.title} + + + {release.indexer || "—"} + + {release.size > 0 ? formatSize(release.size) : "—"} + + {release.seeders != null ? ( + 0 ? "text-green-500 font-medium" : "text-muted-foreground"}> + {release.seeders} + + ) : "—"} + + {release.leechers != null ? release.leechers : "—"} + + {release.protocol && ( + + {release.protocol} + + )} + +
+ {release.downloadUrl && ( + + + + + + )} + {release.infoUrl && ( + + + + )} +
+
+
+
+ )} + + {/* No results */} + {!isSearching && !error && query && results.length === 0 && ( +

{t("prowlarr.noResults")}

+ )} +
+
+
+ , + document.body, + ) + : null; + + return ( + <> + + {modal} + + ); +} diff --git a/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx b/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx index 826e5c1..5c0a0a0 100644 --- a/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx +++ b/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx @@ -4,6 +4,7 @@ import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButto import { MarkBookReadButton } from "../../../../components/MarkBookReadButton"; import { EditSeriesForm } from "../../../../components/EditSeriesForm"; import { MetadataSearchModal } from "../../../../components/MetadataSearchModal"; +import { ProwlarrSearchModal } from "../../../../components/ProwlarrSearchModal"; import { OffsetPagination } from "../../../../components/ui"; import { SafeHtml } from "../../../../components/SafeHtml"; import Image from "next/image"; @@ -138,7 +139,7 @@ export default async function SeriesDetailPage({ - {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" : "" })} {/* Progress bar */} @@ -177,6 +178,10 @@ export default async function SeriesDetailPage({ existingLink={existingLink} initialMissing={missingData} /> +
diff --git a/apps/backoffice/app/libraries/[id]/series/page.tsx b/apps/backoffice/app/libraries/[id]/series/page.tsx index 4c4ece3..45b0c44 100644 --- a/apps/backoffice/app/libraries/[id]/series/page.tsx +++ b/apps/backoffice/app/libraries/[id]/series/page.tsx @@ -95,7 +95,7 @@ export default async function LibrarySeriesPage({

- {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" : "" })}

+ {/* Prowlarr */} + + {/* Komga Sync */} @@ -1217,3 +1220,136 @@ function StatusMappingsCard() { ); } + +// --------------------------------------------------------------------------- +// Prowlarr sub-component +// --------------------------------------------------------------------------- + +function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise }) { + 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 ( + + + + + {t("settings.prowlarr")} + + {t("settings.prowlarrDesc")} + + +
+ + + + setProwlarrUrl(e.target.value)} + onBlur={() => saveProwlarr()} + /> + + + + + + setProwlarrApiKey(e.target.value)} + onBlur={() => saveProwlarr()} + /> + + + + + + setProwlarrCategories(e.target.value)} + onBlur={() => saveProwlarr()} + /> +

{t("settings.prowlarrCategoriesHelp")}

+
+
+ +
+ + {testResult && ( + + {testResult.message} + + )} +
+
+
+
+ ); +} diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index e415b98..a1b1e74 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -876,3 +876,37 @@ export async function getMetadataBatchResults(jobId: string, status?: string) { const params = status ? `?status=${status}` : ""; return apiFetch(`/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; +}; diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 6f2a642..ba9e861 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -458,6 +458,40 @@ const en: Record = { "settings.newTargetPlaceholder": "New target status (e.g. hiatus)", "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": "Language", "settings.languageDesc": "Choose the interface language", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 187e5d0..789590c 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -456,6 +456,40 @@ const fr = { "settings.newTargetPlaceholder": "Nouveau statut cible (ex: hiatus)", "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": "Langue", "settings.languageDesc": "Choisir la langue de l'interface", diff --git a/infra/migrations/0043_add_prowlarr_settings.sql b/infra/migrations/0043_add_prowlarr_settings.sql new file mode 100644 index 0000000..da025e1 --- /dev/null +++ b/infra/migrations/0043_add_prowlarr_settings.sql @@ -0,0 +1,3 @@ +INSERT INTO app_settings (key, value) VALUES + ('prowlarr', '{"url": "", "api_key": "", "categories": [7030, 7020]}') +ON CONFLICT DO NOTHING;