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
13 changes: 13 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,21 @@ border-radius: 128px;

## v2.2.12

- Features
- Sort by Alternate Number, the issue number a comic carries inside its
alternate series (ComicInfo AlternateSeries / AlternateNumber). Filter by
an alternate series first to pick which one to sort by. Comics with no
alternate number fall back to their own issue number.
- The Alternate Series sort is available in cover view, not just the table.
Comics with no alternate series sort by their real series name.
- Read an alternate series as a reading order: pick it in the reader's
reading-order menu and next/prev follow the alternate numbering. Handy for
using alternate series tags as durable reading lists.

- Fixes
- The Admin Tagging Status table shows what is being looked up right now.
- Sorting by a tag column outside the table view (cover cards, OPDS feeds)
no longer errors.

## v2.2.11

Expand Down
10 changes: 8 additions & 2 deletions codex/choices/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
{
"created_at": "Added Time",
"age_rating": "Age Rating",
"alternate_number": "Alternate Number",
"reprints": "Alternate Series",
"characters": "Characters",
"child_count": "Child Count",
Expand Down Expand Up @@ -75,6 +76,8 @@
{
"created_at",
"age_rating",
"alternate_number",
"reprints",
"child_count",
"community_rating",
"filename",
Expand All @@ -94,12 +97,15 @@
# They sort fine as the primary, but the per-extra annotation
# pipeline can't safely produce a value for them on every model
# / context: ``story_arc_number`` requires StoryArc-context ``pks``
# to resolve which arc's number to pick, and ``search_score``'s
# ``ComicFTSRank`` only resolves when an FTS subquery is joined.
# to resolve which arc's number to pick, ``alternate_number`` likewise
# needs the ``reprints`` filter to resolve which alternate series'
# number to pick, and ``search_score``'s ``ComicFTSRank`` only
# resolves when an FTS subquery is joined.
# Mirrored on the frontend so the table headers can grey out the
# affected columns and refuse the shift-click.
BROWSER_EXTRA_SORT_UNSUPPORTED_KEYS = frozenset(
{
"alternate_number",
"story_arc_number",
"search_score",
}
Expand Down
9 changes: 9 additions & 0 deletions codex/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,12 @@ def collection(self) -> str:
Collection.ARC: "Story Arcs",
}
)

# A reader-only pseudo-collection: the reader can follow an alternate
# series (ComicInfo ``AlternateSeries``) as a reading order, ordered by
# ``AlternateNumber``. Deliberately *not* a :class:`Collection` member —
# alternate series aren't browsable, and every map above is exhaustive
# over the enum, so a member without entries would break lookups that
# assume a browse route and a cover exists.
READER_REPRINT_COLLECTION: Final[str] = "reprints"
READER_REPRINT_LABEL: Final[str] = "Alternate Series"
7 changes: 7 additions & 0 deletions codex/librarian/scribe/importer/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,14 @@ def get_through_model(field: ManyToManyField) -> type[BaseModel]:
*COLLECTION_FIELD_NAMES,
"story_arc_numbers",
"folders",
# Not a browse collection, but the reader reads alternate series as a
# reading order off ``Reprint.updated_at``, so a comic leaving one must
# re-stamp it the same way a story arc does.
REPRINTS_FIELD_NAME,
)
# Comic m2m fields whose target model *is* the collection row (no
# intermediate like ``StoryArcNumber`` to walk through).
DIRECT_M2M_COLLECTION_FIELD_NAMES = frozenset({"folders", REPRINTS_FIELD_NAME})

##########
# Failed #
Expand Down
16 changes: 10 additions & 6 deletions codex/librarian/scribe/importer/delete/comics.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"""Delete comics methods."""

from codex.librarian.scribe.importer.const import ALL_COMIC_COLLECTION_FIELD_NAMES
from codex.librarian.scribe.importer.const import (
ALL_COMIC_COLLECTION_FIELD_NAMES,
DIRECT_M2M_COLLECTION_FIELD_NAMES,
)
from codex.librarian.scribe.importer.delete.covers import DeletedCoversImporter
from codex.librarian.scribe.importer.delete.existence import confirm_deleted
from codex.librarian.scribe.importer.statii.delete import ImporterRemoveComicsStatus
from codex.models import Comic, Folder, StoryArc
from codex.models import Comic, StoryArc
from codex.settings import (
IMPORTER_DELETE_MAX_CHUNK_SIZE,
IMPORTER_LINK_FK_BATCH_SIZE,
Expand Down Expand Up @@ -40,9 +43,10 @@ def _populate_deleted_comic_collection(deleted_comic_collections, comic) -> None
"story_arc"
):
deleted_comic_collections[StoryArc].add(san.story_arc.pk)
elif field_name == "folders":
for folder in comic.folders.only("pk"):
deleted_comic_collections[Folder].add(folder.pk)
elif field_name in DIRECT_M2M_COLLECTION_FIELD_NAMES:
related_model = comic._meta.get_field(field_name).related_model
for obj in getattr(comic, field_name).only("pk"):
deleted_comic_collections[related_model].add(obj.pk)
else:
related_model = comic._meta.get_field(field_name).related_model
related_id = getattr(comic, field_name).pk
Expand All @@ -55,7 +59,7 @@ def _populate_deleted_comic_collections(
"""Populate changed collections for cover timestamp updater."""
comics_deleted_qs = delete_qs.only(
*ALL_COMIC_COLLECTION_FIELD_NAMES
).prefetch_related("story_arc_numbers__story_arc")
).prefetch_related("story_arc_numbers__story_arc", *DIRECT_M2M_COLLECTION_FIELD_NAMES)
for comic in comics_deleted_qs.iterator(
chunk_size=IMPORTER_DELETE_MAX_CHUNK_SIZE
):
Expand Down
5 changes: 4 additions & 1 deletion codex/librarian/scribe/importer/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
if TYPE_CHECKING:
from codex.models.base import BaseModel
from codex.models.collections import BrowserCollectionModel, Folder
from codex.models.named import Reprint

_WRITE_WAIT_EXPIRY = 60

Expand Down Expand Up @@ -129,7 +130,9 @@ def __init__(
# move only re-stamps the destination (the one collection a current
# comic still points into) and the browser's ``library.changed`` refresh
# gate never sees the source view change. Keyed by collection model.
self.moved_source_collections: dict[type[BrowserCollectionModel], set[int]] = {}
self.moved_source_collections: dict[
type[BrowserCollectionModel | Reprint], set[int]
] = {}
# Full set of paths this import touched, captured before the chunking
# loop and extract phase zero out ``task.files_*``. Consumed at finish
# to stamp ``Comic.metadata_imported_at`` on every comic a forced/lazy
Expand Down
14 changes: 10 additions & 4 deletions codex/librarian/scribe/importer/query/links_m2m.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
FIELD_NAME_KEY_ATTRS_MAP,
FOLDERS_FIELD_NAME,
LINK_M2MS,
REPRINTS_FIELD_NAME,
STORY_ARC_FIELD_NAME,
STORY_ARC_NUMBERS_FIELD_NAME,
get_through_model,
Expand All @@ -17,7 +18,7 @@
from codex.models.base import NamedModel
from codex.models.collections import BrowserCollectionModel, Folder
from codex.models.comic import Comic
from codex.models.named import StoryArc
from codex.models.named import Reprint, StoryArc
from codex.settings import (
IMPORTER_LINK_FK_BATCH_SIZE,
IMPORTER_LINK_M2M_BATCH_SIZE,
Expand Down Expand Up @@ -71,15 +72,20 @@ def _record_removed_m2m_source_collection(
leaving one must re-stamp the SOURCE arc/folder — ``TimestampUpdater``
only re-stamps collections a *current* comic still links into. Mirrors
the FK move capture in ``CreateComicsImporter``; the delete phase folds
these into the force-update map. Tag-style m2ms (genres, characters, …)
are not collections and are ignored here.
these into the force-update map. ``Reprint`` rides along because the
reader reads alternate series as a reading order off its timestamp.
Tag-style m2ms (genres, characters, …) are not collections and are
ignored here.
"""
if field_name == STORY_ARC_NUMBERS_FIELD_NAME:
model: type[BrowserCollectionModel] = StoryArc
model: type[BrowserCollectionModel | Reprint] = StoryArc
source_pk = story_arc_pk
elif field_name == FOLDERS_FIELD_NAME:
model = Folder
source_pk = target_pk
elif field_name == REPRINTS_FIELD_NAME:
model = Reprint
source_pk = target_pk
else:
return
if source_pk is not None:
Expand Down
20 changes: 15 additions & 5 deletions codex/librarian/scribe/timestamp_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,29 @@
from codex.librarian.notifier.tasks import LIBRARY_CHANGED_TASK
from codex.librarian.scribe.status import UpdateCollectionTimestampsStatus
from codex.librarian.worker import WorkerStatusBase
from codex.models import StoryArc, Volume
from codex.models import Reprint, StoryArc, Volume
from codex.models.collections import BrowserCollectionModel
from codex.models.library import Library
from codex.settings import IMPORTER_LINK_FK_BATCH_SIZE
from codex.views.const import COLLECTION_MODELS

# Rows whose ``updated_at`` gates a client-side reload. Browse collections
# bust cover caches; ``Reprint`` is not browsable but the reader offers
# alternate series as a reading order, and its arc mtime is read from these
# rows — without a re-stamp an open reader never notices a re-import.
_TIMESTAMP_MODELS = (*COLLECTION_MODELS, Reprint)

# Volumes never carry their own custom cover, and ``Reprint`` has no
# ``custom_cover`` column at all.
_NO_CUSTOM_COVER_MODELS = frozenset({Volume, Reprint})


class TimestampUpdater(WorkerStatusBase):
"""Update Collections timestamp for cover cache busting."""

@staticmethod
def _get_update_filter(
model: type[BrowserCollectionModel],
model: type[BrowserCollectionModel | Reprint],
start_time: datetime,
force_update_collection_map: Mapping,
library: Library,
Expand Down Expand Up @@ -55,7 +65,7 @@ def _get_update_filter(
# its own has-children join test. This used to be a
# Count-aggregate filter over the whole OR — a per-row
# GROUP BY join explosion costing ~1.5s per import.
if model != Volume:
if model not in _NO_CUSTOM_COVER_MODELS:
update_filter |= Q(custom_cover__updated_at__gt=start_floor) & Q(
**{rel + "comic__isnull": False}
)
Expand All @@ -75,7 +85,7 @@ def _get_update_filter(
def _update_collection_model(
cls,
force_update_collection_map: Mapping,
model: type[BrowserCollectionModel],
model: type[BrowserCollectionModel | Reprint],
start_time: datetime,
library: Library,
log_list,
Expand Down Expand Up @@ -113,7 +123,7 @@ def update_library_collections(
self.status_controller.start(status)
try:
log_list = []
for model in COLLECTION_MODELS:
for model in _TIMESTAMP_MODELS:
count = self._update_collection_model(
force_update_collection_map, model, start_time, library, log_list
)
Expand Down
121 changes: 121 additions & 0 deletions codex/migrations/0053_reprint_issue_number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""
Split Reprint.issue into sortable number & suffix columns.

The ``alternate_number`` order_by key sorts comics by their
ComicInfo ``AlternateNumber`` within an alternate series. ``issue``
is a string, so sorting it directly puts "#10" before "#2"; these
derived columns mirror ``Comic.issue_number`` / ``issue_suffix``.

Generated by Django 6.1 on 2026-08-30 06:03.
"""

from django.db import migrations, models

import codex.models.fields
from codex.models.util import parse_issue_parts

_BATCH_SIZE = 1000


def _backfill_reprint_issue_parts(apps, _schema_editor) -> None:
"""Derive issue_number & issue_suffix for existing reprints."""
reprint_model = apps.get_model("codex", "Reprint")
# Historical models don't carry ``presave``, so parse here.
updates = []
for reprint in reprint_model.objects.exclude(issue="").iterator():
issue_number, issue_suffix = parse_issue_parts(reprint.issue)
if issue_number is None and not issue_suffix:
continue
reprint.issue_number = issue_number
reprint.issue_suffix = issue_suffix
updates.append(reprint)

for start in range(0, len(updates), _BATCH_SIZE):
reprint_model.objects.bulk_update(
updates[start : start + _BATCH_SIZE], ["issue_number", "issue_suffix"]
)


class Migration(migrations.Migration):
"""Add Reprint issue sort columns and backfill them."""

dependencies = [
("codex", "0052_comicboxtaggingdefaults_comicvine_url"),
]

operations = [
migrations.AddField(
model_name="reprint",
name="issue_number",
field=codex.models.fields.CoercingDecimalField(
decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="reprint",
name="issue_suffix",
field=codex.models.fields.CleaningCharField(
db_collation="nocase", default="", max_length=16
),
),
migrations.AlterField(
model_name="settingsbrowser",
name="order_by",
field=models.CharField(
choices=[
("created_at", "Added Time"),
("age_rating", "Age Rating"),
("alternate_number", "Alternate Number"),
("reprints", "Alternate Series"),
("characters", "Characters"),
("child_count", "Child Count"),
("community_rating", "Community Rating"),
("country", "Country"),
("credits", "Credits"),
("day", "Day"),
("favorite", "Favorite"),
("filename", "Filename"),
("size", "File Size"),
("file_type", "File Type"),
("original_format", "Format"),
("genres", "Genres"),
("identifiers", "Identifiers"),
("imprint_name", "Imprint"),
("issue", "Issue"),
("language", "Language"),
("bookmark_updated_at", "Last Read"),
("locations", "Locations"),
("main_character", "Main Character"),
("main_team", "Main Team"),
("metadata_mtime", "Tags Updated"),
("month", "Month"),
("monochrome", "Monochrome"),
("sort_name", "Name"),
("page_count", "Page Count"),
("publisher_name", "Publisher"),
("date", "Publish Date"),
("reading_direction", "Reading Direction"),
("scan_info", "Scan Info"),
("search_score", "Search Score"),
("series_name", "Series"),
("series_groups", "Series Groups"),
("stories", "Stories"),
("story_arc_number", "Story Arc Number"),
("story_arcs", "Story Arcs"),
("tags", "Tags"),
("tagger", "Tagger"),
("teams", "Teams"),
("universes", "Universes"),
("updated_at", "Updated Time"),
("volume_name", "Volume"),
("year", "Year"),
],
default="",
max_length=32,
),
),
migrations.RunPython(
_backfill_reprint_issue_parts,
migrations.RunPython.noop,
),
]
Loading