Compare commits
3 Commits
e14da4fc8d
...
c6ddd3e6c7
| Author | SHA1 | Date | |
|---|---|---|---|
| c6ddd3e6c7 | |||
| 504185f31f | |||
| acd0cce3f8 |
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "1.10.0"
|
version = "1.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1232,7 +1232,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexer"
|
name = "indexer"
|
||||||
version = "1.10.0"
|
version = "1.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1771,7 +1771,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parsers"
|
name = "parsers"
|
||||||
version = "1.10.0"
|
version = "1.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2906,7 +2906,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stripstream-core"
|
name = "stripstream-core"
|
||||||
version = "1.10.0"
|
version = "1.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "1.10.0"
|
version = "1.10.1"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::{error::ApiError, state::AppState};
|
|||||||
pub struct ProwlarrSearchRequest {
|
pub struct ProwlarrSearchRequest {
|
||||||
pub series_name: String,
|
pub series_name: String,
|
||||||
pub volume_number: Option<i32>,
|
pub volume_number: Option<i32>,
|
||||||
|
pub custom_query: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -104,7 +105,9 @@ pub async fn search_prowlarr(
|
|||||||
) -> Result<Json<ProwlarrSearchResponse>, ApiError> {
|
) -> Result<Json<ProwlarrSearchResponse>, ApiError> {
|
||||||
let (url, api_key, categories) = load_prowlarr_config(&state.pool).await?;
|
let (url, api_key, categories) = load_prowlarr_config(&state.pool).await?;
|
||||||
|
|
||||||
let query = if let Some(vol) = body.volume_number {
|
let query = if let Some(custom) = &body.custom_query {
|
||||||
|
custom.clone()
|
||||||
|
} else if let Some(vol) = body.volume_number {
|
||||||
format!("\"{}\" {}", body.series_name, vol)
|
format!("\"{}\" {}", body.series_name, vol)
|
||||||
} else {
|
} else {
|
||||||
format!("\"{}\"", body.series_name)
|
format!("\"{}\"", body.series_name)
|
||||||
@@ -139,10 +142,19 @@ pub async fn search_prowlarr(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let results: Vec<ProwlarrRelease> = resp
|
let raw_text = resp
|
||||||
.json()
|
.text()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to parse Prowlarr response: {e}")))?;
|
.map_err(|e| ApiError::internal(format!("Failed to read Prowlarr response: {e}")))?;
|
||||||
|
|
||||||
|
tracing::debug!("Prowlarr raw response length: {} chars", raw_text.len());
|
||||||
|
|
||||||
|
let results: Vec<ProwlarrRelease> = serde_json::from_str(&raw_text)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to parse Prowlarr response: {e}");
|
||||||
|
tracing::error!("Raw response (first 500 chars): {}", &raw_text[..raw_text.len().min(500)]);
|
||||||
|
ApiError::internal(format!("Failed to parse Prowlarr response: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(ProwlarrSearchResponse { results, query }))
|
Ok(Json(ProwlarrSearchResponse { results, query }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -683,13 +683,6 @@ export function MetadataSearchModal({
|
|||||||
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
|
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Inline badge when linked */}
|
|
||||||
{existingLink && existingLink.status === "approved" && initialMissing && initialMissing.missing_count > 0 && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 text-xs border border-yellow-500/30">
|
|
||||||
{t("series.missingCount", { count: initialMissing.missing_count, plural: initialMissing.missing_count !== 1 ? "s" : "" })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{existingLink && existingLink.status === "approved" && (
|
{existingLink && existingLink.status === "approved" && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
|
||||||
<ProviderIcon provider={existingLink.provider} size={12} />
|
<ProviderIcon provider={existingLink.provider} size={12} />
|
||||||
|
|||||||
@@ -55,13 +55,16 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
.catch(() => setIsQbConfigured(false));
|
.catch(() => setIsQbConfigured(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const doSearch = useCallback(async (searchSeriesName: string, volumeNumber?: number) => {
|
const [searchInput, setSearchInput] = useState(`"${seriesName}"`);
|
||||||
|
|
||||||
|
const doSearch = useCallback(async (queryOverride?: string) => {
|
||||||
|
const searchQuery = queryOverride ?? searchInput;
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
try {
|
try {
|
||||||
const body: { series_name: string; volume_number?: number } = { series_name: searchSeriesName };
|
const body = { series_name: seriesName, custom_query: searchQuery.trim() };
|
||||||
if (volumeNumber !== undefined) body.volume_number = volumeNumber;
|
|
||||||
const resp = await fetch("/api/prowlarr/search", {
|
const resp = await fetch("/api/prowlarr/search", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -80,15 +83,18 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, [t, seriesName, searchInput]);
|
||||||
|
|
||||||
|
const defaultQuery = `"${seriesName}"`;
|
||||||
|
|
||||||
function handleOpen() {
|
function handleOpen() {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
setSearchInput(defaultQuery);
|
||||||
// Auto-search the series on open
|
// Auto-search the series on open
|
||||||
doSearch(seriesName);
|
doSearch(defaultQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@@ -143,29 +149,56 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
{/* Missing volumes + re-search */}
|
{/* Search input */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchInput.trim()) doSearch(searchInput.trim());
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||||
|
placeholder={t("prowlarr.searchPlaceholder")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSearching || !searchInput.trim()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="search" size="sm" />
|
||||||
|
{t("prowlarr.searchAction")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Quick search badges */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 max-h-24 overflow-y-auto">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => doSearch(seriesName)}
|
onClick={() => { setSearchInput(defaultQuery); doSearch(defaultQuery); }}
|
||||||
disabled={isSearching}
|
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"
|
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}
|
{seriesName}
|
||||||
</button>
|
</button>
|
||||||
{missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => (
|
{missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => {
|
||||||
|
const label = book.title || `Vol. ${book.volume_number}`;
|
||||||
|
const q = book.volume_number != null ? `"${seriesName}" ${book.volume_number}` : `"${seriesName}" ${label}`;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => doSearch(seriesName, book.volume_number ?? undefined)}
|
onClick={() => { setSearchInput(q); doSearch(q); }}
|
||||||
disabled={isSearching}
|
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"
|
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" />
|
{label}
|
||||||
{book.title || `Vol. ${book.volume_number}`}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default async function SeriesDetailPage({
|
|||||||
{t("series.readCount", { read: String(booksReadCount), total: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
|
{t("series.readCount", { read: String(booksReadCount), total: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Reading progress bar */}
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-[120px] max-w-[200px]">
|
<div className="flex items-center gap-2 flex-1 min-w-[120px] max-w-[200px]">
|
||||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -151,6 +151,22 @@ export default async function SeriesDetailPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Collection progress bar (owned / expected) */}
|
||||||
|
{missingData && missingData.total_external > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="w-px h-4 bg-border" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{booksPage.total}/{missingData.total_external} — {t("series.missingCount", { count: missingData.missing_count, plural: missingData.missing_count !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
<div className="w-[150px] h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${Math.round((booksPage.total / missingData.total_external) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -172,16 +188,16 @@ export default async function SeriesDetailPage({
|
|||||||
currentStatus={seriesMeta?.status ?? null}
|
currentStatus={seriesMeta?.status ?? null}
|
||||||
currentLockedFields={seriesMeta?.locked_fields ?? {}}
|
currentLockedFields={seriesMeta?.locked_fields ?? {}}
|
||||||
/>
|
/>
|
||||||
|
<ProwlarrSearchModal
|
||||||
|
seriesName={seriesName}
|
||||||
|
missingBooks={missingData?.missing_books ?? null}
|
||||||
|
/>
|
||||||
<MetadataSearchModal
|
<MetadataSearchModal
|
||||||
libraryId={id}
|
libraryId={id}
|
||||||
seriesName={seriesName}
|
seriesName={seriesName}
|
||||||
existingLink={existingLink}
|
existingLink={existingLink}
|
||||||
initialMissing={missingData}
|
initialMissing={missingData}
|
||||||
/>
|
/>
|
||||||
<ProwlarrSearchModal
|
|
||||||
seriesName={seriesName}
|
|
||||||
missingBooks={missingData?.missing_books ?? null}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -487,6 +487,8 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"prowlarr.columnSeeders": "Seeds",
|
"prowlarr.columnSeeders": "Seeds",
|
||||||
"prowlarr.columnLeechers": "Peers",
|
"prowlarr.columnLeechers": "Peers",
|
||||||
"prowlarr.columnProtocol": "Protocol",
|
"prowlarr.columnProtocol": "Protocol",
|
||||||
|
"prowlarr.searchPlaceholder": "Edit search query...",
|
||||||
|
"prowlarr.searchAction": "Search",
|
||||||
"prowlarr.searchError": "Search failed",
|
"prowlarr.searchError": "Search failed",
|
||||||
"prowlarr.notConfigured": "Prowlarr is not configured",
|
"prowlarr.notConfigured": "Prowlarr is not configured",
|
||||||
"prowlarr.download": "Download",
|
"prowlarr.download": "Download",
|
||||||
|
|||||||
@@ -485,6 +485,8 @@ const fr = {
|
|||||||
"prowlarr.columnSeeders": "Seeds",
|
"prowlarr.columnSeeders": "Seeds",
|
||||||
"prowlarr.columnLeechers": "Peers",
|
"prowlarr.columnLeechers": "Peers",
|
||||||
"prowlarr.columnProtocol": "Protocole",
|
"prowlarr.columnProtocol": "Protocole",
|
||||||
|
"prowlarr.searchPlaceholder": "Modifier la recherche...",
|
||||||
|
"prowlarr.searchAction": "Rechercher",
|
||||||
"prowlarr.searchError": "Erreur lors de la recherche",
|
"prowlarr.searchError": "Erreur lors de la recherche",
|
||||||
"prowlarr.notConfigured": "Prowlarr n'est pas configuré",
|
"prowlarr.notConfigured": "Prowlarr n'est pas configuré",
|
||||||
"prowlarr.download": "Télécharger",
|
"prowlarr.download": "Télécharger",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 7082",
|
"dev": "next dev -p 7082",
|
||||||
|
|||||||
Reference in New Issue
Block a user