mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 05:29:41 +00:00
Merge 5d5b67a069 into 1d0b7e383a
This commit is contained in:
commit
03e00eeeb0
4 changed files with 413 additions and 1 deletions
206
client/components/modals/ChaptersCueImportModal.vue
Normal file
206
client/components/modals/ChaptersCueImportModal.vue
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="import-cue" :width="500">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||||
|
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderImportCue }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative p-4">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<p v-if="!cueChapters && !cueParseError" class="text-xs text-gray-300">{{ $strings.MessageCueSelectFile }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="cueFileName" class="text-xs text-gray-300 mb-2 truncate">{{ cueFileName }}</p>
|
||||||
|
<p v-if="cueParseError" class="text-xs text-error mb-3">{{ cueParseError }}</p>
|
||||||
|
<div v-if="cueChapters">
|
||||||
|
<p class="text-sm mb-2"><span class="font-semibold">{{ cueChapters.length }}</span> {{ $strings.LabelChaptersFound }}</p>
|
||||||
|
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
|
||||||
|
<div class="w-24 px-2">{{ $strings.LabelStart }}</div>
|
||||||
|
<div class="grow px-2">{{ $strings.LabelTitle }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-80 overflow-y-auto my-2">
|
||||||
|
<div v-for="(chapter, index) in cueChapters" :key="index" class="flex py-0.5 text-xs" :class="index % 2 === 0 ? 'bg-primary/30' : ''">
|
||||||
|
<div class="w-24 min-w-24 px-2">
|
||||||
|
<p class="font-mono">{{ $secondsToTimestamp(chapter.start) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grow px-2">
|
||||||
|
<p class="truncate max-w-sm">{{ chapter.title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center pt-2 justify-end gap-2">
|
||||||
|
<ui-btn small @click="show = false">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
|
<ui-btn small color="bg-success" :disabled="!cueChapters || !cueChapters.length" @click="applyChapters">{{ $strings.ButtonApplyChapters }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
cueFile: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cueFileName: '',
|
||||||
|
cueParseError: null,
|
||||||
|
cueChapters: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit('input', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newValue) {
|
||||||
|
if (newValue) {
|
||||||
|
this.resetCueImportState()
|
||||||
|
if (this.cueFile) {
|
||||||
|
this.loadCueFile(this.cueFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cueFile(newValue) {
|
||||||
|
if (this.show && newValue) {
|
||||||
|
this.loadCueFile(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
resetCueImportState() {
|
||||||
|
this.cueFileName = ''
|
||||||
|
this.cueParseError = null
|
||||||
|
this.cueChapters = null
|
||||||
|
},
|
||||||
|
loadCueFile(file) {
|
||||||
|
this.cueFileName = file?.name || ''
|
||||||
|
this.cueParseError = null
|
||||||
|
this.cueChapters = null
|
||||||
|
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const text = reader.result || ''
|
||||||
|
const { chapters, error } = this.parseCueText(String(text))
|
||||||
|
if (error) {
|
||||||
|
this.cueParseError = error
|
||||||
|
this.$toast.error(this.$strings.ToastCueParseFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.cueChapters = chapters
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
this.cueParseError = this.$strings.ToastCueParseFailed
|
||||||
|
this.$toast.error(this.$strings.ToastCueParseFailed)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
},
|
||||||
|
parseCueText(text) {
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
return { chapters: [], error: this.$strings.MessageCueNoChaptersFound }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = text.split(/\r?\n/)
|
||||||
|
const chapters = []
|
||||||
|
let currentTrack = null
|
||||||
|
|
||||||
|
const pushTrack = () => {
|
||||||
|
if (!currentTrack || !Number.isFinite(currentTrack.start)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const title = currentTrack.title || `Track ${currentTrack.number}`
|
||||||
|
chapters.push({
|
||||||
|
start: currentTrack.start,
|
||||||
|
title: title.trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.toUpperCase().startsWith('REM')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackMatch = trimmed.match(/^TRACK\s+(\d+)\s+/i)
|
||||||
|
if (trackMatch) {
|
||||||
|
pushTrack()
|
||||||
|
currentTrack = {
|
||||||
|
number: Number(trackMatch[1]),
|
||||||
|
title: '',
|
||||||
|
start: null
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleMatch = trimmed.match(/^TITLE\s+(.+)$/i)
|
||||||
|
if (titleMatch) {
|
||||||
|
const title = this.stripCueValue(titleMatch[1])
|
||||||
|
if (currentTrack) {
|
||||||
|
currentTrack.title = title
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexMatch = trimmed.match(/^INDEX\s+01\s+(\d{1,3}:\d{2}:\d{2})/i)
|
||||||
|
if (indexMatch && currentTrack) {
|
||||||
|
const start = this.parseCueTime(indexMatch[1])
|
||||||
|
currentTrack.start = start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushTrack()
|
||||||
|
|
||||||
|
const cleaned = chapters.filter((chapter) => Number.isFinite(chapter.start))
|
||||||
|
if (!cleaned.length) {
|
||||||
|
return { chapters: [], error: this.$strings.MessageCueNoChaptersFound }
|
||||||
|
}
|
||||||
|
cleaned.sort((a, b) => a.start - b.start)
|
||||||
|
return { chapters: cleaned, error: null }
|
||||||
|
},
|
||||||
|
stripCueValue(value) {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||||
|
return trimmed.slice(1, -1).trim()
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
},
|
||||||
|
parseCueTime(timecode) {
|
||||||
|
const match = String(timecode || '').match(/^(\d{1,3}):(\d{2}):(\d{2})$/)
|
||||||
|
if (!match) return NaN
|
||||||
|
|
||||||
|
const minutes = Number(match[1])
|
||||||
|
const seconds = Number(match[2])
|
||||||
|
const frames = Number(match[3])
|
||||||
|
if (!Number.isFinite(minutes) || !Number.isFinite(seconds) || !Number.isFinite(frames)) {
|
||||||
|
return NaN
|
||||||
|
}
|
||||||
|
const totalSeconds = minutes * 60 + seconds + frames / 75
|
||||||
|
return Math.round(totalSeconds * 1000) / 1000
|
||||||
|
},
|
||||||
|
applyChapters() {
|
||||||
|
if (!this.cueChapters || !this.cueChapters.length) {
|
||||||
|
this.$toast.error(this.$strings.MessageCueNoChaptersFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$emit('apply', this.cueChapters)
|
||||||
|
this.show = false
|
||||||
|
this.resetCueImportState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
157
client/cypress/tests/components/pages/ChaptersCueImport.cy.js
Normal file
157
client/cypress/tests/components/pages/ChaptersCueImport.cy.js
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import ChaptersCueImportModal from '@/components/modals/ChaptersCueImportModal.vue'
|
||||||
|
|
||||||
|
const cueStrings = {
|
||||||
|
HeaderImportCue: 'Import .cue',
|
||||||
|
MessageCueSelectFile: 'Select a .cue file to preview chapters',
|
||||||
|
LabelChaptersFound: 'Chapters found',
|
||||||
|
LabelStart: 'Start',
|
||||||
|
LabelTitle: 'Title',
|
||||||
|
ButtonCancel: 'Cancel',
|
||||||
|
ButtonApplyChapters: 'Apply Chapters',
|
||||||
|
ToastCueParseFailed: 'Failed to parse .cue file',
|
||||||
|
MessageCueNoChaptersFound: 'No chapters found in .cue file'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMountOptions(onApply, props = {}, toastError = null) {
|
||||||
|
const stubs = {
|
||||||
|
'ui-btn': {
|
||||||
|
template: '<button type="button" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||||
|
},
|
||||||
|
'modals-modal': {
|
||||||
|
props: ['value'],
|
||||||
|
template: '<div v-if="value"><slot name="outer" /><slot /></div>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mocks = {
|
||||||
|
$store: {
|
||||||
|
state: {
|
||||||
|
streamLibraryItem: null
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
'user/getToken': 'token'
|
||||||
|
},
|
||||||
|
commit: () => {},
|
||||||
|
dispatch: () => {}
|
||||||
|
},
|
||||||
|
$toast: {
|
||||||
|
error: toastError || (() => {}),
|
||||||
|
info: () => {},
|
||||||
|
success: () => {},
|
||||||
|
warning: () => {}
|
||||||
|
},
|
||||||
|
$strings: cueStrings,
|
||||||
|
$getString: (value) => value
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stubs,
|
||||||
|
mocks,
|
||||||
|
propsData: {
|
||||||
|
value: true,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
listeners: {
|
||||||
|
apply: onApply
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Chapters cue import', () => {
|
||||||
|
it('imports chapters from a cue file', () => {
|
||||||
|
const cueText = [
|
||||||
|
'PERFORMER "Author"',
|
||||||
|
'TITLE "Test Book"',
|
||||||
|
'FILE "test.mp3" MP3',
|
||||||
|
' TRACK 01 AUDIO',
|
||||||
|
' TITLE "Intro"',
|
||||||
|
' INDEX 01 00:00:00',
|
||||||
|
' TRACK 02 AUDIO',
|
||||||
|
' TITLE "Chapter 1"',
|
||||||
|
' INDEX 01 00:10:00',
|
||||||
|
' TRACK 03 AUDIO',
|
||||||
|
' INDEX 01 00:20:00'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const onApply = cy.spy().as('onApply')
|
||||||
|
const cueFile = new File([cueText], 'chapters.cue', { type: 'text/plain' })
|
||||||
|
cy.mount(ChaptersCueImportModal, buildMountOptions(onApply)).then(({ wrapper }) => {
|
||||||
|
wrapper.setProps({ cueFile })
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.contains('Intro').should('be.visible')
|
||||||
|
cy.contains('Chapter 1').should('be.visible')
|
||||||
|
cy.contains('Track 3').should('be.visible')
|
||||||
|
|
||||||
|
cy.contains(cueStrings.ButtonApplyChapters).click()
|
||||||
|
|
||||||
|
cy.get('@onApply').should('have.been.calledOnce')
|
||||||
|
cy.get('@onApply')
|
||||||
|
.its('firstCall.args.0')
|
||||||
|
.should((chapters) => {
|
||||||
|
expect(chapters).to.have.length(3)
|
||||||
|
expect(chapters[0]).to.include({ title: 'Intro' })
|
||||||
|
expect(chapters[1]).to.include({ title: 'Chapter 1' })
|
||||||
|
expect(chapters[2]).to.include({ title: 'Track 3' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error for invalid cue content', () => {
|
||||||
|
const onApply = cy.spy().as('onApply')
|
||||||
|
const toastError = cy.spy().as('toastError')
|
||||||
|
const cueFile = new File([''], 'invalid.cue', { type: 'text/plain' })
|
||||||
|
|
||||||
|
cy.mount(ChaptersCueImportModal, buildMountOptions(onApply, {}, toastError)).then(({ wrapper }) => {
|
||||||
|
wrapper.setProps({ cueFile })
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.contains(cueStrings.MessageCueNoChaptersFound).should('be.visible')
|
||||||
|
cy.contains(cueStrings.ButtonApplyChapters).should('be.disabled')
|
||||||
|
cy.get('@onApply').should('not.have.been.called')
|
||||||
|
cy.get('@toastError').should('have.been.calledOnceWith', cueStrings.ToastCueParseFailed)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error for malformed cue content', () => {
|
||||||
|
const cueText = [
|
||||||
|
'FILE "test.mp3" MP3',
|
||||||
|
' TRACK 01 AUDIO',
|
||||||
|
' TITLE "Intro"',
|
||||||
|
' INDEX 01 00:00:AA'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const onApply = cy.spy().as('onApply')
|
||||||
|
const toastError = cy.spy().as('toastError')
|
||||||
|
const cueFile = new File([cueText], 'malformed.cue', { type: 'text/plain' })
|
||||||
|
|
||||||
|
cy.mount(ChaptersCueImportModal, buildMountOptions(onApply, {}, toastError)).then(({ wrapper }) => {
|
||||||
|
wrapper.setProps({ cueFile })
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.contains(cueStrings.MessageCueNoChaptersFound).should('be.visible')
|
||||||
|
cy.contains(cueStrings.ButtonApplyChapters).should('be.disabled')
|
||||||
|
cy.get('@onApply').should('not.have.been.called')
|
||||||
|
cy.get('@toastError').should('have.been.calledOnceWith', cueStrings.ToastCueParseFailed)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error for non-cue file content', () => {
|
||||||
|
const cueText = [
|
||||||
|
'{ "not": "a cue file" }',
|
||||||
|
'lorem ipsum',
|
||||||
|
'just some text'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const onApply = cy.spy().as('onApply')
|
||||||
|
const toastError = cy.spy().as('toastError')
|
||||||
|
const cueFile = new File([cueText], 'random.txt', { type: 'text/plain' })
|
||||||
|
|
||||||
|
cy.mount(ChaptersCueImportModal, buildMountOptions(onApply, {}, toastError)).then(({ wrapper }) => {
|
||||||
|
wrapper.setProps({ cueFile })
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.contains(cueStrings.MessageCueNoChaptersFound).should('be.visible')
|
||||||
|
cy.contains(cueStrings.ButtonApplyChapters).should('be.disabled')
|
||||||
|
cy.get('@onApply').should('not.have.been.called')
|
||||||
|
cy.get('@toastError').should('have.been.calledOnceWith', cueStrings.ToastCueParseFailed)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
<ui-btn v-if="chapters.length" color="bg-primary" small class="mx-1 whitespace-nowrap" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
|
<ui-btn v-if="chapters.length" color="bg-primary" small class="mx-1 whitespace-nowrap" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
|
||||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg-bg' : 'bg-primary'" class="mx-1 whitespace-nowrap" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg-bg' : 'bg-primary'" class="mx-1 whitespace-nowrap" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
||||||
<ui-btn color="bg-primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
|
<ui-btn color="bg-primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
|
||||||
|
<ui-btn color="bg-primary" small class="mx-1 whitespace-nowrap" @click="openCueImportModal">{{ $strings.ButtonImportCue }}</ui-btn>
|
||||||
<div class="grow" />
|
<div class="grow" />
|
||||||
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
|
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
|
||||||
<ui-btn v-if="hasChanges" color="bg-success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn v-if="hasChanges" color="bg-success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
|
|
@ -262,6 +263,9 @@
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
|
||||||
|
<input ref="cueFileInput" type="file" accept=".cue" class="hidden" @change="onCueFileSelected" />
|
||||||
|
<modals-chapters-cue-import-modal v-model="showCueImportModal" :cue-file="cueImportFile" @apply="applyCueChapters" />
|
||||||
|
|
||||||
<!-- create bulk chapters modal -->
|
<!-- create bulk chapters modal -->
|
||||||
<modals-modal v-model="showBulkChapterModal" name="bulk-chapters" :width="400">
|
<modals-modal v-model="showBulkChapterModal" name="bulk-chapters" :width="400">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
|
|
@ -358,7 +362,16 @@ export default {
|
||||||
bulkChapterInput: '',
|
bulkChapterInput: '',
|
||||||
showBulkChapterModal: false,
|
showBulkChapterModal: false,
|
||||||
bulkChapterCount: 1,
|
bulkChapterCount: 1,
|
||||||
detectedPattern: null
|
detectedPattern: null,
|
||||||
|
showCueImportModal: false,
|
||||||
|
cueImportFile: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
showCueImportModal(newValue) {
|
||||||
|
if (!newValue) {
|
||||||
|
this.cueImportFile = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -409,6 +422,36 @@ export default {
|
||||||
}
|
}
|
||||||
return number.toString().padStart(pattern.originalPadding, '0')
|
return number.toString().padStart(pattern.originalPadding, '0')
|
||||||
},
|
},
|
||||||
|
openCueImportModal() {
|
||||||
|
if (this.$refs.cueFileInput) {
|
||||||
|
this.$refs.cueFileInput.click()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCueFileSelected(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
this.cueImportFile = file
|
||||||
|
this.showCueImportModal = true
|
||||||
|
event.target.value = ''
|
||||||
|
},
|
||||||
|
applyCueChapters(cueChapters) {
|
||||||
|
const chapters = (cueChapters || []).map((chapter, index) => ({
|
||||||
|
id: index,
|
||||||
|
start: chapter.start,
|
||||||
|
end: null,
|
||||||
|
title: chapter.title || `Track ${index + 1}`
|
||||||
|
}))
|
||||||
|
|
||||||
|
for (let i = 0; i < chapters.length; i++) {
|
||||||
|
const nextChapter = chapters[i + 1]
|
||||||
|
chapters[i].end = nextChapter ? nextChapter.start : this.mediaDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
this.newChapters = chapters
|
||||||
|
this.lockedChapters = new Set()
|
||||||
|
this.checkChapters()
|
||||||
|
},
|
||||||
setChaptersFromTracks() {
|
setChaptersFromTracks() {
|
||||||
let currentStartTime = 0
|
let currentStartTime = 0
|
||||||
let index = 0
|
let index = 0
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"ButtonChangeRootPassword": "Change Root Password",
|
"ButtonChangeRootPassword": "Change Root Password",
|
||||||
"ButtonCheckAndDownloadNewEpisodes": "Check & Download New Episodes",
|
"ButtonCheckAndDownloadNewEpisodes": "Check & Download New Episodes",
|
||||||
"ButtonChooseAFolder": "Choose a folder",
|
"ButtonChooseAFolder": "Choose a folder",
|
||||||
|
"ButtonChooseCueFile": "Choose .cue file",
|
||||||
"ButtonChooseFiles": "Choose files",
|
"ButtonChooseFiles": "Choose files",
|
||||||
"ButtonClearFilter": "Clear Filter",
|
"ButtonClearFilter": "Clear Filter",
|
||||||
"ButtonClose": "Close",
|
"ButtonClose": "Close",
|
||||||
|
|
@ -47,6 +48,7 @@
|
||||||
"ButtonLibrary": "Library",
|
"ButtonLibrary": "Library",
|
||||||
"ButtonLogout": "Logout",
|
"ButtonLogout": "Logout",
|
||||||
"ButtonLookup": "Lookup",
|
"ButtonLookup": "Lookup",
|
||||||
|
"ButtonImportCue": "Import .cue",
|
||||||
"ButtonManageTracks": "Manage Tracks",
|
"ButtonManageTracks": "Manage Tracks",
|
||||||
"ButtonMapChapterTitles": "Map Chapter Titles",
|
"ButtonMapChapterTitles": "Map Chapter Titles",
|
||||||
"ButtonMatchAllAuthors": "Match All Authors",
|
"ButtonMatchAllAuthors": "Match All Authors",
|
||||||
|
|
@ -147,6 +149,7 @@
|
||||||
"HeaderEreaderSettings": "Ereader Settings",
|
"HeaderEreaderSettings": "Ereader Settings",
|
||||||
"HeaderFiles": "Files",
|
"HeaderFiles": "Files",
|
||||||
"HeaderFindChapters": "Find Chapters",
|
"HeaderFindChapters": "Find Chapters",
|
||||||
|
"HeaderImportCue": "Import .cue",
|
||||||
"HeaderIgnoredFiles": "Ignored Files",
|
"HeaderIgnoredFiles": "Ignored Files",
|
||||||
"HeaderItemFiles": "Item Files",
|
"HeaderItemFiles": "Item Files",
|
||||||
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
||||||
|
|
@ -760,6 +763,8 @@
|
||||||
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||||
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
||||||
"MessageChaptersNotFound": "Chapters not found",
|
"MessageChaptersNotFound": "Chapters not found",
|
||||||
|
"MessageCueNoChaptersFound": "No chapters found in .cue file",
|
||||||
|
"MessageCueSelectFile": "Select a .cue file to preview chapters",
|
||||||
"MessageCheckingCron": "Checking cron...",
|
"MessageCheckingCron": "Checking cron...",
|
||||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?",
|
"MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?",
|
||||||
|
|
@ -1023,6 +1028,7 @@
|
||||||
"ToastChaptersMustHaveTitles": "Chapters must have titles",
|
"ToastChaptersMustHaveTitles": "Chapters must have titles",
|
||||||
"ToastChaptersRemoved": "Chapters removed",
|
"ToastChaptersRemoved": "Chapters removed",
|
||||||
"ToastChaptersUpdated": "Chapters updated",
|
"ToastChaptersUpdated": "Chapters updated",
|
||||||
|
"ToastCueParseFailed": "Failed to parse .cue file",
|
||||||
"ToastCollectionItemsAddFailed": "Item(s) added to collection failed",
|
"ToastCollectionItemsAddFailed": "Item(s) added to collection failed",
|
||||||
"ToastCollectionRemoveSuccess": "Collection removed",
|
"ToastCollectionRemoveSuccess": "Collection removed",
|
||||||
"ToastCollectionUpdateSuccess": "Collection updated",
|
"ToastCollectionUpdateSuccess": "Collection updated",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue