Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/legacy-torznab-fields-migrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"comicarr": patch
---

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)
5 changes: 5 additions & 0 deletions .changeset/pack-search-actually-works.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"comicarr": patch
---

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)
18 changes: 18 additions & 0 deletions comicarr/app/series/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
t_comics.c.DetailURL.label("DetailURL"),
t_comics.c.ComicLocation.label("ComicLocation"),
t_comics.c.ContentType.label("ContentType"),
t_comics.c.AllowPacks.label("AllowPacks"),
t_comics.c.IgnoreType.label("IgnoreType"),
]

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


def get_comic_search_settings(comic_id):
"""Get the per-series search flags (pack matching / booktype override)."""
return db.select_one(
select(t_comics.c.ComicID, t_comics.c.AllowPacks, t_comics.c.IgnoreType).where(t_comics.c.ComicID == comic_id)
)


def update_comic_search_settings(comic_id, values):
"""Persist per-series search flags.

``AllowPacks`` is a Text column read as ``== 1 / == "1"`` by search.py, so
it is stored as "1"/"0" strings; ``IgnoreType`` is an Integer flag column.
"""
db.upsert("comics", values, {"ComicID": comic_id})


def pause_comic(comic_id):
"""Set comic status to Paused."""
db.upsert("comics", {"Status": "Paused"}, {"ComicID": comic_id})
Expand Down
28 changes: 28 additions & 0 deletions comicarr/app/series/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,34 @@ def delete_series(
return result


@router.patch("/series/{comic_id}/search-settings", dependencies=[Depends(require_session)])
def update_series_search_settings(
comic_id: str,
request_body: dict = None,
ctx: AppContext = Depends(get_context),
):
"""Update per-series search flags (pack matching / booktype override)."""
if request_body is None:
request_body = {}

allow_packs = request_body.get("allow_packs")
ignore_type = request_body.get("ignore_type")
for name, value in (("allow_packs", allow_packs), ("ignore_type", ignore_type)):
if value is not None and not isinstance(value, bool):
return JSONResponse(status_code=400, content={"detail": "%s must be a boolean" % name})
if allow_packs is None and ignore_type is None:
return JSONResponse(
status_code=400,
content={"detail": "Provide at least one of allow_packs, ignore_type"},
)

result = series_service.update_search_settings(ctx, comic_id, allow_packs=allow_packs, ignore_type=ignore_type)
if not result["success"]:
status = 404 if "not found" in result.get("error", "").lower() else 400
return JSONResponse(status_code=status, content={"detail": result.get("error")})
return result


@router.put("/series/{comic_id}/pause", dependencies=[Depends(require_session)])
def pause_series(comic_id: str, ctx: AppContext = Depends(get_context)):
"""Pause a comic series."""
Expand Down
33 changes: 33 additions & 0 deletions comicarr/app/series/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,39 @@ def delete_comic(ctx, comic_id, delete_directory=False):
}


def update_search_settings(ctx, comic_id, allow_packs=None, ignore_type=None):
"""Update the per-series search flags (#633).

``allow_packs`` gates pack/bundle release matching; ``ignore_type`` lets
results through the booktype-mismatch check in search_filer. Both are
partial — omitted (None) fields are left untouched.
"""
existing = series_queries.get_comic_search_settings(comic_id)
if not existing:
return {"success": False, "error": "ComicID %s not found in watchlist" % comic_id}

values = {}
if allow_packs is not None:
# Text column read as == 1 / == "1" by search.py — store "1"/"0".
values["AllowPacks"] = "1" if allow_packs else "0"
if ignore_type is not None:
values["IgnoreType"] = 1 if ignore_type else 0

if not values:
return {"success": False, "error": "No search settings provided"}

series_queries.update_comic_search_settings(comic_id, values)
logger.fdebug("[SERIES] Updated search settings for %s: %s" % (comic_id, values))
updated = series_queries.get_comic_search_settings(comic_id)
return {
"success": True,
"settings": {
"allow_packs": updated["AllowPacks"] in (1, "1"),
"ignore_type": bool(updated["IgnoreType"]),
},
}


def pause_comic(ctx, comic_id):
"""Set comic status to Paused."""
series_queries.pause_comic(comic_id)
Expand Down
94 changes: 94 additions & 0 deletions comicarr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ def read(self, startup=False):
extra_newznabs, extra_torznabs = self.get_extras()
self.EXTRA_NEWZNABS = extra_newznabs
self.EXTRA_TORZNABS = extra_torznabs
self._absorb_legacy_torznab()
self.IGNORED_PUBLISHERS = self.get_ignored_pubs()

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

def _absorb_legacy_torznab(self):
"""Fold populated legacy torznab_* fields into EXTRA_TORZNABS (#631).

The single-provider [Torznab] fields were retired at config_version 8,
but the keys still exist and a hand-edited config.ini can repopulate
them on any modern version — where they were silently inert. Runs on
every read so the entry is either migrated or loudly flagged.
"""
legacy = {
"name": self.TORZNAB_NAME,
"host": self.TORZNAB_HOST,
"apikey": self.TORZNAB_APIKEY,
"category": self.TORZNAB_CATEGORY,
}
if all(value is None for value in legacy.values()):
return

def _scrub():
for option in (
"torznab_name",
"torznab_host",
"torznab_verify",
"torznab_apikey",
"torznab_category",
):
if config.has_option("Torznab", option):
config.remove_option("Torznab", option)
self.TORZNAB_NAME = None
self.TORZNAB_HOST = None
self.TORZNAB_VERIFY = None
self.TORZNAB_APIKEY = None
self.TORZNAB_CATEGORY = None
self.WRITE_THE_CONFIG = True
Comment thread
coderabbitai[bot] marked this conversation as resolved.

missing = [key for key, value in legacy.items() if value is None]
if missing:
logger.warn(
"[CONFIG] Legacy torznab_* fields under [Torznab] are set but incomplete "
"(missing: %s) and are NOT used for searching. Configure the provider via "
"the Settings UI (extra_torznabs) instead. Ignoring the legacy entry."
% ", ".join("torznab_%s" % m for m in missing)
)
return

if any(str(existing[1]).strip() == str(legacy["host"]).strip() for existing in self.EXTRA_TORZNABS):
logger.warn(
"[CONFIG] Legacy torznab_* fields under [Torznab] duplicate an existing "
"extra_torznabs entry for %s. Removing the inert legacy fields." % legacy["host"]
)
_scrub()
return

canonical_name = str(legacy["name"]).strip().casefold()
# Provider names must be unique across BOTH extras lists — validation
# shares one namespace for newznabs and torznabs.
existing_names = {
str(entry[0] or entry[1]).strip().casefold()
for entries in (self.EXTRA_NEWZNABS, self.EXTRA_TORZNABS)
for entry in entries
}
if canonical_name in existing_names | self._reserved_provider_names():
logger.warn(
"[CONFIG] Legacy torznab_* fields under [Torznab] reuse the provider name "
"'%s' and are NOT used for searching. Rename or remove the legacy fields, or "
"configure the provider via the Settings UI (extra_torznabs) instead." % legacy["name"]
)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Skip ids held by the built-in providers (experimental/DDL) or
# _validate_loaded_provider_extras will reject the migrated entry.
reserved_ids = self._reserved_provider_ids()
candidate_id = comicarr.PROVIDER_START_ID + 1
while candidate_id in reserved_ids:
candidate_id += 1
comicarr.PROVIDER_START_ID = candidate_id
self.EXTRA_TORZNABS.append(
(
legacy["name"],
legacy["host"],
self.TORZNAB_VERIFY,
legacy["apikey"],
legacy["category"],
str(int(bool(self.ENABLE_TORZNAB))),
candidate_id,
)
)
logger.info(
"[CONFIG] Migrated legacy torznab_* fields under [Torznab] into extra_torznabs "
"as provider '%s' (%s). The legacy single-provider fields are no longer read; "
"manage this provider via the Settings UI from now on." % (legacy["name"], legacy["host"])
)
_scrub()

def config_update(self):
logger.info("Updating Configuration from %s to %s" % (self.CONFIG_VERSION, self.newconfig))
if self.CONFIG_VERSION < 8:
Expand Down
7 changes: 5 additions & 2 deletions comicarr/rsscheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -1238,12 +1238,15 @@ def nzbdbsearch(
"RSS": nzb["RSS"],
"ComicID": nzb["ComicID"],
"ComicName_Filesafe": nzb["ComicName_Filesafe"],
"AllowPacks": bool(nzb["AllowPacks"]),
# AllowPacks is a Text column storing "1"/"0";
# bool() would treat "0" as True.
"AllowPacks": nzb["AllowPacks"] in (1, "1"),
"OneOff": bool(nzb["OneOff"]),
"TorrentID_32P": nzb["TorrentID_32P"],
"DigitalDate": nzb["DigitalDate"],
"booktype": nzb["BookType"],
"ignore_booktype": bool(nzb["Ignore_Booktype"]),
# Same Text-column hazard as AllowPacks above.
"ignore_booktype": nzb["Ignore_Booktype"] in (1, "1", True),
},
}
)
Expand Down
7 changes: 4 additions & 3 deletions comicarr/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -909,9 +909,10 @@ def NZB_SEARCH(
smode=None,
):

if any([allow_packs == 1, allow_packs == "1"]) and all(
[comicarr.CONFIG.ENABLE_TORRENT_SEARCH, comicarr.CONFIG.ENABLE_32P]
):
# Pack eligibility only requires torrent search to be enabled; historically it
# was also gated behind ENABLE_32P, which blocked packs from every other
# torrent/Torznab provider (#632).
if any([allow_packs == 1, allow_packs == "1", allow_packs is True]) and comicarr.CONFIG.ENABLE_TORRENT_SEARCH:
allow_packs = True
else:
allow_packs = False
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/hooks/useSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,35 @@ export function useResumeSeries(): UseMutationResult<unknown, Error, string> {
});
}

export interface SeriesSearchSettingsInput {
comicId: string;
allowPacks?: boolean;
ignoreType?: boolean;
}

/**
* Update per-series search flags (pack matching / booktype override)
*/
export function useUpdateSeriesSearchSettings(): UseMutationResult<
unknown,
Error,
SeriesSearchSettingsInput
> {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ comicId, allowPacks, ignoreType }) =>
apiRequest("PATCH", `/api/series/${comicId}/search-settings`, {
...(allowPacks !== undefined && { allow_packs: allowPacks }),
...(ignoreType !== undefined && { ignore_type: ignoreType }),
}),
onSuccess: (_, { comicId }) => {
queryClient.invalidateQueries({ queryKey: ["series"] });
queryClient.invalidateQueries({ queryKey: ["series", comicId] });
},
});
}

/**
* Refresh series metadata
*/
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/pages/SeriesDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "lucide-react";
import StatusBadge from "@/components/StatusBadge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
Expand All @@ -31,6 +32,7 @@ import {
useSearchMissingPreview,
useSearchRun,
useSeriesDetail,
useUpdateSeriesSearchSettings,
} from "@/hooks/useSeries";
import type {
ComicOrManga,
Expand Down Expand Up @@ -163,6 +165,7 @@ export default function SeriesDetailPage() {
const confirmSearch = useConfirmSearchMissing();
const searchRun = useSearchRun(searchRunId);
const retrySearchRun = useRetrySearchRun();
const searchSettingsMutation = useUpdateSeriesSearchSettings();

const fetchSearchPreview = async () => {
setPreview(null);
Expand Down Expand Up @@ -300,6 +303,29 @@ export default function SeriesDetailPage() {
summary?.completionPercent ??
(total > 0 ? Math.round((have / total) * 100) : 0);
const isPaused = comic.Status?.toLowerCase() === "paused";
const allowPacks = comic.AllowPacks === 1 || comic.AllowPacks === "1";
const ignoreType = Boolean(comic.IgnoreType);

const handleSearchSettingChange = async (
setting: "allowPacks" | "ignoreType",
value: boolean,
) => {
if (!comicId) return;
try {
await searchSettingsMutation.mutateAsync({
comicId,
...(setting === "allowPacks"
? { allowPacks: value }
: { ignoreType: value }),
});
} catch {
addToast({
type: "error",
title: "Error",
description: "Failed to update search settings",
});
}
};
const isManga =
comic.ContentType === "manga" ||
comicId?.startsWith("md-") ||
Expand Down Expand Up @@ -612,6 +638,49 @@ export default function SeriesDetailPage() {
))}
</div>
</div>
<div
className="border-t px-3 py-2.5"
style={{ borderColor: "var(--border)" }}
>
<div
className="mb-2 font-mono text-[10px] uppercase tracking-[0.1em]"
style={{ color: "var(--text-muted)" }}
>
Search options
</div>
{[
{
key: "allowPacks" as const,
label: "Allow packs",
title:
"Accept pack/bundle releases (multi-issue or volume torrents) when searching",
checked: allowPacks,
},
{
key: "ignoreType" as const,
label: "Ignore book type",
title:
"Match results even when the release's book type (TPB, GN…) differs from this series",
checked: ignoreType,
},
].map(({ key, label, title, checked }) => (
<label
key={key}
title={title}
className="flex cursor-pointer items-center justify-between gap-2 py-1 font-mono text-[10px]"
>
<span style={{ color: "var(--text-muted)" }}>{label}</span>
<Checkbox
checked={checked}
disabled={searchSettingsMutation.isPending}
onCheckedChange={(value) =>
void handleSearchSettingChange(key, value)
}
aria-label={label}
/>
</label>
))}
</div>
</div>
</div>

Expand Down
Loading
Loading