Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
063f2d2
fix(frontend): send passwordConfirm from profile change-password (#793)
ajslater Jul 3, 2026
3020d67
update deps and version to v2.1.1. bump news
ajslater Jul 3, 2026
27783ee
test(frontend): work around vitest/valid-expect false positive on exp…
ajslater Jul 4, 2026
4963ad9
update deps
ajslater Jul 4, 2026
2462a41
fix(onlinetag): drop dead effort option; model Metron's fewer requests
ajslater Jul 4, 2026
8245efa
trim news
ajslater Jul 4, 2026
6591384
Merge branch 'main' into develop
ajslater Jul 4, 2026
a1845af
refactor(onlinetag): derive source list + issue-id parser from comicbox
ajslater Jul 4, 2026
cd84ed9
fix(onlinetag): scope the match-mode request-count hint to Comic Vine
ajslater Jul 4, 2026
9980c04
fix(fs): don't let one unreadable folder crash the library scan
ajslater Jul 4, 2026
42b7fe9
update deps
ajslater Jul 4, 2026
dfbf390
update version to 2.1.2
ajslater Jul 4, 2026
1d06607
fix typechecking
ajslater Jul 4, 2026
07ffa2a
news for v2.1.2
ajslater Jul 4, 2026
ed38cb5
format
ajslater Jul 4, 2026
a41eede
Squashed commit of the following:
ajslater Jul 4, 2026
8ebc051
update deps
ajslater Jul 4, 2026
08eadba
refactor(onlinetag): extract resume-param sanitizer to cut complexity
ajslater Jul 4, 2026
227cea4
fix(onlinetag): reference defined constant in match-mode hint
ajslater Jul 4, 2026
76a7d3b
Merge branch 'main' into develop
ajslater Jul 4, 2026
97c62aa
update deps
ajslater Jul 7, 2026
f7abf7d
fix(settings): nest comicbox loglevel/delete_keys under general section
ajslater Jul 11, 2026
da257b0
fix(onlinetag): resolve prompts against current DB path, surface appl…
ajslater Jul 11, 2026
a5f37d8
update deps and format
ajslater Jul 11, 2026
e45ef90
Native OIDC single sign-on (Admin Auth tab) (#798)
ajslater Jul 13, 2026
d03666d
bump version 2.2.0
ajslater Jul 13, 2026
698164c
bump news
ajslater Jul 13, 2026
2de8473
update deps and comicbox
ajslater Jul 13, 2026
9243b5e
adapt ComicVine credential check to simyan v3 (comicbox 4.1.1)
ajslater Jul 13, 2026
4c97679
update deps
ajslater Jul 13, 2026
e6bb026
test(onlinetag): assert merge flag is forwarded, not comicbox's arith…
ajslater Jul 13, 2026
19f7b1b
Merge branch 'main' into develop
ajslater Jul 13, 2026
c7dc81f
update deps
ajslater Jul 19, 2026
cb98ea0
v2.2.1: show Metron account rate limits live (comicbox 4.3.0 / mokkar…
ajslater Jul 19, 2026
b3ec559
update deps
ajslater Jul 22, 2026
86b8403
v2.2.1: community ratings replace critical rating (comicbox 4.4.0)
ajslater Jul 22, 2026
04a7b2d
Merge branch 'main' into develop
ajslater Jul 22, 2026
4f7e516
update deps
ajslater Jul 24, 2026
f7ceb6c
v2.2.2: fix rotated pdf page serving (comicbox-pdffile 0.6.3)
ajslater Jul 24, 2026
8086c4c
update deps, including comicbox 4.5.0
ajslater Jul 24, 2026
00c3a99
Fix silent no-op when clearing tag editor fields
ajslater Jul 22, 2026
172a8c9
Fix lint errors from the ruff 0.16 upgrade
ajslater Jul 24, 2026
f1c8405
update comicbox
ajslater Jul 24, 2026
8be22e5
Clear monochrome by deleting the tag, not writing false
ajslater Jul 24, 2026
6b29b63
Merge branch 'main' into develop
ajslater Jul 24, 2026
1acc942
update devenv and deps
ajslater Jul 25, 2026
d358c22
update claude rules about telemetry
ajslater Jul 25, 2026
10d87b0
Fix anonymous stats sending and report what codex grew into
ajslater Jul 26, 2026
9b102cd
update deps
ajslater Jul 26, 2026
a92236d
Support comicbox 4.6.0 series alternative names
ajslater Jul 26, 2026
265f7fa
update deps and devenv
ajslater Jul 26, 2026
5d5b3bd
bump news for comicbox 4.6.1
ajslater Jul 26, 2026
3b500ae
update devenv
ajslater Jul 26, 2026
646fe38
fix cron double enqueue of telemeter task
ajslater Jul 27, 2026
c014acb
test crond double enqueu fix
ajslater Jul 27, 2026
7c65e0d
test telmemeter logging fixes
ajslater Jul 27, 2026
7574042
Merge branch 'main' into develop
ajslater Jul 27, 2026
4bec196
feat(api): reintroduce Swagger UI at /api/v4/
ajslater Jul 27, 2026
8650305
v2.2.4
ajslater Jul 27, 2026
1d0fc47
sub api v3 for v4
ajslater Jul 27, 2026
8fbf7e1
feat(tagging): authenticate to Metron with an API key
ajslater Jul 29, 2026
31d81d2
update deps
ajslater Jul 29, 2026
0dd3e51
feat(tagging): remove the custom URL fields for Metron & Comic Vine
ajslater Jul 29, 2026
0f412f8
fix(tests): clear the two outstanding ty errors
ajslater Jul 29, 2026
f740fff
Merge branch 'main' into develop
ajslater Jul 29, 2026
2a1a170
feat(tagging): present Metron as an API key source & warn on legacy l…
ajslater Jul 29, 2026
7a09ccd
update devenv
ajslater Jul 30, 2026
0c2a0a7
update deps
ajslater Aug 1, 2026
5732a22
fix(importer): import a renamed comic's new tags instead of failing
ajslater Aug 1, 2026
e39ce40
v2.2.5
ajslater Aug 1, 2026
25d1a6d
feat(tagging): combine online search and tag-by-id into one pass
ajslater Aug 2, 2026
97c65d6
update deps
ajslater Aug 2, 2026
f54e70b
style(news): prettier wrap the online tagging entry
ajslater Aug 2, 2026
178a05d
Merge branch 'main' into develop
ajslater Aug 2, 2026
f0b513f
feat(tagging): restore the custom URL for Comic Vine
ajslater Aug 4, 2026
25e4c7e
bump version and update deps
ajslater Aug 4, 2026
97a08ee
bump dockerfile source version
ajslater Aug 4, 2026
e5dec10
granian 2.8.0
ajslater Aug 4, 2026
7a6ee38
update deps
ajslater Aug 4, 2026
b82febd
update devenv
ajslater Aug 4, 2026
7aa1ad4
update deps
ajslater Aug 4, 2026
ba64c91
update devenv and deps
ajslater Aug 6, 2026
8717fcc
use nodejs26 in the builder
ajslater Aug 6, 2026
516b9cd
update deps
ajslater Aug 6, 2026
96f9aaa
remove old ty ignore
ajslater Aug 11, 2026
09d4072
update deps
ajslater Aug 11, 2026
2b90515
fix radon complexity warnings
ajslater Aug 11, 2026
e9454ac
update deps
ajslater Aug 14, 2026
39ed7cf
fix(importer): skip comic moves onto claimed paths
ajslater Aug 14, 2026
7724e8c
bump version
ajslater Aug 14, 2026
6e3ba89
update deps
ajslater Aug 14, 2026
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
###############################################################################

# ---- Stage 1: builder (build tools + Node for compilation) -----------------
FROM nikolaik/python-nodejs:python3.14-nodejs24 AS builder-base
FROM nikolaik/python-nodejs:python3.14-nodejs26 AS builder-base
# nodejs25 blocked on bug https://github.com/nodejs/node/issues/60303

COPY debian.sources /etc/apt/sources.list.d/
Expand Down
8 changes: 8 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ width: 128px;
border-radius: 128px;
" />

## v2.2.7

- Fixes
- A comic moved or renamed onto a path that already belongs to another
comic, like a duplicate landing in a dupes folder, no longer crashes the
scan and no longer crashes every scan after it. The stray move is skipped
and the leftover record is cleaned up by a later scan.

## v2.2.6

- Features
Expand Down
11 changes: 11 additions & 0 deletions bin/release-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Merge main into develop and tag a release
set -euo pipefail
TAG=$1
git checkout main
git pull
git tag v"$TAG"
git push --tags
git checkout develop
git merge main --no-edit
git push
118 changes: 47 additions & 71 deletions bun.lock

Large diffs are not rendered by default.

86 changes: 83 additions & 3 deletions codex/librarian/scribe/importer/moved/comics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Bulk import and move comics."""

from collections.abc import Mapping
from pathlib import Path

from django.db.models.functions import Now
Expand All @@ -15,12 +16,78 @@
from codex.librarian.scribe.importer.statii.create import ImporterCreateTagsStatus
from codex.librarian.scribe.importer.statii.moved import ImporterMoveComicsStatus
from codex.librarian.scribe.importer.statii.query import ImporterQueryMissingTagsStatus
from codex.models import Comic, Folder
from codex.models import Comic, CustomCover, Folder


class MovedComicsImporter(ReadMetadataImporter):
"""Methods for moving comics and folders."""

def _remove_file_move_collisions(
self,
model: type[Comic] | type[CustomCover],
moves: Mapping[str, str],
item_name: str,
) -> dict[str, str]:
"""
Remove moves whose destination path is already claimed.

The poller and the watcher both infer moves from inode matches
without checking that the destination is free in the database.
A destination that already belongs to another row would violate
the ``(library, path)`` unique constraint inside the upcoming
``bulk_update``, and that exception aborted the entire import,
leaving the delete phase unreachable so the same task crashed
again on every subsequent scan.

Two claims are possible. An existing row may sit at the
destination already (a duplicate file landing on a tracked
path, or an overwrite-rename), or two sources in this batch may
resolve to the same destination, which ``files_moved`` can
express because it is a plain dict rather than the bidict
folder moves use. Drop both, keeping the lowest sorted source
for a duplicated destination so the choice is deterministic.

Skipping converges rather than stranding anything. The
destination row's stat is refreshed by the modified reimport or
by the poller's stale stat pass, so the next scan sees two rows
sharing an inode, suppresses the bogus move as ambiguous, and
the stale source falls through to the delete phase. Libraries
that only run the watcher wait for their next poll instead. A
source and destination that swap paths drop both moves and
reconcile as content changes in place, which a single
``bulk_update`` could not express under SQLite anyway.

This is normal reconciliation, not a fault. Log at INFO with a
count and at DEBUG with the paths, matching the folder guard.
"""
if not moves:
return {}
occupied_paths = frozenset(
model.objects.filter(
library=self.library, path__in=frozenset(moves.values())
).values_list(PATH_FIELD_NAME, flat=True)
)
kept_moves: dict[str, str] = {}
claimed_paths: set[str] = set()
skipped_paths: list[str] = []
for src_path in sorted(moves):
dest_path = moves[src_path]
if dest_path in occupied_paths or dest_path in claimed_paths:
skipped_paths.append(src_path)
else:
kept_moves[src_path] = dest_path
claimed_paths.add(dest_path)
if skipped_paths:
count = len(skipped_paths)
plural = "s" if count != 1 else ""
msg = (
f"Resolved {count} phantom {item_name} move{plural} by skipping "
"the rename and leaving the destinations in place."
)
self.log.info(msg)
self.log.debug(f"Skipped {item_name} move sources: {skipped_paths}")
return kept_moves

def _bulk_comics_moved_ensure_folders(self) -> None:
"""Ensure folders we're moving to exist."""
dest_comic_paths = self.task.files_moved.values()
Expand Down Expand Up @@ -125,14 +192,21 @@ def _bulk_comics_move_prepare(self) -> tuple[list, dict, dict]:
return updated_comics, folder_m2m_links, del_rows_map

def bulk_comics_moved(self) -> int:
"""Move comcis."""
"""Move comics."""
count = 0
num_files_moved = len(self.task.files_moved)
status = ImporterMoveComicsStatus(0, num_files_moved)
try:
if not num_files_moved:
return 0
return count
self.status_controller.start(status)

# Drop moves onto claimed paths before creating any folders
# for destinations that will not be used.
self.task.files_moved = self._remove_file_move_collisions(
Comic, self.task.files_moved, "comic"
)

# Prepare
self._bulk_comics_moved_ensure_folders()
updated_comics, folder_m2m_links, del_rows_map = (
Expand All @@ -148,6 +222,12 @@ def bulk_comics_moved(self) -> int:
self.link_comic_m2m_field(FOLDERS_FIELD_NAME, folder_m2m_links, status)

count = len(updated_comics)
except Exception:
# Broad by intent. A failed move phase must degrade to a
# skipped phase that the next scan reconciles, never abort
# the import, which left the delete phase unreachable and
# the same task crashing on every scan.
self.log.exception(f"Moving {num_files_moved} comics")
finally:
self.status_controller.finish(status)
return count
10 changes: 9 additions & 1 deletion codex/librarian/scribe/importer/moved/covers.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,18 @@ def _bulk_covers_moved_unlink(self, unlink_pks) -> None:

def bulk_covers_moved(self, status=None) -> int:
"""Move covers."""
count = 0
num_covers_moved = len(self.task.covers_moved)
status = ImporterMoveCoversStatus(None, num_covers_moved)
try:
if not num_covers_moved:
return 0
return count
self.status_controller.start(status)

self.task.covers_moved = self._remove_file_move_collisions(
CustomCover, self.task.covers_moved, "custom cover"
)

moved_covers, unlink_pks = self._bulk_covers_moved_prepare(status)
if LINK_COVER_PKS not in self.metadata:
self.metadata[LINK_COVER_PKS] = set()
Expand All @@ -83,6 +88,9 @@ def bulk_covers_moved(self, status=None) -> int:
count = len(moved_covers)
level = "INFO" if count else "DEBUG"
self.log.log(level, f"Moved {count} custom covers.")
except Exception:
# Broad by intent, as in bulk_comics_moved.
self.log.exception(f"Moving {num_covers_moved} custom covers")
finally:
self.status_controller.finish(status)
return count
5 changes: 5 additions & 0 deletions codex/librarian/scribe/importer/moved/folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ def bulk_folders_moved(self, *, mark_in_progress=False) -> int:

count += self._bulk_move_folders_and_create_parents(status)
self.task.dirs_moved = {}
except Exception:
# Broad by intent, as in bulk_comics_moved. Converting
# dirs_moved to a bidict raises when two sources share a
# destination, which the watcher can emit.
self.log.exception(f"Moving {num_dirs_moved} folders")
finally:
self.status_controller.finish(status)
if mark_in_progress:
Expand Down
45 changes: 23 additions & 22 deletions codex/views/admin/onlinetag.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import contextlib
import uuid
from dataclasses import fields
from types import MappingProxyType
from typing import Final

from rest_framework.response import Response
from rest_framework.status import HTTP_202_ACCEPTED, HTTP_409_CONFLICT
Expand Down Expand Up @@ -34,6 +36,16 @@
from codex.views.admin.identifier_parse import parse_identifier_input
from codex.views.admin.tagwrite import FilteredComicPksView

# Boolean scan knobs the request may omit, mapped to the field on the stored
# tagging defaults that supplies the fallback.
_FLAG_DEFAULT_FIELDS: Final = MappingProxyType(
{
"delete_original": "delete_original",
"merge_all_sources": "merge_all_sources",
"rename": "rename_files",
}
)


def _configured_sources(defaults: ComicboxTaggingDefaults | None) -> frozenset[str]:
"""Which online sources actually have credentials configured."""
Expand Down Expand Up @@ -123,6 +135,16 @@ def _comic_pks_error(self, comic_pks: frozenset[int], *, pinned: bool) -> str:
return "Tagging by id requires a single comic."
return ""

@staticmethod
def _resolve_flags(data: dict, defaults: ComicboxTaggingDefaults | None) -> dict:
"""Take each boolean knob from the request, falling back to the defaults."""
return {
key: bool(requested)
if (requested := data.get(key)) is not None
else bool(defaults and getattr(defaults, default_field))
for key, default_field in _FLAG_DEFAULT_FIELDS.items()
}

def post(self, request):
"""Validate and enqueue a BulkOnlineTagTask."""
serializer = OnlineTagStartSerializer(data=request.data)
Expand All @@ -146,35 +168,14 @@ def post(self, request):
return Response({"detail": detail}, status=400)

session_id = str(uuid.uuid4())

req_delete = data.get("delete_original")
if req_delete is not None:
delete_original = req_delete
else:
delete_original = bool(defaults and defaults.delete_original)

req_merge = data.get("merge_all_sources")
if req_merge is not None:
merge_all_sources = req_merge
else:
merge_all_sources = bool(defaults and defaults.merge_all_sources)

req_rename = data.get("rename")
if req_rename is not None:
rename = req_rename
else:
rename = bool(defaults and defaults.rename_files)

task = BulkOnlineTagTask(
comic_pks=comic_pks,
session_id=session_id,
sources=tuple(data["sources"]),
mode=data["mode"],
prompts_mode=data["prompts_mode"],
delete_original=delete_original,
merge_all_sources=merge_all_sources,
rename=rename,
ids=ids,
**self._resolve_flags(data, defaults),
)
LIBRARIAN_QUEUE.put(task)
return Response(
Expand Down
2 changes: 1 addition & 1 deletion codex/views/browser/filters/search/field/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class BrowserFieldQueryFilter(ComicFieldFilterView):
def _combine_q(q: Q, other_q: tuple[str, Any] | Q, op: str) -> Q:
if isinstance(other_q, tuple):
rel: str
rel, val = other_q # ty: ignore[invalid-assignment]
rel, val = other_q
if rel.endswith("__like") and val == "%":
# Remove likes that would match everything
return q
Expand Down
Loading