Unused-tag fandom fix + ML-worker logging/tuning + unified dropdown Enter #86

Merged
bvandeusen merged 5 commits from dev into main 2026-06-08 17:27:56 -04:00
9 changed files with 243 additions and 91 deletions
+21 -5
View File
@@ -21,6 +21,7 @@ from sqlalchemy import func, or_, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models import Artist, ImageRecord, LibraryAuditRun, Tag from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage from ..models.series_page import SeriesPage
from ..models.tag import image_tag from ..models.tag import image_tag
@@ -141,21 +142,36 @@ def count_tag_associations(session: Session, *, tag_id: int) -> int:
def find_unused_tags( def find_unused_tags(
session: Session, *, limit: int | None = None, session: Session, *, limit: int | None = None,
) -> list[Tag]: ) -> list[Tag]:
"""Tags with no image_tag rows AND no series_page rows. """Tags genuinely referenced by nothing — safe to sweep.
Sorted by name. Used by both dry-run preview and the live prune. Sorted by name. Used by both dry-run preview and the live prune. A tag is
A tag is "unused" iff it has zero rows in image_tag AND zero rows "unused" iff it has zero references across ALL the ways a tag can be in use:
in series_page (so we don't accidentally prune a series tag that - image_tag (applied to an image)
happens to have no images yet). - series_page (a series tag with ordered pages)
- series_chapter (a series tag with chapters but no pages yet, e.g.
all-placeholder)
- tag.fandom_id (a fandom referenced by a character)
The fandom check is essential: fandom tags are NEVER applied to images — a
character carries its fandom via fandom_id — so without it every assigned
fandom looked "unused", and the FK is ondelete=SET NULL, so deleting one
would silently strip the fandom off all its characters (operator-flagged
2026-06-08).
""" """
used_via_image_tag = select(image_tag.c.tag_id).distinct() used_via_image_tag = select(image_tag.c.tag_id).distinct()
used_via_series = select(SeriesPage.series_tag_id).where( used_via_series = select(SeriesPage.series_tag_id).where(
SeriesPage.series_tag_id.is_not(None) SeriesPage.series_tag_id.is_not(None)
).distinct() ).distinct()
used_via_chapter = select(SeriesChapter.series_tag_id).distinct()
used_via_fandom = select(Tag.fandom_id).where(
Tag.fandom_id.is_not(None)
).distinct()
stmt = ( stmt = (
select(Tag) select(Tag)
.where(Tag.id.not_in(used_via_image_tag)) .where(Tag.id.not_in(used_via_image_tag))
.where(Tag.id.not_in(used_via_series)) .where(Tag.id.not_in(used_via_series))
.where(Tag.id.not_in(used_via_chapter))
.where(Tag.id.not_in(used_via_fandom))
.order_by(Tag.name) .order_by(Tag.name)
) )
if limit is not None: if limit is not None:
+126 -53
View File
@@ -6,8 +6,10 @@ apply_allowlist_tags sweeps which are 'maintenance' lane. Sync sessions
(Celery workers are sync processes), same pattern as FC-2a tasks. (Celery workers are sync processes), same pattern as FC-2a tasks.
""" """
import logging
from pathlib import Path from pathlib import Path
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, OperationalError from sqlalchemy.exc import DBAPIError, OperationalError
@@ -15,6 +17,8 @@ from ..celery_app import celery
from ..models import ImageRecord, MLSettings from ..models import ImageRecord, MLSettings
from ._sync_engine import sync_session_factory as _sync_session_factory from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images") IMAGES_ROOT = Path("/images")
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"} VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"}
@@ -31,8 +35,8 @@ def _is_video(path: Path) -> bool:
retry_backoff_max=60, retry_backoff_max=60,
retry_jitter=True, retry_jitter=True,
max_retries=3, max_retries=3,
# Sized for the video branch: sample 10 frames, run tagger + # Sized for the video branch: sample 6 frames, run tagger +
# embedder on each (≈20 GPU ops vs 2 for an image). A loaded # embedder on each (≈12 GPU ops vs 2 for an image). A loaded
# ml-worker can take 5-10 min on a long video; bumped from # ml-worker can take 5-10 min on a long video; bumped from
# 5min/7min on 2026-05-28 after operator-flagged image 6288 (a # 5min/7min on 2026-05-28 after operator-flagged image 6288 (a
# .mp4) hit the recovery sweep at 5 min while still legitimately # .mp4) hit the recovery sweep at 5 min while still legitimately
@@ -46,71 +50,140 @@ def tag_and_embed(self, image_id: int) -> dict:
then enqueue per-image allowlist application. then enqueue per-image allowlist application.
Video: sample frames between 10% and 90% of duration (VIDEO_ML_FRAMES, Video: sample frames between 10% and 90% of duration (VIDEO_ML_FRAMES,
default 10). Max-pool tagger confidences across frames, mean-pool the default 6). Max-pool tagger confidences across frames, mean-pool the
SigLIP embeddings. On no-frames returns status='no_frames' (not an error). SigLIP embeddings. On no-frames returns status='no_frames' (not an error).
""" """
import os import os
import time
from ..services.ml.embedder import get_embedder from ..services.ml.embedder import get_embedder
from ..services.ml.tagger import get_tagger from ..services.ml.tagger import get_tagger
SessionLocal = _sync_session_factory() # Phase + file context, so a timeout/crash names WHICH file and WHERE it
with SessionLocal() as session: # died instead of a bare SoftTimeLimitExceeded() (operator-flagged 2026-06-08:
record = session.get(ImageRecord, image_id) # the activity told them nothing about the file or why). `ctx` is enriched
if record is None: # once the record is loaded; both feed the worker log AND the re-raised
return {"status": "missing", "image_id": image_id} # exception message (which becomes the activity's error_message).
settings = session.execute( started = time.monotonic()
select(MLSettings).where(MLSettings.id == 1) phase = "open_session"
).scalar_one() ctx = f"image_id={image_id}"
src = Path(record.path) def _elapsed() -> float:
if not src.is_file(): return time.monotonic() - started
return {"status": "file_missing", "image_id": image_id}
tagger = get_tagger() try:
embedder = get_embedder() SessionLocal = _sync_session_factory()
with SessionLocal() as session:
record = session.get(ImageRecord, image_id)
if record is None:
return {"status": "missing", "image_id": image_id}
settings = session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
if _is_video(src): src = Path(record.path)
# Layer-3 isolation: ffprobe (a separate process) validates is_vid = _is_video(src)
# the container before we burn ~20 GPU ops sampling frames ctx = (
# from it. A corrupt video that would crash the frame f"image_id={image_id} path={record.path} mime={record.mime} "
# decoder is rejected cleanly here instead of taking down f"bytes={record.size_bytes} video={is_vid}"
# the ml-worker. Operator-flagged 2026-05-28.
from ..utils import safe_probe
vprobe = safe_probe.probe_video(src)
if not vprobe.ok:
return {
"status": "bad_video", "image_id": image_id,
"reason": vprobe.reason,
}
frames = _sample_video_frames(
src, int(os.environ.get("VIDEO_ML_FRAMES", "10"))
) )
if not frames: log.info("tag_and_embed start: %s", ctx)
return {"status": "no_frames", "image_id": image_id} if not src.is_file():
preds = _maxpool_predictions([tagger.infer(f) for f in frames]) log.warning("tag_and_embed file missing on disk: %s", ctx)
import numpy as np return {"status": "file_missing", "image_id": image_id}
embedding = np.mean( phase = "load_models"
[embedder.infer(f) for f in frames], axis=0 tagger = get_tagger()
).astype("float32") embedder = get_embedder()
for f in frames:
f.unlink(missing_ok=True)
else:
raw = tagger.infer(src)
preds = {
name: {"category": p.category, "confidence": p.confidence}
for name, p in raw.items()
}
embedding = embedder.infer(src)
record.tagger_predictions = preds if is_vid:
record.tagger_model_version = settings.tagger_model_version # Layer-3 isolation: ffprobe (a separate process) validates
record.siglip_embedding = embedding.tolist() # the container before we burn ~20 GPU ops sampling frames
record.siglip_model_version = settings.embedder_model_version # from it. A corrupt video that would crash the frame
session.add(record) # decoder is rejected cleanly here instead of taking down
session.commit() # the ml-worker. Operator-flagged 2026-05-28.
phase = "video_probe"
from ..utils import safe_probe
vprobe = safe_probe.probe_video(src)
if not vprobe.ok:
log.warning(
"tag_and_embed bad video (%s): %s", vprobe.reason, ctx
)
return {
"status": "bad_video", "image_id": image_id,
"reason": vprobe.reason,
}
phase = "video_sample_frames"
t0 = time.monotonic()
frames = _sample_video_frames(
src, int(os.environ.get("VIDEO_ML_FRAMES", "6"))
)
log.info(
"tag_and_embed sampled %d frame(s) in %.1fs: %s",
len(frames), time.monotonic() - t0, ctx,
)
if not frames:
return {"status": "no_frames", "image_id": image_id}
phase = "video_infer"
import numpy as np
preds = _maxpool_predictions([tagger.infer(f) for f in frames])
embedding = np.mean(
[embedder.infer(f) for f in frames], axis=0
).astype("float32")
for f in frames:
f.unlink(missing_ok=True)
else:
phase = "tag"
t0 = time.monotonic()
raw = tagger.infer(src)
log.info(
"tag_and_embed tagged in %.1fs (%d tags): %s",
time.monotonic() - t0, len(raw), ctx,
)
preds = {
name: {"category": p.category, "confidence": p.confidence}
for name, p in raw.items()
}
phase = "embed"
t0 = time.monotonic()
embedding = embedder.infer(src)
log.info(
"tag_and_embed embedded in %.1fs: %s",
time.monotonic() - t0, ctx,
)
phase = "persist"
record.tagger_predictions = preds
record.tagger_model_version = settings.tagger_model_version
record.siglip_embedding = embedding.tolist()
record.siglip_model_version = settings.embedder_model_version
session.add(record)
session.commit()
except SoftTimeLimitExceeded:
log.error(
"tag_and_embed TIMED OUT after %.0fs in phase=%s: %s",
_elapsed(), phase, ctx,
)
# Re-raise as SoftTimeLimitExceeded (preserves the 'timeout' status in
# the task_run signal) but WITH context, so the activity error_message
# names the file + phase instead of being empty.
raise SoftTimeLimitExceeded(
f"timed out in phase={phase} after {_elapsed():.0f}s ({ctx})"
) from None
except Exception:
# OSError/DBAPIError/OperationalError are autoretried — re-raise the
# ORIGINAL so the type is preserved; just make sure it's logged with
# context first.
log.exception(
"tag_and_embed FAILED in phase=%s after %.0fs: %s",
phase, _elapsed(), ctx,
)
raise
log.info(
"tag_and_embed ok in %.1fs (%d tags): %s", _elapsed(), len(preds), ctx
)
apply_allowlist_tags.delay(image_id=image_id) apply_allowlist_tags.delay(image_id=image_id)
return {"status": "ok", "image_id": image_id, "tags": len(preds)} return {"status": "ok", "image_id": image_id, "tags": len(preds)}
@@ -8,6 +8,7 @@
</p> </p>
<v-autocomplete <v-autocomplete
v-model="selectedId" v-model="selectedId"
v-model:menu="menuOpen"
:items="results" :items="results"
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name" :item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
:item-value="(t) => t.id" :item-value="(t) => t.id"
@@ -15,6 +16,7 @@
label="Canonical tag" label="Canonical tag"
no-filter clearable density="compact" no-filter clearable density="compact"
@update:search="onSearch" @update:search="onSearch"
@keydown.enter.capture="onEnter"
/> />
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
@@ -31,9 +33,10 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../../composables/useApi.js' import { useApi } from '../../composables/useApi.js'
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
const props = defineProps({ category: { type: String, required: true } }) const props = defineProps({ category: { type: String, required: true } })
defineEmits(['confirm', 'cancel']) const emit = defineEmits(['confirm', 'cancel'])
const api = useApi() const api = useApi()
const results = ref([]) const results = ref([])
@@ -41,6 +44,11 @@ const loading = ref(false)
const selectedId = ref(null) const selectedId = ref(null)
let debounce = null let debounce = null
// Enter on the closed dropdown confirms the selection instead of re-opening it.
const { menuOpen, onEnter } = useAcceptOnEnter(() => {
if (selectedId.value != null) emit('confirm', selectedId.value)
})
function onSearch(q) { function onSearch(q) {
if (debounce) clearTimeout(debounce) if (debounce) clearTimeout(debounce)
debounce = setTimeout(async () => { debounce = setTimeout(async () => {
+4 -17
View File
@@ -51,16 +51,19 @@
<script setup> <script setup>
import { nextTick, onMounted, ref } from 'vue' import { nextTick, onMounted, ref } from 'vue'
import { useTagStore } from '../../stores/tags.js' import { useTagStore } from '../../stores/tags.js'
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
const emit = defineEmits(['confirm', 'cancel']) const emit = defineEmits(['confirm', 'cancel'])
const store = useTagStore() const store = useTagStore()
const selectedId = ref(null) const selectedId = ref(null)
const newName = ref('') const newName = ref('')
const menuOpen = ref(false)
const fandomRef = ref(null) const fandomRef = ref(null)
const newNameRef = ref(null) const newNameRef = ref(null)
// Enter on the closed dropdown accepts the chosen fandom instead of re-opening it.
const { menuOpen, onEnter: onSearchEnter } = useAcceptOnEnter(() => onConfirm())
// Always refresh on open so the list reflects fandoms created elsewhere (#712). // Always refresh on open so the list reflects fandoms created elsewhere (#712).
onMounted(() => store.loadFandoms()) onMounted(() => store.loadFandoms())
@@ -92,22 +95,6 @@ function onConfirm () {
if (f) emit('confirm', f) if (f) emit('confirm', f)
} }
// Enter on the search field — bound in the CAPTURE phase so this runs BEFORE
// Vuetify's own input keydown handler (which opens the menu on Enter). When the
// menu is open, let Vuetify pick the highlighted item (it sets selectedId +
// closes the menu). When it's closed and a fandom is already chosen, accept it
// and stop the event so Vuetify never (re)opens the menu — that re-open was the
// bug: Enter popped the dropdown instead of submitting (operator-flagged
// 2026-06-07).
function onSearchEnter (e) {
if (menuOpen.value) return
if (selectedId.value != null) {
e.preventDefault()
e.stopPropagation()
onConfirm()
}
}
// Tab from the search field goes to the "new fandom" text field (operator- // Tab from the search field goes to the "new fandom" text field (operator-
// specified). Shift+Tab keeps normal reverse traversal. // specified). Shift+Tab keeps normal reverse traversal.
function onSearchTab (e) { function onSearchTab (e) {
@@ -82,6 +82,7 @@
<script setup> <script setup>
import { nextTick, onMounted, ref } from 'vue' import { nextTick, onMounted, ref } from 'vue'
import { useTagStore } from '../../stores/tags.js' import { useTagStore } from '../../stores/tags.js'
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
const props = defineProps({ tag: { type: Object, required: true } }) const props = defineProps({ tag: { type: Object, required: true } })
const emit = defineEmits(['updated', 'cancel']) const emit = defineEmits(['updated', 'cancel'])
@@ -93,10 +94,15 @@ const newName = ref('')
const busy = ref(false) const busy = ref(false)
const error = ref(null) const error = ref(null)
const collision = ref(null) const collision = ref(null)
const menuOpen = ref(false)
const fandomRef = ref(null) const fandomRef = ref(null)
const newNameRef = ref(null) const newNameRef = ref(null)
// Enter on the closed dropdown Saves the changed fandom instead of re-opening it.
const { menuOpen, onEnter: onSearchEnter } = useAcceptOnEnter(() => {
if (busy.value) return
if (selectedId.value !== (props.tag.fandom_id ?? null)) onSave()
})
// Always refresh on open so the list reflects fandoms created elsewhere (#712). // Always refresh on open so the list reflects fandoms created elsewhere (#712).
onMounted(() => store.loadFandoms()) onMounted(() => store.loadFandoms())
@@ -131,20 +137,6 @@ async function onCreate() {
} }
} }
// Enter on the search field — bound in the CAPTURE phase so it runs BEFORE
// Vuetify's own handler (which opens the menu on Enter). Menu open → let Vuetify
// pick the highlighted item. Menu closed with a changed selection → Save and stop
// the event so Vuetify never (re)opens the dropdown (the bug this fixes).
function onSearchEnter(e) {
if (menuOpen.value) return
if (busy.value) return
if (selectedId.value !== (props.tag.fandom_id ?? null)) {
e.preventDefault()
e.stopPropagation()
onSave()
}
}
// Tab from the search field goes to the "new fandom" text field (operator- // Tab from the search field goes to the "new fandom" text field (operator-
// specified). Shift+Tab keeps normal reverse traversal. // specified). Shift+Tab keeps normal reverse traversal.
function onSearchTab(e) { function onSearchTab(e) {
@@ -32,11 +32,13 @@
</p> </p>
<v-autocomplete <v-autocomplete
v-model="picked" v-model="picked"
v-model:menu="pickerMenuOpen"
:items="hits" :items="hits"
:item-title="(s) => s.name" :item-title="(s) => s.name"
:item-value="(s) => s.id" :item-value="(s) => s.id"
:loading="searching" :loading="searching"
label="Series" density="compact" autofocus clearable label="Series" density="compact" autofocus clearable
@keydown.enter.capture="onEnter"
/> />
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
@@ -56,6 +58,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js' import { useApi } from '../../composables/useApi.js'
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
const props = defineProps({ post: { type: Object, required: true } }) const props = defineProps({ post: { type: Object, required: true } })
@@ -70,6 +73,12 @@ const picked = ref(null)
const hits = ref([]) const hits = ref([])
const searching = ref(false) const searching = ref(false)
// Enter on the closed dropdown adds the chosen series instead of re-opening it.
// (aliased — `menuOpen` above is the outer "Series " v-menu's state.)
const { menuOpen: pickerMenuOpen, onEnter } = useAcceptOnEnter(() => {
if (picked.value != null && !busy.value) onAddExisting()
})
async function onPromote() { async function onPromote() {
menuOpen.value = false menuOpen.value = false
busy.value = true busy.value = true
@@ -0,0 +1,35 @@
import { ref } from 'vue'
/**
* Unifies keyboard behavior for confirm-style `v-autocomplete` / `v-select`
* dropdowns — a single picker whose Enter should trigger the surrounding
* modal's primary action (Merge, Add, Save, Use…).
*
* The recurring bug (operator-flagged across the merge / fandom / series / alias
* dialogs): pressing Enter with the menu CLOSED re-opens the dropdown instead of
* accepting the chosen value, because Vuetify's own Enter handler opens the menu.
* We bind in the CAPTURE phase so this runs BEFORE Vuetify's handler:
* - menu OPEN → return; let Vuetify pick the highlighted item (it sets the
* value and closes the menu).
* - menu CLOSED → preventDefault + stopPropagation so Vuetify can't re-open the
* menu, then call `accept()` — the modal's confirm. `accept`
* should itself no-op when nothing valid is selected.
*
* Usage:
* const { menuOpen, onEnter } = useAcceptOnEnter(onConfirm)
* <v-autocomplete v-model:menu="menuOpen" @keydown.enter.capture="onEnter" … />
*
* `menuOpen` is exposed so a component that programmatically focuses the field
* can force the menu shut (so the very next Enter accepts rather than being
* swallowed as a menu interaction).
*/
export function useAcceptOnEnter(accept) {
const menuOpen = ref(false)
function onEnter(e) {
if (menuOpen.value) return
e.preventDefault()
e.stopPropagation()
accept()
}
return { menuOpen, onEnter }
}
+7
View File
@@ -55,11 +55,13 @@
</p> </p>
<v-autocomplete <v-autocomplete
v-model="mergeTargetId" v-model="mergeTargetId"
v-model:menu="mergeMenuOpen"
:items="mergeHits" item-title="name" item-value="id" :items="mergeHits" item-title="name" item-value="id"
:loading="mergeLoading" :loading="mergeLoading"
density="compact" variant="outlined" hide-details density="compact" variant="outlined" hide-details
placeholder="Search target tag…" no-filter placeholder="Search target tag…" no-filter
@update:search="onMergeSearch" @update:search="onMergeSearch"
@keydown.enter.capture="onMergeEnter"
/> />
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
@@ -107,6 +109,7 @@ import { useTagDirectoryStore } from '../stores/tagDirectory.js'
import { useAdminStore } from '../stores/admin.js' import { useAdminStore } from '../stores/admin.js'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js' import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import { useAcceptOnEnter } from '../composables/useAcceptOnEnter.js'
import TagCard from '../components/discovery/TagCard.vue' import TagCard from '../components/discovery/TagCard.vue'
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue' import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue' import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
@@ -181,6 +184,10 @@ const api = useApi()
// Tag merge via dots-menu (separate from inline-rename collision flow above) // Tag merge via dots-menu (separate from inline-rename collision flow above)
const mergePickerOpen = ref(false) const mergePickerOpen = ref(false)
// Enter on the closed dropdown accepts the target instead of re-opening it.
const { menuOpen: mergeMenuOpen, onEnter: onMergeEnter } = useAcceptOnEnter(
() => onMergeConfirm()
)
const mergeSource = ref(null) const mergeSource = ref(null)
const mergeTargetId = ref(null) const mergeTargetId = ref(null)
const mergeHits = ref([]) const mergeHits = ref([])
+25
View File
@@ -155,6 +155,31 @@ def test_find_unused_tags_returns_only_unreferenced(db_sync, tmp_path):
assert names.index("aaa-unused") < names.index("zzz-unused") assert names.index("aaa-unused") < names.index("zzz-unused")
def test_find_unused_tags_excludes_fandom_referenced_by_character(db_sync):
# A fandom is never on an image — a character carries it via fandom_id — so
# an assigned fandom must NOT be flagged unused (deleting it SET-NULLs the
# fandom off its characters). Operator-flagged 2026-06-08.
fandom = _make_tag(db_sync, name="Creux", kind=TagKind.fandom)
char = _make_tag(db_sync, name="OcChar", kind=TagKind.character)
char.fandom_id = fandom.id
_make_tag(db_sync, name="NobodysFandom", kind=TagKind.fandom)
db_sync.commit()
names = [t.name for t in cleanup_service.find_unused_tags(db_sync)]
assert "Creux" not in names # referenced by a character → kept
assert "NobodysFandom" in names # genuinely orphaned → still swept
def test_find_unused_tags_excludes_series_with_only_chapters(db_sync):
# An all-placeholder series has chapters but no pages yet — not unused.
series = _make_tag(db_sync, name="EmptySeries", kind=TagKind.series)
db_sync.add(SeriesChapter(series_tag_id=series.id, chapter_number=1))
db_sync.commit()
names = [t.name for t in cleanup_service.find_unused_tags(db_sync)]
assert "EmptySeries" not in names
# --- unlink_image_files --------------------------------------------- # --- unlink_image_files ---------------------------------------------