Skip to content

Commit 404d247

Browse files
Merge pull request #635 from frankieramirez/fix/pack-search-per-series-flags
fix: Make pack search workable and absorb legacy torznab config
2 parents dcc6b9e + 0c60b37 commit 404d247

13 files changed

Lines changed: 496 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"comicarr": patch
3+
---
4+
5+
Hand-edited legacy `torznab_*` fields under `[Torznab]` in config.ini no longer sit silently inert. A complete legacy entry (name, host, API key, category) is folded into the real multi-provider `extra_torznabs` list on startup and the stale keys are removed; an incomplete one is called out in the log with a pointer to the Settings UI instead of being ignored. (#631)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"comicarr": patch
3+
---
4+
5+
Pack and bundle releases can now actually be matched. Each series page has two new Search options — **Allow packs** accepts multi-issue/volume bundle releases (the norm for manga and manhwa torrents), and **Ignore book type** lets results through when the release's book type (TPB, GN, …) differs from the series. These per-series flags existed in the database but had no way to be set, so packs were rejected for every series. Pack matching also no longer requires the 32P tracker to be enabled — any torrent or Torznab provider qualifies once torrent search is on. (#632, #633)

comicarr/app/series/queries.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
t_comics.c.DetailURL.label("DetailURL"),
4343
t_comics.c.ComicLocation.label("ComicLocation"),
4444
t_comics.c.ContentType.label("ContentType"),
45+
t_comics.c.AllowPacks.label("AllowPacks"),
46+
t_comics.c.IgnoreType.label("IgnoreType"),
4547
]
4648

4749
ISSUES_COLUMNS = [
@@ -168,6 +170,22 @@ def delete_comic(comic_id):
168170
conn.execute(delete(t_upcoming).where(t_upcoming.c.ComicID == comic_id))
169171

170172

173+
def get_comic_search_settings(comic_id):
174+
"""Get the per-series search flags (pack matching / booktype override)."""
175+
return db.select_one(
176+
select(t_comics.c.ComicID, t_comics.c.AllowPacks, t_comics.c.IgnoreType).where(t_comics.c.ComicID == comic_id)
177+
)
178+
179+
180+
def update_comic_search_settings(comic_id, values):
181+
"""Persist per-series search flags.
182+
183+
``AllowPacks`` is a Text column read as ``== 1 / == "1"`` by search.py, so
184+
it is stored as "1"/"0" strings; ``IgnoreType`` is an Integer flag column.
185+
"""
186+
db.upsert("comics", values, {"ComicID": comic_id})
187+
188+
171189
def pause_comic(comic_id):
172190
"""Set comic status to Paused."""
173191
db.upsert("comics", {"Status": "Paused"}, {"ComicID": comic_id})

comicarr/app/series/router.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,34 @@ def delete_series(
8484
return result
8585

8686

87+
@router.patch("/series/{comic_id}/search-settings", dependencies=[Depends(require_session)])
88+
def update_series_search_settings(
89+
comic_id: str,
90+
request_body: dict = None,
91+
ctx: AppContext = Depends(get_context),
92+
):
93+
"""Update per-series search flags (pack matching / booktype override)."""
94+
if request_body is None:
95+
request_body = {}
96+
97+
allow_packs = request_body.get("allow_packs")
98+
ignore_type = request_body.get("ignore_type")
99+
for name, value in (("allow_packs", allow_packs), ("ignore_type", ignore_type)):
100+
if value is not None and not isinstance(value, bool):
101+
return JSONResponse(status_code=400, content={"detail": "%s must be a boolean" % name})
102+
if allow_packs is None and ignore_type is None:
103+
return JSONResponse(
104+
status_code=400,
105+
content={"detail": "Provide at least one of allow_packs, ignore_type"},
106+
)
107+
108+
result = series_service.update_search_settings(ctx, comic_id, allow_packs=allow_packs, ignore_type=ignore_type)
109+
if not result["success"]:
110+
status = 404 if "not found" in result.get("error", "").lower() else 400
111+
return JSONResponse(status_code=status, content={"detail": result.get("error")})
112+
return result
113+
114+
87115
@router.put("/series/{comic_id}/pause", dependencies=[Depends(require_session)])
88116
def pause_series(comic_id: str, ctx: AppContext = Depends(get_context)):
89117
"""Pause a comic series."""

comicarr/app/series/service.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,39 @@ def delete_comic(ctx, comic_id, delete_directory=False):
405405
}
406406

407407

408+
def update_search_settings(ctx, comic_id, allow_packs=None, ignore_type=None):
409+
"""Update the per-series search flags (#633).
410+
411+
``allow_packs`` gates pack/bundle release matching; ``ignore_type`` lets
412+
results through the booktype-mismatch check in search_filer. Both are
413+
partial — omitted (None) fields are left untouched.
414+
"""
415+
existing = series_queries.get_comic_search_settings(comic_id)
416+
if not existing:
417+
return {"success": False, "error": "ComicID %s not found in watchlist" % comic_id}
418+
419+
values = {}
420+
if allow_packs is not None:
421+
# Text column read as == 1 / == "1" by search.py — store "1"/"0".
422+
values["AllowPacks"] = "1" if allow_packs else "0"
423+
if ignore_type is not None:
424+
values["IgnoreType"] = 1 if ignore_type else 0
425+
426+
if not values:
427+
return {"success": False, "error": "No search settings provided"}
428+
429+
series_queries.update_comic_search_settings(comic_id, values)
430+
logger.fdebug("[SERIES] Updated search settings for %s: %s" % (comic_id, values))
431+
updated = series_queries.get_comic_search_settings(comic_id)
432+
return {
433+
"success": True,
434+
"settings": {
435+
"allow_packs": updated["AllowPacks"] in (1, "1"),
436+
"ignore_type": bool(updated["IgnoreType"]),
437+
},
438+
}
439+
440+
408441
def pause_comic(ctx, comic_id):
409442
"""Set comic status to Paused."""
410443
series_queries.pause_comic(comic_id)

comicarr/config.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,7 @@ def read(self, startup=False):
523523
extra_newznabs, extra_torznabs = self.get_extras()
524524
self.EXTRA_NEWZNABS = extra_newznabs
525525
self.EXTRA_TORZNABS = extra_torznabs
526+
self._absorb_legacy_torznab()
526527
self.IGNORED_PUBLISHERS = self.get_ignored_pubs()
527528

528529
provider_migration_needed = self._load_provider_extra_credentials()
@@ -539,6 +540,99 @@ def read(self, startup=False):
539540
raise OSError("Unable to persist configuration")
540541
return self
541542

543+
def _absorb_legacy_torznab(self):
544+
"""Fold populated legacy torznab_* fields into EXTRA_TORZNABS (#631).
545+
546+
The single-provider [Torznab] fields were retired at config_version 8,
547+
but the keys still exist and a hand-edited config.ini can repopulate
548+
them on any modern version — where they were silently inert. Runs on
549+
every read so the entry is either migrated or loudly flagged.
550+
"""
551+
legacy = {
552+
"name": self.TORZNAB_NAME,
553+
"host": self.TORZNAB_HOST,
554+
"apikey": self.TORZNAB_APIKEY,
555+
"category": self.TORZNAB_CATEGORY,
556+
}
557+
if all(value is None for value in legacy.values()):
558+
return
559+
560+
def _scrub():
561+
for option in (
562+
"torznab_name",
563+
"torznab_host",
564+
"torznab_verify",
565+
"torznab_apikey",
566+
"torznab_category",
567+
):
568+
if config.has_option("Torznab", option):
569+
config.remove_option("Torznab", option)
570+
self.TORZNAB_NAME = None
571+
self.TORZNAB_HOST = None
572+
self.TORZNAB_VERIFY = None
573+
self.TORZNAB_APIKEY = None
574+
self.TORZNAB_CATEGORY = None
575+
self.WRITE_THE_CONFIG = True
576+
577+
missing = [key for key, value in legacy.items() if value is None]
578+
if missing:
579+
logger.warn(
580+
"[CONFIG] Legacy torznab_* fields under [Torznab] are set but incomplete "
581+
"(missing: %s) and are NOT used for searching. Configure the provider via "
582+
"the Settings UI (extra_torznabs) instead. Ignoring the legacy entry."
583+
% ", ".join("torznab_%s" % m for m in missing)
584+
)
585+
return
586+
587+
if any(str(existing[1]).strip() == str(legacy["host"]).strip() for existing in self.EXTRA_TORZNABS):
588+
logger.warn(
589+
"[CONFIG] Legacy torznab_* fields under [Torznab] duplicate an existing "
590+
"extra_torznabs entry for %s. Removing the inert legacy fields." % legacy["host"]
591+
)
592+
_scrub()
593+
return
594+
595+
canonical_name = str(legacy["name"]).strip().casefold()
596+
# Provider names must be unique across BOTH extras lists — validation
597+
# shares one namespace for newznabs and torznabs.
598+
existing_names = {
599+
str(entry[0] or entry[1]).strip().casefold()
600+
for entries in (self.EXTRA_NEWZNABS, self.EXTRA_TORZNABS)
601+
for entry in entries
602+
}
603+
if canonical_name in existing_names | self._reserved_provider_names():
604+
logger.warn(
605+
"[CONFIG] Legacy torznab_* fields under [Torznab] reuse the provider name "
606+
"'%s' and are NOT used for searching. Rename or remove the legacy fields, or "
607+
"configure the provider via the Settings UI (extra_torznabs) instead." % legacy["name"]
608+
)
609+
return
610+
611+
# Skip ids held by the built-in providers (experimental/DDL) or
612+
# _validate_loaded_provider_extras will reject the migrated entry.
613+
reserved_ids = self._reserved_provider_ids()
614+
candidate_id = comicarr.PROVIDER_START_ID + 1
615+
while candidate_id in reserved_ids:
616+
candidate_id += 1
617+
comicarr.PROVIDER_START_ID = candidate_id
618+
self.EXTRA_TORZNABS.append(
619+
(
620+
legacy["name"],
621+
legacy["host"],
622+
self.TORZNAB_VERIFY,
623+
legacy["apikey"],
624+
legacy["category"],
625+
str(int(bool(self.ENABLE_TORZNAB))),
626+
candidate_id,
627+
)
628+
)
629+
logger.info(
630+
"[CONFIG] Migrated legacy torznab_* fields under [Torznab] into extra_torznabs "
631+
"as provider '%s' (%s). The legacy single-provider fields are no longer read; "
632+
"manage this provider via the Settings UI from now on." % (legacy["name"], legacy["host"])
633+
)
634+
_scrub()
635+
542636
def config_update(self):
543637
logger.info("Updating Configuration from %s to %s" % (self.CONFIG_VERSION, self.newconfig))
544638
if self.CONFIG_VERSION < 8:

comicarr/rsscheck.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,12 +1238,15 @@ def nzbdbsearch(
12381238
"RSS": nzb["RSS"],
12391239
"ComicID": nzb["ComicID"],
12401240
"ComicName_Filesafe": nzb["ComicName_Filesafe"],
1241-
"AllowPacks": bool(nzb["AllowPacks"]),
1241+
# AllowPacks is a Text column storing "1"/"0";
1242+
# bool() would treat "0" as True.
1243+
"AllowPacks": nzb["AllowPacks"] in (1, "1"),
12421244
"OneOff": bool(nzb["OneOff"]),
12431245
"TorrentID_32P": nzb["TorrentID_32P"],
12441246
"DigitalDate": nzb["DigitalDate"],
12451247
"booktype": nzb["BookType"],
1246-
"ignore_booktype": bool(nzb["Ignore_Booktype"]),
1248+
# Same Text-column hazard as AllowPacks above.
1249+
"ignore_booktype": nzb["Ignore_Booktype"] in (1, "1", True),
12471250
},
12481251
}
12491252
)

comicarr/search.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -909,9 +909,10 @@ def NZB_SEARCH(
909909
smode=None,
910910
):
911911

912-
if any([allow_packs == 1, allow_packs == "1"]) and all(
913-
[comicarr.CONFIG.ENABLE_TORRENT_SEARCH, comicarr.CONFIG.ENABLE_32P]
914-
):
912+
# Pack eligibility only requires torrent search to be enabled; historically it
913+
# was also gated behind ENABLE_32P, which blocked packs from every other
914+
# torrent/Torznab provider (#632).
915+
if any([allow_packs == 1, allow_packs == "1", allow_packs is True]) and comicarr.CONFIG.ENABLE_TORRENT_SEARCH:
915916
allow_packs = True
916917
else:
917918
allow_packs = False

frontend/src/hooks/useSeries.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,35 @@ export function useResumeSeries(): UseMutationResult<unknown, Error, string> {
175175
});
176176
}
177177

178+
export interface SeriesSearchSettingsInput {
179+
comicId: string;
180+
allowPacks?: boolean;
181+
ignoreType?: boolean;
182+
}
183+
184+
/**
185+
* Update per-series search flags (pack matching / booktype override)
186+
*/
187+
export function useUpdateSeriesSearchSettings(): UseMutationResult<
188+
unknown,
189+
Error,
190+
SeriesSearchSettingsInput
191+
> {
192+
const queryClient = useQueryClient();
193+
194+
return useMutation({
195+
mutationFn: ({ comicId, allowPacks, ignoreType }) =>
196+
apiRequest("PATCH", `/api/series/${comicId}/search-settings`, {
197+
...(allowPacks !== undefined && { allow_packs: allowPacks }),
198+
...(ignoreType !== undefined && { ignore_type: ignoreType }),
199+
}),
200+
onSuccess: (_, { comicId }) => {
201+
queryClient.invalidateQueries({ queryKey: ["series"] });
202+
queryClient.invalidateQueries({ queryKey: ["series", comicId] });
203+
},
204+
});
205+
}
206+
178207
/**
179208
* Refresh series metadata
180209
*/

frontend/src/pages/SeriesDetailPage.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "lucide-react";
1212
import StatusBadge from "@/components/StatusBadge";
1313
import { Button } from "@/components/ui/button";
14+
import { Checkbox } from "@/components/ui/checkbox";
1415
import {
1516
Dialog,
1617
DialogContent,
@@ -31,6 +32,7 @@ import {
3132
useSearchMissingPreview,
3233
useSearchRun,
3334
useSeriesDetail,
35+
useUpdateSeriesSearchSettings,
3436
} from "@/hooks/useSeries";
3537
import type {
3638
ComicOrManga,
@@ -163,6 +165,7 @@ export default function SeriesDetailPage() {
163165
const confirmSearch = useConfirmSearchMissing();
164166
const searchRun = useSearchRun(searchRunId);
165167
const retrySearchRun = useRetrySearchRun();
168+
const searchSettingsMutation = useUpdateSeriesSearchSettings();
166169

167170
const fetchSearchPreview = async () => {
168171
setPreview(null);
@@ -300,6 +303,29 @@ export default function SeriesDetailPage() {
300303
summary?.completionPercent ??
301304
(total > 0 ? Math.round((have / total) * 100) : 0);
302305
const isPaused = comic.Status?.toLowerCase() === "paused";
306+
const allowPacks = comic.AllowPacks === 1 || comic.AllowPacks === "1";
307+
const ignoreType = Boolean(comic.IgnoreType);
308+
309+
const handleSearchSettingChange = async (
310+
setting: "allowPacks" | "ignoreType",
311+
value: boolean,
312+
) => {
313+
if (!comicId) return;
314+
try {
315+
await searchSettingsMutation.mutateAsync({
316+
comicId,
317+
...(setting === "allowPacks"
318+
? { allowPacks: value }
319+
: { ignoreType: value }),
320+
});
321+
} catch {
322+
addToast({
323+
type: "error",
324+
title: "Error",
325+
description: "Failed to update search settings",
326+
});
327+
}
328+
};
303329
const isManga =
304330
comic.ContentType === "manga" ||
305331
comicId?.startsWith("md-") ||
@@ -612,6 +638,49 @@ export default function SeriesDetailPage() {
612638
))}
613639
</div>
614640
</div>
641+
<div
642+
className="border-t px-3 py-2.5"
643+
style={{ borderColor: "var(--border)" }}
644+
>
645+
<div
646+
className="mb-2 font-mono text-[10px] uppercase tracking-[0.1em]"
647+
style={{ color: "var(--text-muted)" }}
648+
>
649+
Search options
650+
</div>
651+
{[
652+
{
653+
key: "allowPacks" as const,
654+
label: "Allow packs",
655+
title:
656+
"Accept pack/bundle releases (multi-issue or volume torrents) when searching",
657+
checked: allowPacks,
658+
},
659+
{
660+
key: "ignoreType" as const,
661+
label: "Ignore book type",
662+
title:
663+
"Match results even when the release's book type (TPB, GN…) differs from this series",
664+
checked: ignoreType,
665+
},
666+
].map(({ key, label, title, checked }) => (
667+
<label
668+
key={key}
669+
title={title}
670+
className="flex cursor-pointer items-center justify-between gap-2 py-1 font-mono text-[10px]"
671+
>
672+
<span style={{ color: "var(--text-muted)" }}>{label}</span>
673+
<Checkbox
674+
checked={checked}
675+
disabled={searchSettingsMutation.isPending}
676+
onCheckedChange={(value) =>
677+
void handleSearchSettingChange(key, value)
678+
}
679+
aria-label={label}
680+
/>
681+
</label>
682+
))}
683+
</div>
615684
</div>
616685
</div>
617686

0 commit comments

Comments
 (0)