feat(library): read series from folders for comics and manga

Offer the one-folder-per-series layout Komga and Kavita use as a File as Book path-rule preset
rather than a new organization mode, so released iOS builds and both existing modes are unaffected.
Add a job that applies saved rules to books already in a library, and keep a run year such as
"Batman (2016)" when ComicInfo or a provider names the series without it.

Ref #1167
Ref #1918
Ref #839
This commit is contained in:
neon
2026-10-24 04:43:17 +00:00
parent faee78370d
commit 946855cd30
57 changed files with 2177 additions and 221 deletions
@@ -2,8 +2,9 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown, ArrowUp, FileCode, FileText, GripVertical, Headphones, RotateCcw } from '@lucide/vue'
import type { RegexMetadataConfig } from '@bookorbit/types'
import type { OrganizationMode, RegexMetadataConfig, RegexMetadataReapplyJob } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import LibraryRegexReapply from './LibraryRegexReapply.vue'
import RegexMetadataDialog from './RegexMetadataDialog.vue'
import { DEFAULT_FORMAT_PRIORITY, isReadAlongFormatKey, withReadAlongFormatPriority } from '@bookorbit/types'
import { formatNumber } from '@/i18n/formatters'
@@ -26,8 +27,15 @@ const props = withDefaults(
formatPriority: string[]
allowedFormats?: string[]
formatCounts?: Record<string, number> | null
organizationMode?: OrganizationMode
/** True once the library exists, so saved rules can be applied to the books already in it. */
canReapplyRules?: boolean
storedRegexMetadata?: RegexMetadataConfig | null
reapplyingRules?: boolean
reapplyJob?: RegexMetadataReapplyJob | null
reapplyErrorKey?: string | null
}>(),
{ allowedFormats: () => [], formatCounts: null },
{ allowedFormats: () => [], formatCounts: null, storedRegexMetadata: null, reapplyJob: null, reapplyErrorKey: null },
)
const emit = defineEmits<{
@@ -35,6 +43,7 @@ const emit = defineEmits<{
'update:regexOpen': [value: boolean]
'update:metadataPrecedence': [value: string[]]
'update:formatPriority': [value: string[]]
reapply: []
}>()
const regexOpen = ref(false)
@@ -185,6 +194,10 @@ function applyRegex(config: RegexMetadataConfig | null) {
closeRegex()
}
function reapplyRules() {
emit('reapply')
}
function sourceTitle(source: string) {
return t(
source === 'regex'
@@ -329,7 +342,23 @@ function sourceHint(source: string) {
</li>
</ol>
<p class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.metadata.regex.precedenceHint') }}</p>
<LibraryRegexReapply
v-if="canReapplyRules && (storedRegexMetadata || regexMetadata)"
:stored="storedRegexMetadata"
:pending="regexMetadata ?? null"
:running="reapplyingRules ?? false"
:job="reapplyJob"
:error-key="reapplyErrorKey"
@reapply="reapplyRules"
/>
</LibraryCreatorCard>
<RegexMetadataDialog v-if="regexOpen" :config="regexMetadata ?? null" :library-id="libraryId" @close="closeRegex" @apply="applyRegex" />
<RegexMetadataDialog
v-if="regexOpen"
:config="regexMetadata ?? null"
:library-id="libraryId"
:organization-mode="organizationMode"
@close="closeRegex"
@apply="applyRegex"
/>
</div>
</template>
@@ -32,6 +32,7 @@ import { useModal } from '@/composables/useModal'
import { formatNumber } from '@/i18n/formatters'
import { api } from '@/lib/api'
import { useLibraryAddedAt } from '../composables/useLibraryAddedAt'
import { useRegexMetadataReapply } from '../composables/useRegexMetadataReapply'
import { LIBRARY_ACCESS_KEY, useLibraryAccess } from '../composables/useLibraryAccess'
import { useLibraryCreator, type LibraryCreatorSectionId } from '../composables/useLibraryCreator'
import { writtenKindCount } from '../utils/library-summary'
@@ -92,6 +93,9 @@ const { form, mode, editingLibraryId, loading, folderChecks, stats, error, valid
const access = useLibraryAccess(editingLibraryId)
provide(LIBRARY_ACCESS_KEY, access)
const addedAtRecompute = useLibraryAddedAt(editingLibraryId)
const regexReapply = useRegexMetadataReapply(editingLibraryId)
/** Custom path rules the Series folders layout replaced in this session, kept outside the saved form. */
const replacedRegexMetadata = ref<Library['regexMetadata']>(null)
/** Below this the sheet cannot carry the rail, so sections become a drill-in list. */
const isWide = useMediaQuery('(min-width: 768px)')
@@ -289,6 +293,8 @@ const sectionProps = computed(() => ({
recomputeJob: addedAtRecompute.job.value,
recomputeErrorKey: addedAtRecompute.errorKey.value,
excludePatterns: form.excludePatterns,
regexMetadata: form.regexMetadata ?? null,
replacedRegexMetadata: replacedRegexMetadata.value,
},
metadata: {
metadataPrecedence: form.metadataPrecedence,
@@ -297,6 +303,12 @@ const sectionProps = computed(() => ({
formatPriority: form.formatPriority,
allowedFormats: form.allowedFormats,
formatCounts: stats.value?.formatCounts ?? null,
organizationMode: form.organizationMode,
canReapplyRules: !creating.value,
storedRegexMetadata: creator.storedRegexMetadata.value ?? null,
reapplyingRules: regexReapply.running.value,
reapplyJob: regexReapply.job.value,
reapplyErrorKey: regexReapply.errorKey.value,
},
reading: {
readingThreshold: form.readingThreshold,
@@ -484,6 +496,10 @@ function handleRecomputeAddedAt() {
if (!addedAtRecompute.running.value) void addedAtRecompute.start()
}
function handleReapplyRegexMetadata() {
if (!regexReapply.running.value) void regexReapply.start()
}
function handleWatchUpdate(value: boolean) {
if (podcast.value) form.watchLocalFolders = value
else form.watch = value
@@ -503,8 +519,10 @@ const sectionListeners = {
'update:organizationMode': (value: OrganizationMode) => (form.organizationMode = value),
'update:addedAtSource': (value: Library['addedAtSource']) => (form.addedAtSource = value),
recompute: handleRecomputeAddedAt,
reapply: handleReapplyRegexMetadata,
check: handleCheckFolders,
'update:regexMetadata': (value: Library['regexMetadata']) => (form.regexMetadata = value),
'update:replacedRegexMetadata': (value: Library['regexMetadata']) => (replacedRegexMetadata.value = value),
'update:metadataPrecedence': (value: string[]) => (form.metadataPrecedence = value),
'update:formatPriority': (value: string[]) => (form.formatPriority = value),
'update:allowedFormats': (value: string[]) => (form.allowedFormats = value),
@@ -2,12 +2,22 @@
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowRight, Lock, Plus, RefreshCw, TriangleAlert, X } from '@lucide/vue'
import { BOOK_FORMATS, type AddedAtRecomputeJob, type AddedAtSource, type OrganizationMode } from '@bookorbit/types'
import {
BOOK_FORMATS,
FOLDER_SERIES_REGEX_METADATA,
isFolderSeriesRegexMetadata,
type AddedAtRecomputeJob,
type AddedAtSource,
type OrganizationMode,
type RegexMetadataConfig,
} from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { formatNumber } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
import { formatFamilyColor } from '@/features/settings/libraries/lib/library-formats'
import LibraryCreatorCard from './LibraryCreatorCard.vue'
import LibraryOrganizationPreview from './LibraryOrganizationPreview.vue'
import LibrarySeriesPreview from './LibrarySeriesPreview.vue'
const { t } = useI18n()
@@ -22,6 +32,9 @@ const props = defineProps<{
recomputeJob?: AddedAtRecomputeJob | null
recomputeErrorKey?: string | null
excludePatterns: string[]
regexMetadata?: RegexMetadataConfig | null
/** Custom rules that turning folders-as-series on replaced, brought back when it is turned off. */
replacedRegexMetadata?: RegexMetadataConfig | null
}>()
const emit = defineEmits<{
@@ -29,10 +42,18 @@ const emit = defineEmits<{
'update:allowedFormats': [value: string[]]
'update:addedAtSource': [value: AddedAtSource]
'update:excludePatterns': [value: string[]]
'update:regexMetadata': [value: RegexMetadataConfig | null]
'update:replacedRegexMetadata': [value: RegexMetadataConfig | null]
recompute: []
}>()
const ORGANIZATION_MODES: OrganizationMode[] = ['book_per_folder', 'book_per_file']
/**
* The layouts offered at creation. "Series folders" is not a third organization mode: it is File as
* Book with folders as series, offered as its own choice because that is the layout Komga and Kavita
* users arrive with.
*/
type LayoutId = OrganizationMode | 'series_folders'
const LAYOUTS: LayoutId[] = ['book_per_folder', 'book_per_file', 'series_folders']
const ADDED_AT_SOURCES: AddedAtSource[] = ['imported', 'file_modified', 'file_created']
const FORMAT_GROUPS: { id: 'ebook' | 'kindle' | 'document' | 'comic' | 'audio'; formats: string[] }[] = [
{ id: 'ebook', formats: ['epub', 'kepub', 'fb2'] },
@@ -42,7 +63,7 @@ const FORMAT_GROUPS: { id: 'ebook' | 'kindle' | 'document' | 'comic' | 'audio';
{ id: 'audio', formats: ['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'] },
]
const modeCopy = computed<Record<OrganizationMode, { title: string; hint: string; result: string }>>(() => ({
const layoutCopy = computed<Record<LayoutId, { title: string; hint: string; result: string }>>(() => ({
book_per_folder: {
title: t('library.creator.scanner.scanMode.folderAsBook.title'),
hint: t('library.creator.scanner.mode.folderHint'),
@@ -53,7 +74,16 @@ const modeCopy = computed<Record<OrganizationMode, { title: string; hint: string
hint: t('library.creator.scanner.mode.fileHint'),
result: t('library.creator.scanner.mode.fileResult'),
},
series_folders: {
title: t('library.creator.scanner.scanMode.seriesFolders.title'),
hint: t('library.creator.scanner.mode.seriesHint'),
result: t('library.creator.scanner.mode.seriesResult'),
},
}))
// Once the library exists only the mode is shown, and File as Book also covers series folders there.
const lockedHint = computed(() =>
props.organizationMode === 'book_per_folder' ? t('library.creator.scanner.mode.folderHint') : t('library.creator.scanner.mode.fileHintSeries'),
)
// ── Import ────────────────────────────────────────────────────────────────────
@@ -116,9 +146,36 @@ function onPatternKeydown(event: KeyboardEvent) {
// ── Organization ────────────────────────────────────────────────────────────────
function selectMode(event: Event) {
const layout = computed<LayoutId>(() => {
if (props.organizationMode === 'book_per_folder') return 'book_per_folder'
return folderSeries.value ? 'series_folders' : 'book_per_file'
})
function selectLayout(event: Event) {
if (props.organizationModeLocked) return
emit('update:organizationMode', (event.target as HTMLInputElement).value as OrganizationMode)
const next = (event.target as HTMLInputElement).value as LayoutId
const mode: OrganizationMode = next === 'book_per_folder' ? 'book_per_folder' : 'book_per_file'
if (mode !== props.organizationMode) emit('update:organizationMode', mode)
// In Folder as Book the folder is the book, so the folder rules would name each series after its book.
if (next === 'series_folders') {
if (!folderSeries.value) setFolderSeries(true)
} else if (folderSeries.value) setFolderSeries(false)
}
// ── Series ────────────────────────────────────────────────────────────────────
const folderSeries = computed(() => isFolderSeriesRegexMetadata(props.regexMetadata))
const customRules = computed(() => Boolean(props.regexMetadata) && !folderSeries.value)
function setFolderSeries(value: boolean) {
if (value) {
const replaced = customRules.value && props.regexMetadata ? { rules: props.regexMetadata.rules.map((rule) => ({ ...rule })) } : null
emit('update:replacedRegexMetadata', replaced)
emit('update:regexMetadata', { preset: 'folder_series', rules: FOLDER_SERIES_REGEX_METADATA.rules.map((rule) => ({ ...rule })) })
} else {
emit('update:regexMetadata', props.replacedRegexMetadata ?? null)
emit('update:replacedRegexMetadata', null)
}
}
// ── Date added ────────────────────────────────────────────────────────────────
@@ -180,8 +237,8 @@ async function confirmRecompute() {
<div v-if="organizationModeLocked" class="flex flex-col gap-3 @md:flex-row @md:items-center">
<LibraryOrganizationPreview :mode="organizationMode" class="w-full shrink-0 @md:w-44" />
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground">{{ modeCopy[organizationMode].title }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ modeCopy[organizationMode].hint }}</p>
<p class="text-sm font-semibold text-foreground">{{ layoutCopy[organizationMode].title }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ lockedHint }}</p>
<p class="mt-1.5 text-xs text-muted-foreground">{{ t('library.creator.scanner.scanMode.lockTooltip') }}</p>
</div>
</div>
@@ -189,33 +246,40 @@ async function confirmRecompute() {
<template v-else>
<div role="radiogroup" aria-labelledby="organization-mode-title" class="grid gap-2.5">
<label
v-for="mode in ORGANIZATION_MODES"
:key="mode"
v-for="id in LAYOUTS"
:key="id"
class="flex cursor-pointer flex-col gap-2.5 rounded-xl border p-3 transition-colors focus-within:ring-2 focus-within:ring-ring"
:class="organizationMode === mode ? 'border-primary bg-primary/7 ring-1 ring-primary' : 'border-border bg-background hover:bg-muted'"
:class="layout === id ? 'border-primary bg-primary/7 ring-1 ring-primary' : 'border-border bg-background hover:bg-muted'"
>
<span class="flex items-center gap-2">
<span class="flex flex-wrap items-center gap-2">
<input
type="radio"
name="organization-mode"
:value="mode"
:checked="organizationMode === mode"
:value="id"
:checked="layout === id"
class="size-4 shrink-0 accent-primary"
@change="selectMode"
@change="selectLayout"
/>
<span class="text-sm font-semibold text-foreground">{{ modeCopy[mode].title }}</span>
<span v-if="mode === 'book_per_folder'" class="ms-auto rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span class="text-sm font-semibold text-foreground">{{ layoutCopy[id].title }}</span>
<span v-if="id === 'book_per_folder'" class="ms-auto rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{{ t('library.creator.scanner.scanMode.recommended') }}
</span>
<span
v-else-if="id === 'series_folders'"
class="ms-auto rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>
{{ t('library.creator.scanner.scanMode.seriesFolders.uses') }}
</span>
</span>
<span class="flex flex-col gap-2.5 @md:flex-row @md:items-center">
<LibraryOrganizationPreview :mode="mode" class="shrink-0 @md:w-44" />
<LibrarySeriesPreview v-if="id === 'series_folders'" class="shrink-0 @md:w-44" />
<LibraryOrganizationPreview v-else :mode="id" class="shrink-0 @md:w-44" />
<span class="flex min-w-0 flex-col gap-1">
<span class="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<ArrowRight :size="12" class="shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true" />
{{ modeCopy[mode].result }}
{{ layoutCopy[id].result }}
</span>
<span class="text-xs text-muted-foreground">{{ modeCopy[mode].hint }}</span>
<span class="text-xs text-muted-foreground">{{ layoutCopy[id].hint }}</span>
</span>
</span>
</label>
@@ -227,6 +291,28 @@ async function confirmRecompute() {
</template>
</LibraryCreatorCard>
<LibraryCreatorCard v-if="organizationMode === 'book_per_file'" :label="t('library.creator.scanner.series.title')" label-id="series-card-title">
<div class="flex flex-col gap-3 @md:flex-row @md:items-start">
<LibrarySeriesPreview v-if="folderSeries && organizationModeLocked" class="w-full shrink-0 @md:w-56" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2.5">
<ToggleSwitch
:model-value="folderSeries"
aria-labelledby="folder-series-title"
aria-describedby="folder-series-hint"
@update:model-value="setFolderSeries"
/>
<span id="folder-series-title" class="text-sm font-semibold text-foreground">{{ t('library.creator.scanner.series.toggle') }}</span>
</div>
<div id="folder-series-hint" class="mt-1.5 space-y-1 text-xs text-muted-foreground">
<p>{{ folderSeries ? t('library.creator.scanner.series.hintOn') : t('library.creator.scanner.series.hintOff') }}</p>
<p v-if="customRules">{{ t('library.creator.scanner.series.replacesCustom') }}</p>
<p v-else-if="folderSeries && organizationModeLocked">{{ t('library.creator.scanner.series.existingBooks') }}</p>
</div>
</div>
</div>
</LibraryCreatorCard>
<LibraryCreatorCard :label="t('library.creator.scanner.import.title')" label-id="import-formats-title">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div role="radiogroup" aria-labelledby="import-formats-title" class="inline-flex gap-0.5 rounded-lg border border-border bg-muted p-0.5">
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RefreshCw } from '@lucide/vue'
import type { RegexMetadataConfig, RegexMetadataReapplyJob } from '@bookorbit/types'
import { formatNumber } from '@/i18n/formatters'
const props = defineProps<{
/** The rules the server has saved for this library. */
stored: RegexMetadataConfig | null
/** The rules in the form, which may not be saved yet. */
pending: RegexMetadataConfig | null
running: boolean
job: RegexMetadataReapplyJob | null
errorKey: string | null
}>()
const emit = defineEmits<{ reapply: [] }>()
const { t } = useI18n()
const confirming = ref(false)
const root = ref<HTMLElement | null>(null)
const startButton = ref<HTMLButtonElement | null>(null)
const confirmButton = ref<HTMLButtonElement | null>(null)
const rulesKey = (config: RegexMetadataConfig | null) => JSON.stringify(config?.rules.map(({ pattern, flags }) => [pattern, flags]) ?? null)
const unsaved = computed(() => rulesKey(props.stored) !== rulesKey(props.pending))
const disabled = computed(() => props.running || !props.stored || unsaved.value)
const hint = computed(() => {
if (unsaved.value) return t('library.creator.metadata.regex.reapply.saveFirst')
return t('library.creator.metadata.regex.reapply.hint')
})
const progressText = computed(() => {
const job = props.job
if (!job) return ''
const counters = ['processed', 'total', 'updated', 'unchanged', 'skipped', 'failed'] as const
return t('library.creator.metadata.regex.reapply.progressSummary', Object.fromEntries(counters.map((key) => [key, formatNumber(job[key])])))
})
async function requestReapply() {
confirming.value = true
await nextTick()
confirmButton.value?.focus()
}
async function cancelReapply() {
confirming.value = false
await nextTick()
startButton.value?.focus()
}
async function confirmReapply() {
if (disabled.value) return
confirming.value = false
emit('reapply')
await nextTick()
// The start button is disabled while the job runs, so focus stays on this block instead of the page.
root.value?.focus()
}
</script>
<template>
<div ref="root" tabindex="-1" class="mt-3 border-t border-border pt-3 outline-none">
<div class="flex flex-wrap items-center gap-3">
<button
ref="startButton"
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
:disabled="disabled"
@click="requestReapply"
>
<RefreshCw :size="13" :class="running ? 'motion-safe:animate-spin' : ''" aria-hidden="true" />
{{ running ? t('library.creator.metadata.regex.reapply.applying') : t('library.creator.metadata.regex.reapply.action') }}
</button>
<p class="min-w-0 flex-1 text-xs text-muted-foreground">{{ hint }}</p>
</div>
<div
v-if="confirming"
role="group"
:aria-label="t('library.creator.metadata.regex.reapply.action')"
class="mt-3 rounded-lg border border-border bg-background p-3"
>
<p class="text-sm text-muted-foreground">{{ t('library.creator.metadata.regex.reapply.confirm') }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<button
ref="confirmButton"
type="button"
:disabled="disabled"
class="h-8 rounded-md border border-border px-3 text-[13px] font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
@click="confirmReapply"
>
{{ t('library.creator.metadata.regex.reapply.action') }}
</button>
<button
type="button"
class="h-8 rounded-md px-3 text-[13px] font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="cancelReapply"
>
{{ t('common.cancel') }}
</button>
</div>
</div>
<div v-if="job" class="mt-3 space-y-2 text-sm text-muted-foreground">
<!-- Only the outcome is live; counters that tick every poll would be read out each second. -->
<div role="status" class="space-y-2">
<p>{{ t(`library.creator.metadata.regex.reapply.jobStatus.${job.status}`) }}</p>
<p v-if="job.status !== 'running'">{{ progressText }}</p>
</div>
<template v-if="job.status === 'running'">
<progress
:value="job.total ? job.processed : undefined"
:max="job.total || 1"
:aria-label="t('library.creator.metadata.regex.reapply.applying')"
class="h-2 w-full accent-primary"
/>
<p>{{ progressText }}</p>
</template>
<ul v-if="job.failureSamples.length" class="space-y-1">
<li v-for="failure in job.failureSamples" :key="failure.bookId">
{{
t('library.creator.metadata.regex.reapply.failureSample', {
bookId: formatNumber(failure.bookId),
reason: t(`library.creator.metadata.regex.reapply.failureReasons.${failure.code}`),
})
}}
</li>
</ul>
</div>
<p v-if="errorKey" role="alert" class="mt-3 text-sm text-destructive">{{ t(errorKey) }}</p>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { FileImage, Folder } from '@lucide/vue'
import { formatNumber } from '@/i18n/formatters'
const { t } = useI18n()
interface TreeLine {
name: string
folder: boolean
index?: number
}
// Sample file names, not interface copy: they show the shape of a folder, so they stay untranslated.
const lines: TreeLine[] = [
{ name: 'Saga/', folder: true },
{ name: 'Saga #01.cbz', folder: false, index: 1 },
{ name: 'Saga #02.cbz', folder: false, index: 2 },
{ name: 'Akira/', folder: true },
{ name: 'Akira v01.cbz', folder: false, index: 1 },
]
</script>
<template>
<div class="rounded-lg border border-border bg-background px-2.5 py-2" aria-hidden="true">
<div v-for="line in lines" :key="line.name" class="flex h-5 items-center gap-1.5" :class="line.folder ? '' : 'ps-4'">
<span v-if="!line.folder" class="-ms-2.5 -mt-2.5 h-2.5 w-2 shrink-0 rounded-es-sm border-b border-s border-border" />
<component :is="line.folder ? Folder : FileImage" :size="12" class="shrink-0 text-foreground" />
<span class="min-w-0 truncate font-mono text-[11.5px] text-foreground" dir="ltr">{{ line.name }}</span>
<span v-if="line.folder" class="ms-auto shrink-0 rounded-full bg-primary/14 px-1.5 py-px text-[10.5px] font-medium text-primary">
{{ t('library.creator.scanner.series.previewSeries') }}
</span>
<span v-else class="ms-auto shrink-0 text-[11px] tabular-nums text-muted-foreground">#{{ formatNumber(line.index!) }}</span>
</div>
</div>
</template>
@@ -2,14 +2,14 @@
import { useI18n } from 'vue-i18n'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { ArrowDown, ArrowUp, Plus, Trash2 } from '@lucide/vue'
import { REGEX_METADATA_GROUPS, REGEX_METADATA_LIMITS, type RegexMetadataConfig } from '@bookorbit/types'
import { REGEX_METADATA_GROUPS, REGEX_METADATA_LIMITS, type OrganizationMode, type RegexMetadataConfig } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { formatNumber } from '@/i18n/formatters'
import { useRegexMetadataEditor } from '../composables/useRegexMetadataEditor'
const props = defineProps<{ config: RegexMetadataConfig | null; libraryId?: number }>()
const props = defineProps<{ config: RegexMetadataConfig | null; libraryId?: number; organizationMode?: OrganizationMode }>()
const emit = defineEmits<{ close: []; apply: [config: RegexMetadataConfig | null] }>()
const { t } = useI18n()
const {
@@ -34,6 +34,7 @@ const {
removeRule,
moveUp,
moveDown,
applyFolderSeriesPreset,
value,
preview,
} = useRegexMetadataEditor(props.config, props.libraryId)
@@ -88,6 +89,17 @@ function apply() {
<ToggleSwitch v-model="enabled" aria-labelledby="regex-enable-label" />
<span id="regex-enable-label">{{ t('library.creator.metadata.regex.enable') }}</span>
</div>
<section
v-if="organizationMode === 'book_per_file'"
aria-labelledby="regex-preset-title"
class="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md border border-border p-3"
>
<div class="min-w-0 flex-1">
<h3 id="regex-preset-title" class="text-sm font-medium">{{ t('library.creator.metadata.regex.preset.title') }}</h3>
<p class="text-xs text-muted-foreground">{{ t('library.creator.metadata.regex.preset.hint') }}</p>
</div>
<Button variant="outline" size="sm" @click="applyFolderSeriesPreset">{{ t('library.creator.metadata.regex.preset.apply') }}</Button>
</section>
<p class="text-xs text-muted-foreground">
{{ t('library.creator.metadata.regex.groups') }} <code>{{ REGEX_METADATA_GROUPS.join(', ') }}</code>
</p>
@@ -95,4 +95,67 @@ describe('LibraryCreatorMetadata', () => {
expect(wrapper.emitted('update:metadataPrecedence')).toEqual([[['opfFile', 'embedded', 'regex']]])
})
describe('applying rules to existing books', () => {
const rules = { rules: [{ pattern: '(?<series>[^/]+)/', flags: 'u' }] }
const job = {
id: 'j',
libraryId: 1,
status: 'running' as const,
total: 4,
processed: 1,
updated: 1,
unchanged: 0,
skipped: 0,
failed: 0,
failureSamples: [],
}
function mountReapply(props: Record<string, unknown>) {
return mount(LibraryCreatorMetadata, {
props: { metadataPrecedence: ['embedded', 'opfFile', 'regex'], formatPriority: ['cbz'], canReapplyRules: true, ...props },
})
}
const actionButton = (wrapper: ReturnType<typeof mountReapply>) =>
wrapper.findAll('button').find((button) => button.text().includes('Apply rules to existing books'))
it('is not offered before the library exists or without rules', () => {
expect(actionButton(mountReapply({ canReapplyRules: false, regexMetadata: rules, storedRegexMetadata: rules }))).toBeUndefined()
expect(actionButton(mountReapply({ regexMetadata: null, storedRegexMetadata: null }))).toBeUndefined()
})
it('asks for unsaved rules to be saved first', () => {
const wrapper = mountReapply({ regexMetadata: rules, storedRegexMetadata: null })
expect(actionButton(wrapper)!.attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('Save your changes first')
})
it('confirms before applying the saved rules', async () => {
const wrapper = mountReapply({ regexMetadata: rules, storedRegexMetadata: rules })
await actionButton(wrapper)!.trigger('click')
expect(wrapper.emitted('reapply')).toBeUndefined()
expect(wrapper.text()).toContain('Only fields the rules decide are written')
await wrapper.findAll('[role="group"] button')[0]!.trigger('click')
expect(wrapper.emitted('reapply')).toEqual([[]])
})
it('reports progress while the rules are applied, announcing only the outcome', () => {
const wrapper = mountReapply({ regexMetadata: rules, storedRegexMetadata: rules, reapplyingRules: true, reapplyJob: job })
expect(wrapper.text()).toContain('Processed 1 of 4: 1 updated')
expect(wrapper.get('[role="status"]').text()).not.toContain('Processed')
expect(actionButton(wrapper)).toBeUndefined()
})
it('announces the final counts once the rules are applied', () => {
const done = { ...job, status: 'completed' as const, processed: 4, updated: 3, unchanged: 1 }
const wrapper = mountReapply({ regexMetadata: rules, storedRegexMetadata: rules, reapplyJob: done })
expect(wrapper.get('[role="status"]').text()).toContain('Rules applied to existing books.')
expect(wrapper.get('[role="status"]').text()).toContain('Processed 4 of 4: 3 updated, 1 unchanged')
})
})
})
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { BOOK_FORMATS } from '@bookorbit/types'
import { BOOK_FORMATS, FOLDER_SERIES_REGEX_METADATA } from '@bookorbit/types'
import LibraryCreatorScanner from '../LibraryCreatorScanner.vue'
@@ -120,4 +120,101 @@ describe('LibraryCreatorScanner', () => {
expect(wrapper.get('[role="status"]').text()).toContain('1 failed')
wrapper.unmount()
})
it('offers series folders as a third layout that sets File as Book with folders as series', async () => {
const wrapper = mountScanner()
const layouts = wrapper.findAll('input[name="organization-mode"]').map((input) => (input.element as HTMLInputElement).value)
expect(layouts).toEqual(['book_per_folder', 'book_per_file', 'series_folders'])
expect(wrapper.text()).toContain('Uses File as Book')
await wrapper.get('input[name="organization-mode"][value="series_folders"]').setValue()
expect(wrapper.emitted('update:organizationMode')).toEqual([['book_per_file']])
expect(wrapper.emitted('update:regexMetadata')).toEqual([[FOLDER_SERIES_REGEX_METADATA]])
})
it('shows series folders as the chosen layout while File as Book uses the folder rules', async () => {
const wrapper = mountScanner({ organizationMode: 'book_per_file', regexMetadata: FOLDER_SERIES_REGEX_METADATA })
const checked = wrapper.get('input[name="organization-mode"]:checked').element as HTMLInputElement
expect(checked.value).toBe('series_folders')
await wrapper.get('input[name="organization-mode"][value="book_per_file"]').setValue()
expect(wrapper.emitted('update:organizationMode')).toBeUndefined()
expect(wrapper.emitted('update:regexMetadata')).toEqual([[null]])
})
it('drops the folder rules when switching to Folder as Book, where the folder is the book', async () => {
const wrapper = mountScanner({ organizationMode: 'book_per_file', regexMetadata: FOLDER_SERIES_REGEX_METADATA })
await wrapper.get('input[name="organization-mode"][value="book_per_folder"]').setValue()
expect(wrapper.emitted('update:organizationMode')).toEqual([['book_per_folder']])
expect(wrapper.emitted('update:regexMetadata')).toEqual([[null]])
})
it('keeps custom path rules when switching between book layouts', async () => {
const custom = { rules: [{ pattern: '(?<series>[^/]+)/', flags: '' }] }
const wrapper = mountScanner({ organizationMode: 'book_per_file', regexMetadata: custom })
await wrapper.get('input[name="organization-mode"][value="book_per_folder"]').setValue()
expect(wrapper.emitted('update:regexMetadata')).toBeUndefined()
})
it('offers folders as series, in its own card, only when every file is its own book', async () => {
expect(mountScanner().find('#series-card-title').exists()).toBe(false)
const wrapper = mountScanner({ organizationMode: 'book_per_file' })
const toggle = wrapper.get('[aria-labelledby="folder-series-title"]')
expect(wrapper.get('#series-card-title').text()).toBe('Series')
expect(toggle.attributes('aria-checked')).toBe('false')
expect(wrapper.text()).toContain('so each folder names the series of the files inside it')
await toggle.trigger('click')
expect(wrapper.emitted('update:regexMetadata')).toEqual([[FOLDER_SERIES_REGEX_METADATA]])
})
it('shows the series-folder layout in the Series card once the library exists, and turns it off', async () => {
const wrapper = mountScanner({ organizationMode: 'book_per_file', organizationModeLocked: true, regexMetadata: FOLDER_SERIES_REGEX_METADATA })
const card = wrapper.get('#series-card-title').element.closest('section')!
const toggle = wrapper.get('[aria-labelledby="folder-series-title"]')
expect(toggle.attributes('aria-checked')).toBe('true')
expect(card.textContent).toContain('Saga #01.cbz')
expect(wrapper.text()).toContain('Each folder becomes a series')
expect(wrapper.text()).toContain('Books already in this library get these series')
await toggle.trigger('click')
expect(wrapper.emitted('update:regexMetadata')).toEqual([[null]])
})
it('describes File as Book as covering series folders once the library exists', () => {
const wrapper = mountScanner({ organizationMode: 'book_per_file', organizationModeLocked: true })
expect(wrapper.text()).toContain('Best for comics and manga kept in one folder per series')
})
it('warns that turning folders as series on replaces custom path rules', () => {
const wrapper = mountScanner({ organizationMode: 'book_per_file', regexMetadata: { rules: [{ pattern: '(?<series>[^/]+)/', flags: '' }] } })
expect(wrapper.get('[aria-labelledby="folder-series-title"]').attributes('aria-checked')).toBe('false')
expect(wrapper.text()).toContain('replaces the path rules set under Metadata')
})
it('keeps the custom rules that Series folders replaced and brings them back when it is left', async () => {
const custom = { rules: [{ pattern: '(?<series>[^/]+)/', flags: '' }] }
const wrapper = mountScanner({ organizationMode: 'book_per_file', regexMetadata: custom })
await wrapper.get('input[name="organization-mode"][value="series_folders"]').setValue()
expect(wrapper.emitted('update:replacedRegexMetadata')).toEqual([[custom]])
expect(wrapper.emitted('update:regexMetadata')).toEqual([[FOLDER_SERIES_REGEX_METADATA]])
await wrapper.setProps({ regexMetadata: FOLDER_SERIES_REGEX_METADATA, replacedRegexMetadata: custom })
await wrapper.get('[aria-labelledby="folder-series-title"]').trigger('click')
expect(wrapper.emitted('update:regexMetadata')![1]).toEqual([custom])
expect(wrapper.emitted('update:replacedRegexMetadata')![1]).toEqual([null])
})
})
@@ -1,5 +1,6 @@
import { effectScope } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FOLDER_SERIES_REGEX_METADATA } from '@bookorbit/types'
import { api } from '@/lib/api'
import { useRegexMetadataEditor } from '../useRegexMetadataEditor'
@@ -155,4 +156,25 @@ describe('useRegexMetadataEditor', () => {
await state.validateRules()
expect(state.canApply.value).toBe(true)
})
it('replaces the draft with the folder-as-series rules and enables them', () => {
const state = editor()
state.enabled.value = false
state.applyFolderSeriesPreset()
expect(state.enabled.value).toBe(true)
expect(state.value()).toEqual(FOLDER_SERIES_REGEX_METADATA)
expect(state.value()!.rules[0]).not.toBe(FOLDER_SERIES_REGEX_METADATA.rules[0])
})
it('opens a preset saved with older rules on its current rules and keeps it marked', () => {
const state = editor({ preset: 'folder_series', rules: [{ pattern: '^(?<series>[^/]+)/', flags: '' }] } as never)
expect(state.rules.value).toEqual(FOLDER_SERIES_REGEX_METADATA.rules)
expect(state.value()).toEqual(FOLDER_SERIES_REGEX_METADATA)
state.rules.value[0]!.flags = 'i'
expect(state.value()).not.toHaveProperty('preset')
})
})
@@ -1,84 +1,17 @@
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
import type { Ref } from 'vue'
import type { AddedAtRecomputeJob } from '@bookorbit/types'
import { api } from '@/lib/api'
import { useLibraryJob } from './useLibraryJob'
const ERROR_KEYS: Record<string, string> = {
ADDED_AT_SOURCE_IMPORTED: 'sourceImported',
ADDED_AT_ALREADY_RUNNING: 'alreadyRunning',
ADDED_AT_BUSY: 'busy',
}
const ERROR_PREFIX = 'library.creator.scanner.addedAt.errors.'
export function useLibraryAddedAt(libraryId: Ref<number | null>) {
const job = ref<AddedAtRecomputeJob | null>(null)
const starting = ref(false)
const errorKey = ref<string | null>(null)
const running = computed(() => starting.value || job.value?.status === 'running')
let timer: ReturnType<typeof setTimeout> | undefined
let controller: AbortController | undefined
let generation = 0
let disposed = false
function cancelRequest() {
clearTimeout(timer)
controller?.abort()
generation++
}
async function request(method: 'GET' | 'POST') {
cancelRequest()
const id = libraryId.value
if (id === null || disposed) return
const current = generation
const currentController = new AbortController()
controller = currentController
const signal = currentController.signal
const timeout = setTimeout(() => currentController.abort(), 10_000)
starting.value = method === 'POST'
errorKey.value = null
try {
const response = await api(`/api/v1/libraries/${id}/recompute-added-at`, { method, signal })
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { errorCode?: string }
if (current === generation) errorKey.value = ERROR_PREFIX + (ERROR_KEYS[body.errorCode ?? ''] ?? 'requestFailed')
return
}
const result = (await response.json()) as AddedAtRecomputeJob | null
if (current !== generation) return
if (!result && job.value?.status === 'running') errorKey.value = ERROR_PREFIX + 'interrupted'
job.value = result
} catch {
if (current === generation) errorKey.value = ERROR_PREFIX + 'requestFailed'
} finally {
clearTimeout(timeout)
if (current === generation && !disposed) {
starting.value = false
if (job.value?.status === 'running') timer = setTimeout(refresh, errorKey.value ? 5000 : 1000)
}
}
}
function refresh() {
return request('GET')
}
function start() {
if (running.value) return Promise.resolve()
return request('POST')
}
watch(
libraryId,
() => {
job.value = null
void refresh()
},
{ immediate: true },
)
onScopeDispose(() => {
disposed = true
cancelRequest()
return useLibraryJob<AddedAtRecomputeJob>(libraryId, {
path: (id) => `/api/v1/libraries/${id}/recompute-added-at`,
errorKeys: ERROR_KEYS,
errorPrefix: 'library.creator.scanner.addedAt.errors.',
})
return { job, running, errorKey, start, refresh }
}
@@ -3,6 +3,7 @@ import { api } from '@/lib/api'
import {
DEFAULT_FORMAT_PRIORITY,
isFiveFieldCronExpression,
resolveRegexMetadataConfig,
withoutImplicitReadAlongFormatPriority,
withReadAlongFormatPriority,
} from '@bookorbit/types'
@@ -81,6 +82,8 @@ export function useLibraryCreator() {
const stats = ref<LibraryStats | null>(null)
const error = ref<string | null>(null)
const storedAddedAtSource = ref<AddedAtSource | null>(null)
/** The path rules the server has, which is what applying them to existing books uses. */
const storedRegexMetadata = ref<Library['regexMetadata']>(null)
const latestCheck = new Map<string, number>()
let checkSequence = 0
@@ -121,6 +124,7 @@ export function useLibraryCreator() {
function initCreate() {
storedAddedAtSource.value = null
storedRegexMetadata.value = null
Object.assign(form, blankForm())
mode.value = 'create'
editingLibraryId.value = null
@@ -149,6 +153,7 @@ export function useLibraryCreator() {
form.folders = []
form.localFolders = []
storedAddedAtSource.value = null
storedRegexMetadata.value = null
}
function applyLibrary(library: Library) {
@@ -163,7 +168,12 @@ export function useLibraryCreator() {
form.watchLocalFolders = library.watchLocalFolders ?? true
form.autoScanCronExpression = library.autoScanCronExpression ?? null
form.metadataPrecedence = [...library.metadataPrecedence]
form.regexMetadata = library.regexMetadata ? { rules: library.regexMetadata.rules.map((rule) => ({ ...rule })) } : null
// A preset is shown with its current rules, which are what the server runs for it.
const regexMetadata = resolveRegexMetadataConfig(library.regexMetadata)
form.regexMetadata = regexMetadata
? { ...(regexMetadata.preset ? { preset: regexMetadata.preset } : {}), rules: regexMetadata.rules.map((rule) => ({ ...rule })) }
: null
storedRegexMetadata.value = regexMetadata
const missing = DEFAULT_FORMAT_PRIORITY.filter((f) => !library.formatPriority.includes(f))
form.formatPriority = withReadAlongFormatPriority([...library.formatPriority, ...missing])
form.allowedFormats = [...library.allowedFormats]
@@ -304,6 +314,7 @@ export function useLibraryCreator() {
return {
form,
storedAddedAtSource,
storedRegexMetadata,
mode,
editingLibraryId,
loading,
@@ -0,0 +1,84 @@
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
import { api } from '@/lib/api'
export interface LibraryJobOptions {
path: (libraryId: number) => string
/** Server error codes mapped to the last segment of a translation key under `errorPrefix`. */
errorKeys: Record<string, string>
errorPrefix: string
}
/** Starts a library's background job and polls it until it settles; one job per library. */
export function useLibraryJob<T extends { status: string }>(libraryId: Ref<number | null>, options: LibraryJobOptions) {
const job = ref<T | null>(null) as Ref<T | null>
const starting = ref(false)
const errorKey = ref<string | null>(null)
const running = computed(() => starting.value || job.value?.status === 'running')
let timer: ReturnType<typeof setTimeout> | undefined
let controller: AbortController | undefined
let generation = 0
let disposed = false
function cancelRequest() {
clearTimeout(timer)
controller?.abort()
generation++
}
async function request(method: 'GET' | 'POST') {
cancelRequest()
const id = libraryId.value
if (id === null || disposed) return
const current = generation
const currentController = new AbortController()
controller = currentController
const signal = currentController.signal
const timeout = setTimeout(() => currentController.abort(), 10_000)
starting.value = method === 'POST'
errorKey.value = null
try {
const response = await api(options.path(id), { method, signal })
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { errorCode?: string }
if (current === generation) errorKey.value = options.errorPrefix + (options.errorKeys[body.errorCode ?? ''] ?? 'requestFailed')
return
}
const result = (await response.json()) as T | null
if (current !== generation) return
if (!result && job.value?.status === 'running') errorKey.value = options.errorPrefix + 'interrupted'
job.value = result
} catch {
if (current === generation) errorKey.value = options.errorPrefix + 'requestFailed'
} finally {
clearTimeout(timeout)
if (current === generation && !disposed) {
starting.value = false
if (job.value?.status === 'running') timer = setTimeout(refresh, errorKey.value ? 5000 : 1000)
}
}
}
function refresh() {
return request('GET')
}
function start() {
if (running.value) return Promise.resolve()
return request('POST')
}
watch(
libraryId,
() => {
job.value = null
void refresh()
},
{ immediate: true },
)
onScopeDispose(() => {
disposed = true
cancelRequest()
})
return { job, running, errorKey, start, refresh }
}
@@ -1,8 +1,17 @@
import { computed, onScopeDispose, ref, watch } from 'vue'
import { REGEX_METADATA_LIMITS, type RegexMetadataConfig, type RegexMetadataPreview, type RegexMetadataValidation } from '@bookorbit/types'
import {
FOLDER_SERIES_REGEX_METADATA,
isFolderSeriesRegexMetadata,
REGEX_METADATA_LIMITS,
resolveRegexMetadataConfig,
type RegexMetadataConfig,
type RegexMetadataPreview,
type RegexMetadataValidation,
} from '@bookorbit/types'
import { api } from '@/lib/api'
export function useRegexMetadataEditor(config: RegexMetadataConfig | null, libraryId?: number) {
export function useRegexMetadataEditor(saved: RegexMetadataConfig | null, libraryId?: number) {
const config = resolveRegexMetadataConfig(saved)
const enabled = ref(config !== null)
const rules = ref(config?.rules.map((rule) => ({ ...rule })) ?? [{ pattern: '', flags: '' }])
const relativePath = ref('')
@@ -120,8 +129,16 @@ export function useRegexMetadataEditor(config: RegexMetadataConfig | null, libra
moveRule(index, 1)
}
function applyFolderSeriesPreset() {
enabled.value = true
rules.value = FOLDER_SERIES_REGEX_METADATA.rules.map((rule) => ({ ...rule }))
}
function value(): RegexMetadataConfig | null {
return enabled.value ? { rules: rules.value.map((rule) => ({ ...rule })) } : null
if (!enabled.value) return null
const config: RegexMetadataConfig = { rules: rules.value.map((rule) => ({ ...rule })) }
// Unedited preset rules stay marked as the preset, so the library keeps getting its fixes.
return isFolderSeriesRegexMetadata(config) ? { preset: 'folder_series', ...config } : config
}
async function preview() {
@@ -172,6 +189,7 @@ export function useRegexMetadataEditor(config: RegexMetadataConfig | null, libra
removeRule,
moveUp,
moveDown,
applyFolderSeriesPreset,
value,
preview,
}
@@ -0,0 +1,17 @@
import type { Ref } from 'vue'
import type { RegexMetadataReapplyJob } from '@bookorbit/types'
import { useLibraryJob } from './useLibraryJob'
const ERROR_KEYS: Record<string, string> = {
REGEX_METADATA_NOT_CONFIGURED: 'notConfigured',
REGEX_REAPPLY_ALREADY_RUNNING: 'alreadyRunning',
REGEX_REAPPLY_BUSY: 'busy',
}
export function useRegexMetadataReapply(libraryId: Ref<number | null>) {
return useLibraryJob<RegexMetadataReapplyJob>(libraryId, {
path: (id) => `/api/v1/scanner/libraries/${id}/reapply-regex-metadata`,
errorKeys: ERROR_KEYS,
errorPrefix: 'library.creator.metadata.regex.reapply.errors.',
})
}
+50 -1
View File
@@ -5450,6 +5450,7 @@
"LibraryWriteMetadataToFiles": "Write metadata to files",
"LibraryBulkRename": "Rename library files",
"LibraryRecomputeAddedAt": "Recompute date added",
"LibraryReapplyRegexMetadata": "Apply path rules to existing books",
"PodcastCreate": "Create podcast",
"PodcastUpdate": "Update podcast",
"PodcastArchive": "Archive podcast",
@@ -11354,6 +11355,10 @@
"fileAsBook": {
"title": "File as Book",
"hint": "Treats every file as an individual book. Best suited for libraries with one format per title. Avoid if your audiobooks span multiple files."
},
"seriesFolders": {
"title": "Series folders",
"uses": "Uses File as Book"
}
},
"addedAt": {
@@ -11419,8 +11424,11 @@
"cannotChange": "This is the one setting you cannot change later.",
"folderHint": "Every folder is one book. Best when a title keeps several formats together, or an audiobook spans many files.",
"fileHint": "Every file is its own book. Best for flat folders with one format per title.",
"fileHintSeries": "Every file is its own book. Best for comics and manga kept in one folder per series, or a flat folder with one format per title.",
"folderResult": "One book with two formats",
"fileResult": "Three books, one per file"
"fileResult": "Three books, one per file",
"seriesHint": "Every file is its own book, and the folder it sits in is its series, the layout Komga and Kavita use. Best for comics and manga.",
"seriesResult": "Two series, one book per file"
},
"import": {
"title": "Import",
@@ -11442,6 +11450,15 @@
"placeholder": "Type a pattern like **/samples/** and press Enter",
"placeholderMore": "Add another pattern",
"remove": "Remove pattern {pattern}"
},
"series": {
"title": "Series",
"toggle": "Folders are series",
"hintOn": "Each folder becomes a series, and the number in each file name sets the book's place in it, as Komga and Kavita do. A different series named inside a file still comes first.",
"hintOff": "Turn this on for comics and manga kept in one folder per series, so each folder names the series of the files inside it.",
"replacesCustom": "Turning this on replaces the path rules set under Metadata. Turning it off again before you save brings them back.",
"existingBooks": "Books already in this library get these series when you apply the rules to them under Metadata.",
"previewSeries": "Series"
}
},
"metadata": {
@@ -11536,6 +11553,38 @@
"seriesIndex": "Series number",
"publishedYear": "Published year",
"publishedDate": "Published date"
},
"preset": {
"title": "Folders are series",
"hint": "Rules for one folder per series and one file per volume, chapter or issue, as Komga and Kavita expect. They replace the rules below.",
"apply": "Use these rules"
},
"reapply": {
"action": "Apply rules to existing books",
"applying": "Applying rules...",
"hint": "Updates books already in this library with the fields these rules decide. Nothing else changes, and locked fields are kept.",
"saveFirst": "Save your changes first, then apply the rules to existing books.",
"confirm": "Apply the saved rules to every book in this library? This runs in the background, and you can close this editor. Only fields the rules decide are written; locked fields and metadata the files supply themselves are kept.",
"progressSummary": "Processed {processed} of {total}: {updated} updated, {unchanged} unchanged, {skipped} skipped, {failed} failed.",
"jobStatus": {
"running": "Applying rules in the background.",
"completed": "Rules applied to existing books.",
"failed": "Applying stopped before finishing. Changes already made were kept; you can safely run it again."
},
"errors": {
"notConfigured": "Save path rules for this library before applying them.",
"alreadyRunning": "The rules are already being applied to this library.",
"busy": "Other libraries are applying rules. Try again shortly.",
"requestFailed": "Could not reach the server. Close and reopen this editor to check the status.",
"interrupted": "The job status is no longer available. It may have been interrupted by a server restart. You can safely run it again."
},
"failureSample": "Book {bookId}: {reason}",
"failureReasons": {
"rules_unavailable": "The rules could not be evaluated in time.",
"rules_invalid": "The saved rules could not be run. Open Configure, check them, and save again.",
"file_unavailable": "The book's file could not be read, so nothing was changed. Check that the library folder is reachable.",
"metadata_failed": "The book's metadata could not be saved."
}
}
}
},
+1
View File
@@ -43,6 +43,7 @@ export enum AuditAction {
LibraryWriteMetadataToFiles = "library.write_metadata_to_files",
LibraryBulkRename = "library.bulk_rename",
LibraryRecomputeAddedAt = "library.recompute_added_at",
LibraryReapplyRegexMetadata = "library.reapply_regex_metadata",
PodcastCreate = "podcast.create",
PodcastUpdate = "podcast.update",
+101
View File
@@ -11,8 +11,16 @@ export interface RegexMetadataRule {
flags: string;
}
export const REGEX_METADATA_PRESETS = ["folder_series"] as const;
export type RegexMetadataPreset = (typeof REGEX_METADATA_PRESETS)[number];
export interface RegexMetadataConfig {
rules: RegexMetadataRule[];
/**
* Set when the rules are a built-in preset. The server then runs the preset's current rules, so a
* library saved with an earlier version of the preset picks up later fixes.
*/
preset?: RegexMetadataPreset;
}
export interface RegexMetadataFields {
@@ -46,3 +54,96 @@ export interface RegexMetadataValidation {
valid: boolean;
diagnostics: RegexMetadataDiagnostic[];
}
export type RegexMetadataReapplyFailureCode = "rules_unavailable" | "rules_invalid" | "file_unavailable" | "metadata_failed";
/** Progress of applying a library's saved path rules to the books already in it. */
export interface RegexMetadataReapplyJob {
id: string;
libraryId: number;
status: "running" | "completed" | "failed";
total: number;
processed: number;
updated: number;
unchanged: number;
/** Books no rule matched, or whose files were being written at the time. */
skipped: number;
failed: number;
failureSamples: { bookId: number; code: RegexMetadataReapplyFailureCode }[];
}
/**
* Folders that only group one series' files. Taking one as the series would merge every "Volume 1"
* folder in the library into one series. Files in a specials folder join the series unnumbered, as
* in Kavita, so "Annual 01" does not take issue 1's place.
*/
const VOLUME_FOLDER = String.raw`(?:\d+[\s._-]+)?(?:vol(?:ume)?|tome|tomo|band|livre|libro|part|pt|arc|season|saison|v)\.?[\s_-]*\d+[^/]*|(?:19|20)\d{2}`;
const SPECIALS_FOLDER = String.raw`specials?|extras?|annuals?|bonus|one[\s_-]?shots?|_oneshots`;
const GROUPING_FOLDER = `${VOLUME_FOLDER}|${SPECIALS_FOLDER}`;
/** A release tag such as "[Digital]", "[Group]" or "{Kodansha}"; parentheses are left alone, they carry run years. */
const RELEASE_TAG = String.raw`[\[{][^\]}/]*[\]}]`;
/**
* The series is the nearest folder above the file, without its release tags, skipping one grouping
* folder or a "Series/Series Vol. 3/" volume folder. The lazy lead makes the shallower folder win
* when both fit. The series can neither start nor end with a tag, so a name splits only one way;
* without that, a folder of many tags backtracks for seconds.
*/
const SERIES_FOLDER = String.raw`^(?:.*?/)??(?!(?:${GROUPING_FOLDER})/)(?:${RELEASE_TAG}\s*)*(?<series>[^/\[{\s][^/]*?)(?<![\]}\s])(?:\s*${RELEASE_TAG})*/`;
const FOLDER_SERIES = String.raw`${SERIES_FOLDER}(?:(?:${VOLUME_FOLDER}|\k<series>[\s._-]*(?:vol(?:ume)?|tome|v)\.?[\s_-]*\d+[^/]*)/)?`;
const SPECIALS_IN_SERIES = String.raw`${SERIES_FOLDER}(?:${SPECIALS_FOLDER})/[^/]+$`;
/** "Supergirl/Volume 05 (2005)/Supergirl (2005) ...": the file names the run the folder above does not. */
const RUN_YEAR_FILE_SERIES = String.raw`^(?:.*?/)??(?<runParent>[^/]+)/(?:vol(?:ume)?|tome)\.?[\s_-]*\d+[^/]*/(?<series>\k<runParent>\s*\((?:19|20)\d{2}\))`;
// Also skips markers inside brackets, where "(v2)" is a release version and "(c2004)" a copyright year.
const BEFORE_MARKER = String.raw`(?<![\p{L}\p{N}(\[])`;
const NUMBER = String.raw`0*(?<seriesIndex>\d+(?:[.,]\d+)?)(?![\p{L}\p{N}])`;
const CHAPTER_OR_ISSUE = String.raw`[^/]*?(?:${BEFORE_MARKER}(?:c|ch|chap|chapter|chapitre|cap|capitulo|issue|no|nr|n°)\.?[\s_-]*(?!(?:19|20)\d{2}(?!\d))|#\s*|第\s*)${NUMBER}[^/]*$`;
const VOLUME_THEN_ISSUE = String.raw`[^/]*?${BEFORE_MARKER}(?:v|vol|volume)\.?[\s_-]*\d+[\s_]+(?:#\s*)?${NUMBER}[^/]*$`;
const VOLUME = String.raw`[^/]*?${BEFORE_MARKER}(?:t|tome|tomo|v|vol|volume|band|book|livre|libro)\.?[\s_-]*${NUMBER}[^/]*$`;
const CJK_CHAPTER = String.raw`[^/]*?(?<!\p{N})0*(?<seriesIndex>\d+(?:\.\d+)?)\s*(?:話|话|화|회|章)[^/]*$`;
const CJK_VOLUME = String.raw`[^/]*?(?<!\p{N})0*(?<seriesIndex>\d+(?:\.\d+)?)\s*(?:巻|卷|권|冊|册)[^/]*$`;
// Needs punctuation after the number, so "100 Bullets 050" is not read as number 100.
const LEADING_NUMBER = String.raw`0*(?<seriesIndex>(?!(?:19|20)\d{2}(?!\d))\d{1,4}(?:\.\d+)?)(?:\s*[-\u2013_.)\]]\s*(?!\d)[^/]*)?$`;
const TRAILING_NUMBER = String.raw`[^/]*?[\s_]0*(?<seriesIndex>(?!(?:19|20)\d{2}(?!\d))\d{1,4}(?:\.\d+)?)(?![\p{L}\p{N}])(?:\s*[\[(][^\]()/]*[\])])*(?:\s+-\s[^/]*)?\s*$`;
const ANY_FILE = String.raw`[^/]+$`;
/**
* Rules for the layout Komga and Kavita use, one folder per series and one file per volume,
* chapter or issue. Files at the library root match none of them and keep their own metadata.
*/
export const FOLDER_SERIES_REGEX_METADATA: RegexMetadataConfig = {
preset: "folder_series",
rules: [
RUN_YEAR_FILE_SERIES + CHAPTER_OR_ISSUE,
RUN_YEAR_FILE_SERIES + TRAILING_NUMBER,
SPECIALS_IN_SERIES,
FOLDER_SERIES + CHAPTER_OR_ISSUE,
FOLDER_SERIES + VOLUME_THEN_ISSUE,
FOLDER_SERIES + VOLUME,
FOLDER_SERIES + CJK_CHAPTER,
FOLDER_SERIES + CJK_VOLUME,
FOLDER_SERIES + LEADING_NUMBER,
FOLDER_SERIES + TRAILING_NUMBER,
FOLDER_SERIES + ANY_FILE,
].map((pattern) => ({ pattern, flags: "iu" })),
};
export function isFolderSeriesRegexMetadata(config: RegexMetadataConfig | null | undefined): boolean {
const preset = FOLDER_SERIES_REGEX_METADATA.rules;
return (
!!config &&
(config.preset === "folder_series" ||
(config.rules.length === preset.length &&
config.rules.every((rule, index) => rule.pattern === preset[index]!.pattern && rule.flags === preset[index]!.flags)))
);
}
/** The rules to run for a saved config: a preset's current rules, or the config's own. */
export function resolveRegexMetadataConfig(config: RegexMetadataConfig | null | undefined): RegexMetadataConfig | null {
if (!config) return null;
if (isFolderSeriesRegexMetadata(config)) return { preset: "folder_series", rules: FOLDER_SERIES_REGEX_METADATA.rules.map((rule) => ({ ...rule })) };
return config;
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { isRunYearQualifiedName, splitSeriesRunYear } from './series-run-year.utils';
describe('splitSeriesRunYear', () => {
it('splits a trailing run year off the name', () => {
expect(splitSeriesRunYear('Batman (2016)')).toEqual({ base: 'Batman', year: 2016 });
expect(splitSeriesRunYear('Spider-Man 2099 (1992)')).toEqual({ base: 'Spider-Man 2099', year: 1992 });
expect(splitSeriesRunYear(' Saga(2012) ')).toEqual({ base: 'Saga', year: 2012 });
});
it('ignores names without a trailing year in parentheses', () => {
expect(splitSeriesRunYear('Batman')).toBeNull();
expect(splitSeriesRunYear('Spider-Man 2099')).toBeNull();
expect(splitSeriesRunYear("The Novel's Extra (Remake)")).toBeNull();
expect(splitSeriesRunYear('Batman (2016) Annual')).toBeNull();
expect(splitSeriesRunYear('(2016)')).toBeNull();
expect(splitSeriesRunYear(null)).toBeNull();
});
it('only treats plausible publication years as run years', () => {
expect(splitSeriesRunYear('Star Trek (1701)')).toBeNull();
expect(splitSeriesRunYear('Odyssey (2101)')).toBeNull();
});
});
describe('isRunYearQualifiedName', () => {
it('matches the bare name case- and spacing-insensitively', () => {
expect(isRunYearQualifiedName('Batman (2016)', 'Batman')).toBe(true);
expect(isRunYearQualifiedName('The Boys (2006)', 'the boys')).toBe(true);
});
it('rejects a different title or a name without a year', () => {
expect(isRunYearQualifiedName('Batman (2016)', 'Batgirl')).toBe(false);
expect(isRunYearQualifiedName('Batman', 'Batman')).toBe(false);
expect(isRunYearQualifiedName('Batman (2016)', 'Batman (2011)')).toBe(false);
expect(isRunYearQualifiedName('Batman (2016)', null)).toBe(false);
});
});
@@ -0,0 +1,29 @@
import { normalizeMetadataTextKey } from './metadata-text-normalize.utils';
const RUN_YEAR_SUFFIX = /^(.*\S)\s*\(((?:19|20)\d{2})\)$/u;
export interface SeriesRunYear {
base: string;
year: number;
}
/**
* Comic runs that share a title are told apart by the year the run started, written the way
* Mylar, Komga and Kavita name series folders: "Batman (2016)". Returns null for a name without it.
*/
export function splitSeriesRunYear(name: string | null | undefined): SeriesRunYear | null {
const match = name ? RUN_YEAR_SUFFIX.exec(name.trim()) : null;
if (!match) return null;
return { base: match[1], year: Number(match[2]) };
}
/**
* True when `qualified` is `bare` plus a run year, so replacing it with `bare` would only drop
* the year that keeps two runs of one title apart.
*/
export function isRunYearQualifiedName(qualified: string | null | undefined, bare: string | null | undefined): boolean {
const run = splitSeriesRunYear(qualified);
if (!run) return false;
const bareKey = normalizeMetadataTextKey(bare);
return bareKey !== null && bareKey === normalizeMetadataTextKey(run.base);
}
@@ -4,6 +4,7 @@ import {
ArrayMinSize,
IsArray,
IsDefined,
IsIn,
IsInt,
IsObject,
IsOptional,
@@ -14,7 +15,13 @@ import {
MinLength,
ValidateNested,
} from 'class-validator';
import { REGEX_METADATA_LIMITS, type RegexMetadataConfig, type RegexMetadataRule } from '@bookorbit/types';
import {
REGEX_METADATA_LIMITS,
REGEX_METADATA_PRESETS,
type RegexMetadataConfig,
type RegexMetadataPreset,
type RegexMetadataRule,
} from '@bookorbit/types';
export class RegexMetadataRuleDto implements RegexMetadataRule {
@IsString()
@@ -34,6 +41,10 @@ export class RegexMetadataConfigDto implements RegexMetadataConfig {
@ValidateNested({ each: true })
@Type(() => RegexMetadataRuleDto)
rules: RegexMetadataRuleDto[];
@IsOptional()
@IsIn(REGEX_METADATA_PRESETS)
preset?: RegexMetadataPreset;
}
export class RegexMetadataValidationDto {
@@ -22,7 +22,7 @@ vi.mock('@bookorbit/types', async (importOriginal) => {
});
import { BadRequestException, ConflictException, ForbiddenException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
import { EMPTY_CONTENT_FILTER_RULES, FOLDER_SERIES_REGEX_METADATA } from '@bookorbit/types';
import { readdir, realpath, rm, stat } from 'fs/promises';
import { ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED } from '../achievement/achievement-events.service';
@@ -247,6 +247,21 @@ describe('LibraryService', () => {
expect(libraryRepo.update).toHaveBeenCalledWith(2, { regexMetadata: changed });
});
it('stores a preset as its current rules, whatever rules came with it', async () => {
await service.update(2, { regexMetadata: { preset: 'folder_series', rules: [first, second] } });
expect(regexMetadata.validate).toHaveBeenCalledWith(FOLDER_SERIES_REGEX_METADATA);
expect(libraryRepo.update).toHaveBeenCalledWith(2, { regexMetadata: FOLDER_SERIES_REGEX_METADATA });
});
it('treats a re-sent preset as unchanged after the preset itself changed', async () => {
libraryRepo.findById.mockResolvedValue([
{ id: 2, type: 'books', icon: 'BookOpen', regexMetadata: { preset: 'folder_series', rules: [first] } },
]);
await service.update(2, plainToInstance(UpdateLibraryDto, { coverAspectRatio: '1/1', regexMetadata: FOLDER_SERIES_REGEX_METADATA }));
expect(regexMetadata.validate).not.toHaveBeenCalled();
expect(libraryRepo.update).toHaveBeenCalledWith(2, { coverAspectRatio: '1/1' });
});
it('does not persist a change that fails validation', async () => {
regexMetadata.validate.mockRejectedValueOnce(new BadRequestException('Invalid regex metadata configuration'));
await expect(service.update(2, { regexMetadata: null })).rejects.toThrow('Invalid regex metadata configuration');
+13 -6
View File
@@ -15,7 +15,7 @@ import { ConfigService } from '@nestjs/config';
import { readdir, realpath, rm, stat } from 'fs/promises';
import { dirname, isAbsolute, join, relative } from 'path';
import { APP_FEATURES, DEFAULT_FORMAT_PRIORITY } from '@bookorbit/types';
import { APP_FEATURES, DEFAULT_FORMAT_PRIORITY, resolveRegexMetadataConfig } from '@bookorbit/types';
import type {
AccessLevel,
LibraryFileSyncProgressEvent,
@@ -208,7 +208,8 @@ export class LibraryService {
}
if (libraryType === 'podcasts') this.assertNoBookOnlyFields(dto);
else this.assertNoPodcastOnlyFields(dto);
await this.regexMetadata.validate(dto.regexMetadata);
const regexMetadata = resolveRegexMetadataConfig(dto.regexMetadata);
await this.regexMetadata.validate(regexMetadata);
await this.assertNameAvailable(dto.name);
const folderInputs = resolveLibraryFolderRoles(
libraryType,
@@ -228,7 +229,7 @@ export class LibraryService {
watch: libraryType === 'books' ? (dto.watch ?? false) : false,
watchLocalFolders: libraryType === 'podcasts' ? (dto.watchLocalFolders ?? true) : false,
autoScanCronExpression: libraryType === 'books' ? (dto.autoScanCronExpression ?? null) : null,
regexMetadata: dto.regexMetadata ?? null,
regexMetadata,
metadataPrecedence: libraryType === 'books' ? (dto.metadataPrecedence ?? [...LIBRARY_METADATA_PRECEDENCE_DEFAULT]) : [],
formatPriority: libraryType === 'books' ? (dto.formatPriority ?? [...DEFAULT_FORMAT_PRIORITY]) : [],
allowedFormats: libraryType === 'books' ? (dto.allowedFormats ?? []) : [],
@@ -282,10 +283,15 @@ export class LibraryService {
if (existing.type === 'podcasts') this.assertNoBookOnlyFields(dto);
else this.assertNoPodcastOnlyFields(dto);
const regexRules = (config: RegexMetadataConfig | null | undefined) => config?.rules.map(({ pattern, flags }) => [pattern, flags]) ?? null;
// Compared as the rules that would run: a preset runs its current rules whatever copy is stored,
// so re-sending a preset is no change even after the preset itself was updated.
const regexRules = (config: RegexMetadataConfig | null | undefined) =>
config ? [config.preset ?? null, config.rules.map(({ pattern, flags }) => [pattern, flags])] : null;
const incomingRegexMetadata = dto.regexMetadata === undefined ? undefined : resolveRegexMetadataConfig(dto.regexMetadata);
const regexMetadataChanged =
dto.regexMetadata !== undefined && !isDeepStrictEqual(regexRules(dto.regexMetadata), regexRules(existing.regexMetadata));
if (regexMetadataChanged) await this.regexMetadata.validate(dto.regexMetadata);
incomingRegexMetadata !== undefined &&
!isDeepStrictEqual(regexRules(incomingRegexMetadata), regexRules(resolveRegexMetadataConfig(existing.regexMetadata)));
if (regexMetadataChanged) await this.regexMetadata.validate(incomingRegexMetadata);
const existingOrganizationMode = normalizeOrganizationMode(existing.organizationMode);
if (dto.organizationMode !== undefined && dto.organizationMode !== existingOrganizationMode) {
@@ -300,6 +306,7 @@ export class LibraryService {
const { folders: rawFolderPaths, localFolders: rawLocalFolderPaths, ...fields } = dto;
if (!regexMetadataChanged) delete fields.regexMetadata;
else fields.regexMetadata = incomingRegexMetadata;
let existingFolders: Awaited<ReturnType<LibraryRepository['findFoldersByLibrary']>> | undefined;
let folderInputs: LibraryFolderInput[] | undefined;
if (rawFolderPaths !== undefined || rawLocalFolderPaths !== undefined) {
@@ -930,6 +930,44 @@ describe('MetadataFetchPipeline', () => {
expect(sources.description).toBe(MetadataProviderKey.OPEN_LIBRARY);
});
it('keeps a run-year series name when a provider names the same run without its year', async () => {
const prefs = createPreferences((fields) => {
fields.seriesName = { enabled: true, providers: [MetadataProviderKey.COMICVINE], mergeStrategy: 'overwrite' };
fields.seriesIndex = { enabled: true, providers: [MetadataProviderKey.COMICVINE], mergeStrategy: 'overwrite' };
});
preferencesService.getGlobal.mockResolvedValue(prefs);
resolver.resolve.mockReturnValue(prefs);
resolver.withForwardCompatibility.mockReturnValue(prefs);
registry.all.mockReturnValue([{ key: MetadataProviderKey.COMICVINE }] as never);
fetchService.searchCandidates.mockReturnValue(of(candidate(MetadataProviderKey.COMICVINE, 'cv1', { seriesName: 'Batman', seriesIndex: '7' })));
const { resolved, sources } = await pipeline.runWithSources(
{ title: 'Query', seriesName: 'Batman (2016)', seriesIndex: '7' },
{ seriesName: 'Batman (2016)', seriesIndex: '7' },
);
expect(resolved.seriesName).toBeUndefined();
expect(sources.seriesName).toBeUndefined();
expect(resolved.seriesIndex).toBe('7');
});
it('still lets a provider replace a series name that only differs by more than the run year', async () => {
const prefs = createPreferences((fields) => {
fields.seriesName = { enabled: true, providers: [MetadataProviderKey.COMICVINE], mergeStrategy: 'overwrite' };
});
preferencesService.getGlobal.mockResolvedValue(prefs);
resolver.resolve.mockReturnValue(prefs);
resolver.withForwardCompatibility.mockReturnValue(prefs);
registry.all.mockReturnValue([{ key: MetadataProviderKey.COMICVINE }] as never);
fetchService.searchCandidates.mockReturnValue(of(candidate(MetadataProviderKey.COMICVINE, 'cv1', { seriesName: 'Detective Comics' })));
const { resolved } = await pipeline.runWithSources({ title: 'Query' }, { seriesName: 'Batman (2016)' });
expect(resolved.seriesName).toBe('Detective Comics');
});
it('preserves empty arrays as explicit clears under overwrite', async () => {
const prefs = createPreferences((fields) => {
fields.authors = {
@@ -28,6 +28,7 @@ import { MetadataPreferencesService } from '../metadata-preferences/metadata-pre
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
import { applyGenreFetchOptions, createGenreBlocklistTokenSet, mergeExistingGenres } from '../../common/utils/genre-fetch-options.utils';
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
import { isRunYearQualifiedName } from '../../common/utils/series-run-year.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { canonicalIsbn13, isValidIsbn10, isValidIsbn13, normalizeMetadataIsbn } from '../../common/text-match/isbn-normalize';
import { acceptAgainstAnchor, resolveCandidateAgreement } from './candidate-agreement';
@@ -813,6 +814,10 @@ export class MetadataFetchPipeline {
break;
case 'overwrite':
case 'overwriteIfProvided':
// Providers name a comic run without its start year, so taking their name would merge
// "Batman (2016)" back into every other "Batman" run.
if (field === 'seriesName' && typeof value === 'string' && isRunYearQualifiedName(existingValue as string | null | undefined, value))
break;
(result as Record<string, unknown>)[field] = value;
this.copyPublishedDateForYear(result, candidate, field);
sources[field] = providerKey;
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { normalizeIssueNumber, rankVolumes } from './comicvine.matching';
import { normalizeIssueNumber, rankVolumes, resolveComicVineIssueQuery } from './comicvine.matching';
import { ComicVineVolume } from './comicvine.types';
function volume(id: number, name: string, startYear: string | null, countOfIssues: number): ComicVineVolume {
@@ -120,4 +120,42 @@ describe('rankVolumes', () => {
it('returns an empty list unchanged', () => {
expect(rankVolumes([], 'Anything', '1')).toEqual([]);
});
it('puts the run whose start year the series was named with first', () => {
const ranked = rankVolumes(AMAZING_SPIDER_MAN, 'Amazing Spider-Man', '5', 1963);
expect(ranked[0].id).toBe(1218);
});
it('never lets a matching year outrank a better name match', () => {
const ranked = rankVolumes(
[volume(1, 'The Amazing Spider-Man Annual', '2018', 5), volume(2, 'The Amazing Spider-Man', '2022', 93)],
'Amazing Spider-Man',
'5',
2018,
);
expect(ranked[0].id).toBe(2);
});
});
describe('resolveComicVineIssueQuery', () => {
it('searches a year-qualified series by its title and keeps the year for ranking', () => {
expect(resolveComicVineIssueQuery({ title: 'I Am Gotham', seriesName: 'Batman (2016)', seriesIndex: '1' })).toEqual({
seriesName: 'Batman',
issueNumber: '1',
startYear: 2016,
});
});
it('reads the year from an explicit issue target too', () => {
expect(resolveComicVineIssueQuery({ title: 'Witchblade (2018) #003' })).toEqual({ seriesName: 'Witchblade', issueNumber: '3', startYear: 2018 });
});
it('leaves a series without a run year alone', () => {
expect(resolveComicVineIssueQuery({ title: 'Some title', seriesName: 'Spider-Man 2099', seriesIndex: '12' })).toEqual({
seriesName: 'Spider-Man 2099',
issueNumber: '12',
});
});
});
@@ -1,6 +1,7 @@
import { parseSeriesIndex, type MetadataCandidate } from '@bookorbit/types';
import { normalizeTitleText, scoreTitleMatch } from '../../../../common/text-match/title-match';
import { splitSeriesRunYear } from '../../../../common/utils/series-run-year.utils';
import type { MetadataSearchParams } from '../metadata-search-params';
import { ComicVineVolume } from './comicvine.types';
@@ -11,19 +12,33 @@ const ISSUE_PATTERN = /^(.*?)\s*#(\d[\d.]*)(.*)$/;
interface ParsedIssueTitle {
seriesName: string;
issueNumber: string;
/** The run's start year when the series was named "Title (Year)". */
startYear?: number;
}
/** An explicit issue target wins; a typed series overrides the stored series but keeps its issue. */
export function resolveComicVineIssueQuery(params: MetadataSearchParams): ParsedIssueTitle | null {
const match = params.title ? ISSUE_PATTERN.exec(params.title.trim()) : null;
if (match?.[1].trim()) return { seriesName: match[1].trim(), issueNumber: normalizeIssueNumber(match[2]) };
if (match?.[1].trim()) return issueQuery(match[1], match[2]);
const issueNumber = parseSeriesIndex(params.seriesIndex);
const typedSeriesName = params.titleIsExplicitQuery ? params.title?.trim() : undefined;
if (typedSeriesName) return issueNumber ? { seriesName: typedSeriesName, issueNumber: normalizeIssueNumber(issueNumber) } : null;
if (typedSeriesName) return issueNumber ? issueQuery(typedSeriesName, issueNumber) : null;
const seriesName = params.seriesName?.trim();
return seriesName && issueNumber ? { seriesName, issueNumber: normalizeIssueNumber(issueNumber) } : null;
return seriesName && issueNumber ? issueQuery(seriesName, issueNumber) : null;
}
/**
* ComicVine names a run without its year and `filter=name:` is a substring match, so a series
* folder named "Batman (2016)" would find nothing. The year moves into ranking instead.
*/
function issueQuery(seriesName: string, issueNumber: string): ParsedIssueTitle {
const trimmed = seriesName.trim();
const run = splitSeriesRunYear(trimmed);
return run
? { seriesName: run.base, issueNumber: normalizeIssueNumber(issueNumber), startYear: run.year }
: { seriesName: trimmed, issueNumber: normalizeIssueNumber(issueNumber) };
}
/** An issue subtitle is not evidence that a different run is a better series/number match. */
@@ -75,22 +90,24 @@ function holdsIssuePlausibly(volume: ComicVineVolume, issueNumber: string): bool
return volume.count_of_issues >= target;
}
function startYear(volume: ComicVineVolume): number {
function volumeStartYear(volume: ComicVineVolume): number {
const year = volume.start_year ? parseInt(volume.start_year, 10) : NaN;
return Number.isFinite(year) ? year : 0;
}
export function volumeMatchScore(volume: ComicVineVolume, seriesName: string, issueNumber: string): number {
return nameScore(volume.name, seriesName) * 2 + (holdsIssuePlausibly(volume, issueNumber) ? 1 : 0);
export function volumeMatchScore(volume: ComicVineVolume, seriesName: string, issueNumber: string, startYear?: number): number {
const yearMatches = startYear !== undefined && startYear === volumeStartYear(volume);
return nameScore(volume.name, seriesName) * 4 + (yearMatches ? 2 : 0) + (holdsIssuePlausibly(volume, issueNumber) ? 1 : 0);
}
/**
* Orders candidate volumes by how likely each is to hold the wanted issue: name match first, then
* whether the volume ran long enough, then the most recent run. Stable within equal scores.
* the run year the series was named with, then whether the volume ran long enough, then the most
* recent run. Stable within equal scores.
*/
export function rankVolumes(volumes: ComicVineVolume[], seriesName: string, issueNumber: string): ComicVineVolume[] {
export function rankVolumes(volumes: ComicVineVolume[], seriesName: string, issueNumber: string, startYear?: number): ComicVineVolume[] {
return volumes
.map((volume, index) => ({ volume, index, score: volumeMatchScore(volume, seriesName, issueNumber) }))
.sort((a, b) => b.score - a.score || startYear(b.volume) - startYear(a.volume) || a.index - b.index)
.map((volume, index) => ({ volume, index, score: volumeMatchScore(volume, seriesName, issueNumber, startYear) }))
.sort((a, b) => b.score - a.score || volumeStartYear(b.volume) - volumeStartYear(a.volume) || a.index - b.index)
.map((entry) => entry.volume);
}
@@ -62,7 +62,7 @@ export class ComicVineProvider implements IdentifiableProvider {
private readonly logger = new Logger(ComicVineProvider.name);
readonly cache: ProviderCacheSpec<MetadataSearchParams> = {
version: 2,
version: 3,
trustEmpty: true,
// The issue endpoint answers an unknown id with 404.
authoritativeLookupStatuses: [404],
@@ -105,6 +105,7 @@ export class ComicVineProvider implements IdentifiableProvider {
maxCandidates,
deadline,
params.requireUnambiguousMatch ?? false,
parsed.startYear,
)
: await this.generalSearch(params.title, apiKey, maxCandidates, deadline);
@@ -171,6 +172,7 @@ export class ComicVineProvider implements IdentifiableProvider {
maxCandidates: number,
deadline: SearchDeadline,
requireUnambiguousMatch: boolean,
startYear?: number,
): Promise<VolumeIssueMatch[]> {
const startedAt = Date.now();
const volumes = await this.client.searchVolumes(seriesName, apiKey, deadline.signal);
@@ -179,7 +181,7 @@ export class ComicVineProvider implements IdentifiableProvider {
return [];
}
const ranked = rankVolumes(volumes, seriesName, issueNumber);
const ranked = rankVolumes(volumes, seriesName, issueNumber, startYear);
const matches: VolumeIssueMatch[] = [];
const seenIssueIds = new Set<number>();
@@ -189,9 +191,9 @@ export class ComicVineProvider implements IdentifiableProvider {
complete = false;
break;
}
const groupScore = volumeMatchScore(ranked[offset], seriesName, issueNumber);
const groupScore = volumeMatchScore(ranked[offset], seriesName, issueNumber, startYear);
let groupEnd = offset + 1;
while (groupEnd < ranked.length && volumeMatchScore(ranked[groupEnd], seriesName, issueNumber) === groupScore) groupEnd++;
while (groupEnd < ranked.length && volumeMatchScore(ranked[groupEnd], seriesName, issueNumber, startYear) === groupScore) groupEnd++;
const waveEnd = Math.min(offset + VOLUME_PROBE_BATCH, groupEnd, VOLUME_PROBE_LIMIT);
const wave = ranked.slice(offset, waveEnd);
// Settled rather than all: one probe being throttled or cut off by the deadline must not
@@ -301,7 +301,7 @@ describe('provider cache declarations', () => {
describe.each(CASES)('$key', (testCase) => {
it('declares the current mapping version', () => {
const bumped: Partial<Record<MetadataProviderKey, number>> = {
[MetadataProviderKey.COMICVINE]: 2,
[MetadataProviderKey.COMICVINE]: 3,
[MetadataProviderKey.HARDCOVER]: 2,
};
expect(specOf(testCase.key).version).toBe(bumped[testCase.key] ?? 1);
@@ -1,9 +1,9 @@
import { extractAudioMetadata } from './audio.extractor';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
import type { FormatExtractOptions, FormatExtractor, ParsedBookData } from './format-extractor.interface';
export class AudioFormatExtractor implements FormatExtractor {
async extract(absolutePath: string): Promise<ParsedBookData | null> {
const audio = await extractAudioMetadata(absolutePath);
async extract(absolutePath: string, options?: FormatExtractOptions): Promise<ParsedBookData | null> {
const audio = await extractAudioMetadata(absolutePath, { cover: options?.cover });
if (!audio) return null;
return {
title: audio.title,
@@ -59,7 +59,7 @@ interface FfprobeOutput {
// Null means the file could not be read, which is not the same as a file that carries no tags:
// callers persist what they get, so a failed read must never be mistaken for empty metadata.
export async function extractAudioMetadata(absolutePath: string): Promise<AudioExtractResult | null> {
export async function extractAudioMetadata(absolutePath: string, options: { cover?: boolean } = {}): Promise<AudioExtractResult | null> {
try {
const { stdout } = await execFile(
FFPROBE_PATH,
@@ -114,7 +114,7 @@ export async function extractAudioMetadata(absolutePath: string): Promise<AudioE
const mappedChapters = mapChapters(chapters);
const coverBytes = await extractCoverBytes(absolutePath, streams);
const coverBytes = options.cover === false ? null : await extractCoverBytes(absolutePath, streams);
return {
title,
@@ -4,7 +4,7 @@ import { extractCbzCover } from '../lib/cover-cbz';
import { extractCb7Metadata, extractCbrMetadata, extractCbzMetadata } from '../lib/cbz-metadata';
import { parseBookFilename } from '../lib/filename-parser';
import { detectComicContainerFormat } from '../../../common/comic-format-detect';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
import type { FormatExtractOptions, FormatExtractor, ParsedBookData } from './format-extractor.interface';
type ComicFormat = 'cbz' | 'cbr' | 'cb7';
@@ -23,11 +23,11 @@ const coverExtractors: Record<ComicFormat, (path: string) => Promise<Buffer | nu
export class ComicFormatExtractor implements FormatExtractor {
constructor(private readonly format: ComicFormat) {}
async extract(absolutePath: string): Promise<ParsedBookData | null> {
async extract(absolutePath: string, options?: FormatExtractOptions): Promise<ParsedBookData | null> {
const actualFormat = await detectComicContainerFormat(absolutePath, this.format);
const [comicMetadata, cover] = await Promise.all([
metadataExtractors[actualFormat](absolutePath),
coverExtractors[actualFormat](absolutePath).catch(() => null),
options?.cover === false ? null : coverExtractors[actualFormat](absolutePath).catch(() => null),
]);
const fb = parseBookFilename(absolutePath);
@@ -1,11 +1,14 @@
import { extractEpubCover } from '../lib/cover-epub';
import { extractEpubMetadata, isPrePaginated } from '../lib/epub';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
import type { FormatExtractOptions, FormatExtractor, ParsedBookData } from './format-extractor.interface';
import { mapOpfMetadata } from './opf-metadata.mapper';
export class EpubFormatExtractor implements FormatExtractor {
async extract(absolutePath: string): Promise<ParsedBookData | null> {
const [metadata, cover] = await Promise.all([extractEpubMetadata(absolutePath), extractEpubCover(absolutePath).catch(() => null)]);
async extract(absolutePath: string, options?: FormatExtractOptions): Promise<ParsedBookData | null> {
const [metadata, cover] = await Promise.all([
extractEpubMetadata(absolutePath),
options?.cover === false ? null : extractEpubCover(absolutePath).catch(() => null),
]);
if (!metadata) return null;
return { ...mapOpfMetadata(metadata, cover ?? null), isFixedLayout: isPrePaginated(metadata) };
}
@@ -1,6 +1,6 @@
import { extractFb2Cover } from '../lib/cover-fb2';
import { parseFb2File } from '../lib/fb2-parser';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
import type { FormatExtractOptions, FormatExtractor, ParsedBookData } from './format-extractor.interface';
function toNumber(value: string | undefined): number | null {
if (!value) return null;
@@ -9,8 +9,11 @@ function toNumber(value: string | undefined): number | null {
}
export class Fb2FormatExtractor implements FormatExtractor {
async extract(absolutePath: string): Promise<ParsedBookData | null> {
const [fb2, cover] = await Promise.all([parseFb2File(absolutePath), extractFb2Cover(absolutePath).catch(() => null)]);
async extract(absolutePath: string, options?: FormatExtractOptions): Promise<ParsedBookData | null> {
const [fb2, cover] = await Promise.all([
parseFb2File(absolutePath),
options?.cover === false ? null : extractFb2Cover(absolutePath).catch(() => null),
]);
if (!fb2) return null;
const custom = fb2.custom;
@@ -47,6 +47,11 @@ export interface ParsedBookData {
comicMetadata?: ParsedCbzComicMetadata | null;
}
export interface FormatExtractor {
extract(absolutePath: string): Promise<ParsedBookData | null>;
export interface FormatExtractOptions {
/** False skips reading the cover; the result's cover is then null. */
cover?: boolean;
}
export interface FormatExtractor {
extract(absolutePath: string, options?: FormatExtractOptions): Promise<ParsedBookData | null>;
}
@@ -624,6 +624,38 @@ describe('metadata format extractors', () => {
);
});
// Applying path rules to a whole library reads only text fields; covers there are wasted work.
it('every extractor skips the cover when told to', async () => {
mockExtractEpubMetadata.mockResolvedValue(null);
mockParseMobiFile.mockResolvedValue(null);
mockParseFb2File.mockResolvedValue(null);
mockParsePdfFile.mockResolvedValue(null);
mockExtractAudioMetadata.mockResolvedValue(null);
mockReadFile.mockResolvedValueOnce(
`
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>No Cover Read</dc:title></metadata>
<guide><reference type="cover" href="cover.jpg"/></guide>
</package>
` as any,
);
await new EpubFormatExtractor().extract('/books/a.epub', { cover: false });
await new MobiFormatExtractor().extract('/books/a.mobi', { cover: false });
await new Fb2FormatExtractor().extract('/books/a.fb2', { cover: false });
await new PdfFormatExtractor({ extractCover: true }).extract('/books/a.pdf', { cover: false });
await new AudioFormatExtractor().extract('/books/a.m4b', { cover: false });
const opf = await new OpfFormatExtractor().extract('/books/metadata.opf', { cover: false });
expect(mockExtractEpubCover).not.toHaveBeenCalled();
expect(mockExtractMobiCover).not.toHaveBeenCalled();
expect(mockExtractFb2Cover).not.toHaveBeenCalled();
expect(mockParsePdfFile).toHaveBeenCalledWith('/books/a.pdf', expect.objectContaining({ extractCover: false }));
expect(mockExtractAudioMetadata).toHaveBeenCalledWith('/books/a.m4b', { cover: false });
expect(opf).toEqual(expect.objectContaining({ title: 'No Cover Read', cover: null }));
expect(mockReadFile).toHaveBeenCalledTimes(1);
});
// Persisting an unreadable file as empty metadata would wipe whatever the book already had.
it('AudioFormatExtractor returns null when the file cannot be read', async () => {
mockExtractAudioMetadata.mockResolvedValue(null);
@@ -1,11 +1,14 @@
import { extractMobiCover, parseMobiFile } from '../lib/mobi-parser';
import { splitMetadataIsbn } from '../../../common/text-match/isbn-normalize';
import { parsePublishedDateKey, parsePublishedYear, publishedYearFromDateKey } from '../../../common/utils/published-date.utils';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
import type { FormatExtractOptions, FormatExtractor, ParsedBookData } from './format-extractor.interface';
export class MobiFormatExtractor implements FormatExtractor {
async extract(absolutePath: string): Promise<ParsedBookData | null> {
const [mobi, cover] = await Promise.all([parseMobiFile(absolutePath), extractMobiCover(absolutePath).catch(() => null)]);
async extract(absolutePath: string, options?: FormatExtractOptions): Promise<ParsedBookData | null> {
const [mobi, cover] = await Promise.all([
parseMobiFile(absolutePath),
options?.cover === false ? null : extractMobiCover(absolutePath).catch(() => null),
]);
if (!mobi) return null;
const publishedDate = parsePublishedDateKey(mobi.publishedDate ?? undefined) ?? null;
@@ -2,17 +2,17 @@ import { readFile } from 'fs/promises';
import path from 'path';
import { parseOpf } from '../lib/opf-parser';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
import type { FormatExtractOptions, FormatExtractor, ParsedBookData } from './format-extractor.interface';
import { hasOpfMetadata, mapOpfMetadata } from './opf-metadata.mapper';
export class OpfFormatExtractor implements FormatExtractor {
async extract(absolutePath: string): Promise<ParsedBookData | null> {
async extract(absolutePath: string, options?: FormatExtractOptions): Promise<ParsedBookData | null> {
const xml = await readFile(absolutePath, 'utf8');
const metadata = parseOpf(xml);
if (!hasOpfMetadata(metadata)) return null;
let cover: Buffer | null = null;
if (metadata.coverHref) {
if (metadata.coverHref && options?.cover !== false) {
const coverPath = path.resolve(path.dirname(absolutePath), metadata.coverHref);
try {
cover = await readFile(coverPath);
@@ -1,15 +1,15 @@
import { parseBookFilename } from '../lib/filename-parser';
import { parsePdfFile, type PdfParseOptions } from '../lib/pdf-parser';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
import type { FormatExtractOptions, FormatExtractor, ParsedBookData } from './format-extractor.interface';
type PdfFormatExtractorOptions = Pick<PdfParseOptions, 'extractCover' | 'onWarning'>;
export class PdfFormatExtractor implements FormatExtractor {
constructor(private readonly options: PdfFormatExtractorOptions = {}) {}
async extract(absolutePath: string): Promise<ParsedBookData | null> {
async extract(absolutePath: string, options?: FormatExtractOptions): Promise<ParsedBookData | null> {
const pdf = await parsePdfFile(absolutePath, {
extractCover: this.options.extractCover === true,
extractCover: this.options.extractCover === true && options?.cover !== false,
onWarning: this.options.onWarning,
});
if (!pdf) return null;
@@ -7,7 +7,7 @@ import { AudioFormatExtractor } from './extractors/audio-format.extractor';
import { ComicFormatExtractor } from './extractors/comic-format.extractor';
import { EpubFormatExtractor } from './extractors/epub-format.extractor';
import { Fb2FormatExtractor } from './extractors/fb2-format.extractor';
import type { FormatExtractor, ParsedBookData } from './extractors/format-extractor.interface';
import type { FormatExtractOptions, FormatExtractor, ParsedBookData } from './extractors/format-extractor.interface';
import { MobiFormatExtractor } from './extractors/mobi-format.extractor';
import { OpfFormatExtractor } from './extractors/opf-format.extractor';
import { PdfFormatExtractor } from './extractors/pdf-format.extractor';
@@ -56,8 +56,9 @@ export class MetadataExtractionService {
return format === 'djvu' || this.extractors.has(format);
}
async extract(absolutePath: string, format: string): Promise<ParsedBookData | null> {
async extract(absolutePath: string, format: string, options?: FormatExtractOptions): Promise<ParsedBookData | null> {
if (format === 'djvu') {
if (options?.cover === false) return { ...(await this.extractDjvuDetails(absolutePath)), authors: [], genres: [], cover: null };
const { pageCount, cover } = await this.djvu.extractMetadata(absolutePath);
const filename = parseBookFilename(absolutePath);
return {
@@ -69,7 +70,9 @@ export class MetadataExtractionService {
cover,
};
}
return (await this.extractors.get(format)?.extract(absolutePath)) ?? null;
const extractor = this.extractors.get(format);
if (!extractor) return null;
return (await (options ? extractor.extract(absolutePath, options) : extractor.extract(absolutePath))) ?? null;
}
/** What a DjVu file supplies, read without rendering its cover. */
@@ -357,6 +357,125 @@ describe('MetadataService', () => {
});
});
describe('applying path rules to books already in a library', () => {
const sources = [{ key: 'embedded', absolutePath: '/comics/Saga/Saga #12.cbz', format: 'cbz' }];
const precedence = ['embedded', 'opfFile', 'regex'];
const stored = { title: 'Fetched title', seriesName: null, seriesIndex: null, publishedYear: null, publishedDate: null };
afterEach(() => vi.restoreAllMocks());
it('writes only the fields the rules decide and never reads the cover', async () => {
const { db, updateSet, selectLimit } = makeDb();
selectLimit.mockResolvedValueOnce([stored]);
const scoreService = { calculateAndSave: vi.fn().mockResolvedValue(undefined) };
const service = makeService(db, undefined, { scoreService });
const extraction = vi
.spyOn(MetadataExtractionService.prototype, 'extract')
.mockResolvedValueOnce({ title: 'File title', authors: [], genres: [], cover: null });
const changed = await service.applyRegexMetadataRules(
9,
sources,
{ title: 'Regex title', seriesName: 'Saga', seriesIndex: '12' },
precedence,
);
expect(changed).toBe('updated');
expect(extraction).toHaveBeenCalledWith('/comics/Saga/Saga #12.cbz', 'cbz', { cover: false });
expect(updateSet).toHaveBeenCalledTimes(1);
expect(updateSet.mock.calls[0][0]).toMatchObject({ seriesName: 'Saga', seriesIndex: '12' });
expect(updateSet.mock.calls[0][0]).not.toHaveProperty('title');
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(9);
});
it('adds the run year to a series the file names without it', async () => {
const { db, updateSet, selectLimit } = makeDb();
selectLimit.mockResolvedValueOnce([{ ...stored, seriesName: 'Batman', seriesIndex: '12' }]);
const service = makeService(db);
vi.spyOn(MetadataExtractionService.prototype, 'extract').mockResolvedValueOnce({
seriesName: 'Batman',
seriesIndex: '12',
authors: [],
genres: [],
cover: null,
});
const changed = await service.applyRegexMetadataRules(9, sources, { seriesName: 'Batman (2016)', seriesIndex: '12' }, precedence);
expect(changed).toBe('updated');
expect(updateSet.mock.calls[0][0]).toMatchObject({ seriesName: 'Batman (2016)' });
expect(updateSet.mock.calls[0][0]).not.toHaveProperty('seriesIndex');
});
it('leaves a series the file supplies alone when the file ranks above the rules', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
vi.spyOn(MetadataExtractionService.prototype, 'extract').mockResolvedValueOnce({
seriesName: 'Detective Comics',
seriesIndex: '7',
authors: [],
genres: [],
cover: null,
});
const changed = await service.applyRegexMetadataRules(9, sources, { seriesName: 'Batman (2016)', seriesIndex: '12' }, precedence);
expect(changed).toBe('unchanged');
expect(updateSet).not.toHaveBeenCalled();
});
it('reports no change when the book already carries what the rules decide', async () => {
const { db, updateSet, selectLimit } = makeDb();
selectLimit.mockResolvedValueOnce([{ ...stored, seriesName: 'Saga', seriesIndex: '12' }]);
const scoreService = { calculateAndSave: vi.fn() };
const service = makeService(db, undefined, { scoreService });
vi.spyOn(MetadataExtractionService.prototype, 'extract').mockResolvedValueOnce({ authors: [], genres: [], cover: null });
expect(await service.applyRegexMetadataRules(9, sources, { seriesName: 'Saga', seriesIndex: '12' }, precedence)).toBe('unchanged');
expect(updateSet).not.toHaveBeenCalled();
expect(scoreService.calculateAndSave).not.toHaveBeenCalled();
});
it('writes nothing when no file of the book can be read', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
const extraction = vi.spyOn(MetadataExtractionService.prototype, 'extract').mockRejectedValue(new Error('EIO: i/o error'));
const result = await service.applyRegexMetadataRules(
9,
[...sources, { key: 'opfFile', absolutePath: '/comics/Saga/metadata.opf', format: 'opf' }],
{ title: 'Regex title', seriesName: 'Saga', seriesIndex: '12' },
precedence,
);
expect(result).toBe('unreadable');
expect(extraction).toHaveBeenCalledTimes(2);
expect(updateSet).not.toHaveBeenCalled();
});
it('lets the rules lead when the files simply carry no metadata', async () => {
const { db, updateSet, selectLimit } = makeDb();
selectLimit.mockResolvedValueOnce([stored]);
const service = makeService(db);
vi.spyOn(MetadataExtractionService.prototype, 'extract').mockResolvedValueOnce(null);
expect(await service.applyRegexMetadataRules(9, sources, { seriesName: 'Saga', seriesIndex: '12' }, precedence)).toBe('updated');
expect(updateSet.mock.calls[0][0]).toMatchObject({ seriesName: 'Saga', seriesIndex: '12' });
});
it('respects locked fields', async () => {
const { db, updateSet, selectLimit } = makeDb();
selectLimit.mockResolvedValueOnce([stored]);
const filter = vi.fn().mockResolvedValue({ dto: {}, skippedFields: ['seriesName', 'seriesIndex'] });
const service = makeService(db, undefined, { bookMetadataLockService: { isFieldLocked: vi.fn(), filterAutomatedBookUpdate: filter } });
vi.spyOn(MetadataExtractionService.prototype, 'extract').mockResolvedValueOnce({ authors: [], genres: [], cover: null });
expect(await service.applyRegexMetadataRules(9, sources, { seriesName: 'Saga', seriesIndex: '12' }, precedence)).toBe('unchanged');
expect(filter).toHaveBeenCalledWith(9, { seriesName: 'Saga', seriesIndex: '12' });
expect(updateSet).not.toHaveBeenCalled();
});
});
describe('file extraction failures', () => {
const sources = [
{ key: 'embedded', absolutePath: '/books/file.epub', format: 'epub' },
+130 -29
View File
@@ -1,5 +1,5 @@
import type { RegexMetadataFields } from '@bookorbit/types';
import { mergeRegexMetadata } from './regex/merge-regex-metadata';
import { decideRegexFields, mergeRegexMetadata } from './regex/merge-regex-metadata';
import { stat } from 'fs/promises';
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { and, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm';
@@ -41,7 +41,7 @@ import {
} from '@bookorbit/types';
import { chaptersReachLastFile, mergeAudioChapters, type AudioChapterSource } from './extractors/audio-chapter-merge';
import { parseAudioDuration, probeAudioChapters } from './extractors/audio.extractor';
import type { ParsedBookData } from './extractors/format-extractor.interface';
import type { FormatExtractOptions, ParsedBookData } from './extractors/format-extractor.interface';
import { METADATA_AUDIO_FORMATS, MetadataExtractionService } from './metadata-extraction.service';
import { MetadataEventsService, METADATA_AUTHORS_REPLACED } from './metadata-events.service';
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
@@ -75,6 +75,22 @@ function normalizePublishedYear(year: number | null | undefined): number | null
return year;
}
export interface MetadataSourceFile {
key: string;
absolutePath: string;
format: string;
}
/** What applying path rules did to a book; `unreadable` means its files could not be read, so nothing was written. */
export type RegexRulesWriteResult = 'updated' | 'unchanged' | 'unreadable';
/** Rules lead when nothing was read from the files or the library ranks regex above the source that was. */
function regexLeads(precedence: string[], selectedKey: string | undefined): boolean {
const regexIndex = precedence.indexOf('regex');
const baseIndex = selectedKey ? precedence.indexOf(selectedKey) : -1;
return !selectedKey || (regexIndex !== -1 && (baseIndex === -1 || regexIndex < baseIndex));
}
@Injectable()
export class MetadataService {
private readonly logger = new Logger(MetadataService.name);
@@ -144,36 +160,12 @@ export class MetadataService {
}
}
async extractAndSaveWithRegex(
bookId: number,
sources: { key: string; absolutePath: string; format: string }[],
regex: RegexMetadataFields,
precedence: string[],
): Promise<void> {
async extractAndSaveWithRegex(bookId: number, sources: MetadataSourceFile[], regex: RegexMetadataFields, precedence: string[]): Promise<void> {
const startedAt = Date.now();
this.logger.debug(`[metadata.extract_with_regex] [start] bookId=${bookId} sourceCount=${sources.length} - metadata extraction started`);
try {
let base: ParsedBookData | null = null;
let selected: (typeof sources)[number] | undefined;
for (const source of sources) {
const sourceStartedAt = Date.now();
try {
base = await this.extractionService.extract(source.absolutePath, source.format);
} catch (error) {
this.logger.warn(
`[metadata.extract_source] [fail] bookId=${bookId} source="${sanitizeLogValue(source.key)}" format="${sanitizeLogValue(source.format)}" durationMs=${Date.now() - sourceStartedAt} errorClass=${error instanceof Error ? error.name : 'Error'} error="${sanitizeLogValue(error instanceof Error ? error.message : String(error))}" - file metadata extraction failed; continuing with remaining sources and regex`,
);
continue;
}
if (base) {
selected = source;
break;
}
}
const regexIndex = precedence.indexOf('regex');
const baseIndex = selected ? precedence.indexOf(selected.key) : -1;
const preferRegex = !selected || (regexIndex !== -1 && (baseIndex === -1 || regexIndex < baseIndex));
const data = mergeRegexMetadata(base, regex, preferRegex);
const { base, selected } = await this.readFirstMetadataSource(bookId, sources);
const data = mergeRegexMetadata(base, regex, regexLeads(precedence, selected?.key));
await this.persistMetadata(bookId, data, selected?.format ?? 'regex', true);
if (selected) {
if (base?.cover) await this.persistSourceCover(bookId, selected.format, base.cover);
@@ -191,6 +183,115 @@ export class MetadataService {
}
}
/**
* Writes only the fields a library's path rules decide for this book, so applying rules to books
* already in a library leaves every value a file, a provider or a person supplied alone. Covers
* are not read.
*/
async applyRegexMetadataRules(
bookId: number,
sources: MetadataSourceFile[],
regex: RegexMetadataFields,
precedence: string[],
): Promise<RegexRulesWriteResult> {
const { base, selected, readFailed } = await this.readFirstMetadataSource(bookId, sources, { cover: false });
// A file that exists but cannot be read right now is not a file without metadata: letting the
// rules lead would replace what it says, and an unchanged file is never re-read to repair that.
if (!base && readFailed) return 'unreadable';
const decided = decideRegexFields(base, regex, regexLeads(precedence, selected?.key));
if (Object.keys(decided).length === 0) return 'unchanged';
const [current] = await this.db
.select({
title: bookMetadata.title,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
publishedYear: bookMetadata.publishedYear,
publishedDate: bookMetadata.publishedDate,
})
.from(bookMetadata)
.where(eq(bookMetadata.bookId, bookId))
.limit(1);
if (!current) return 'unchanged';
const update: Parameters<BookMetadataLockService['filterAutomatedBookUpdate']>[1] = {};
if (decided.title !== undefined && decided.title !== current.title) update.title = decided.title;
const seriesName = normalizeMetadataText(decided.seriesName);
if (seriesName && seriesName !== current.seriesName) update.seriesName = seriesName;
if (decided.seriesIndex !== undefined && decided.seriesIndex !== current.seriesIndex) update.seriesIndex = decided.seriesIndex;
if (Object.prototype.hasOwnProperty.call(decided, 'publishedYear')) {
const publishedDate = normalizePublishedDate(decided.publishedDate) ?? null;
const publishedYear = publishedDate ? publishedYearFromDateKey(publishedDate) : normalizePublishedYear(decided.publishedYear);
if (publishedYear !== current.publishedYear || publishedDate !== current.publishedDate) {
update.publishedYear = publishedYear;
update.publishedDate = publishedDate;
}
}
if (decided.authors?.length) {
const currentAuthors = await this.db
.select({ name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, bookId))
.orderBy(bookAuthors.displayOrder);
const key = (names: (string | null)[]) => names.map((name) => normalizeMetadataTextKey(name)).join('\u0000');
if (key(decided.authors) !== key(currentAuthors.map((author) => author.name))) update.authors = decided.authors;
}
if (Object.keys(update).length === 0) return 'unchanged';
const { dto: filtered } = await this.bookMetadataLockService.filterAutomatedBookUpdate(bookId, update);
const scalarFields: Partial<typeof schema.bookMetadata.$inferInsert> = {};
if (filtered.title !== undefined) scalarFields.title = filtered.title;
if (filtered.seriesName !== undefined) scalarFields.seriesName = filtered.seriesName;
if (filtered.seriesIndex !== undefined) scalarFields.seriesIndex = filtered.seriesIndex;
if (filtered.publishedYear !== undefined) {
scalarFields.publishedYear = filtered.publishedYear;
scalarFields.publishedDate = filtered.publishedDate ?? null;
}
const writesAuthors = filtered.authors !== undefined;
if (Object.keys(scalarFields).length === 0 && !writesAuthors) return 'unchanged';
if (Object.keys(scalarFields).length > 0) {
scalarFields.updatedAt = new Date();
const patch = (await this.seriesIdentity?.resolveMetadataPatch(scalarFields)) ?? scalarFields;
await this.db.update(bookMetadata).set(patch).where(eq(bookMetadata.bookId, bookId));
if (scalarFields.seriesName !== undefined || scalarFields.seriesIndex !== undefined)
await this.seriesMemberships?.syncPrimaryFromMetadata(bookId);
}
if (writesAuthors) {
await this.replaceAuthors(
bookId,
filtered.authors!.map((name) => ({ name, sortName: null })),
);
}
await this.scoreService.calculateAndSave(bookId);
return 'updated';
}
/** The first configured source that yields metadata, with a failing source logged and skipped. */
private async readFirstMetadataSource(
bookId: number,
sources: MetadataSourceFile[],
options?: FormatExtractOptions,
): Promise<{ base: ParsedBookData | null; selected: MetadataSourceFile | undefined; readFailed: boolean }> {
let readFailed = false;
for (const source of sources) {
const sourceStartedAt = Date.now();
try {
const base = options
? await this.extractionService.extract(source.absolutePath, source.format, options)
: await this.extractionService.extract(source.absolutePath, source.format);
if (base) return { base, selected: source, readFailed };
} catch (error) {
readFailed = true;
this.logger.warn(
`[metadata.extract_source] [fail] bookId=${bookId} source="${sanitizeLogValue(source.key)}" format="${sanitizeLogValue(source.format)}" durationMs=${Date.now() - sourceStartedAt} errorClass=${error instanceof Error ? error.name : 'Error'} error="${sanitizeLogValue(error instanceof Error ? error.message : String(error))}" - file metadata extraction failed; continuing with remaining sources and regex`,
);
}
}
return { base: null, selected: undefined, readFailed };
}
// Called when ebook is the winner but audio files are also present.
// Saves audio-specific fields that no ebook format can provide, plus audio provider IDs, and the
// track's art into the audio slot. The leading source owns the ebook slot, and a custom audio
@@ -0,0 +1,166 @@
import {
FOLDER_SERIES_REGEX_METADATA,
isFolderSeriesRegexMetadata,
REGEX_METADATA_GROUPS,
REGEX_METADATA_LIMITS,
resolveRegexMetadataConfig,
SERIES_INDEX_MAX_LENGTH,
SERIES_INDEX_PATTERN,
} from '@bookorbit/types';
import { describe, expect, it } from 'vitest';
import { evaluateRegexMetadata } from './regex-metadata-engine';
import { prepareRegexMetadataPath } from './regex-metadata-path';
const shared = { groups: REGEX_METADATA_GROUPS, seriesIndexPattern: SERIES_INDEX_PATTERN, seriesIndexMaxLength: SERIES_INDEX_MAX_LENGTH };
function evaluate(relativePath: string) {
const result = evaluateRegexMetadata({ config: FOLDER_SERIES_REGEX_METADATA, inputPath: prepareRegexMetadataPath(relativePath) }, shared);
expect(result.diagnostics).toEqual([]);
return result.matched ? result.metadata : null;
}
// Layouts taken from Komga and Kavita documentation, Kavita's scanner fixtures and folder trees
// users posted in Komga, Kavita and BookOrbit issues.
describe('folder-as-series preset', () => {
it('runs the current rules for a library saved with an earlier version of the preset', () => {
const saved = { preset: 'folder_series' as const, rules: [{ pattern: '^(?<series>[^/]+)/', flags: '' }] };
expect(isFolderSeriesRegexMetadata(saved)).toBe(true);
expect(resolveRegexMetadataConfig(saved)).toEqual(FOLDER_SERIES_REGEX_METADATA);
});
it('recognises unmarked preset rules and leaves custom rules alone', () => {
const custom = { rules: [{ pattern: '^(?<series>[^/]+)/', flags: '' }] };
expect(resolveRegexMetadataConfig({ rules: FOLDER_SERIES_REGEX_METADATA.rules })).toEqual(FOLDER_SERIES_REGEX_METADATA);
expect(isFolderSeriesRegexMetadata(custom)).toBe(false);
expect(resolveRegexMetadataConfig(custom)).toBe(custom);
expect(resolveRegexMetadataConfig(null)).toBeNull();
});
it('stays inside the rule limits', () => {
expect(FOLDER_SERIES_REGEX_METADATA.rules.length).toBeLessThanOrEqual(REGEX_METADATA_LIMITS.rules);
for (const rule of FOLDER_SERIES_REGEX_METADATA.rules) expect(rule.pattern.length).toBeLessThanOrEqual(REGEX_METADATA_LIMITS.patternLength);
});
it.each([
['Marvel/Deadpool (2016)/Deadpool 001 (2016).cbz', 'Deadpool (2016)', '1'],
['Antarctic Press/Plush (2018)/Plush 002 (2019).cbz', 'Plush (2018)', '2'],
['12-Gauge Comics/Plush (2022)/Plush 1 (2022).cbz', 'Plush (2022)', '1'],
['Ablaze/Traveling to Mars (2022)/Traveling to Mars 010 (2024).cbz', 'Traveling to Mars (2022)', '10'],
['Witchblade (2018)/Witchblade 003 (2018).cbz', 'Witchblade (2018)', '3'],
['Spider-Man 2099 (1992)/Spider-Man 2099 012 (1992) [Digital].cbz', 'Spider-Man 2099 (1992)', '12'],
['2000 AD/2000 AD 2150.cbz', '2000 AD', '2150'],
['100 Bullets/100 Bullets 050.cbz', '100 Bullets', '50'],
['Saga/Saga #12.cbz', 'Saga', '12'],
['Batman (2016)/Batman #0.cbz', 'Batman (2016)', '0'],
['Superman/Superman v1 024 (09-10 1943).cbz', 'Superman', '24'],
['Batgirl/Batgirl Vol.2000 #57 (December, 2004).cbz', 'Batgirl', '57'],
['Invincible/Invincible 070.5 - The Death of Everyone.cbz', 'Invincible', '70.5'],
['Batman - The Long Halloween/Batman - The Long Halloween 01 (of 13) (1996).cbz', 'Batman - The Long Halloween', '1'],
['Space Adventures/Space_Adventures_001.cbz', 'Space Adventures', '1'],
])('US comics: %s', (path, series, index) => {
expect(evaluate(path)).toEqual({ seriesName: series, seriesIndex: index });
});
it.each([
['Supergirl/Volume 05 (2005)/Supergirl (2005) Volume 05 Issue 001.cbz', 'Supergirl (2005)', '1'],
['Supergirl/Volume 07 (2016)/Supergirl (2016) Volume 07 Issue 012.cbz', 'Supergirl (2016)', '12'],
['Supergirl/Volume 06 (2011)/Supergirl (2011) 004.cbz', 'Supergirl (2011)', '4'],
])('runs nested under one title keep their run year: %s', (path, series, index) => {
expect(evaluate(path)).toEqual({ seriesName: series, seriesIndex: index });
});
it.each([
['Blake et Mortimer/Blake et Mortimer T01.cbz', 'Blake et Mortimer', '1'],
['Astérix/Astérix - T01 - Astérix le Gaulois.cbz', 'Astérix', '1'],
['Les Tuniques Bleues/Les Tuniques Bleues - Tome 05 - Vol c2004.cbz', 'Les Tuniques Bleues', '5'],
['Dragon Ball/01 - Dragon Ball - Bulma et Son Goku.cbz', 'Dragon Ball', '1'],
])('European comics: %s', (path, series, index) => {
expect(evaluate(path)).toEqual({ seriesName: series, seriesIndex: index });
});
it.each([
['One Punch Man/One Punch Man T01.cbr', 'One Punch Man', '1'],
['Berserk/Berserk v01.cbz', 'Berserk', '1'],
["VizMedia/Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 2.cbz", "Frieren - Beyond Journey's End", '2'],
['YenPress/Spice and Wolf/Spice and Wolf Vol. 2.cbz', 'Spice and Wolf', '2'],
['My Dress-Up Darling/My Dress-Up Darling v02.cbz', 'My Dress-Up Darling', '2'],
['My Dress-Up Darling/My Dress-Up Darling ch 10.cbz', 'My Dress-Up Darling', '10'],
['One Piece/One Piece c1001.cbz', 'One Piece', '1001'],
["The Novel's Extra (Remake)/Vol.01.cbz", "The Novel's Extra (Remake)", '1'],
["The Novel's Extra (Remake)/The Novel's Extra Chapter 100.cbz", "The Novel's Extra (Remake)", '100'],
['Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0011.cbz', 'Spice and Wolf', '11'],
['Fairy Tail/volume 01/Fairy Tail - Chapter 001.cbz', 'Fairy Tail', '1'],
['Kaiji/01. Part 1/Volume 1.cbz', 'Kaiji', '1'],
['Solo Leveling/Season 2/Solo Leveling S2 Chapter 5.cbz', 'Solo Leveling', '5'],
['나 혼자만 레벨업/나 혼자만 레벨업 01화.cbz', '나 혼자만 레벨업', '1'],
['원피스/원피스 3권.cbz', '원피스', '3'],
['ワンピース/ワンピース 第1話.cbz', 'ワンピース', '1'],
['ワンピース/ワンピース 第12巻.cbz', 'ワンピース', '12'],
])('manga: %s', (path, series, index) => {
expect(evaluate(path)).toEqual({ seriesName: series, seriesIndex: index });
});
it.each([
['My Dress-Up Darling/Specials/Official Anime Fanbook SP05 (2024) (Digital).cbz', 'My Dress-Up Darling'],
['Space Adventures/_oneshots/Pluto Adventures.cbz', 'Space Adventures'],
['Batman/Annuals/Batman Annual 01.cbz', 'Batman'],
['Saga/Extras/Saga Artbook 02.cbz', 'Saga'],
['Heavy Metal/1986/Heavy Metal 1986-04.cbz', 'Heavy Metal'],
['Watchmen/Watchmen.cbz', 'Watchmen'],
])('takes the series without a number when the file names none: %s', (path, series) => {
expect(evaluate(path)).toEqual({ seriesName: series });
});
it.each([
['One Piece [Digital]/One Piece v01.cbz', 'One Piece', '1'],
['One Piece {Viz Media}/One Piece v02.cbz', 'One Piece', '2'],
['[Group] Series Name/[Group] Series Name - 01 [1080p].cbz', 'Series Name', '1'],
['Spider-Man 2099 (1992) [Digital]/Spider-Man 2099 001.cbz', 'Spider-Man 2099 (1992)', '1'],
['Series [Digital]/Series Vol. 3/Series Vol. 3 Ch. 0011.cbz', 'Series', '11'],
['Series/Series - 01 (v2).cbz', 'Series', '1'],
["The Novel's Extra (Remake)/The Novel's Extra Chapter 100.cbz", "The Novel's Extra (Remake)", '100'],
])('leaves release tags out of the series and number: %s', (path, series, index) => {
expect(evaluate(path)).toEqual({ seriesName: series, seriesIndex: index });
});
// A badly sorted library still gets a predictable answer: the folder above each file names its series.
it.each([
['Comics/Batman 001.cbz', 'Comics', '1'],
['Comics/Superman 001.cbz', 'Comics', '1'],
['Marvel/Batman/Batman 001.cbz', 'Batman', '1'],
['DC/Batman/Batman 001.cbz', 'Batman', '1'],
['Supergirl/Volume 05/Supergirl 001.cbz', 'Supergirl', '1'],
['Batman/Batman Annual 001.cbz', 'Batman', '1'],
["Manga/Rumiko Takahashi/Maison Ikkoku/Collector's Edition/Maison Ikkoku CE 01.cbz", "Collector's Edition", '1'],
])('names the series after the folder, however the library is sorted: %s', (path, series, index) => {
expect(evaluate(path)).toEqual({ seriesName: series, seriesIndex: index });
});
it('reads a folder made of hundreds of release tags without runaway backtracking', () => {
const tags = Array.from({ length: 300 }, () => '[a] {b} [c]').join(' ');
expect(evaluate(`${tags}/file 01.cbz`)).toBeNull();
expect(evaluate(`Name ${tags}/file 01.cbz`)).toEqual({ seriesName: 'Name', seriesIndex: '1' });
});
it('reads no number from a bare year', () => {
expect(evaluate('Batman/Batman 2016.cbz')).toEqual({ seriesName: 'Batman' });
});
it('drops a folder name too long to be a series but keeps the number', () => {
const result = evaluateRegexMetadata(
{ config: FOLDER_SERIES_REGEX_METADATA, inputPath: prepareRegexMetadataPath(`${'x'.repeat(220)}/file 01.cbz`) },
shared,
);
expect(result.metadata).toEqual({ seriesIndex: '1' });
});
it.each(['Watchmen.cbz', '_oneshots/A oneshot.cbz', 'Specials/Annual.cbz', '1986/Heavy Metal 04.cbz'])(
'leaves a file with no series folder to its own metadata: %s',
(path) => {
expect(evaluate(path)).toBeNull();
},
);
});
@@ -1,18 +1,35 @@
import type { RegexMetadataFields } from '@bookorbit/types';
import { isRunYearQualifiedName } from '../../../common/utils/series-run-year.utils';
import type { ParsedBookData } from '../extractors/format-extractor.interface';
/** The fields a rule supplies over `base`: all of them when the rule leads, otherwise only what the file lacks. */
export function decideRegexFields(base: ParsedBookData | null, regex: RegexMetadataFields, preferRegex: boolean): RegexMetadataFields {
const decided: RegexMetadataFields = {};
for (const key of ['title', 'seriesName', 'seriesIndex'] as const) {
if (regex[key] !== undefined && (preferRegex || !base?.[key]?.trim())) decided[key] = regex[key];
}
// ComicInfo names a run without its start year; a "Batman (2016)" folder names the same run more
// precisely, and only the year keeps it apart from every other "Batman".
if (!preferRegex && isRunYearQualifiedName(regex.seriesName, base?.seriesName)) decided.seriesName = regex.seriesName;
if (regex.authors?.length && (preferRegex || !base?.authors.length)) decided.authors = regex.authors;
// Date and year describe the same value; a higher-priority year must not retain a conflicting date.
if (regex.publishedYear !== undefined && (preferRegex || (!base?.publishedYear && !base?.publishedDate))) {
decided.publishedYear = regex.publishedYear;
decided.publishedDate = regex.publishedDate;
}
return decided;
}
export function mergeRegexMetadata(base: ParsedBookData | null, regex: RegexMetadataFields, preferRegex: boolean): ParsedBookData {
const result: ParsedBookData = base ? { ...base } : { authors: [], genres: [], cover: null };
const decided = decideRegexFields(base, regex, preferRegex);
for (const key of ['title', 'seriesName', 'seriesIndex'] as const) {
if (regex[key] !== undefined && (preferRegex || !result[key]?.trim())) result[key] = regex[key];
if (decided[key] !== undefined) result[key] = decided[key];
}
if (regex.authors?.length && (preferRegex || !result.authors.length)) {
result.authors = regex.authors.map((name) => ({ name, sortName: null }));
}
// Date and year describe the same value; a higher-priority year must not retain a conflicting date.
if (regex.publishedYear !== undefined && (preferRegex || (!result.publishedYear && !result.publishedDate))) {
result.publishedYear = regex.publishedYear;
result.publishedDate = regex.publishedDate;
if (decided.authors) result.authors = decided.authors.map((name) => ({ name, sortName: null }));
if (Object.prototype.hasOwnProperty.call(decided, 'publishedYear')) {
result.publishedYear = decided.publishedYear;
result.publishedDate = decided.publishedDate;
}
return result;
}
@@ -128,6 +128,23 @@ describe('RegexMetadataService', () => {
expect(recovered.metadata.title).toBe('Recovered');
});
it('does not mistake a stalled event loop for a slow expression', async () => {
const config = { rules: [{ pattern: '^(?<series>[^/]+)/', flags: '' }] };
await service.preview(config, 'warm/up.epub');
const pending = service.preview(config, 'Series/Book.epub');
// Holds the main thread past the time limit while the worker answers. Stalling in the check phase
// makes the next loop turn run the expired timer before it reads the answer, as a busy scan can.
setImmediate(() => {
const until = Date.now() + 300;
while (Date.now() < until) {
// busy
}
});
expect(await pending).toMatchObject({ matched: true, metadata: { seriesName: 'Series' }, diagnostics: [] });
});
it('bounds queued work and resolves pending requests on shutdown', async () => {
const config = { rules: [{ pattern: '^(?<title>.+)$', flags: '' }] };
const tasks = Array.from({ length: 40 }, () => service.preview(config, 'Book.epub'));
@@ -201,6 +218,14 @@ describe('regex metadata precedence', () => {
it('fills missing values when the file source has priority', () => {
expect(mergeRegexMetadata(base, { title: 'Regex', seriesIndex: '08' }, false)).toEqual({ ...base, seriesIndex: '08' });
});
it('lets a run-year series from the path name the same run more precisely than the file', () => {
expect(mergeRegexMetadata({ ...base, seriesName: 'Batman' }, { seriesName: 'Batman (2016)', seriesIndex: '3' }, false)).toMatchObject({
seriesName: 'Batman (2016)',
});
expect(mergeRegexMetadata({ ...base, seriesName: 'Detective Comics' }, { seriesName: 'Batman (2016)' }, false).seriesName).toBe(
'Detective Comics',
);
});
it('does not keep a lower-priority date when regex overrides the year', () => {
expect(mergeRegexMetadata({ ...base, publishedDate: '2020-01-01', publishedYear: 2020 }, { publishedYear: 2024 }, true)).toMatchObject({
publishedYear: 2024,
@@ -104,9 +104,19 @@ export class RegexMetadataService implements OnModuleDestroy {
if (this.stopped) return;
for (const slot of this.slots) {
if (!slot.ready || slot.discarding || slot.task || !this.queue.length) continue;
slot.task = this.queue.shift()!;
slot.timer = setTimeout(() => this.discard(slot, 'timeout'), 100);
slot.worker.postMessage(slot.task.input);
const task = this.queue.shift()!;
slot.task = task;
// The limit is timed on the main thread, so a stalled event loop runs it out while the answer
// already waits to be read. setImmediate runs after pending worker messages are handled, so
// only a task still unanswered by then counts as slow.
slot.timer = setTimeout(
() =>
setImmediate(() => {
if (slot.task === task) this.discard(slot, 'timeout');
}),
100,
);
slot.worker.postMessage(task.input);
}
if (this.queue.length && this.slots.size < 2) this.spawn();
}
@@ -0,0 +1,161 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import type { RegexMetadataReapplyJob } from '@bookorbit/types';
import { RegexMetadataReapplyService } from './regex-metadata-reapply.service';
const rules = { rules: [{ pattern: '^(?<series>[^/]+)/', flags: '' }] };
const owner = { id: 3, isSuperuser: false } as never;
const otherUser = { id: 4, isSuperuser: false } as never;
function makeRepo(books: { id: number; primaryFileId: number | null }[], regexMetadata: unknown = rules) {
return {
findLibrarySettings: vi.fn().mockResolvedValue({ regexMetadata, metadataPrecedence: ['embedded', 'regex'] }),
getRuleReapplyBounds: vi.fn().mockResolvedValue({ total: books.length, maxId: Math.max(0, ...books.map(({ id }) => id)) }),
findRuleReapplyBookBatch: vi
.fn()
.mockImplementation((_libraryId: number, afterId: number, maxId: number, limit: number) =>
Promise.resolve(books.filter(({ id }) => id > afterId && id <= maxId).slice(0, limit)),
),
findBookFilesByBookIds: vi.fn().mockImplementation((ids: number[]) => Promise.resolve(ids.map((id) => ({ id: id * 10, bookId: id })))),
};
}
async function finished(service: RegexMetadataReapplyService, libraryId: number): Promise<RegexMetadataReapplyJob> {
let job: RegexMetadataReapplyJob | null = null;
await vi.waitFor(() => {
job = service.get(libraryId, owner);
expect(job?.status).not.toBe('running');
});
return job!;
}
describe('RegexMetadataReapplyService', () => {
it('refuses a library without saved rules', async () => {
const service = new RegexMetadataReapplyService(makeRepo([], null) as never, {} as never);
await expect(service.start(1, owner)).rejects.toBeInstanceOf(BadRequestException);
});
it('applies the rules to every present book and counts each outcome', async () => {
const books = [1, 2, 3, 4].map((id) => ({ id, primaryFileId: id * 10 }));
const repo = makeRepo(books);
const outcomes = ['updated', 'unchanged', 'skipped', 'updated'];
const scanner = { applyLibraryRulesToBook: vi.fn().mockImplementation((book: { id: number }) => Promise.resolve(outcomes[book.id - 1])) };
const service = new RegexMetadataReapplyService(repo as never, scanner as never);
await service.start(1, owner);
const job = await finished(service, 1);
expect(job).toMatchObject({ status: 'completed', total: 4, processed: 4, updated: 2, unchanged: 1, skipped: 1, failed: 0 });
expect(scanner.applyLibraryRulesToBook).toHaveBeenCalledWith(books[0], [{ id: 10, bookId: 1 }], {
config: rules,
precedence: ['embedded', 'regex'],
});
});
it('allows one run per library at a time', async () => {
let release!: () => void;
const gate = new Promise<string>((resolve) => (release = () => resolve('unchanged')));
const scanner = { applyLibraryRulesToBook: vi.fn().mockReturnValue(gate) };
const service = new RegexMetadataReapplyService(makeRepo([{ id: 1, primaryFileId: 10 }]) as never, scanner as never);
await service.start(1, owner);
await expect(service.start(1, owner)).rejects.toBeInstanceOf(ConflictException);
release();
await finished(service, 1);
});
it('stops when the rules themselves cannot run', async () => {
const books = [1, 2, 3].map((id) => ({ id, primaryFileId: id * 10 }));
const scanner = { applyLibraryRulesToBook: vi.fn().mockResolvedValue('rules_invalid') };
const service = new RegexMetadataReapplyService(makeRepo(books) as never, scanner as never);
await service.start(1, owner);
expect(await finished(service, 1)).toMatchObject({
status: 'failed',
processed: 1,
failed: 1,
failureSamples: [{ bookId: 1, code: 'rules_invalid' }],
});
});
it('reports a book whose files could not be read as failed', async () => {
const books = [1, 2].map((id) => ({ id, primaryFileId: id * 10 }));
const scanner = { applyLibraryRulesToBook: vi.fn().mockResolvedValueOnce('file_unavailable').mockResolvedValueOnce('updated') };
const service = new RegexMetadataReapplyService(makeRepo(books) as never, scanner as never);
await service.start(1, owner);
expect(await finished(service, 1)).toMatchObject({
status: 'completed',
processed: 2,
updated: 1,
failed: 1,
failureSamples: [{ bookId: 1, code: 'file_unavailable' }],
});
});
it('keeps the counts adding up when books disappear or come back during a run', async () => {
const scanner = { applyLibraryRulesToBook: vi.fn().mockResolvedValue('updated') };
const gone = makeRepo([{ id: 1, primaryFileId: 10 }]);
gone.getRuleReapplyBounds.mockResolvedValue({ total: 3, maxId: 3 });
const back = makeRepo([1, 2, 3].map((id) => ({ id, primaryFileId: id * 10 })));
back.getRuleReapplyBounds.mockResolvedValue({ total: 2, maxId: 3 });
const goneService = new RegexMetadataReapplyService(gone as never, scanner as never);
const backService = new RegexMetadataReapplyService(back as never, scanner as never);
await goneService.start(1, owner);
await backService.start(1, owner);
expect(await finished(goneService, 1)).toMatchObject({ total: 3, processed: 3, updated: 1, skipped: 2 });
expect(await finished(backService, 1)).toMatchObject({ total: 3, processed: 3, updated: 3, skipped: 0 });
});
it('records a failing book and carries on', async () => {
const books = [1, 2, 3].map((id) => ({ id, primaryFileId: id * 10 }));
const scanner = {
applyLibraryRulesToBook: vi
.fn()
.mockRejectedValueOnce(new Error('archive unreadable'))
.mockResolvedValueOnce('rules_unavailable')
.mockResolvedValueOnce('updated')
.mockResolvedValueOnce('rules_unavailable'),
};
const service = new RegexMetadataReapplyService(makeRepo(books) as never, scanner as never);
await service.start(1, owner);
const job = await finished(service, 1);
expect(job).toMatchObject({ status: 'completed', updated: 1, failed: 2 });
expect(job.failureSamples).toEqual([
{ bookId: 1, code: 'metadata_failed' },
{ bookId: 2, code: 'rules_unavailable' },
]);
});
it('retries a book the rule worker could not evaluate once the rest of its batch is done', async () => {
const books = [1, 2].map((id) => ({ id, primaryFileId: id * 10 }));
const scanner = {
applyLibraryRulesToBook: vi.fn().mockResolvedValueOnce('rules_unavailable').mockResolvedValueOnce('updated').mockResolvedValueOnce('updated'),
};
const service = new RegexMetadataReapplyService(makeRepo(books) as never, scanner as never);
await service.start(1, owner);
const job = await finished(service, 1);
expect(job).toMatchObject({ status: 'completed', updated: 2, failed: 0 });
expect(scanner.applyLibraryRulesToBook).toHaveBeenCalledTimes(3);
expect(scanner.applyLibraryRulesToBook.mock.calls[2][0]).toEqual(books[0]);
});
it('shows a run only to the person who started it or a superuser', async () => {
const scanner = { applyLibraryRulesToBook: vi.fn().mockResolvedValue('unchanged') };
const service = new RegexMetadataReapplyService(makeRepo([{ id: 1, primaryFileId: 10 }]) as never, scanner as never);
await service.start(1, owner);
await finished(service, 1);
expect(service.get(1, otherUser)).toBeNull();
expect(service.get(1, { id: 9, isSuperuser: true } as never)).not.toBeNull();
});
});
@@ -0,0 +1,207 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'crypto';
import {
resolveRegexMetadataConfig,
type RegexMetadataConfig,
type RegexMetadataReapplyFailureCode,
type RegexMetadataReapplyJob,
} from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { mapWithConcurrency } from '../../common/utils/batch.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { LIBRARY_METADATA_PRECEDENCE_DEFAULT } from '../library/library.constants';
import { ScannerRepository } from './scanner.repository';
import { ScannerService, type LibraryRulesOutcome } from './scanner.service';
const BOOK_BATCH_SIZE = 100;
// Each book reads its primary file's embedded metadata, so this bounds open archives too.
const BOOK_CONCURRENCY = 4;
const MAX_RUNNING_JOBS = 2;
const MAX_RETAINED_JOBS = 64;
const RESULT_TTL_MS = 60 * 60 * 1000;
const MAX_FAILURE_SAMPLES = 10;
type JobEntry = { job: RegexMetadataReapplyJob; userId: number; startedAt: number; finishedAt?: number };
type Rules = { config: RegexMetadataConfig; precedence: string[] };
/**
* Applies a library's saved path rules to the books already in it. A scan only re-reads books whose
* files changed, so rules added or edited later would otherwise reach only new books.
*/
@Injectable()
export class RegexMetadataReapplyService implements OnModuleDestroy {
private readonly logger = new Logger(RegexMetadataReapplyService.name);
private readonly jobs = new Map<number, JobEntry>();
private readonly runs = new Set<Promise<void>>();
private stopping = false;
constructor(
private readonly scannerRepo: ScannerRepository,
private readonly scanner: ScannerService,
) {}
async start(libraryId: number, user: RequestUser): Promise<RegexMetadataReapplyJob> {
const settings = await this.scannerRepo.findLibrarySettings(libraryId);
if (!settings) throw new NotFoundException('Library not found');
const config = resolveRegexMetadataConfig(settings.regexMetadata);
if (!config?.rules.length) {
throw new BadRequestException({ errorCode: 'REGEX_METADATA_NOT_CONFIGURED', message: 'Save path rules for this library first.' });
}
this.prune();
if (this.jobs.get(libraryId)?.job.status === 'running') {
throw new ConflictException({ errorCode: 'REGEX_REAPPLY_ALREADY_RUNNING', message: 'Path rules are already being applied to this library.' });
}
if (this.stopping || [...this.jobs.values()].filter(({ job }) => job.status === 'running').length >= MAX_RUNNING_JOBS) {
throw new ServiceUnavailableException({ errorCode: 'REGEX_REAPPLY_BUSY', message: 'Rule application capacity is busy. Try again later.' });
}
if (!this.jobs.has(libraryId) && this.jobs.size >= MAX_RETAINED_JOBS) {
const oldest = [...this.jobs.entries()]
.filter(([, entry]) => entry.finishedAt !== undefined)
.sort((a, b) => a[1].finishedAt! - b[1].finishedAt!)[0];
if (oldest) this.jobs.delete(oldest[0]);
}
const job: RegexMetadataReapplyJob = {
id: randomUUID(),
libraryId,
status: 'running',
total: 0,
processed: 0,
updated: 0,
unchanged: 0,
skipped: 0,
failed: 0,
failureSamples: [],
};
const entry: JobEntry = { job, userId: user.id, startedAt: Date.now() };
const rules: Rules = {
config,
precedence: settings.metadataPrecedence ?? [...LIBRARY_METADATA_PRECEDENCE_DEFAULT],
};
this.jobs.set(libraryId, entry);
const run = new Promise<void>((resolve) => setImmediate(resolve)).then(() => this.run(entry, rules));
this.runs.add(run);
void run.finally(() => this.runs.delete(run));
return this.snapshot(job);
}
get(libraryId: number, user: RequestUser): RegexMetadataReapplyJob | null {
this.prune();
const entry = this.jobs.get(libraryId);
if (!entry || (entry.userId !== user.id && !user.isSuperuser)) return null;
return this.snapshot(entry.job);
}
async onModuleDestroy(): Promise<void> {
this.stopping = true;
await Promise.allSettled(this.runs);
}
private snapshot(job: RegexMetadataReapplyJob): RegexMetadataReapplyJob {
return { ...job, failureSamples: job.failureSamples.map((failure) => ({ ...failure })) };
}
private prune(): void {
for (const [libraryId, entry] of this.jobs) {
if (entry.finishedAt !== undefined && Date.now() - entry.finishedAt >= RESULT_TTL_MS) this.jobs.delete(libraryId);
}
}
private async run(entry: JobEntry, rules: Rules): Promise<void> {
const { job } = entry;
this.logger.log(
`[scanner.reapply_regex_metadata] [start] libraryId=${job.libraryId} userId=${entry.userId} jobId=${job.id} rules=${rules.config.rules.length} - applying path rules to existing books`,
);
try {
const { total, maxId } = await this.scannerRepo.getRuleReapplyBounds(job.libraryId);
job.total = total;
let afterId = 0;
while (afterId < maxId && job.status === 'running') {
if (this.stopping) {
job.status = 'failed';
break;
}
const books = await this.scannerRepo.findRuleReapplyBookBatch(job.libraryId, afterId, maxId, BOOK_BATCH_SIZE);
if (books.length === 0) break;
await this.processBatch(entry, books, rules);
afterId = books[books.length - 1].id;
}
if (job.status === 'running') {
// Books deleted or moved after the count were never reached and count as skipped; books that
// came back meanwhile were processed, so the total grows to include them.
const unreached = job.total - job.processed;
if (unreached > 0) job.skipped += unreached;
job.total = job.processed = Math.max(job.total, job.processed);
job.status = 'completed';
}
} catch (error) {
job.status = 'failed';
this.logFailure(entry, error);
} finally {
entry.finishedAt = Date.now();
this.logger.log(
`[scanner.reapply_regex_metadata] [end] libraryId=${job.libraryId} userId=${entry.userId} jobId=${job.id} durationMs=${entry.finishedAt - entry.startedAt} status=${job.status} total=${job.total} processed=${job.processed} updated=${job.updated} unchanged=${job.unchanged} skipped=${job.skipped} failed=${job.failed} - path rules applied`,
);
}
}
private async processBatch(entry: JobEntry, books: { id: number; primaryFileId: number | null }[], rules: Rules): Promise<void> {
const { job } = entry;
const files = await this.scannerRepo.findBookFilesByBookIds(books.map(({ id }) => id));
const filesByBook = new Map<number, typeof files>();
for (const file of files) {
const list = filesByBook.get(file.bookId);
if (list) list.push(file);
else filesByBook.set(file.bookId, [file]);
}
const unavailable: typeof books = [];
const apply = async (book: (typeof books)[number], retry: boolean) => {
if (job.status !== 'running') return;
let outcome: LibraryRulesOutcome;
try {
outcome = await this.scanner.applyLibraryRulesToBook(book, filesByBook.get(book.id) ?? [], rules);
} catch (error) {
this.recordFailure(job, book.id, 'metadata_failed');
this.logFailure(entry, error, book.id);
return;
}
if (outcome === 'rules_unavailable' && retry) unavailable.push(book);
else if (outcome === 'updated' || outcome === 'unchanged' || outcome === 'skipped') {
job[outcome]++;
job.processed++;
} else if (outcome === 'rules_invalid') {
// Rules that cannot compile fail every book the same way, so the first one ends the job.
if (job.status !== 'running') return;
job.status = 'failed';
this.recordFailure(job, book.id, 'rules_invalid');
this.logFailure(entry, new Error('the saved path rules could not be compiled'), book.id);
} else this.recordFailure(job, book.id, outcome);
};
await mapWithConcurrency(books, BOOK_CONCURRENCY, (book) => apply(book, true));
// The rule worker's time limit runs on the main thread, which concurrent archive reads can stall;
// one at a time, with nothing else from this job in flight, a book gets a fair second chance.
for (const book of unavailable) await apply(book, false);
}
private recordFailure(job: RegexMetadataReapplyJob, bookId: number, code: RegexMetadataReapplyFailureCode): void {
job.failed++;
job.processed++;
if (job.failureSamples.length < MAX_FAILURE_SAMPLES) job.failureSamples.push({ bookId, code });
}
private logFailure(entry: JobEntry, error: unknown, bookId?: number): void {
const errorClass = error instanceof Error ? error.name : 'Error';
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`[scanner.reapply_regex_metadata] [fail] libraryId=${entry.job.libraryId} userId=${entry.userId} jobId=${entry.job.id}${bookId === undefined ? '' : ` bookId=${bookId}`} durationMs=${Date.now() - entry.startedAt} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - path rules could not be applied`,
);
}
}
@@ -3,7 +3,7 @@ import { ScannerController } from './scanner.controller';
describe('ScannerController', () => {
it('delegates scan requests to ScannerService.startScan with manual trigger', () => {
const scannerService = { startScan: vi.fn().mockResolvedValue({ jobId: 11 }), refreshCovers: vi.fn() } as any;
const controller = new ScannerController(scannerService);
const controller = new ScannerController(scannerService, {} as any);
void controller.scan(42);
@@ -12,7 +12,7 @@ describe('ScannerController', () => {
it('delegates scan history requests with the caller-supplied limit', () => {
const scannerService = { startScan: vi.fn(), refreshCovers: vi.fn(), getScanHistory: vi.fn().mockResolvedValue([]) } as any;
const controller = new ScannerController(scannerService);
const controller = new ScannerController(scannerService, {} as any);
void controller.scanHistory(7, 5);
@@ -21,10 +21,22 @@ describe('ScannerController', () => {
it('delegates cover refresh requests to ScannerService.refreshCovers', () => {
const scannerService = { startScan: vi.fn(), refreshCovers: vi.fn().mockResolvedValue({ queued: 3 }) } as any;
const controller = new ScannerController(scannerService);
const controller = new ScannerController(scannerService, {} as any);
void controller.refreshCovers(9);
expect(scannerService.refreshCovers).toHaveBeenCalledWith(9);
});
it('starts and reads path-rule application for the calling user', () => {
const regexReapply = { start: vi.fn().mockResolvedValue({ id: 'job' }), get: vi.fn().mockReturnValue(null) } as any;
const controller = new ScannerController({} as any, regexReapply);
const user = { id: 3, isSuperuser: false } as any;
void controller.reapplyRegexMetadata(9, user);
controller.getRegexReapply(9, user);
expect(regexReapply.start).toHaveBeenCalledWith(9, user);
expect(regexReapply.get).toHaveBeenCalledWith(9, user);
});
});
@@ -1,15 +1,22 @@
import { Permission } from '@bookorbit/types';
import { AuditAction, AuditResource, Permission } from '@bookorbit/types';
import { Controller, DefaultValuePipe, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { Auditable } from '../../common/decorators/auditable.decorator';
import { RequireLibraryAccess } from '../../common/decorators/require-library-access.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { RequireLibraryType } from '../../common/decorators/require-library-type.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { RegexMetadataReapplyService } from './regex-metadata-reapply.service';
import { ScannerService } from './scanner.service';
@Controller('scanner')
@RequireLibraryType('books')
export class ScannerController {
constructor(private readonly scannerService: ScannerService) {}
constructor(
private readonly scannerService: ScannerService,
private readonly regexReapply: RegexMetadataReapplyService,
) {}
@Post('libraries/:id/scan')
@RequirePermission(Permission.ManageLibraries)
@@ -29,6 +36,28 @@ export class ScannerController {
return this.scannerService.getScanHistory(libraryId, limit);
}
@Get('libraries/:id/reapply-regex-metadata')
@RequirePermission(Permission.ManageLibraries)
@RequireLibraryAccess('editor')
getRegexReapply(@Param('id', ParseIntPipe) libraryId: number, @CurrentUser() user: RequestUser) {
return this.regexReapply.get(libraryId, user);
}
/** Writes only the fields the saved rules decide, so it is safe to run on a library with fetched metadata. */
@Post('libraries/:id/reapply-regex-metadata')
@RequirePermission(Permission.ManageLibraries)
@RequireLibraryAccess('editor')
@HttpCode(HttpStatus.ACCEPTED)
@Auditable({
action: AuditAction.LibraryReapplyRegexMetadata,
resource: AuditResource.Library,
getResourceId: (req) => parseInt(req.params['id'], 10),
description: (req) => `Started applying path rules to existing books in library #${req.params['id']}`,
})
reapplyRegexMetadata(@Param('id', ParseIntPipe) libraryId: number, @CurrentUser() user: RequestUser) {
return this.regexReapply.start(libraryId, user);
}
@Post('libraries/:id/refresh-covers')
@RequirePermission(Permission.ManageLibraries)
@HttpCode(HttpStatus.ACCEPTED)
@@ -15,6 +15,7 @@ import { MetadataModule } from '../metadata/metadata.module';
import { NotificationModule } from '../notification/notification.module';
import { FileEventProcessorService } from './file-event-processor.service';
import { FileWatcherService } from './file-watcher.service';
import { RegexMetadataReapplyService } from './regex-metadata-reapply.service';
import { ScanGateway } from './scan.gateway';
import { ScanJobStore } from './scan-job-store.service';
import { ScannerController } from './scanner.controller';
@@ -42,6 +43,7 @@ describe('ScannerModule', () => {
ScanJobStore,
FileEventProcessorService,
FileWatcherService,
RegexMetadataReapplyService,
]);
expect(Reflect.getMetadata('exports', ScannerModule)).toEqual([ScannerService, FileWatcherService, ScanGateway]);
});
+10 -1
View File
@@ -12,6 +12,7 @@ import { MetadataModule } from '../metadata/metadata.module';
import { NotificationModule } from '../notification/notification.module';
import { FileEventProcessorService } from './file-event-processor.service';
import { FileWatcherService } from './file-watcher.service';
import { RegexMetadataReapplyService } from './regex-metadata-reapply.service';
import { ScanGateway } from './scan.gateway';
import { ScanJobStore } from './scan-job-store.service';
import { ScannerController } from './scanner.controller';
@@ -36,7 +37,15 @@ import { ScannerService } from './scanner.service';
}),
],
controllers: [ScannerController],
providers: [ScannerService, ScannerRepository, ScanGateway, ScanJobStore, FileEventProcessorService, FileWatcherService],
providers: [
ScannerService,
ScannerRepository,
ScanGateway,
ScanJobStore,
FileEventProcessorService,
FileWatcherService,
RegexMetadataReapplyService,
],
exports: [ScannerService, FileWatcherService, ScanGateway],
})
export class ScannerModule {}
@@ -1,7 +1,7 @@
import { realignAudioTrackOrder } from '../../db/audio-track-order';
import { recordBookFileHashHistory, deleteBookFilesWithHashInvalidation, deleteBooksWithHashInvalidation } from '../../db/book-file-hash-history';
import { Inject, Injectable } from '@nestjs/common';
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -703,6 +703,24 @@ export class ScannerRepository {
return row ?? null;
}
/** Present books of a library, counted once up front so a re-apply job reports a stable total. */
async getRuleReapplyBounds(libraryId: number): Promise<{ total: number; maxId: number }> {
const [row] = await this.db
.select({ total: sql<number>`count(*)::int`, maxId: sql<number>`coalesce(max(${books.id}), 0)::int` })
.from(books)
.where(and(eq(books.libraryId, libraryId), eq(books.status, 'present')));
return { total: row?.total ?? 0, maxId: row?.maxId ?? 0 };
}
async findRuleReapplyBookBatch(libraryId: number, afterId: number, maxId: number, limit: number) {
return this.db
.select({ id: books.id, primaryFileId: books.primaryFileId })
.from(books)
.where(and(eq(books.libraryId, libraryId), eq(books.status, 'present'), gt(books.id, afterId), lte(books.id, maxId)))
.orderBy(asc(books.id))
.limit(limit);
}
async findBooksByIds(bookIds: number[]) {
if (bookIds.length === 0) return [];
return this.db
@@ -154,6 +154,7 @@ const mockGateway = {
const mockMetadata = {
extractAndSave: vi.fn().mockResolvedValue(undefined),
extractAndSaveWithRegex: vi.fn().mockResolvedValue(undefined),
applyRegexMetadataRules: vi.fn().mockResolvedValue('unchanged'),
refreshCoverForBook: vi.fn().mockResolvedValue(false),
extractAudioFileDuration: vi.fn().mockResolvedValue(undefined),
aggregateAudioDuration: vi.fn().mockResolvedValue(undefined),
@@ -4724,4 +4725,60 @@ describe('regex metadata outside a scan', () => {
expect(regexMetadata.evaluate).not.toHaveBeenCalled();
expect(mockMetadata.extractAndSave).not.toHaveBeenCalled();
});
describe('applying saved rules to a book already in the library', () => {
const rules = { config: regexConfig, precedence: ['embedded', 'regex'] };
it('writes only rule-decided fields through the metadata service', async () => {
const { service, regexMetadata } = makeService(rulesRepo(regexConfig));
regexMetadata.evaluate.mockResolvedValue({ matched: true, metadata: { seriesName: 'Series' }, diagnostics: [] });
mockMetadata.applyRegexMetadataRules.mockResolvedValueOnce('updated');
await expect(service.applyLibraryRulesToBook(book, [primary] as never, rules)).resolves.toBe('updated');
expect(regexMetadata.evaluate).toHaveBeenCalledWith(regexConfig, 'Series/Book/book.epub');
expect(mockMetadata.applyRegexMetadataRules).toHaveBeenCalledWith(
7,
[{ key: 'embedded', absolutePath: '/library/Series/Book/book.epub', format: 'epub' }],
{ seriesName: 'Series' },
['embedded', 'regex'],
);
expect(mockMetadata.extractAndSaveWithRegex).not.toHaveBeenCalled();
});
it('reports an unchanged book when the rules decide what it already has', async () => {
const { service, regexMetadata } = makeService(rulesRepo(regexConfig));
regexMetadata.evaluate.mockResolvedValue({ matched: true, metadata: { seriesName: 'Series' }, diagnostics: [] });
await expect(service.applyLibraryRulesToBook(book, [primary] as never, rules)).resolves.toBe('unchanged');
});
it('reports a book whose files could not be read', async () => {
const { service, regexMetadata } = makeService(rulesRepo(regexConfig));
regexMetadata.evaluate.mockResolvedValue({ matched: true, metadata: { seriesName: 'Series' }, diagnostics: [] });
mockMetadata.applyRegexMetadataRules.mockResolvedValueOnce('unreadable');
await expect(service.applyLibraryRulesToBook(book, [primary] as never, rules)).resolves.toBe('file_unavailable');
});
it('skips a book no rule matches, one without a primary file, and one being written', async () => {
const { service, regexMetadata, selfWriteRegistry } = makeService(rulesRepo(regexConfig));
regexMetadata.evaluate.mockResolvedValue({ matched: false, metadata: {}, diagnostics: [] });
await expect(service.applyLibraryRulesToBook(book, [primary] as never, rules)).resolves.toBe('skipped');
await expect(service.applyLibraryRulesToBook({ ...book, primaryFileId: null }, [primary] as never, rules)).resolves.toBe('skipped');
selfWriteRegistry.begin([primary.absolutePath]);
await expect(service.applyLibraryRulesToBook(book, [primary] as never, rules)).resolves.toBe('skipped');
selfWriteRegistry.end([primary.absolutePath]);
expect(regexMetadata.evaluate).toHaveBeenCalledTimes(1);
expect(mockMetadata.applyRegexMetadataRules).not.toHaveBeenCalled();
});
it('tells a pool failure apart from rules that cannot run', async () => {
const { service, regexMetadata } = makeService(rulesRepo(regexConfig));
regexMetadata.evaluate.mockResolvedValueOnce({ matched: false, metadata: {}, diagnostics: [{ code: 'timeout' }] });
regexMetadata.evaluate.mockResolvedValueOnce({ matched: false, metadata: {}, diagnostics: [{ code: 'invalid_pattern', ruleIndex: 0 }] });
await expect(service.applyLibraryRulesToBook(book, [primary] as never, rules)).resolves.toBe('rules_unavailable');
await expect(service.applyLibraryRulesToBook(book, [primary] as never, rules)).resolves.toBe('rules_invalid');
});
});
});
+55 -16
View File
@@ -22,7 +22,7 @@ import type {
ScanProgressEvent,
ScanTriggeredBy,
} from '@bookorbit/types';
import { NotificationType } from '@bookorbit/types';
import { NotificationType, resolveRegexMetadataConfig } from '@bookorbit/types';
import { AchievementEventsService, ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED } from '../achievement/achievement-events.service';
import { BookMetadataFetchOrchestratorService } from '../book-metadata-fetch/book-metadata-fetch-orchestrator.service';
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
@@ -103,7 +103,7 @@ interface RegexScanContext {
const REGEX_SCAN_FAILURE_LIMIT = 3;
function createRegexScanContext(config: RegexMetadataConfig | null | undefined): RegexScanContext {
return { config: config ?? null, disabled: false, failures: 0 };
return { config: resolveRegexMetadataConfig(config), disabled: false, failures: 0 };
}
interface LibraryScanSettings {
@@ -183,6 +183,28 @@ interface RegisteredFile {
previousSortOrder: number | null;
}
type StoredBookFile = Awaited<ReturnType<ScannerRepository['findBookFilesByBookIds']>>[number];
export type LibraryRulesOutcome = 'updated' | 'unchanged' | 'skipped' | 'file_unavailable' | 'rules_unavailable' | 'rules_invalid';
/** Files read back from the database, shaped like a scan that found nothing new about them. */
function storedFilesAsRegistered(files: StoredBookFile[]): RegisteredFile[] {
return files.map((file) => ({
fileId: file.id,
format: file.format,
role: file.role as FileRole,
absolutePath: file.absolutePath,
sizeBytes: file.sizeBytes ?? 0,
durationSeconds: file.durationSeconds,
isNew: false,
wasReassigned: false,
wasChanged: false,
wasPathChanged: false,
mediaOverlayAvailable: file.mediaOverlayAvailable,
previousSortOrder: file.sortOrder,
}));
}
interface MetadataExtractionSource {
key: ScannerMetadataSource;
file: RegisteredFile;
@@ -2144,20 +2166,7 @@ export class ScannerService implements OnApplicationBootstrap {
const files = await this.scannerRepo.findBookFilesByBookId(bookId);
if (files.some((file) => this.selfWriteRegistry.isSuppressed(file.absolutePath))) return true;
const registered: RegisteredFile[] = files.map((file) => ({
fileId: file.id,
format: file.format,
role: file.role as FileRole,
absolutePath: file.absolutePath,
sizeBytes: file.sizeBytes ?? 0,
durationSeconds: file.durationSeconds,
isNew: false,
wasReassigned: false,
wasChanged: false,
wasPathChanged: false,
mediaOverlayAvailable: file.mediaOverlayAvailable,
previousSortOrder: file.sortOrder,
}));
const registered = storedFilesAsRegistered(files);
const winner = registered.find((file) => file.fileId === book.primaryFileId) ?? null;
const relativePath = files.find((file) => file.id === book.primaryFileId)?.relPath ?? undefined;
const precedence = settings.metadataPrecedence ?? [...LIBRARY_METADATA_PRECEDENCE_DEFAULT];
@@ -2168,6 +2177,36 @@ export class ScannerService implements OnApplicationBootstrap {
return true;
}
/**
* Applies a library's current path rules to a book already in it, writing only the fields the
* rules decide. A scan still re-reads everything for files that changed; this is for rules that
* changed while the files did not.
*/
async applyLibraryRulesToBook(
book: { id: number; primaryFileId: number | null },
files: StoredBookFile[],
rules: { config: RegexMetadataConfig; precedence: string[] },
): Promise<LibraryRulesOutcome> {
const relativePath = files.find((file) => file.id === book.primaryFileId)?.relPath;
if (!relativePath || files.some((file) => this.selfWriteRegistry.isSuppressed(file.absolutePath))) return 'skipped';
const result = await this.regexMetadata.evaluate(rules.config, relativePath);
const failure = result.diagnostics.find((entry) => entry.code !== 'invalid_value');
if (failure) return isTransientRegexFailure(failure.code) ? 'rules_unavailable' : 'rules_invalid';
if (!result.matched) return 'skipped';
const registered = storedFilesAsRegistered(files);
const winner = registered.find((file) => file.fileId === book.primaryFileId) ?? null;
const sources = this.buildMetadataExtractionSources(registered, winner, rules.precedence);
const written = await this.metadataService.applyRegexMetadataRules(
book.id,
sources.map((source) => ({ key: source.key, absolutePath: source.file.absolutePath, format: source.format })),
result.metadata,
rules.precedence,
);
return written === 'unreadable' ? 'file_unavailable' : written;
}
/** Re-applies regex rules after the watcher moved these books' files; books in libraries without rules are left alone. */
refreshRegexMetadataAfterMoveAsync(bookIds: number[]): void {
void (async () => {
@@ -941,6 +941,8 @@ describe('Authorization matrix (e2e)', () => {
'POST /libraries/:id/recompute-added-at',
'POST /libraries/:id/write-metadata-to-files',
'GET /scanner/libraries/:id/scan-history',
'GET /scanner/libraries/:id/reapply-regex-metadata',
'POST /scanner/libraries/:id/reapply-regex-metadata',
'POST /libraries/:id/books/jump-buckets',
'POST /libraries/:id/books/facets',
'GET /libraries/:id/bulk-rename/preview',
@@ -1,5 +1,5 @@
{
"totalRoutes": 719,
"totalRoutes": 721,
"byPermission": {
"(none)": 298,
"Permission.ManageMetadataConfig": 47,
@@ -7,7 +7,7 @@
"Permission.LibraryEditMetadata": 54,
"Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 62,
"Permission.ManageLibraries": 31,
"Permission.ManageLibraries": 33,
"Permission.ManageUsers": 14,
"Permission.ManageEmail": 8,
"Permission.KoboSync": 13,
@@ -37,7 +37,7 @@
},
"byLibraryAccess": {
"'viewer'": 10,
"'editor'": 15,
"'editor'": 17,
"'owner'": 4
},
"routeChunks": [
@@ -719,6 +719,30 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/scanner/scanner.controller.ts",
"className": "ScannerController",
"methodName": "getRegexReapply",
"httpMethod": "GET",
"path": "/scanner/libraries/:id/reapply-regex-metadata",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": ["'editor'"],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/scanner/scanner.controller.ts",
"className": "ScannerController",
"methodName": "reapplyRegexMetadata",
"httpMethod": "POST",
"path": "/scanner/libraries/:id/reapply-regex-metadata",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": ["'editor'"],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/scanner/scanner.controller.ts",
"className": "ScannerController",