Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 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
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
14 changes: 14 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ width: 128px;
border-radius: 128px;
" />

## v2.2.5

- Features
- Online tagging combines search and tag by id. A source given an issue id
fetches it directly, the rest search, and the results merge (comicbox
4.8.0).

- Fixes
- Tagging a comic with renaming enabled no longer fails the import and
discards the new tags. Comics renamed this way since v2.1.0 need a Force
Update Tags to pick up their metadata.
- Comics renamed or moved by another program mid-scan no longer leave a
permanent failed import.

## v2.2.4

- Features
Expand Down
18 changes: 4 additions & 14 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion codex/librarian/fs/import_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,18 @@ def _deduplicate(kwargs: dict[str, Any]) -> None:

kwargs["files_modified"] -= kwargs["files_added"]
kwargs["files_modified"] -= kwargs["files_deleted"]
kwargs["files_modified"] -= files_dest_paths
# A modified path that takes part in a move refers to content that now
# lives at the destination. The watcher emits the source path (an
# external tagger writing tags and then renaming produces the modify
# before the rename), while the poller emits the destination path on
# purpose for a move whose stats also changed. Remap sources and keep
# destinations so both reach the read phase alive. A pure rename's
# destination costs only a stat() there, because a move preserves the
# stored stat (see MOVED_BULK_COMIC_UPDATE_FIELDS).
files_moved = kwargs["files_moved"]
kwargs["files_modified"] = {
files_moved.get(path, path) for path in kwargs["files_modified"]
}

kwargs["covers_modified"] -= kwargs["covers_deleted"]
kwargs["covers_modified"] -= kwargs["covers_added"]
Expand Down
18 changes: 12 additions & 6 deletions codex/librarian/onlinetag/explicit_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@
Fetch comic metadata by a known online issue id (comicbox explicit-id tagging).

Codex's online tagging normally *searches* a source by filename and ranks
candidates. When the operator already knows the exact Metron / Comic Vine issue
id, this module drives comicbox's explicit-id path instead
(``online.lookup.ids``), which skips the search entirely and calls
``source.get(issue_id)`` for that one issue. It mirrors the per-file flow of
``comicbox.online_session.OnlineSession._run_one`` minus the selector — an
explicit-id fetch never reaches the matcher, so it never prompts.
candidates. When a comic's issue id is already known, this module drives
comicbox's explicit-id path instead (``online.lookup.ids``), which skips the
search entirely and calls ``source.get(issue_id)`` for that one issue. It
mirrors the per-file flow of ``comicbox.online_session.OnlineSession._run_one``
minus the selector — an explicit-id fetch never reaches the matcher, so it
never prompts.

Two callers, both outside a running session: the stored-id prepass (comics
Codex already holds an identifier for) and prompt resolution (an admin pinned
the issue by hand). Ids pinned in the tagging request itself go through the
session — ``OnlineSession(ids=...)`` — so pinned sources and searched sources
resolve in one lookup and merge.
"""

from __future__ import annotations
Expand Down
3 changes: 0 additions & 3 deletions codex/librarian/onlinetag/onlinetagd.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from codex.librarian.onlinetag.tasks import (
BulkOnlineTagTask,
OnlineTagAbortTask,
OnlineTagByIdTask,
OnlineTagDismissTask,
OnlineTagPromptResponseTask,
OnlineTagSkipAllPromptsTask,
Expand Down Expand Up @@ -64,8 +63,6 @@ def process_item(self, item) -> None:
match item:
case BulkOnlineTagTask():
self.session_manager.run_session(item)
case OnlineTagByIdTask():
self.session_manager.tag_by_id(item)
case OnlineTagAbortTask():
self.session_manager.cancel_session(item.session_id)
case OnlineTagPromptResponseTask():
Expand Down
81 changes: 10 additions & 71 deletions codex/librarian/onlinetag/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
from codex.librarian.onlinetag.tasks import (
BulkOnlineTagTask,
OnlineTagAbortTask,
OnlineTagByIdTask,
OnlineTagPromptResponseTask,
OnlineTagSkipAllPromptsTask,
)
Expand Down Expand Up @@ -130,71 +129,6 @@ def _source_has_credentials(credentials: OnlineCredentials, source: str) -> bool
return bool(credentials.comicvine_key)
return False

# --- tag by explicit id (no search) --------------------------------

def tag_by_id(self, task: OnlineTagByIdTask) -> None:
"""
Tag one comic by a known online issue id, skipping search entirely.

The operator already knows the exact Metron / Comic Vine issue, so we
fetch that record directly and hand the tags to the same
``BulkTagWriteTask`` write + re-import path the scan uses. A wrong or
unknown id resolves to nothing; that surfaces in the admin Tagging-tab
error panel rather than silently re-writing the comic's existing tags.
"""
comic = (
Comic.objects.filter(pk=task.comic_pk)
.exclude(library__read_only=True)
.only("pk", "path")
.first()
)
if not comic:
self.log.warning(f"Online tag by id: comic {task.comic_pk} not found.")
return
path = Path(comic.path)
credentials = self._build_credentials()
if not credentials or not self._source_has_credentials(
credentials, task.source
):
self.log.warning(
f"Online tag by id: no {task.source} credentials configured."
)
return

try:
tags = fetch_tags_by_explicit_id(
path,
task.source,
task.issue_id,
credentials,
extra_ids=task.extra_ids,
)
except (ComicboxError, OSError) as exc:
msg = f"Fetching {task.source} issue {task.issue_id} failed: {exc}"
self.log.warning(f"Online tag by id: {msg} ({path})")
add_tag_write_error(str(path), msg)
self.librarian_queue.put(TAG_WRITE_ERRORS_CHANGED_TASK)
return
if not tags:
msg = f"No {task.source} issue found for id {task.issue_id}."
self.log.warning(f"Online tag by id: {msg} ({path})")
add_tag_write_error(str(path), msg)
self.librarian_queue.put(TAG_WRITE_ERRORS_CHANGED_TASK)
return

write_task = BulkTagWriteTask(
comic_pks=frozenset({comic.pk}),
per_comic_patches={comic.pk: tags},
mode="update",
formats=task.formats,
delete_original=task.delete_original,
rename=task.rename,
)
self.librarian_queue.put(write_task)
self.log.info(
f"Online tag by id: applied {task.source}:{task.issue_id} to {path}."
)

# --- events --------------------------------------------------------

def _active_state(self) -> SessionState | None:
Expand Down Expand Up @@ -527,10 +461,8 @@ def _prefetch_stored_ids(
Fetches each already-identified comic in one API call, writes the tags
through the same ``BulkTagWriteTask`` path the scan uses, and drops it
from ``comic_paths`` so the search session only handles the rest.
Skipped on dry runs and for sources without credentials.
Skipped for sources without credentials.
"""
if task.dry_run:
return
usable_sources = tuple(
source
for source in task.sources
Expand Down Expand Up @@ -586,6 +518,8 @@ def run_session(self, task: BulkOnlineTagTask) -> None:
)
session = OnlineSession(
sources=task.sources,
# Pinned sources fetch their issue id directly; the rest search.
ids=dict(task.ids),
credentials=credentials,
mode=MatchMode(task.mode),
defer_prompts=defer_prompts,
Expand Down Expand Up @@ -615,7 +549,7 @@ def run_session(self, task: BulkOnlineTagTask) -> None:
"delete_original": task.delete_original,
"merge_all_sources": task.merge_all_sources,
"rename": task.rename,
"dry_run": task.dry_run,
"ids": dict(task.ids),
},
)
self._active_session_id = task.session_id
Expand All @@ -633,7 +567,12 @@ def run_session(self, task: BulkOnlineTagTask) -> None:
# Fast path: comics codex already has an issue id for are fetched
# directly by that id (one API call each) and dropped from the set,
# so the search pass below only handles the unidentified remainder.
self._prefetch_stored_ids(state, comic_paths, task, credentials)
# Skipped when the request pinned ids: the prepass would drop that
# single comic out of the session entirely, but the pinned sources
# must be fetched *and* the unpinned ones searched in one lookup so
# merge_all_sources can merge both results.
if not task.ids:
self._prefetch_stored_ids(state, comic_paths, task, credentials)
# Pass 1: auto-match and write the confident comics. When deferring,
# ambiguous matches become deferred prompts persisted for later,
# independent resolution; with "never" prompts they're skipped inline
Expand Down
23 changes: 5 additions & 18 deletions codex/librarian/onlinetag/tasks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Online tagging tasks."""

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any

from comicbox.formats.base.online import SOURCE_NAMES
Expand All @@ -22,12 +22,15 @@ class BulkOnlineTagTask(OnlineTagTask):
mode: str = "auto"
prompts_mode: str = "ask"
delete_original: bool = False
dry_run: bool = False
# Query every source per comic and merge (comicbox first_wins=False)
# instead of stopping at the first match.
merge_all_sources: bool = False
# Rename each written archive to the comicbox filename scheme.
rename: bool = False
# Pinned issue ids by source for a single-comic session. A pinned source
# fetches that issue directly; the rest of ``sources`` search as usual, so
# one session mixes tag-by-id and search and merges both results.
ids: dict[str, int] = field(default_factory=dict)


@dataclass
Expand All @@ -37,22 +40,6 @@ class OnlineTagAbortTask(OnlineTagTask):
session_id: str = ""


@dataclass
class OnlineTagByIdTask(OnlineTagTask):
"""Tag one comic by a known online issue id (skips search)."""

comic_pk: int
source: str
issue_id: int
formats: tuple[str, ...] = ("COMIC_INFO",)
delete_original: bool = False
rename: bool = False
# Additional (source, issue_id) pairs to fetch by explicit id and merge
# onto the primary fetch — the Tag-by-id "merge all sources" path. Empty
# means a single-source fetch.
extra_ids: tuple[tuple[str, int], ...] = ()


@dataclass
class OnlineTagPromptResponseTask(OnlineTagTask):
"""Admin response to a deferred online tagging prompt (keyed by fingerprint)."""
Expand Down
11 changes: 10 additions & 1 deletion codex/librarian/scribe/importer/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,16 @@ def get_through_model(field: ManyToManyField) -> type[BaseModel]:
#########
# MOVED #
#########
MOVED_BULK_COMIC_UPDATE_FIELDS = ("path", "parent_folder", "stat", "updated_at")
# ``stat`` is deliberately absent: the stored stat means "the state of the
# file the last time its tags were imported", not "the last time codex saw
# the file". A pure rename changes neither inode, mtime, nor size, so the
# preserved stat still matches disk exactly and inode move-detection keeps
# working. When content changed too (an external tagger writes tags and
# then renames), the preserved stat is the only evidence the read phase's
# filesystem prefilter has that the archive needs re-reading. update_comics
# writes a fresh stat once the new tags actually import
# (ALWAYS_UPDATE_COMIC_FIELDS).
MOVED_BULK_COMIC_UPDATE_FIELDS = ("path", "parent_folder", "updated_at")
CUSTOM_COVER_UPDATE_FIELDS = ("path", "stat", "updated_at", "sort_name", "collection")

###########
Expand Down
6 changes: 5 additions & 1 deletion codex/librarian/scribe/importer/failed/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,12 @@ def _bulk_create_failed_imports(
library=self.library, path=path, parent_folder=None
)
fi.set_reason(exc)
create_objs.append(fi)
# presave() stats the path, so it must succeed before
# the row is queued. A file that vanished mid-import
# (renamed out from under us) raises here and gets no
# failed import row.
fi.presave()
create_objs.append(fi)
except OSError:
self.log.warning(
f"Error preparing failed import create for {path}: {exc}"
Expand Down
4 changes: 3 additions & 1 deletion codex/librarian/scribe/importer/read/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ def _extract_post_process_comic(
result, exc = value
path_str = str(path)
if exc:
self.metadata[FIS].update({path: exc})
# str keys, not Path: the failed import phase matches these
# against path strings queried from the db.
self.metadata[FIS].update({path_str: exc})
self.metadata[SKIPPED].add(path_str)
status.increment_complete()
self.status_controller.update(status)
Expand Down
Loading