mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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.',
|
||||
})
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user