Merge branch 'refs/heads/master' into mf/rssInboundManager

This commit is contained in:
mfcar 2024-05-27 22:41:19 +01:00
commit 31e50e44ed
No known key found for this signature in database
62 changed files with 1248 additions and 793 deletions

View file

@ -9,7 +9,7 @@
<div class="flex">
<div class="w-40 p-2">
<div class="w-full h-45 relative">
<covers-author-image :author="author" />
<covers-author-image :author="authorCopy" />
<div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
</div>
@ -30,9 +30,6 @@
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
</div>
</div>
<!-- <div class="p-2">
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
</div> -->
<div class="p-2">
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
</div>
@ -106,9 +103,9 @@ export default {
methods: {
init() {
this.imageUrl = ''
this.authorCopy.name = this.author.name
this.authorCopy.asin = this.author.asin
this.authorCopy.description = this.author.description
this.authorCopy = {
...this.author
}
},
removeClick() {
const payload = {
@ -171,7 +168,9 @@ export default {
.$delete(`/api/authors/${this.authorId}/image`)
.then((data) => {
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
this.$store.commit('globals/showEditAuthorModal', data.author)
this.authorCopy.updatedAt = data.author.updatedAt
this.authorCopy.imagePath = data.author.imagePath
})
.catch((error) => {
console.error('Failed', error)
@ -196,7 +195,9 @@ export default {
.then((data) => {
this.imageUrl = ''
this.$toast.success('Author image updated')
this.$store.commit('globals/showEditAuthorModal', data.author)
this.authorCopy.updatedAt = data.author.updatedAt
this.authorCopy.imagePath = data.author.imagePath
})
.catch((error) => {
console.error('Failed', error)
@ -231,8 +232,11 @@ export default {
} else if (response.updated) {
if (response.author.imagePath) {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
this.$store.commit('globals/showEditAuthorModal', response.author)
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
this.authorCopy = {
...response.author
}
} else {
this.$toast.info('No updates were made for Author')
}

View file

@ -46,7 +46,12 @@ export default {
ereaderDevice: {
type: Object,
default: () => null
}
},
users: {
type: Array,
default: () => []
},
loadUsers: Function
},
data() {
return {
@ -56,8 +61,7 @@ export default {
email: '',
availabilityOption: 'adminAndUp',
users: []
},
users: []
}
}
},
watch: {
@ -108,25 +112,13 @@ export default {
methods: {
availabilityOptionChanged(option) {
if (option === 'specificUsers' && !this.users.length) {
this.loadUsers()
this.callLoadUsers()
}
},
async loadUsers() {
async callLoadUsers() {
this.processing = true
this.users = await this.$axios
.$get('/api/users')
.then((res) => {
return res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})
.catch((error) => {
console.error('Failed', error)
return []
})
.finally(() => {
this.processing = false
})
await this.loadUsers()
this.processing = false
},
submitForm() {
this.$refs.ereaderNameInput.blur()
@ -226,10 +218,6 @@ export default {
this.newDevice.email = this.ereaderDevice.email
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp'
this.newDevice.users = this.ereaderDevice.users || []
if (this.newDevice.availabilityOption === 'specificUsers' && !this.users.length) {
this.loadUsers()
}
} else {
this.newDevice.name = ''
this.newDevice.email = ''

View file

@ -79,7 +79,7 @@
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.narratorName || '' }}</p>
</div>
</div>
@ -122,7 +122,7 @@
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
<ui-multi-select v-model="selectedMatch.tags" :items="tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
<p v-if="media.tags" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ media.tags.join(', ') }}</p>
</div>
</div>
@ -280,6 +280,9 @@ export default {
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
filterData() {
return this.$store.state.libraries.filterData
},
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
@ -305,11 +308,16 @@ export default {
isPodcast() {
return this.mediaType == 'podcast'
},
narrators() {
return this.filterData.narrators || []
},
genres() {
const filterData = this.$store.state.libraries.filterData || {}
const currentGenres = filterData.genres || []
const currentGenres = this.filterData.genres || []
const selectedMatchGenres = this.selectedMatch.genres || []
return [...new Set([...currentGenres, ...selectedMatchGenres])]
},
tags() {
return this.filterData.tags || []
}
},
methods: {
@ -479,6 +487,12 @@ export default {
// match.genres = match.genres.join(',')
match.genres = match.genres.split(',').map((g) => g.trim())
}
if (match.tags && !Array.isArray(match.tags)) {
match.tags = match.tags.split(',').map((g) => g.trim())
}
if (match.narrator && !Array.isArray(match.narrator)) {
match.narrator = match.narrator.split(',').map((g) => g.trim())
}
}
console.log('Select Match', match)
@ -522,11 +536,11 @@ export default {
)
updatePayload.metadata.authors = authorPayload
} else if (key === 'narrator') {
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
updatePayload.metadata.narrators = this.selectedMatch[key]
} else if (key === 'genres') {
updatePayload.metadata.genres = [...this.selectedMatch[key]]
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
updatePayload.tags = this.selectedMatch[key]
} else if (key === 'itunesId') {
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
} else {

View file

@ -60,6 +60,17 @@
</ui-tooltip>
</div>
</div>
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="epubsAllowScriptedContent" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsEpubsAllowScriptedContentHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsEpubsAllowScriptedContent }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div>
<div v-if="isPodcastLibrary" class="py-3">
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-72" menu-max-height="200px" @input="formUpdated" />
</div>
@ -83,6 +94,7 @@ export default {
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
audiobooksOnly: false,
epubsAllowScriptedContent: false,
hideSingleBookSeries: false,
onlyShowLaterBooksInContinueSeries: false,
podcastSearchRegion: 'us'
@ -118,6 +130,7 @@ export default {
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
audiobooksOnly: !!this.audiobooksOnly,
epubsAllowScriptedContent: !!this.epubsAllowScriptedContent,
hideSingleBookSeries: !!this.hideSingleBookSeries,
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
podcastSearchRegion: this.podcastSearchRegion
@ -133,6 +146,7 @@ export default {
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
this.epubsAllowScriptedContent = !!this.librarySettings.epubsAllowScriptedContent
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
@ -142,4 +156,4 @@ export default {
this.init()
}
}
</script>
</script>

View file

@ -63,6 +63,9 @@ export default {
libraryItemId() {
return this.libraryItem?.id
},
allowScriptedContent() {
return this.$store.getters['libraries/getLibraryEpubsAllowScriptedContent']
},
hasPrev() {
return !this.rendition?.location?.atStart
},
@ -316,7 +319,7 @@ export default {
reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth,
height: this.readerHeight * 0.8,
allowScriptedContent: true,
allowScriptedContent: this.allowScriptedContent,
spread: 'auto',
snap: true,
manager: 'continuous',

View file

@ -38,7 +38,7 @@
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
</div>
<div v-if="userCanDelete" class="mx-1">
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
<ui-icon-btn icon="close" borderless @click="removeClick" />
</div>
</div>
@ -75,8 +75,7 @@ export default {
},
computed: {
translateDistance() {
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
if (!this.userCanUpdate) return '-translate-x-12'
return '-translate-x-24'
},
libraryItem() {
@ -233,4 +232,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View file

@ -302,6 +302,14 @@ export default {
this.recalcMenuPos()
})
},
resetInput() {
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$nextTick(() => {
this.blur()
})
},
insertNewItem(item) {
this.selected.push(item)
this.$emit('input', this.selected)
@ -316,15 +324,18 @@ export default {
submitForm() {
if (!this.textInput) return
var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => {
return i === cleaned
})
if (matchesItem) {
this.clickedOption(null, matchesItem)
const cleaned = this.textInput.trim()
if (!cleaned) {
this.resetInput()
} else {
this.insertNewItem(this.textInput)
const matchesItem = this.items.find((i) => i === cleaned)
if (matchesItem) {
this.clickedOption(null, matchesItem)
} else {
this.insertNewItem(cleaned)
}
}
if (this.$refs.input) this.$refs.input.style.width = '24px'
},
scroll() {
@ -352,4 +363,4 @@ input:read-only {
color: #aaa;
background-color: #444;
}
</style>
</style>

View file

@ -1,5 +1,5 @@
<template>
<div class="relative">
<div tabindex="0" @focus="focusDigit('second0')" class="relative">
<div class="rounded text-gray-200 border w-full px-3 py-2" :class="focusedDigit ? 'bg-primary bg-opacity-50 border-gray-300' : 'bg-primary border-gray-600'" @click="clickInput" v-click-outside="clickOutsideObj">
<div class="flex items-center">
<template v-for="(digit, index) in digitDisplay">
@ -174,7 +174,7 @@ export default {
return this.increaseFocused()
} else if (evt.key === 'ArrowDown') {
return this.decreaseFocused()
} else if (evt.key === 'Enter' || evt.key === 'Escape') {
} else if (evt.key === 'Enter' || evt.key === 'Escape' || evt.key === 'Tab') {
return this.removeFocus()
}
@ -209,4 +209,4 @@ export default {
.digit-focused {
background-color: #555;
}
</style>
</style>

View file

@ -1,5 +1,5 @@
<template>
<div ref="wrapper" class="absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" :style="{ width: menuWidth + 'px' }" style="top: 0; left: 0">
<div ref="wrapper" class="absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" :style="{ width: menuWidth + 'px' }">
<template v-for="(item, index) in items">
<template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
@ -94,4 +94,4 @@ export default {
},
beforeDestroy() {}
}
</script>
</script>

View file

@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.9.0",
"version": "2.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.9.0",
"version": "2.10.1",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.9.0",
"version": "2.10.1",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
@ -40,4 +40,4 @@
"postcss": "^8.3.6",
"tailwindcss": "^3.4.1"
}
}
}

View file

@ -59,7 +59,7 @@
</div>
</app-settings-content>
<app-settings-content :header-text="$strings.HeaderEreaderDevices" :description="''">
<app-settings-content :header-text="$strings.HeaderEreaderDevices" :description="$strings.MessageEreaderDevices">
<template #header-items>
<div class="flex-grow" />
@ -70,6 +70,7 @@
<tr>
<th class="text-left">{{ $strings.LabelName }}</th>
<th class="text-left">{{ $strings.LabelEmail }}</th>
<th class="text-left">{{ $strings.LabelAccessibleBy }}</th>
<th class="w-40"></th>
</tr>
<tr v-for="device in existingEReaderDevices" :key="device.name">
@ -79,6 +80,9 @@
<td class="text-left">
<p class="text-sm md:text-base text-gray-100">{{ device.email }}</p>
</td>
<td class="text-left">
<p class="text-sm md:text-base text-gray-100">{{ getAccessibleBy(device) }}</p>
</td>
<td class="w-40">
<div class="flex justify-end items-center h-10">
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" class="mx-1" @click="editDeviceClick(device)" />
@ -87,12 +91,12 @@
</td>
</tr>
</table>
<div v-else class="text-center py-4">
<div v-else-if="!loading" class="text-center py-4">
<p class="text-lg text-gray-100">No Devices</p>
</div>
</app-settings-content>
<modals-emails-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="existingEReaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
<modals-emails-e-reader-device-modal v-model="showEReaderDeviceModal" :users="users" :existing-devices="existingEReaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" :loadUsers="loadUsers" />
</div>
</template>
@ -105,6 +109,7 @@ export default {
},
data() {
return {
users: [],
loading: false,
savingSettings: false,
sendingTest: false,
@ -146,6 +151,30 @@ export default {
...this.settings
}
},
async loadUsers() {
if (this.users.length) return
this.users = await this.$axios
.$get('/api/users')
.then((res) => {
return res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error(this.$strings.ToastFailedToLoadData)
return []
})
},
getAccessibleBy(device) {
const user = device.availabilityOption
if (user === 'userOrUp') return 'Users (excluding Guests)'
if (user === 'guestOrUp') return 'Users (including Guests)'
if (user === 'specificUsers') {
return device.users.map((id) => this.users.find((u) => u.id === id)?.username).join(', ')
}
return 'Admins Only'
},
editDeviceClick(device) {
this.selectedEReaderDevice = device
this.showEReaderDeviceModal = true
@ -184,6 +213,11 @@ export default {
ereaderDevicesUpdated(ereaderDevices) {
this.settings.ereaderDevices = ereaderDevices
this.newSettings.ereaderDevices = ereaderDevices.map((d) => ({ ...d }))
// Load users if a device has availability set to specific users
if (ereaderDevices.some((device) => device.availabilityOption === 'specificUsers')) {
this.loadUsers()
}
},
addNewDeviceClick() {
this.selectedEReaderDevice = null
@ -251,7 +285,12 @@ export default {
this.$axios
.$get(`/api/emails/settings`)
.then((data) => {
.then(async (data) => {
// Load users if a device has availability set to specific users
if (data.settings.ereaderDevices.some((device) => device.availabilityOption === 'specificUsers')) {
await this.loadUsers()
}
this.settings = data.settings
this.newSettings = {
...this.settings

View file

@ -79,9 +79,6 @@ export default {
}
},
authorUpdated(author) {
if (this.selectedAuthor && this.selectedAuthor.id === author.id) {
this.$store.commit('globals/setSelectedAuthor', author)
}
this.authors = this.authors.map((au) => {
if (au.id === author.id) {
return author

View file

@ -19,9 +19,9 @@
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
<ui-icon-btn v-if="userCanUpdate" icon="edit" class="mx-0.5" @click="editClick" />
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
<ui-icon-btn v-if="userCanDelete" icon="delete" class="mx-0.5" @click="removeClick" />
<ui-icon-btn icon="delete" class="mx-0.5" @click="removeClick" />
</div>
<div class="my-8 max-w-2xl">

View file

@ -16,8 +16,8 @@ export const state = () => ({
})
export const getters = {
getCurrentLibrary: state => {
return state.libraries.find(lib => lib.id === state.currentLibraryId)
getCurrentLibrary: (state) => {
return state.libraries.find((lib) => lib.id === state.currentLibraryId)
},
getCurrentLibraryName: (state, getters) => {
var currentLibrary = getters.getCurrentLibrary
@ -28,11 +28,11 @@ export const getters = {
if (!getters.getCurrentLibrary) return null
return getters.getCurrentLibrary.mediaType
},
getSortedLibraries: state => () => {
return state.libraries.map(lib => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
getSortedLibraries: (state) => () => {
return state.libraries.map((lib) => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
},
getLibraryProvider: state => libraryId => {
var library = state.libraries.find(l => l.id === libraryId)
getLibraryProvider: (state) => (libraryId) => {
var library = state.libraries.find((l) => l.id === libraryId)
if (!library) return null
return library.provider
},
@ -60,11 +60,14 @@ export const getters = {
getLibraryIsAudiobooksOnly: (state, getters) => {
return !!getters.getCurrentLibrarySettings?.audiobooksOnly
},
getCollection: state => id => {
return state.collections.find(c => c.id === id)
getLibraryEpubsAllowScriptedContent: (state, getters) => {
return !!getters.getCurrentLibrarySettings?.epubsAllowScriptedContent
},
getPlaylist: state => id => {
return state.userPlaylists.find(p => p.id === id)
getCollection: (state) => (id) => {
return state.collections.find((c) => c.id === id)
},
getPlaylist: (state) => (id) => {
return state.userPlaylists.find((p) => p.id === id)
}
}
@ -75,7 +78,8 @@ export const actions = {
loadFolders({ state, commit }) {
if (state.folders.length) {
const lastCheck = Date.now() - state.folderLastUpdate
if (lastCheck < 1000 * 5) { // 5 seconds
if (lastCheck < 1000 * 5) {
// 5 seconds
// Folders up to date
return state.folders
}
@ -204,7 +208,7 @@ export const mutations = {
})
},
addUpdate(state, library) {
var index = state.libraries.findIndex(a => a.id === library.id)
var index = state.libraries.findIndex((a) => a.id === library.id)
if (index >= 0) {
state.libraries.splice(index, 1, library)
} else {
@ -216,19 +220,19 @@ export const mutations = {
})
},
remove(state, library) {
state.libraries = state.libraries.filter(a => a.id !== library.id)
state.libraries = state.libraries.filter((a) => a.id !== library.id)
state.listeners.forEach((listener) => {
listener.meth()
})
},
addListener(state, listener) {
var index = state.listeners.findIndex(l => l.id === listener.id)
var index = state.listeners.findIndex((l) => l.id === listener.id)
if (index >= 0) state.listeners.splice(index, 1, listener)
else state.listeners.push(listener)
},
removeListener(state, listenerId) {
state.listeners = state.listeners.filter(l => l.id !== listenerId)
state.listeners = state.listeners.filter((l) => l.id !== listenerId)
},
setLibraryFilterData(state, filterData) {
state.filterData = filterData
@ -238,7 +242,7 @@ export const mutations = {
},
removeSeriesFromFilterData(state, seriesId) {
if (!seriesId || !state.filterData) return
state.filterData.series = state.filterData.series.filter(se => se.id !== seriesId)
state.filterData.series = state.filterData.series.filter((se) => se.id !== seriesId)
},
updateFilterDataWithItem(state, libraryItem) {
if (!libraryItem || !state.filterData) return
@ -260,12 +264,12 @@ export const mutations = {
// Add/update book authors
if (mediaMetadata.authors?.length) {
mediaMetadata.authors.forEach((author) => {
const indexOf = state.filterData.authors.findIndex(au => au.id === author.id)
const indexOf = state.filterData.authors.findIndex((au) => au.id === author.id)
if (indexOf >= 0) {
state.filterData.authors.splice(indexOf, 1, author)
} else {
state.filterData.authors.push(author)
state.filterData.authors.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
state.filterData.authors.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}
})
}
@ -273,12 +277,12 @@ export const mutations = {
// Add/update series
if (mediaMetadata.series?.length) {
mediaMetadata.series.forEach((series) => {
const indexOf = state.filterData.series.findIndex(se => se.id === series.id)
const indexOf = state.filterData.series.findIndex((se) => se.id === series.id)
if (indexOf >= 0) {
state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name })
} else {
state.filterData.series.push({ id: series.id, name: series.name })
state.filterData.series.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
state.filterData.series.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}
})
}
@ -329,7 +333,7 @@ export const mutations = {
state.collections = collections
},
addUpdateCollection(state, collection) {
var index = state.collections.findIndex(c => c.id === collection.id)
var index = state.collections.findIndex((c) => c.id === collection.id)
if (index >= 0) {
state.collections.splice(index, 1, collection)
} else {
@ -337,14 +341,14 @@ export const mutations = {
}
},
removeCollection(state, collection) {
state.collections = state.collections.filter(c => c.id !== collection.id)
state.collections = state.collections.filter((c) => c.id !== collection.id)
},
setUserPlaylists(state, playlists) {
state.userPlaylists = playlists
state.numUserPlaylists = playlists.length
},
addUpdateUserPlaylist(state, playlist) {
const index = state.userPlaylists.findIndex(p => p.id === playlist.id)
const index = state.userPlaylists.findIndex((p) => p.id === playlist.id)
if (index >= 0) {
state.userPlaylists.splice(index, 1, playlist)
} else {
@ -353,10 +357,10 @@ export const mutations = {
}
},
removeUserPlaylist(state, playlist) {
state.userPlaylists = state.userPlaylists.filter(p => p.id !== playlist.id)
state.userPlaylists = state.userPlaylists.filter((p) => p.id !== playlist.id)
state.numUserPlaylists = state.userPlaylists.length
},
setEReaderDevices(state, ereaderDevices) {
state.ereaderDevices = ereaderDevices
}
}
}

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Съкратен",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Тип на Профила",
"LabelAccountTypeAdmin": "Администратор",
"LabelAccountTypeGuest": "Гост",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Включи наблюдателя",
"LabelSettingsEnableWatcherForLibrary": "Включи наблюдателя за библиотека",
"LabelSettingsEnableWatcherHelp": "Включва автоматичното добавяне/обновяване на елементи, когато се открият промени във файловете. *Изисква рестарт на сървъра",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Експериментални Функции",
"LabelSettingsExperimentalFeaturesHelp": "Функции в разработка, които могат да изискват вашето мнение и помощ за тестване. Кликнете за да отворите дискусия в github.",
"LabelSettingsFindCovers": "Намери Корици",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Плъзнете файлове в правилния ред на каналите",
"MessageEmbedFinished": "Вграждането завърши!",
"MessageEpisodesQueuedForDownload": "{0} епизод(и) в опашка за изтегляне",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed URL-a ще бъде {0}",
"MessageFetching": "Взимане...",
"MessageForceReScanDescription": "ще сканира всички файлове отново като прясно сканиране. Аудио файлове ID3 тагове, OPF файлове и текстови файлове ще бъдат сканирани като нови.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "সংক্ষিপ্ত",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "অ্যাকাউন্টের প্রকার",
"LabelAccountTypeAdmin": "প্রশাসন",
"LabelAccountTypeGuest": "অতিথি",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "প্রহরী সক্ষম করুন",
"LabelSettingsEnableWatcherForLibrary": "লাইব্রেরির জন্য ফোল্ডার প্রহরী সক্ষম করুন",
"LabelSettingsEnableWatcherHelp": "ফাইলের পরিবর্তন শনাক্ত হলে আইটেমগুলির স্বয়ংক্রিয় যোগ/আপডেট সক্ষম করবে। *সার্ভার পুনরায় চালু করতে হবে",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "পরীক্ষামূলক বৈশিষ্ট্য",
"LabelSettingsExperimentalFeaturesHelp": "ফিচারের বৈশিষ্ট্য যা আপনার প্রতিক্রিয়া ব্যবহার করতে পারে এবং পরীক্ষায় সহায়তা করতে পারে। গিটহাব আলোচনা খুলতে ক্লিক করুন।",
"LabelSettingsFindCovers": "কভার খুঁজুন",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
"MessageEmbedFinished": "এম্বেড করা শেষ!",
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
"MessageFetching": "আনয় হচ্ছে...",
"MessageForceReScanDescription": "সকল ফাইল আবার নতুন স্ক্যানের মত স্ক্যান করবে। অডিও ফাইল ID3 ট্যাগ, OPF ফাইল, এবং টেক্সট ফাইলগুলি নতুন হিসাবে স্ক্যান করা হবে।",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Zkráceno",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Typ účtu",
"LabelAccountTypeAdmin": "Správce",
"LabelAccountTypeGuest": "Host",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Povolit sledování",
"LabelSettingsEnableWatcherForLibrary": "Povolit sledování složky pro knihovnu",
"LabelSettingsEnableWatcherHelp": "Povoluje automatické přidávání/aktualizaci položek, když jsou zjištěny změny souborů. *Vyžaduje restart serveru",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Experimentální funkce",
"LabelSettingsExperimentalFeaturesHelp": "Funkce ve vývoji, které by mohly využít vaši zpětnou vazbu a pomoc s testováním. Kliknutím otevřete diskuzi na githubu.",
"LabelSettingsFindCovers": "Najít obálky",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
"MessageEmbedFinished": "Vložení dokončeno!",
"MessageEpisodesQueuedForDownload": "{0} epizody zařazené do fronty ke stažení",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "URL zdroje bude {0}",
"MessageFetching": "Stahování...",
"MessageForceReScanDescription": "znovu prohledá všechny soubory jako při novém skenování. ID3 tagy zvukových souborů OPF soubory a textové soubory budou skenovány jako nové.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Abridged",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Kontotype",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gæst",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Aktiver overvågning",
"LabelSettingsEnableWatcherForLibrary": "Aktiver mappeovervågning for bibliotek",
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk tilføjelse/opdatering af elementer, når filændringer registreres. *Kræver servergenstart",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Eksperimentelle funktioner",
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under udvikling, der kunne bruge din feedback og hjælp til test. Klik for at åbne Github-diskussionen.",
"LabelSettingsFindCovers": "Find omslag",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Træk filer ind i korrekt spororden",
"MessageEmbedFinished": "Indlejring færdig!",
"MessageEpisodesQueuedForDownload": "{0} episoder er sat i kø til download",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed-URL vil være {0}",
"MessageFetching": "Henter...",
"MessageForceReScanDescription": "vil scanne alle filer igen som en frisk scanning. Lydfilens ID3-tags, OPF-filer og tekstfiler scannes som nye.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Gekürzt",
"LabelAbridgedChecked": "Gekürzt (angehakt)",
"LabelAbridgedUnchecked": "Ungekürzt (nicht angehakt)",
"LabelAccessibleBy": "Zugänglich für",
"LabelAccountType": "Kontoart",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gast",
@ -270,7 +271,7 @@
"LabelDownloadNEpisodes": "Download {0} Episoden",
"LabelDuration": "Laufzeit",
"LabelDurationComparisonExactMatch": "(genauer Treffer)",
"LabelDurationComparisonLonger": "({0} änger)",
"LabelDurationComparisonLonger": "({0} länger)",
"LabelDurationComparisonShorter": "({0} kürzer)",
"LabelDurationFound": "Gefundene Laufzeit:",
"LabelEbook": "E-Book",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Überwachung aktivieren",
"LabelSettingsEnableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek aktivieren",
"LabelSettingsEnableWatcherHelp": "Aktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart",
"LabelSettingsEpubsAllowScriptedContent": "Skriptinhalte in Epubs zulassen",
"LabelSettingsEpubsAllowScriptedContentHelp": "Erlaube Epub-Dateien, Skripte auszuführen. Es wird empfohlen, diese Einstellung deaktiviert zu lassen, es sei denn, du vertraust der Quelle der Epub-Dateien.",
"LabelSettingsExperimentalFeatures": "Experimentelle Funktionen",
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen dein Feedback und deine Hilfe beim Testen. Klicke hier, um die Github-Diskussion zu öffnen.",
"LabelSettingsFindCovers": "Suche Titelbilder",
@ -498,7 +501,7 @@
"LabelShowAll": "Alles anzeigen",
"LabelShowSeconds": "Zeige Sekunden",
"LabelSize": "Größe",
"LabelSleepTimer": "Sleep-Timer",
"LabelSleepTimer": "Schlummerfunktion",
"LabelSlug": "URL Teil",
"LabelStart": "Start",
"LabelStarted": "Gestartet",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge",
"MessageEmbedFinished": "Einbettung abgeschlossen!",
"MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen",
"MessageEreaderDevices": "Um die Zustellung von E-Books sicherzustellen, musst du eventuell die oben genannte E-Mail-Adresse als gültigen Absender für jedes unten aufgeführte Gerät hinzufügen.",
"MessageFeedURLWillBe": "Feed-URL wird {0} sein",
"MessageFetching": "Abrufen...",
"MessageForceReScanDescription": "Durchsucht alle Dateien erneut, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.",
@ -803,4 +807,4 @@
"ToastSortingPrefixesUpdateSuccess": "Die Sortier-Prefixe wirden geupdated ({0} Einträge)",
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
"ToastUserDeleteSuccess": "Benutzer gelöscht"
}
}

View file

@ -195,6 +195,7 @@
"LabelAbridged": "Abridged",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
@ -483,6 +484,8 @@
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@ -643,6 +646,7 @@
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed URL will be {0}",
"MessageFetching": "Fetching...",
"MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",

View file

@ -61,9 +61,9 @@
"ButtonQueueRemoveItem": "Remover de la Fila",
"ButtonQuickMatch": "Encontrar Rápido",
"ButtonRead": "Leer",
"ButtonReadLess": "Read less",
"ButtonReadMore": "Read more",
"ButtonRefresh": "Refresh",
"ButtonReadLess": "Lea menos",
"ButtonReadMore": "Lea mas",
"ButtonRefresh": "Refrecar",
"ButtonRemove": "Remover",
"ButtonRemoveAll": "Remover Todos",
"ButtonRemoveAllLibraryItems": "Remover Todos los Elementos de la Biblioteca",
@ -72,7 +72,7 @@
"ButtonRemoveSeriesFromContinueSeries": "Remover Serie de Continuar Series",
"ButtonReScan": "Re-Escanear",
"ButtonReset": "Reiniciar",
"ButtonResetToDefault": "Reset to default",
"ButtonResetToDefault": "Restaurar valores por defecto",
"ButtonRestore": "Restaurar",
"ButtonSave": "Guardar",
"ButtonSaveAndClose": "Guardar y Cerrar",
@ -83,7 +83,7 @@
"ButtonSelectFolderPath": "Seleccionar Ruta de Carpeta",
"ButtonSeries": "Series",
"ButtonSetChaptersFromTracks": "Seleccionar Capítulos Según las Pistas",
"ButtonShare": "Share",
"ButtonShare": "Compartir",
"ButtonShiftTimes": "Desplazar Tiempos",
"ButtonShow": "Mostrar",
"ButtonStartM4BEncode": "Iniciar Codificación M4B",
@ -161,11 +161,11 @@
"HeaderRemoveEpisodes": "Remover {0} Episodios",
"HeaderRSSFeedGeneral": "Detalles RSS",
"HeaderRSSFeedIsOpen": "Fuente RSS esta abierta",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderRSSFeeds": "Fuentes RSS",
"HeaderSavedMediaProgress": "Guardar Progreso de Multimedia",
"HeaderSchedule": "Horario",
"HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca",
"HeaderSession": "Session",
"HeaderSession": "Sesn",
"HeaderSetBackupSchedule": "Programar Respaldo",
"HeaderSettings": "Configuraciones",
"HeaderSettingsDisplay": "Interfaz",
@ -191,6 +191,7 @@
"LabelAbridged": "Abreviado",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Tipo de Cuenta",
"LabelAccountTypeAdmin": "Administrador",
"LabelAccountTypeGuest": "Invitado",
@ -207,7 +208,7 @@
"LabelAllUsers": "Todos los Usuarios",
"LabelAllUsersExcludingGuests": "Todos los usuarios excepto invitados",
"LabelAllUsersIncludingGuests": "Todos los usuarios e invitados",
"LabelAlreadyInYourLibrary": "Ya en la Biblioteca",
"LabelAlreadyInYourLibrary": "Ya existe en la Biblioteca",
"LabelAppend": "Adjuntar",
"LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Nombre Apellido)",
@ -269,9 +270,9 @@
"LabelDownload": "Descargar",
"LabelDownloadNEpisodes": "Descargar {0} episodios",
"LabelDuration": "Duración",
"LabelDurationComparisonExactMatch": "(exact match)",
"LabelDurationComparisonLonger": "({0} longer)",
"LabelDurationComparisonShorter": "({0} shorter)",
"LabelDurationComparisonExactMatch": "(coincidencia exacta)",
"LabelDurationComparisonLonger": "({0} más largo)",
"LabelDurationComparisonShorter": "({0} más corto)",
"LabelDurationFound": "Duración Comprobada:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
@ -289,8 +290,8 @@
"LabelEpisodeType": "Tipo de Episodio",
"LabelExample": "Ejemplo",
"LabelExplicit": "Explicito",
"LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
"LabelExplicitChecked": "Explícito (marcado)",
"LabelExplicitUnchecked": "No Explícito (sin marcar)",
"LabelFeedURL": "Fuente de URL",
"LabelFetchingMetadata": "Obteniendo metadatos",
"LabelFile": "Archivo",
@ -365,8 +366,8 @@
"LabelMetaTags": "Metaetiquetas",
"LabelMinute": "Minuto",
"LabelMissing": "Ausente",
"LabelMissingEbook": "Has no ebook",
"LabelMissingSupplementaryEbook": "Has no supplementary ebook",
"LabelMissingEbook": "No tiene ebook",
"LabelMissingSupplementaryEbook": "No tiene ebook suplementario",
"LabelMobileRedirectURIs": "URIs de redirección a móviles permitidos",
"LabelMobileRedirectURIsDescription": "Esta es una lista de URIs válidos para redireccionamiento de apps móviles. La URI por defecto es <code>audiobookshelf://oauth</code>, la cual puedes remover or corroborar con URIs adicionales para la integración con apps de terceros. Utilizando un asterisco (<code>*</code>) como el único punto de entrada permite cualquier URI.",
"LabelMore": "Más",
@ -469,7 +470,9 @@
"LabelSettingsDisableWatcherHelp": "Deshabilitar la función de agregar/actualizar elementos automáticamente cuando se detectan cambios en los archivos. *Require Reiniciar el Servidor",
"LabelSettingsEnableWatcher": "Habilitar Watcher",
"LabelSettingsEnableWatcherForLibrary": "Habilitar Watcher para la carpeta de esta biblioteca",
"LabelSettingsEnableWatcherHelp": "Permite agregar/actualizar elementos automáticamente cuando se detectan cambios en los archivos. *Requires server restart",
"LabelSettingsEnableWatcherHelp": "Permite agregar/actualizar elementos automáticamente cuando se detectan cambios en los archivos. *Requiere reiniciar el servidor",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Funciones Experimentales",
"LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo que se beneficiarían de sus comentarios y experiencias de prueba. Haga click aquí para abrir una conversación en Github.",
"LabelSettingsFindCovers": "Buscar Portadas",
@ -479,7 +482,7 @@
"LabelSettingsHomePageBookshelfView": "Usar la vista de librero en la página principal",
"LabelSettingsLibraryBookshelfView": "Usar la vista de librero en la biblioteca",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Skip earlier books in Continue Series",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "The Continue Series home page shelf shows the first book not started in series that have at least one book finished and no books in progress. Enabling this setting will continue series from the furthest completed book instead of the first book not started.",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "El estante de la página de inicio de Continuar Serie muestra el primer libro no iniciado de una serie que tenga por lo menos un libro finalizado y no tenga libros en progreso. Habilitar esta opción le permitirá continuar series desde el último libro que ha completado en vez del primer libro que no ha empezado.",
"LabelSettingsParseSubtitles": "Extraer Subtítulos",
"LabelSettingsParseSubtitlesHelp": "Extraer subtítulos de los nombres de las carpetas de los audiolibros.<br>Los subtítulos deben estar separados por \" - \"<br>Por ejemplo: \"Ejemplo de Título - Subtítulo Aquí\" tiene el subtítulo \"Subtítulo Aquí\"",
"LabelSettingsPreferMatchedMetadata": "Preferir metadatos encontrados",
@ -496,7 +499,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
"LabelSettingsTimeFormat": "Formato de Tiempo",
"LabelShowAll": "Mostrar Todos",
"LabelShowSeconds": "Show seconds",
"LabelShowSeconds": "Mostrar segundos",
"LabelSize": "Tamaño",
"LabelSleepTimer": "Temporizador para Dormir",
"LabelSlug": "Slug",
@ -576,8 +579,8 @@
"LabelViewQueue": "Ver Fila del Reproductor",
"LabelVolume": "Volumen",
"LabelWeekdaysToRun": "Correr en Días de la Semana",
"LabelYearReviewHide": "Hide Year in Review",
"LabelYearReviewShow": "See Year in Review",
"LabelYearReviewHide": "Ocultar Year in Review",
"LabelYearReviewShow": "Ver Year in Review",
"LabelYourAudiobookDuration": "Duración de tu Audiolibro",
"LabelYourBookmarks": "Tus Marcadores",
"LabelYourPlaylists": "Tus Listas",
@ -608,14 +611,14 @@
"MessageConfirmMarkAllEpisodesNotFinished": "¿Está seguro de que desea marcar todos los episodios como no terminados?",
"MessageConfirmMarkSeriesFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como terminados?",
"MessageConfirmMarkSeriesNotFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como no terminados?",
"MessageConfirmPurgeCache": "Purge cache will delete the entire directory at <code>/metadata/cache</code>. <br /><br />Are you sure you want to remove the cache directory?",
"MessageConfirmPurgeCache": "Purgar el caché eliminará el directorio completo ubicado en <code>/metadata/cache</code>. <br /><br />¿Está seguro que desea eliminar el directorio del caché?",
"MessageConfirmQuickEmbed": "¡Advertencia! La integración rápida no realiza copias de seguridad a ninguno de tus archivos de audio. Asegúrate de haber realizado una copia de los mismos previamente. <br><br>¿Deseas continuar?",
"MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?",
"MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?",
"MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?",
"MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveListeningSessions": "¿Está seguro que desea remover {0} sesiones de escuchar?",
"MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?",
"MessageConfirmRemovePlaylist": "¿Está seguro de que desea remover la lista de reproducción \"{0}\"?",
"MessageConfirmRenameGenre": "¿Está seguro de que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas.",
"MessageEmbedFinished": "Incrustación Terminada!",
"MessageEpisodesQueuedForDownload": "{0} Episodio(s) en cola para descargar",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "URL de la fuente será {0}",
"MessageFetching": "Buscando...",
"MessageForceReScanDescription": "Escaneará todos los archivos como un nuevo escaneo. Archivos de audio con etiquetas ID3, archivos OPF y archivos de texto serán escaneados como nuevos.",
@ -641,7 +645,7 @@
"MessageListeningSessionsInTheLastYear": "{0} sesiones de escucha en el último año",
"MessageLoading": "Cargando...",
"MessageLoadingFolders": "Cargando archivos...",
"MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.",
"MessageLogsDescription": "Logs son almacenados en <code>/metadata/logs</code> en archivos bajo formato JSON. Logs de fallos son almacenados en <code>/metadata/logs/crash_logs.txt</code>.",
"MessageM4BFailed": "¡Fallo de M4B!",
"MessageM4BFinished": "¡M4B Terminado!",
"MessageMapChapterTitles": "Asignar los nombres de capítulos a los capítulos existentes en tu audiolibro sin ajustar sus tiempos",
@ -689,7 +693,7 @@
"MessageQuickMatchDescription": "Rellenar detalles de elementos vacíos y portada con los primeros resultados de '{0}'. No sobrescribe los detalles a menos que la opción \"Preferir Metadatos Encontrados\" del servidor esté habilitada.",
"MessageRemoveChapter": "Remover capítulos",
"MessageRemoveEpisodes": "Remover {0} episodio(s)",
"MessageRemoveFromPlayerQueue": "Romover la cola de reproducción",
"MessageRemoveFromPlayerQueue": "Remover la cola de reproducción",
"MessageRemoveUserWarning": "¿Está seguro de que desea eliminar el usuario \"{0}\"?",
"MessageReportBugsAndContribute": "Reporte erres, solicite funciones y contribuya en",
"MessageResetChaptersConfirm": "¿Está seguro de que desea deshacer los cambios y revertir los capítulos a su estado original?",
@ -704,9 +708,9 @@
"MessageUploaderItemFailed": "Error al Subir",
"MessageUploaderItemSuccess": "¡Éxito al Subir!",
"MessageUploading": "Subiendo...",
"MessageValidCronExpression": "Expresión de Cron bálida",
"MessageValidCronExpression": "Expresión de Cron válida",
"MessageWatcherIsDisabledGlobally": "El watcher está desactivado globalmente en la configuración del servidor",
"MessageXLibraryIsEmpty": "La biblioteca {0} está vacía!",
"MessageXLibraryIsEmpty": "¡La biblioteca {0} está vacía!",
"MessageYourAudiobookDurationIsLonger": "La duración de su audiolibro es más larga que la duración encontrada",
"MessageYourAudiobookDurationIsShorter": "La duración de su audiolibro es más corta que la duración encontrada",
"NoteChangeRootPassword": "El usuario Root es el único usuario que puede no tener una contraseña",
@ -745,8 +749,8 @@
"ToastBookmarkRemoveSuccess": "Marcador eliminado",
"ToastBookmarkUpdateFailed": "Error al actualizar el marcador",
"ToastBookmarkUpdateSuccess": "Marcador actualizado",
"ToastCachePurgeFailed": "Failed to purge cache",
"ToastCachePurgeSuccess": "Cache purged successfully",
"ToastCachePurgeFailed": "Error al purgar el caché",
"ToastCachePurgeSuccess": "Caché purgado de manera exitosa",
"ToastChaptersHaveErrors": "Los capítulos tienen errores",
"ToastChaptersMustHaveTitles": "Los capítulos tienen que tener un título",
"ToastCollectionItemsRemoveFailed": "Error al remover elemento(s) de la colección",
@ -755,9 +759,9 @@
"ToastCollectionRemoveSuccess": "Colección removida",
"ToastCollectionUpdateFailed": "Error al actualizar la colección",
"ToastCollectionUpdateSuccess": "Colección actualizada",
"ToastDeleteFileFailed": "Failed to delete file",
"ToastDeleteFileSuccess": "File deleted",
"ToastFailedToLoadData": "Failed to load data",
"ToastDeleteFileFailed": "Error el eliminar archivo",
"ToastDeleteFileSuccess": "Archivo eliminado",
"ToastFailedToLoadData": "Error al cargar data",
"ToastItemCoverUpdateFailed": "Error al actualizar la portada del elemento",
"ToastItemCoverUpdateSuccess": "Portada del elemento actualizada",
"ToastItemDetailsUpdateFailed": "Error al actualizar los detalles del elemento",
@ -791,16 +795,16 @@
"ToastSendEbookToDeviceSuccess": "Ebook enviado al dispositivo \"{0}\"",
"ToastSeriesUpdateFailed": "Error al actualizar la serie",
"ToastSeriesUpdateSuccess": "Serie actualizada",
"ToastServerSettingsUpdateFailed": "Failed to update server settings",
"ToastServerSettingsUpdateSuccess": "Server settings updated",
"ToastServerSettingsUpdateFailed": "Error al actualizar configuración del servidor",
"ToastServerSettingsUpdateSuccess": "Configuración del servidor actualizada",
"ToastSessionDeleteFailed": "Error al eliminar sesión",
"ToastSessionDeleteSuccess": "Sesión eliminada",
"ToastSocketConnected": "Socket conectado",
"ToastSocketDisconnected": "Socket desconectado",
"ToastSocketFailedToConnect": "Error al conectar al Socket",
"ToastSortingPrefixesEmptyError": "Must have at least 1 sorting prefix",
"ToastSortingPrefixesUpdateFailed": "Failed to update sorting prefixes",
"ToastSortingPrefixesUpdateSuccess": "Sorting prefixes updated ({0} items)",
"ToastSortingPrefixesEmptyError": "Debe tener por lo menos 1 prefijo para ordenar",
"ToastSortingPrefixesUpdateFailed": "Error al actualizar los prefijos de ordenar",
"ToastSortingPrefixesUpdateSuccess": "Prefijos de ordenar actualizaron ({0} items)",
"ToastUserDeleteFailed": "Error al eliminar el usuario",
"ToastUserDeleteSuccess": "Usuario eliminado"
}

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Kärbitud",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Konto tüüp",
"LabelAccountTypeAdmin": "Administraator",
"LabelAccountTypeGuest": "Külaline",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Luba vaatamine",
"LabelSettingsEnableWatcherForLibrary": "Luba kaustavaatamine raamatukogu jaoks",
"LabelSettingsEnableWatcherHelp": "Lubab automaatset lisamist/uuendamist, kui tuvastatakse failimuudatused. *Nõuab serveri taaskäivitamist",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Eksperimentaalsed funktsioonid",
"LabelSettingsExperimentalFeaturesHelp": "Arengus olevad funktsioonid, mis vajavad teie tagasisidet ja abi testimisel. Klõpsake GitHubi arutelu avamiseks.",
"LabelSettingsFindCovers": "Leia ümbrised",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Lohistage failid õigesse järjekorda",
"MessageEmbedFinished": "Manustamine lõpetatud!",
"MessageEpisodesQueuedForDownload": "{0} Episood(i) on allalaadimiseks järjekorras",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Toite URL saab olema {0}",
"MessageFetching": "Hangitakse...",
"MessageForceReScanDescription": "skaneerib kõik failid uuesti nagu värsket skannimist. Heli faili ID3 silte, OPF faile ja tekstifaile skaneeritakse uuesti.",

View file

@ -61,8 +61,8 @@
"ButtonQueueRemoveItem": "Supprimer de la liste de lecture",
"ButtonQuickMatch": "Recherche rapide",
"ButtonRead": "Lire",
"ButtonReadLess": "Read less",
"ButtonReadMore": "Read more",
"ButtonReadLess": "Lire moins",
"ButtonReadMore": "Lire la suite",
"ButtonRefresh": "Rafraîchir",
"ButtonRemove": "Supprimer",
"ButtonRemoveAll": "Supprimer tout",
@ -186,11 +186,12 @@
"HeaderUpdateDetails": "Mettre à jour les détails",
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
"HeaderUsers": "Utilisateurs",
"HeaderYearReview": "Year {0} in Review",
"HeaderYearReview": "Bilan de lannée {0}",
"HeaderYourStats": "Vos statistiques",
"LabelAbridged": "Version courte",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAbridgedChecked": "Abrégé (vérifié)",
"LabelAbridgedUnchecked": "Intégral (non vérifié)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Type de compte",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Invité",
@ -217,7 +218,7 @@
"LabelAutoFetchMetadata": "Recherche automatique de métadonnées",
"LabelAutoFetchMetadataHelp": "Récupère les métadonnées du titre, de lauteur et de la série pour simplifier le téléchargement. Il se peut que des métadonnées supplémentaires doivent être ajoutées après le téléchargement.",
"LabelAutoLaunch": "Lancement automatique",
"LabelAutoLaunchDescription": "Redirection automatique vers le fournisseur d'authentification lors de la navigation vers la page de connexion (chemin de remplacement manuel <code>/login?autoLaunch=0</code>)",
"LabelAutoLaunchDescription": "Redirection automatique vers le fournisseur dauthentification lors de la navigation vers la page de connexion (chemin de remplacement manuel <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Enregistrement automatique",
"LabelAutoRegisterDescription": "Créer automatiquement de nouveaux utilisateurs après la connexion",
"LabelBackToUser": "Retour à lutilisateur",
@ -231,7 +232,7 @@
"LabelBitrate": "Bitrate",
"LabelBooks": "Livres",
"LabelButtonText": "Texte du bouton",
"LabelByAuthor": "by {0}",
"LabelByAuthor": "par {0}",
"LabelChangePassword": "Modifier le mot de passe",
"LabelChannels": "Canaux",
"LabelChapters": "Chapitres",
@ -269,9 +270,9 @@
"LabelDownload": "Téléchargement",
"LabelDownloadNEpisodes": "Télécharger {0} épisode(s)",
"LabelDuration": "Durée",
"LabelDurationComparisonExactMatch": "(exact match)",
"LabelDurationComparisonLonger": "({0} longer)",
"LabelDurationComparisonShorter": "({0} shorter)",
"LabelDurationComparisonExactMatch": "(correspondance exacte)",
"LabelDurationComparisonLonger": "({0} plus long)",
"LabelDurationComparisonShorter": "({0} plus court)",
"LabelDurationFound": "Durée trouvée :",
"LabelEbook": "Livre numérique",
"LabelEbooks": "Livres numériques",
@ -289,8 +290,8 @@
"LabelEpisodeType": "Type de lépisode",
"LabelExample": "Exemple",
"LabelExplicit": "Restriction",
"LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
"LabelExplicitChecked": "Explicite (vérifié)",
"LabelExplicitUnchecked": "Non explicite (non vérifié)",
"LabelFeedURL": "URL du flux",
"LabelFetchingMetadata": "Récupération des métadonnées",
"LabelFile": "Fichier",
@ -365,10 +366,10 @@
"LabelMetaTags": "Balises de métadonnée",
"LabelMinute": "Minute",
"LabelMissing": "Manquant",
"LabelMissingEbook": "Has no ebook",
"LabelMissingSupplementaryEbook": "Has no supplementary ebook",
"LabelMissingEbook": "Ne possède pas de livre numérique",
"LabelMissingSupplementaryEbook": "Ne possède pas de livre numérique supplémentaire",
"LabelMobileRedirectURIs": "URI de redirection mobile autorisés",
"LabelMobileRedirectURIsDescription": "Il s'agit d'une liste blanche dURI de redirection valides pour les applications mobiles. Celui par défaut est <code>audiobookshelf://oauth</code>, que vous pouvez supprimer ou compléter avec des URIs supplémentaires pour l'intégration d'applications tierces. Lutilisation dun astérisque (<code>*</code>) comme seule entrée autorise nimporte quel URI.",
"LabelMobileRedirectURIsDescription": "Il sagit dune liste blanche dURI de redirection valides pour les applications mobiles. Celui par défaut est <code>audiobookshelf://oauth</code>, que vous pouvez supprimer ou compléter avec des URIs supplémentaires pour lintégration dapplications tierces. Lutilisation dun astérisque (<code>*</code>) comme seule entrée autorise nimporte quel URI.",
"LabelMore": "Plus",
"LabelMoreInfo": "Plus dinformations",
"LabelName": "Nom",
@ -395,9 +396,9 @@
"LabelNotStarted": "Pas commencé",
"LabelNumberOfBooks": "Nombre de livres",
"LabelNumberOfEpisodes": "Nombre dépisodes",
"LabelOpenIDAdvancedPermsClaimDescription": "Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (<b>if configured</b>). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as <code>false</code>. Ensure the identity provider's claim matches the expected structure:",
"LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.",
"LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as <code>groups</code>. <b>If configured</b>, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.",
"LabelOpenIDAdvancedPermsClaimDescription": "Nom de la demande OpenID qui contient des autorisations avancées pour les actions de lutilisateur dans lapplication, qui sappliqueront à des rôles autres que celui dadministrateur (<b>sil est configuré</b>). Si la demande est absente de la réponse, laccès à ABS sera refusé. Si une seule option est manquante, elle sera considérée comme <code>false</code>. Assurez-vous que la demande du fournisseur didentité correspond à la structure attendue :",
"LabelOpenIDClaims": "Laissez les options suivantes vides pour désactiver lattribution avancée de groupes et dautorisations, en attribuant alors automatiquement le groupe « Utilisateur ».",
"LabelOpenIDGroupClaimDescription": "Nom de la demande OpenID qui contient une liste des groupes de lutilisateur. Communément appelé <code>groups</code>. <b>Si elle est configurée</b>, lapplication attribuera automatiquement des rôles en fonction de lappartenance de lutilisateur à un groupe, à condition que ces groupes soient nommés -sensible à la casse- tel que « admin », « user » ou « guest » dans la demande. Elle doit contenir une liste, et si un utilisateur appartient à plusieurs groupes, lapplication attribuera le rôle correspondant au niveau daccès le plus élevé. Si aucun groupe ne correspond, laccès sera refusé.",
"LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelOverwrite": "Écraser",
"LabelPassword": "Mot de passe",
@ -447,7 +448,7 @@
"LabelSearchTitle": "Titre de recherche",
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
"LabelSeason": "Saison",
"LabelSelectAll": "Select all",
"LabelSelectAll": "Tout sélectionner",
"LabelSelectAllEpisodes": "Sélectionner tous les épisodes",
"LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours",
"LabelSelectUsers": "Sélectionner les utilisateurs",
@ -460,7 +461,7 @@
"LabelSetEbookAsPrimary": "Définir comme principale",
"LabelSetEbookAsSupplementary": "Définir comme supplémentaire",
"LabelSettingsAudiobooksOnly": "Livres audios seulement",
"LabelSettingsAudiobooksOnlyHelp": "L'activation de ce paramètre ignorera les fichiers de type « livre numériques », sauf s'ils se trouvent dans un dossier spécifique , auquel cas ils seront définis comme des livres numériques supplémentaires.",
"LabelSettingsAudiobooksOnlyHelp": "Lactivation de ce paramètre ignorera les fichiers de type « livre numériques », sauf sils se trouvent dans un dossier spécifique , auquel cas ils seront définis comme des livres numériques supplémentaires.",
"LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois",
"LabelSettingsChromecastSupport": "Support du Chromecast",
"LabelSettingsDateFormat": "Format de date",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Activer la veille",
"LabelSettingsEnableWatcherForLibrary": "Activer la surveillance des dossiers pour la bibliothèque",
"LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion GitHub.",
"LabelSettingsFindCovers": "Chercher des couvertures de livre",
@ -478,10 +481,10 @@
"LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent quun seul livre seront masquées sur la page de la série et sur les étagères de la page daccueil.",
"LabelSettingsHomePageBookshelfView": "La page daccueil utilise la vue étagère",
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Skip earlier books in Continue Series",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "The Continue Series home page shelf shows the first book not started in series that have at least one book finished and no books in progress. Enabling this setting will continue series from the furthest completed book instead of the first book not started.",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sauter les livres précédents dans « Continuer la série »",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Létagère de la page daccueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre nest en cours. Lactivation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.",
"LabelSettingsParseSubtitles": "Analyser les sous-titres",
"LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du livre audio.<br>Les sous-titres doivent être séparés par « - »<br>cest-à-dire : « Titre du livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »",
"LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du livre audio.<br>Les sous-titres doivent être séparés par des « - »<br>cest-à-dire : « Titre du livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »",
"LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance",
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de larticle lors dune recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN",
@ -496,7 +499,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items",
"LabelSettingsTimeFormat": "Format dheure",
"LabelShowAll": "Tout afficher",
"LabelShowSeconds": "Show seconds",
"LabelShowSeconds": "Afficher le seondes",
"LabelSize": "Taille",
"LabelSleepTimer": "Minuterie",
"LabelSlug": "Balise",
@ -517,7 +520,7 @@
"LabelStatsMinutes": "minutes",
"LabelStatsMinutesListening": "Minutes découte",
"LabelStatsOverallDays": "Nombre total de jours",
"LabelStatsOverallHours": "Nombre total d'heures",
"LabelStatsOverallHours": "Nombre total dheures",
"LabelStatsWeekListening": "Écoute de la semaine",
"LabelSubtitle": "Sous-titre",
"LabelSupportedFileTypes": "Types de fichiers supportés",
@ -604,12 +607,12 @@
"MessageConfirmDeleteLibraryItems": "Cette opération supprimera {0} éléments de la base de données et de votre système de fichiers. Êtes-vous sûr ?",
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?",
"MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?",
"MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?",
"MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?",
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?",
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?",
"MessageConfirmPurgeCache": "Purge cache will delete the entire directory at <code>/metadata/cache</code>. <br /><br />Are you sure you want to remove the cache directory?",
"MessageConfirmQuickEmbed": "Attention ! Lintégration rapide ne sauvegardera pas vos fichiers audio. Assurez-vous davoir effectuer une sauvegarde de vos fichiers audio.<br><br>Souhaitez-vous continuer ?",
"MessageConfirmPurgeCache": "La purge du cache supprimera lintégralité du répertoire à <code>/metadata/cache</code>.<br><br>Êtes-vous sûr de vouloir supprimer le répertoire de cache ?",
"MessageConfirmQuickEmbed": "Attention ! Lintégration rapide ne sauvegardera pas vos fichiers audio. Assurez-vous davoir effectuer une sauvegarde de vos fichiers audio.<br><br>Souhaitez-vous continuer ?",
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
"MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans lordre correct des pistes",
"MessageEmbedFinished": "Intégration terminée !",
"MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "LURL du flux sera {0}",
"MessageFetching": "Récupération…",
"MessageForceReScanDescription": "analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme sils étaient nouveaux.",
@ -641,7 +645,7 @@
"MessageListeningSessionsInTheLastYear": "{0} sessions découte lan dernier",
"MessageLoading": "Chargement…",
"MessageLoadingFolders": "Chargement des dossiers…",
"MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.",
"MessageLogsDescription": "Les journaux sont stockés dans <code>/metadata/logs</code> sous forme de fichiers JSON. Les journaux dincidents sont stockés dans <code>/metadata/logs/crash_logs.txt</code>.",
"MessageM4BFailed": "M4B échec",
"MessageM4BFinished": "M4B terminé",
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster lhorodatage.",
@ -745,8 +749,8 @@
"ToastBookmarkRemoveSuccess": "Signet supprimé",
"ToastBookmarkUpdateFailed": "Échec de la mise à jour de signet",
"ToastBookmarkUpdateSuccess": "Signet mis à jour",
"ToastCachePurgeFailed": "Failed to purge cache",
"ToastCachePurgeSuccess": "Cache purged successfully",
"ToastCachePurgeFailed": "Échec de la purge du cache",
"ToastCachePurgeSuccess": "Cache purgé avec succès",
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
"ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre",
"ToastCollectionItemsRemoveFailed": "Échec de la suppression de(s) article(s) de la collection",
@ -755,9 +759,9 @@
"ToastCollectionRemoveSuccess": "Collection supprimée",
"ToastCollectionUpdateFailed": "Échec de la mise à jour de la collection",
"ToastCollectionUpdateSuccess": "Collection mise à jour",
"ToastDeleteFileFailed": "Failed to delete file",
"ToastDeleteFileSuccess": "File deleted",
"ToastFailedToLoadData": "Failed to load data",
"ToastDeleteFileFailed": "Échec de la suppression du fichier",
"ToastDeleteFileSuccess": "Fichier supprimé",
"ToastFailedToLoadData": "Échec du chargement des données",
"ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de larticle",
"ToastItemCoverUpdateSuccess": "Couverture de larticle mise à jour",
"ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de larticle",
@ -791,16 +795,16 @@
"ToastSendEbookToDeviceSuccess": "Livre numérique envoyé à lappareil : {0}",
"ToastSeriesUpdateFailed": "Échec de la mise à jour de la série",
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
"ToastServerSettingsUpdateFailed": "Failed to update server settings",
"ToastServerSettingsUpdateSuccess": "Server settings updated",
"ToastServerSettingsUpdateFailed": "Échec de la mise à jour des paramètres du serveur",
"ToastServerSettingsUpdateSuccess": "Mise à jour des paramètres du serveur",
"ToastSessionDeleteFailed": "Échec de la suppression de session",
"ToastSessionDeleteSuccess": "Session supprimée",
"ToastSocketConnected": "WebSocket connecté",
"ToastSocketDisconnected": "WebSocket déconnecté",
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastSortingPrefixesEmptyError": "Must have at least 1 sorting prefix",
"ToastSortingPrefixesUpdateFailed": "Failed to update sorting prefixes",
"ToastSortingPrefixesUpdateSuccess": "Sorting prefixes updated ({0} items)",
"ToastSortingPrefixesEmptyError": "Doit avoir au moins 1 préfixe de tri",
"ToastSortingPrefixesUpdateFailed": "Échec de la mise à jour des préfixes de tri",
"ToastSortingPrefixesUpdateSuccess": "Mise à jour des préfixes de tri ({0} élément)",
"ToastUserDeleteFailed": "Échec de la suppression de lutilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé"
}

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Abridged",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed URL will be {0}",
"MessageFetching": "Fetching...",
"MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "מקוצר",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "סוג חשבון",
"LabelAccountTypeAdmin": "מנהל",
"LabelAccountTypeGuest": "אורח",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "הפעל עוקב",
"LabelSettingsEnableWatcherForLibrary": "הפעל עוקב תיקייה עבור ספרייה",
"LabelSettingsEnableWatcherHelp": "מאפשר הוספת/עדכון אוטומטי של פריטים כאשר שינויי קבצים זוהים. *דורש איתחול שרת",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "תכונות ניסיוניות",
"LabelSettingsExperimentalFeaturesHelp": "תכונות בפיתוח שדורשות משובך ובדיקה. לחץ לפתיחת דיון ב-GitHub.",
"LabelSettingsFindCovers": "מצא כריכות",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "גרור קבצים לסדר ההשמעה נכון",
"MessageEmbedFinished": "ההטמעה הושלמה!",
"MessageEpisodesQueuedForDownload": "{0} פרקים בתור להורדה",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "כתובת URL של העדכון תהיה {0}",
"MessageFetching": "מושך...",
"MessageForceReScanDescription": "תבוצע סריקה מחדש כמו סריקה חדש מאפס, תגי ID3 של קבצי קול, קבצי OPF, וקבצי טקסט ייסרקו כחדשים.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Abridged",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed URL will be {0}",
"MessageFetching": "Fetching...",
"MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Abridged",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Vrsta korisničkog računa",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gost",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Eksperimentalni features",
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
"LabelSettingsFindCovers": "Pronađi covers",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.",
"MessageEmbedFinished": "Embed završen!",
"MessageEpisodesQueuedForDownload": "{0} Epizoda/-e u redu za preuzimanje",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed URL će biti {0}",
"MessageFetching": "Dobavljam...",
"MessageForceReScanDescription": "će skenirati sve datoteke ponovno kao svježi sken. ID3 tagovi od audio datoteka, OPF datoteke i tekst datoteke će biti skenirane kao da su nove.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Tömörített",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Fióktípus",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Vendég",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Figyelő engedélyezése",
"LabelSettingsEnableWatcherForLibrary": "Mappafigyelő engedélyezése a könyvtárban",
"LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Kísérleti funkciók",
"LabelSettingsExperimentalFeaturesHelp": "Fejlesztés alatt álló funkciók, amelyek visszajelzésre és tesztelésre szorulnak. Kattintson a github megbeszélés megnyitásához.",
"LabelSettingsFindCovers": "Borítók keresése",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
"MessageEmbedFinished": "Beágyazás befejeződött!",
"MessageEpisodesQueuedForDownload": "{0} Epizód letöltésre várakozik",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
"MessageFetching": "Lekérés...",
"MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Abbreviato",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Tipo di Account",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Ospite",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Abilita Watcher",
"LabelSettingsEnableWatcherForLibrary": "Abilita il controllo cartelle per la libreria",
"LabelSettingsEnableWatcherHelp": "Abilita l'aggiunta/aggiornamento automatico degli elementi quando vengono rilevate modifiche ai file. *Richiede il riavvio del Server",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Opzioni Sperimentali",
"LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.",
"LabelSettingsFindCovers": "Trova covers",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
"MessageEmbedFinished": "Incorporamento finito!",
"MessageEpisodesQueuedForDownload": "{0} Episodio(i) in coda per il Download",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed URL Saranno {0}",
"MessageFetching": "Recupero Info...",
"MessageForceReScanDescription": "eseguirà nuovamente la scansione di tutti i file come una nuova scansione. I tag ID3 dei file audio, i file OPF e i file di testo verranno scansionati come nuovi.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Santrauka",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Paskyros tipas",
"LabelAccountTypeAdmin": "Administratorius",
"LabelAccountTypeGuest": "Svečias",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai",
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
"LabelSettingsFindCovers": "Rasti viršelius",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Surikiuokite takelius vilkdami failus",
"MessageEmbedFinished": "Įterpimas baigtas!",
"MessageEpisodesQueuedForDownload": "{0} epizodai laukia atsisiuntimo",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Srauto URL bus {0}",
"MessageFetching": "Surenkama...",
"MessageForceReScanDescription": "skenuos visus failus lyg iš naujo. Garsinių failų ID3 žymos, OPF failai ir tekstiniai failai bus nuskenuoti kaip nauji.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Verkort",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Accounttype",
"LabelAccountTypeAdmin": "Beheerder",
"LabelAccountTypeGuest": "Gast",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Watcher inschakelen",
"LabelSettingsEnableWatcherForLibrary": "Map-watcher voor bibliotheek inschakelen",
"LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Experimentele functies",
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
"LabelSettingsFindCovers": "Zoek covers",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde",
"MessageEmbedFinished": "Insluiting voltooid!",
"MessageEpisodesQueuedForDownload": "{0} aflevering(en) in de rij om te downloaden",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed URL zal {0} zijn",
"MessageFetching": "Aan het ophalen...",
"MessageForceReScanDescription": "zal alle bestanden opnieuw scannen als een verse scan. Audiobestanden ID3-tags, OPF-bestanden en textbestanden zullen als nieuw worden gescand.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Forkortet",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Kontotype",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gjest",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Aktiver overvåker",
"LabelSettingsEnableWatcherForLibrary": "Aktiver mappe overvåker for bibliotek",
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Eksperimentelle funksjoner",
"LabelSettingsExperimentalFeaturesHelp": "Funksjoner under utvikling som kan trenge din tilbakemelding og hjelp med testing. Klikk for å åpne GitHub diskusjon.",
"LabelSettingsFindCovers": "Finn omslag",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
"MessageEmbedFinished": "Bak inn Fullført!",
"MessageEpisodesQueuedForDownload": "{0} Episode(r) lagt til i kø for nedlasting",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Feed URL vil bli {0}",
"MessageFetching": "Henter...",
"MessageForceReScanDescription": "vil skanne alle filene igjen som en ny skann. Lyd fil ID3 tagger, OPF filer og tekstfiler vil bli skannet som nye.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Abridged",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Typ konta",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gość",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne",
"LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.",
"LabelSettingsFindCovers": "Szukanie okładek",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów",
"MessageEmbedFinished": "Osadzanie zakończone!",
"MessageEpisodesQueuedForDownload": "{0} odcinki w kolejce do pobrania",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "URL kanału: {0}",
"MessageFetching": "Pobieranie...",
"MessageForceReScanDescription": "przeskanuje wszystkie pliki ponownie, jak przy świeżym skanowaniu. Tagi ID3 plików audio, pliki OPF i pliki tekstowe będą skanowane jak nowe.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Versão Abreviada",
"LabelAbridgedChecked": "Abreviada (verificada)",
"LabelAbridgedUnchecked": "Não Abreviada (não verificada)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Tipo de Conta",
"LabelAccountTypeAdmin": "Administrador",
"LabelAccountTypeGuest": "Convidado",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Ativar Monitoramento",
"LabelSettingsEnableWatcherForLibrary": "Ativa o monitoramento de pastas para a biblioteca",
"LabelSettingsEnableWatcherHelp": "Ativa o acréscimo/atualização de itens quando forem detectadas mudanças no arquivo. *Requer reiniciar o servidor",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Funcionalidade experimentais",
"LabelSettingsExperimentalFeaturesHelp": "Funcionalidade em desenvolvimento que se beneficiairam dos seus comentários e da sua ajuda para testar. Clique para abrir a discussão no github.",
"LabelSettingsFindCovers": "Localizar capas",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Arraste os arquivos para ordenar as trilhas corretamente",
"MessageEmbedFinished": "Inclusão Concluída!",
"MessageEpisodesQueuedForDownload": "{0} Episódio(s) na fila de download",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "URL do Feed será {0}",
"MessageFetching": "Buscando...",
"MessageForceReScanDescription": "verificará todos os arquivos, como uma verificação nova. Etiquetas ID3 de arquivos de áudio, arquivos OPF e arquivos de texto serão tratados como novos.",
@ -803,4 +807,4 @@
"ToastSortingPrefixesUpdateSuccess": "Prefixos de ordenação atualizados ({0} item(ns))",
"ToastUserDeleteFailed": "Falha ao apagar usuário",
"ToastUserDeleteSuccess": "Usuário apagado"
}
}

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Сокращенное издание",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Тип учетной записи",
"LabelAccountTypeAdmin": "Администратор",
"LabelAccountTypeGuest": "Гость",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Включить отслеживание",
"LabelSettingsEnableWatcherForLibrary": "Включить отслеживание за папками библиотеки",
"LabelSettingsEnableWatcherHelp": "Включает автоматическое добавление/обновление элементов при обнаружении изменений файлов. *Требуется перезапуск сервера",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Экспериментальные функции",
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
"LabelSettingsFindCovers": "Найти обложки",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
"MessageEmbedFinished": "Встраивание завершено!",
"MessageEpisodesQueuedForDownload": "{0} Эпизод(ов) запланировано для закачки",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "URL канала будет {0}",
"MessageFetching": "Завершается...",
"MessageForceReScanDescription": "будет сканировать все файлы снова, как свежее сканирование. Теги ID3 аудиофайлов, OPF-файлы и текстовые файлы будут сканироваться как новые.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Förkortad",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Kontotyp",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gäst",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Aktivera Watcher",
"LabelSettingsEnableWatcherForLibrary": "Aktivera mappbevakning för bibliotek",
"LabelSettingsEnableWatcherHelp": "Aktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Experimentella funktioner",
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.",
"LabelSettingsFindCovers": "Hitta omslag",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning",
"MessageEmbedFinished": "Inbäddning klar!",
"MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}",
"MessageFetching": "Hämtar...",
"MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Скорочена",
"LabelAbridgedChecked": "Скорочена (з прапорцем)",
"LabelAbridgedUnchecked": "Нескорочена (без прапорця)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Тип профілю",
"LabelAccountTypeAdmin": "Адміністратор",
"LabelAccountTypeGuest": "Гість",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Увімкнути спостерігача",
"LabelSettingsEnableWatcherForLibrary": "Увімкнути спостерігання тек бібліотеки",
"LabelSettingsEnableWatcherHelp": "Вмикає автоматичне додавання/оновлення елементів, коли спостерігаються зміни файлів. *Потребує перезавантаження сервера",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Експериментальні функції",
"LabelSettingsExperimentalFeaturesHelp": "Функції в розробці, які потребують вашого відгуку та допомоги в тестуванні. Натисніть, щоб відкрити обговорення на Github.",
"LabelSettingsFindCovers": "Пошук обкладинок",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
"MessageEmbedFinished": "Вбудовано!",
"MessageEpisodesQueuedForDownload": "Епізодів у черзі завантаження: {0}",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "URL-адреса каналу буде {0}",
"MessageFetching": "Отримання...",
"MessageForceReScanDescription": "Просканує усі файли заново, неначе вперше. ID3-мітки, файли OPF та текстові файли будуть проскановані як нові.",
@ -803,4 +807,4 @@
"ToastSortingPrefixesUpdateSuccess": "Префікси сортування оновлено ({0})",
"ToastUserDeleteFailed": "Не вдалося видалити користувача",
"ToastUserDeleteSuccess": "Користувача видалено"
}
}

View file

@ -191,6 +191,7 @@
"LabelAbridged": "Rút Gọn",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "Loại Tài Khoản",
"LabelAccountTypeAdmin": "Quản Trị Viên",
"LabelAccountTypeGuest": "Khách",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "Bật Watcher",
"LabelSettingsEnableWatcherForLibrary": "Bật watcher thư mục cho thư viện",
"LabelSettingsEnableWatcherHelp": "Bật chức năng tự động thêm/cập nhật các mục khi phát hiện thay đổi tập tin. *Yêu cầu khởi động lại máy chủ",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Tính năng thử nghiệm",
"LabelSettingsExperimentalFeaturesHelp": "Các tính năng đang phát triển có thể cần phản hồi của bạn và sự giúp đỡ trong thử nghiệm. Nhấp để mở thảo luận trên github.",
"LabelSettingsFindCovers": "Tìm ảnh bìa",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "Kéo tệp vào thứ tự track đúng",
"MessageEmbedFinished": "Nhúng Hoàn thành!",
"MessageEpisodesQueuedForDownload": "{0} Tập(s) đã được thêm vào hàng đợi để tải xuống",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "URL nguồn cấp sẽ là {0}",
"MessageFetching": "Đang tìm...",
"MessageForceReScanDescription": "sẽ quét lại tất cả các tệp như một quét mới. Các thẻ ID3 của tệp âm thanh, tệp OPF và tệp văn bản sẽ được quét làm mới.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "概要",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "帐户类型",
"LabelAccountTypeAdmin": "管理员",
"LabelAccountTypeGuest": "来宾",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "启用监视程序",
"LabelSettingsEnableWatcherForLibrary": "为库启用文件夹监视程序",
"LabelSettingsEnableWatcherHelp": "当检测到文件更改时, 启用项目的自动添加/更新. *需要重新启动服务器",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "实验功能",
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
"LabelSettingsFindCovers": "查找封面",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFinished": "嵌入完成!",
"MessageEpisodesQueuedForDownload": "{0} 个剧集排队等待下载",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "源 URL 将改为 {0}",
"MessageFetching": "正在获取...",
"MessageForceReScanDescription": "将像重新扫描一样再次扫描所有文件. 音频文件 ID3 标签, OPF 文件和文本文件将被扫描为新文件.",

View file

@ -191,6 +191,7 @@
"LabelAbridged": "概要",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAccountType": "帳號類型",
"LabelAccountTypeAdmin": "管理員",
"LabelAccountTypeGuest": "來賓",
@ -470,6 +471,8 @@
"LabelSettingsEnableWatcher": "啟用監視程序",
"LabelSettingsEnableWatcherForLibrary": "為庫啟用資料夾監視程序",
"LabelSettingsEnableWatcherHelp": "當檢測到檔案更改時, 啟用項目的自動新增/更新. *需要重新啟動伺服器",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "實驗功能",
"LabelSettingsExperimentalFeaturesHelp": "開發中的功能需要你的反饋並幫助測試. 點擊打開 github 討論.",
"LabelSettingsFindCovers": "查找封面",
@ -630,6 +633,7 @@
"MessageDragFilesIntoTrackOrder": "將檔案拖動到正確的音軌順序",
"MessageEmbedFinished": "嵌入完成!",
"MessageEpisodesQueuedForDownload": "{0} 個劇集排隊等待下載",
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
"MessageFeedURLWillBe": "源 URL 將改為 {0}",
"MessageFetching": "正在獲取...",
"MessageForceReScanDescription": "將像重新掃描一樣再次掃描所有檔案. 音頻檔 ID3 標籤, OPF 檔和文本檔將被掃描為新檔案.",

View file

@ -4,7 +4,6 @@ global.appRoot = __dirname
const isDev = process.env.NODE_ENV !== 'production'
if (isDev) {
const devEnv = require('./dev').config
process.env.NODE_ENV = 'development'
if (devEnv.Port) process.env.PORT = devEnv.Port
if (devEnv.ConfigPath) process.env.CONFIG_PATH = devEnv.ConfigPath
if (devEnv.MetadataPath) process.env.METADATA_PATH = devEnv.MetadataPath

6
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.9.0",
"version": "2.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.9.0",
"version": "2.10.1",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
@ -5554,4 +5554,4 @@
}
}
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.9.0",
"version": "2.10.1",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
@ -60,4 +60,4 @@
"nyc": "^15.1.0",
"sinon": "^17.0.1"
}
}
}

View file

@ -7,6 +7,7 @@ class Logger {
this.logManager = null
this.isDev = process.env.NODE_ENV !== 'production'
this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE
this.socketListeners = []
}
@ -49,7 +50,7 @@ class Logger {
}
addSocketListener(socket, level) {
var index = this.socketListeners.findIndex(s => s.id === socket.id)
var index = this.socketListeners.findIndex((s) => s.id === socket.id)
if (index >= 0) {
this.socketListeners.splice(index, 1, {
id: socket.id,
@ -66,7 +67,7 @@ class Logger {
}
removeSocketListener(socketId) {
this.socketListeners = this.socketListeners.filter(s => s.id !== socketId)
this.socketListeners = this.socketListeners.filter((s) => s.id !== socketId)
}
/**
@ -135,8 +136,8 @@ class Logger {
/**
* Fatal errors are ones that exit the process
* Fatal logs are saved to crash_logs.txt
*
* @param {...any} args
*
* @param {...any} args
*/
fatal(...args) {
console.error(`[${this.timestamp}] FATAL:`, ...args, `(${this.source})`)
@ -148,4 +149,4 @@ class Logger {
this.handleLog(LogLevel.NOTE, args, this.source)
}
}
module.exports = new Logger()
module.exports = new Logger()

View file

@ -50,6 +50,7 @@ class Server {
global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
global.RouterBasePath = ROUTER_BASE_PATH
global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1'
if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath)
@ -182,11 +183,12 @@ class Server {
* @see https://ionicframework.com/docs/troubleshooting/cors
*
* Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1'
*/
app.use((req, res, next) => {
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
if (Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
res.header('Access-Control-Allow-Origin', req.get('origin'))
res.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header('Access-Control-Allow-Headers', '*')

View file

@ -512,8 +512,7 @@ class LibraryController {
* @param {*} res
*/
async getUserPlaylistsForLibrary(req, res) {
let playlistsForUser = await Database.playlistModel.getPlaylistsForUserAndLibrary(req.user.id, req.library.id)
playlistsForUser = await Promise.all(playlistsForUser.map(async (p) => p.getOldJsonExpanded()))
let playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id, req.library.id)
const payload = {
results: [],

View file

@ -6,7 +6,7 @@ const { toNumber } = require('../utils/index')
const userStats = require('../utils/queries/userStats')
class MeController {
constructor() { }
constructor() {}
getCurrentUser(req, res) {
res.json(req.user.toJSONForBrowser())
@ -33,6 +33,43 @@ class MeController {
res.json(payload)
}
/**
* GET: /api/me/item/listening-sessions/:libraryItemId/:episodeId
*
* @this import('../routers/ApiRouter')
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getItemListeningSessions(req, res) {
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)
if (!libraryItem || (libraryItem.mediaType === 'podcast' && !episode)) {
Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`)
return res.sendStatus(404)
}
const mediaItemId = episode?.id || libraryItem.mediaId
let listeningSessions = await this.getUserItemListeningSessionsHelper(req.user.id, mediaItemId)
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
const page = toNumber(req.query.page, 0)
const start = page * itemsPerPage
const sessions = listeningSessions.slice(start, start + itemsPerPage)
const payload = {
total: listeningSessions.length,
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
page,
itemsPerPage,
sessions
}
res.json(payload)
}
// GET: api/me/listening-stats
async getListeningStats(req, res) {
const listeningStats = await this.getUserListeningStatsHelpers(req.user.id)
@ -80,7 +117,7 @@ class MeController {
if (!libraryItem) {
return res.status(404).send('Item not found')
}
if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) {
Logger.error(`[MeController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
return res.status(404).send('Episode not found')
}
@ -123,7 +160,7 @@ class MeController {
// POST: api/me/item/:id/bookmark
async createBookmark(req, res) {
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const { time, title } = req.body
const bookmark = req.user.createBookmark(req.params.id, time, title)
@ -134,7 +171,7 @@ class MeController {
// PATCH: api/me/item/:id/bookmark
async updateBookmark(req, res) {
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const { time, title } = req.body
if (!req.user.findBookmark(req.params.id, time)) {
@ -152,7 +189,7 @@ class MeController {
// DELETE: api/me/item/:id/bookmark/:time
async removeBookmark(req, res) {
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const time = Number(req.params.time)
if (isNaN(time)) return res.sendStatus(500)
@ -254,11 +291,10 @@ class MeController {
// TODO: More efficient to do this in a single query
for (const mediaProgress of req.user.mediaProgress) {
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
const libraryItem = await Database.libraryItemModel.getOldById(mediaProgress.libraryItemId)
if (libraryItem) {
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
const episode = libraryItem.media.episodes.find((ep) => ep.id === mediaProgress.episodeId)
if (episode) {
const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
@ -277,7 +313,9 @@ class MeController {
}
}
itemsInProgress = sort(itemsInProgress).desc(li => li.progressLastUpdate).slice(0, limit)
itemsInProgress = sort(itemsInProgress)
.desc((li) => li.progressLastUpdate)
.slice(0, limit)
res.json({
libraryItems: itemsInProgress
})
@ -317,19 +355,22 @@ class MeController {
// GET: api/me/progress/:id/remove-from-continue-listening
async removeItemFromContinueListening(req, res) {
const mediaProgress = req.user.mediaProgress.find(mp => mp.id === req.params.id)
const mediaProgress = req.user.mediaProgress.find((mp) => mp.id === req.params.id)
if (!mediaProgress) {
return res.sendStatus(404)
}
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
if (hasUpdated) {
await Database.mediaProgressModel.update({
hideFromContinueListening: true
}, {
where: {
id: mediaProgress.id
await Database.mediaProgressModel.update(
{
hideFromContinueListening: true
},
{
where: {
id: mediaProgress.id
}
}
})
)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.json(req.user.toJSONForBrowser())
@ -337,9 +378,9 @@ class MeController {
/**
* GET: /api/me/stats/year/:year
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getStatsForYear(req, res) {
const year = Number(req.params.year)
@ -351,4 +392,4 @@ class MeController {
res.json(data)
}
}
module.exports = new MeController()
module.exports = new MeController()

View file

@ -10,6 +10,8 @@ const Logger = require('../Logger')
const { levenshteinDistance, escapeRegExp } = require('../utils/index')
class BookFinder {
#providerResponseTimeout = 30000
constructor() {
this.openLibrary = new OpenLibrary()
this.googleBooks = new GoogleBooks()
@ -36,63 +38,75 @@ class BookFinder {
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
var searchTitle = cleanTitleForCompares(title)
var searchAuthor = cleanAuthorForCompares(author)
return books.map(b => {
b.cleanedTitle = cleanTitleForCompares(b.title)
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
return books
.map((b) => {
b.cleanedTitle = cleanTitleForCompares(b.title)
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
// Total length of search (title or both title & author)
b.totalPossibleDistance = b.title.length
// Total length of search (title or both title & author)
b.totalPossibleDistance = b.title.length
if (author) {
if (!b.author) {
b.authorDistance = author.length
} else {
b.totalPossibleDistance += b.author.length
b.cleanedAuthor = cleanAuthorForCompares(b.author)
if (author) {
if (!b.author) {
b.authorDistance = author.length
} else {
b.totalPossibleDistance += b.author.length
b.cleanedAuthor = cleanAuthorForCompares(b.author)
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
var authorDistance = levenshteinDistance(b.author || '', author)
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
var authorDistance = levenshteinDistance(b.author || '', author)
// Use best distance
b.authorDistance = Math.min(cleanedAuthorDistance, authorDistance)
// Use best distance
b.authorDistance = Math.min(cleanedAuthorDistance, authorDistance)
// Check book author contains searchAuthor
if (searchAuthor.length > 4 && b.cleanedAuthor.includes(searchAuthor)) b.includesAuthor = searchAuthor
else if (author.length > 4 && b.author.includes(author)) b.includesAuthor = author
// Check book author contains searchAuthor
if (searchAuthor.length > 4 && b.cleanedAuthor.includes(searchAuthor)) b.includesAuthor = searchAuthor
else if (author.length > 4 && b.author.includes(author)) b.includesAuthor = author
}
}
}
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
// Check book title contains the searchTitle
if (searchTitle.length > 4 && b.cleanedTitle.includes(searchTitle)) b.includesTitle = searchTitle
else if (title.length > 4 && b.title.includes(title)) b.includesTitle = title
// Check book title contains the searchTitle
if (searchTitle.length > 4 && b.cleanedTitle.includes(searchTitle)) b.includesTitle = searchTitle
else if (title.length > 4 && b.title.includes(title)) b.includesTitle = title
return b
}).filter(b => {
if (b.includesTitle) { // If search title was found in result title then skip over leven distance check
if (this.verbose) Logger.debug(`Exact title was included in "${b.title}", Search: "${b.includesTitle}"`)
} else if (b.titleDistance > maxTitleDistance) {
if (this.verbose) Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
return false
}
if (author) {
if (b.includesAuthor) { // If search author was found in result author then skip over leven distance check
if (this.verbose) Logger.debug(`Exact author was included in "${b.author}", Search: "${b.includesAuthor}"`)
} else if (b.authorDistance > maxAuthorDistance) {
if (this.verbose) Logger.debug(`Filtering out search result "${b.author}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
return b
})
.filter((b) => {
if (b.includesTitle) {
// If search title was found in result title then skip over leven distance check
if (this.verbose) Logger.debug(`Exact title was included in "${b.title}", Search: "${b.includesTitle}"`)
} else if (b.titleDistance > maxTitleDistance) {
if (this.verbose) Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
return false
}
}
// If book total search length < 5 and was not exact match, then filter out
if (b.totalPossibleDistance < 5 && b.totalDistance > 0) return false
return true
})
if (author) {
if (b.includesAuthor) {
// If search author was found in result author then skip over leven distance check
if (this.verbose) Logger.debug(`Exact author was included in "${b.author}", Search: "${b.includesAuthor}"`)
} else if (b.authorDistance > maxAuthorDistance) {
if (this.verbose) Logger.debug(`Filtering out search result "${b.author}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
return false
}
}
// If book total search length < 5 and was not exact match, then filter out
if (b.totalPossibleDistance < 5 && b.totalDistance > 0) return false
return true
})
}
/**
*
* @param {string} title
* @param {string} author
* @param {number} maxTitleDistance
* @param {number} maxAuthorDistance
* @returns {Promise<Object[]>}
*/
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.openLibrary.searchTitle(title)
var books = await this.openLibrary.searchTitle(title, this.#providerResponseTimeout)
if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`OpenLib Search Error ${books.errorCode}`)
@ -109,8 +123,14 @@ class BookFinder {
return booksFiltered
}
/**
*
* @param {string} title
* @param {string} author
* @returns {Promise<Object[]>}
*/
async getGoogleBooksResults(title, author) {
var books = await this.googleBooks.search(title, author)
var books = await this.googleBooks.search(title, author, this.#providerResponseTimeout)
if (this.verbose) Logger.debug(`GoogleBooks Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`GoogleBooks Search Error ${books.errorCode}`)
@ -120,8 +140,14 @@ class BookFinder {
return books
}
/**
*
* @param {string} title
* @param {string} author
* @returns {Promise<Object[]>}
*/
async getFantLabResults(title, author) {
var books = await this.fantLab.search(title, author)
var books = await this.fantLab.search(title, author, this.#providerResponseTimeout)
if (this.verbose) Logger.debug(`FantLab Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`FantLab Search Error ${books.errorCode}`)
@ -131,41 +157,58 @@ class BookFinder {
return books
}
/**
*
* @param {string} search
* @returns {Promise<Object[]>}
*/
async getAudiobookCoversResults(search) {
const covers = await this.audiobookCovers.search(search)
const covers = await this.audiobookCovers.search(search, this.#providerResponseTimeout)
if (this.verbose) Logger.debug(`AudiobookCovers Search Results: ${covers.length || 0}`)
return covers || []
}
async getiTunesAudiobooksResults(title, author) {
return this.iTunesApi.searchAudiobooks(title)
/**
*
* @param {string} title
* @returns {Promise<Object[]>}
*/
async getiTunesAudiobooksResults(title) {
return this.iTunesApi.searchAudiobooks(title, this.#providerResponseTimeout)
}
/**
*
* @param {string} title
* @param {string} author
* @param {string} asin
* @param {string} provider
* @returns {Promise<Object[]>}
*/
async getAudibleResults(title, author, asin, provider) {
const region = provider.includes('.') ? provider.split('.').pop() : ''
const books = await this.audible.search(title, author, asin, region)
const books = await this.audible.search(title, author, asin, region, this.#providerResponseTimeout)
if (this.verbose) Logger.debug(`Audible Book Search Results: ${books.length || 0}`)
if (!books) return []
return books
}
/**
*
* @param {string} title
*
* @param {string} title
* @param {string} author
* @param {string} isbn
* @param {string} providerSlug
* @param {string} isbn
* @param {string} providerSlug
* @returns {Promise<Object[]>}
*/
async getCustomProviderResults(title, author, isbn, providerSlug) {
const books = await this.customProviderAdapter.search(title, author, isbn, providerSlug, 'book')
const books = await this.customProviderAdapter.search(title, author, isbn, providerSlug, 'book', this.#providerResponseTimeout)
if (this.verbose) Logger.debug(`Custom provider '${providerSlug}' Search Results: ${books.length || 0}`)
return books
}
static TitleCandidates = class {
constructor(cleanAuthor) {
this.candidates = new Set()
this.cleanAuthor = cleanAuthor
@ -179,13 +222,13 @@ class BookFinder {
title = this.#removeAuthorFromTitle(title)
const titleTransformers = [
[/([,:;_]| by ).*/g, ''], // Remove subtitle
[/(^| )\d+k(bps)?( |$)/, ' '], // Remove bitrate
[/([,:;_]| by ).*/g, ''], // Remove subtitle
[/(^| )\d+k(bps)?( |$)/, ' '], // Remove bitrate
[/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition
[/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type
[/ a novel.*$/g, ''], // Remove "a novel"
[/(^| )(un)?abridged( |$)/g, ' '], // Remove "unabridged/abridged"
[/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers
[/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type
[/ a novel.*$/g, ''], // Remove "a novel"
[/(^| )(un)?abridged( |$)/g, ' '], // Remove "unabridged/abridged"
[/^\d+ | \d+$/g, ''] // Remove preceding/trailing numbers
]
// Main variant
@ -197,8 +240,7 @@ class BookFinder {
let candidate = cleanTitle
for (const transformer of titleTransformers)
candidate = candidate.replace(transformer[0], transformer[1]).trim()
for (const transformer of titleTransformers) candidate = candidate.replace(transformer[0], transformer[1]).trim()
if (candidate != cleanTitle) {
if (candidate) {
@ -240,7 +282,7 @@ class BookFinder {
#removeAuthorFromTitle(title) {
if (!this.cleanAuthor) return title
const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g")
const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, 'g')
const authorCleanedTitle = cleanAuthorForCompares(title)
const authorCleanedTitleWithoutAuthor = authorCleanedTitle.replace(authorRe, '')
if (authorCleanedTitleWithoutAuthor !== authorCleanedTitle) {
@ -297,7 +339,7 @@ class BookFinder {
promises.push(this.validateAuthor(candidate))
}
const results = [...new Set(await Promise.all(promises))]
filteredCandidates = results.filter(author => author)
filteredCandidates = results.filter((author) => author)
// If no valid candidates were found, add back an aggresively cleaned author version
if (!filteredCandidates.length && this.cleanAuthor) filteredCandidates.push(this.agressivelyCleanAuthor)
// Always add an empty author candidate
@ -312,17 +354,16 @@ class BookFinder {
}
}
/**
* Search for books including fuzzy searches
*
*
* @param {Object} libraryItem
* @param {string} provider
* @param {string} title
* @param {string} author
* @param {string} isbn
* @param {string} asin
* @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options
* @param {string} provider
* @param {string} title
* @param {string} author
* @param {string} isbn
* @param {string} asin
* @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options
* @returns {Promise<Object[]>}
*/
async search(libraryItem, provider, title, author, isbn, asin, options = {}) {
@ -337,8 +378,7 @@ class BookFinder {
return this.getCustomProviderResults(title, author, isbn, provider)
}
if (!title)
return books
if (!title) return books
books = await this.runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance)
@ -353,17 +393,14 @@ class BookFinder {
let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus)
// Remove underscores and parentheses with their contents, and replace with a separator
const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ")
const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, ' - ')
// Split title into hypen-separated parts
const titleParts = cleanTitle.split(/ - | -|- /)
for (const titlePart of titleParts)
authorCandidates.add(titlePart)
for (const titlePart of titleParts) authorCandidates.add(titlePart)
authorCandidates = await authorCandidates.getCandidates()
loop_author:
for (const authorCandidate of authorCandidates) {
loop_author: for (const authorCandidate of authorCandidates) {
let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)
for (const titlePart of titleParts)
titleCandidates.add(titlePart)
for (const titlePart of titleParts) titleCandidates.add(titlePart)
titleCandidates = titleCandidates.getCandidates()
for (const titleCandidate of titleCandidates) {
if (titleCandidate == title && authorCandidate == author) continue // We already tried this
@ -393,10 +430,10 @@ class BookFinder {
/**
* Search for books
*
* @param {string} title
* @param {string} author
* @param {string} provider
*
* @param {string} title
* @param {string} author
* @param {string} provider
* @param {string} asin only used for audible providers
* @param {number} maxTitleDistance only used for openlibrary provider
* @param {number} maxAuthorDistance only used for openlibrary provider
@ -412,7 +449,7 @@ class BookFinder {
} else if (provider.startsWith('audible')) {
books = await this.getAudibleResults(title, author, asin, provider)
} else if (provider === 'itunes') {
books = await this.getiTunesAudiobooksResults(title, author)
books = await this.getiTunesAudiobooksResults(title)
} else if (provider === 'openlibrary') {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'fantlab') {
@ -448,7 +485,7 @@ class BookFinder {
covers.push(result.cover)
}
})
return [...(new Set(covers))]
return [...new Set(covers)]
}
findChapters(asin, region) {
@ -468,7 +505,7 @@ function stripSubtitle(title) {
function replaceAccentedChars(str) {
try {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
} catch (error) {
Logger.error('[BookFinder] str normalize error', error)
return str
@ -483,7 +520,7 @@ function cleanTitleForCompares(title) {
let stripped = stripSubtitle(title)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
let cleaned = stripped.replace(/ *\([^)]*\) */g, "")
let cleaned = stripped.replace(/ *\([^)]*\) */g, '')
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')

View file

@ -43,21 +43,24 @@ class Playlist extends Model {
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
return playlists.map(p => this.getOldPlaylist(p))
return playlists.map((p) => this.getOldPlaylist(p))
}
static getOldPlaylist(playlistExpanded) {
const items = playlistExpanded.playlistMediaItems.map(pmi => {
const libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || pmi.mediaItem?.libraryItem?.id || null
if (!libraryItemId) {
Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2))
return null
}
return {
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '',
libraryItemId
}
}).filter(pmi => pmi)
const items = playlistExpanded.playlistMediaItems
.map((pmi) => {
const mediaItem = pmi.mediaItem || pmi.dataValues?.mediaItem
const libraryItemId = mediaItem?.podcast?.libraryItem?.id || mediaItem?.libraryItem?.id || null
if (!libraryItemId) {
Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2))
return null
}
return {
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '',
libraryItemId
}
})
.filter((pmi) => pmi)
return new oldPlaylist({
id: playlistExpanded.id,
@ -77,25 +80,26 @@ class Playlist extends Model {
* @returns {Promise<object>} oldPlaylist.toJSONExpanded
*/
async getOldJsonExpanded(include) {
this.playlistMediaItems = await this.getPlaylistMediaItems({
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
this.playlistMediaItems =
(await this.getPlaylistMediaItems({
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
}
],
order: [['order', 'ASC']]
}) || []
],
order: [['order', 'ASC']]
})) || []
const oldPlaylist = this.sequelize.models.playlist.getOldPlaylist(this)
const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId)
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId)
let libraryItems = await this.sequelize.models.libraryItem.getAllOldLibraryItems({
id: libraryItemIds
@ -138,7 +142,7 @@ class Playlist extends Model {
/**
* Get playlist by id
* @param {string} playlistId
* @param {string} playlistId
* @returns {Promise<oldPlaylist|null>} returns null if not found
*/
static async getById(playlistId) {
@ -167,12 +171,13 @@ class Playlist extends Model {
}
/**
* Get playlists for user and optionally for library
* @param {string} userId
* @param {[string]} libraryId optional
* @returns {Promise<Playlist[]>}
* Get old playlists for user and optionally for library
*
* @param {string} userId
* @param {string} [libraryId]
* @returns {Promise<oldPlaylist[]>}
*/
static async getPlaylistsForUserAndLibrary(userId, libraryId = null) {
static async getOldPlaylistsForUserAndLibrary(userId, libraryId = null) {
if (!userId && !libraryId) return []
const whereQuery = {}
if (userId) {
@ -181,7 +186,7 @@ class Playlist extends Model {
if (libraryId) {
whereQuery.libraryId = libraryId
}
const playlists = await this.findAll({
const playlistsExpanded = await this.findAll({
where: whereQuery,
include: {
model: this.sequelize.models.playlistMediaItem,
@ -204,14 +209,44 @@ class Playlist extends Model {
['playlistMediaItems', 'order', 'ASC']
]
})
return playlists
const oldPlaylists = []
for (const playlistExpanded of playlistsExpanded) {
const oldPlaylist = this.getOldPlaylist(playlistExpanded)
const libraryItems = []
for (const pmi of playlistExpanded.playlistMediaItems) {
let mediaItem = pmi.mediaItem || pmi.dataValues.mediaItem
if (!mediaItem) {
Logger.error(`[Playlist] Invalid playlist media item - No media item found`, JSON.stringify(mediaItem, null, 2))
continue
}
let libraryItem = mediaItem.libraryItem || mediaItem.podcast?.libraryItem
if (mediaItem.podcast) {
libraryItem.media = mediaItem.podcast
libraryItem.media.podcastEpisodes = [mediaItem]
delete mediaItem.podcast.libraryItem
} else {
libraryItem.media = mediaItem
delete mediaItem.libraryItem
}
const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
libraryItems.push(oldLibraryItem)
}
const oldPlaylistJson = oldPlaylist.toJSONExpanded(libraryItems)
oldPlaylists.push(oldPlaylistJson)
}
return oldPlaylists
}
/**
* Get number of playlists for a user and library
* @param {string} userId
* @param {string} libraryId
* @returns
* @param {string} userId
* @param {string} libraryId
* @returns
*/
static async getNumPlaylistsForUserAndLibrary(userId, libraryId) {
return this.count({
@ -224,7 +259,7 @@ class Playlist extends Model {
/**
* Get all playlists for mediaItemIds
* @param {string[]} mediaItemIds
* @param {string[]} mediaItemIds
* @returns {Promise<Playlist[]>}
*/
static async getPlaylistsForMediaItemIds(mediaItemIds) {
@ -263,9 +298,9 @@ class Playlist extends Model {
const playlists = []
for (const playlistMediaItem of playlistMediaItemsExpanded) {
const playlist = playlistMediaItem.playlist
if (playlists.some(p => p.id === playlist.id)) continue
if (playlists.some((p) => p.id === playlist.id)) continue
playlist.playlistMediaItems = playlist.playlistMediaItems.map(pmi => {
playlist.playlistMediaItems = playlist.playlistMediaItems.map((pmi) => {
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
pmi.mediaItem = pmi.book
pmi.dataValues.mediaItem = pmi.dataValues.book
@ -286,21 +321,24 @@ class Playlist extends Model {
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
description: DataTypes.TEXT
},
name: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'playlist'
})
{
sequelize,
modelName: 'playlist'
}
)
const { library, user } = sequelize.models
library.hasMany(Playlist)
@ -311,14 +349,14 @@ class Playlist extends Model {
})
Playlist.belongsTo(user)
Playlist.addHook('afterFind', findResult => {
Playlist.addHook('afterFind', (findResult) => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.playlistMediaItems?.length) {
instance.playlistMediaItems = instance.playlistMediaItems.map(pmi => {
instance.playlistMediaItems = instance.playlistMediaItems.map((pmi) => {
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
pmi.mediaItem = pmi.book
pmi.dataValues.mediaItem = pmi.dataValues.book
@ -334,10 +372,9 @@ class Playlist extends Model {
return pmi
})
}
}
})
}
}
module.exports = Playlist
module.exports = Playlist

View file

@ -8,6 +8,7 @@ class LibrarySettings {
this.skipMatchingMediaWithIsbn = false
this.autoScanCronExpression = null
this.audiobooksOnly = false
this.epubsAllowScriptedContent = false
this.hideSingleBookSeries = false // Do not show series that only have 1 book
this.onlyShowLaterBooksInContinueSeries = false // Skip showing books that are earlier than the max sequence read
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
@ -25,6 +26,7 @@ class LibrarySettings {
this.skipMatchingMediaWithIsbn = !!settings.skipMatchingMediaWithIsbn
this.autoScanCronExpression = settings.autoScanCronExpression || null
this.audiobooksOnly = !!settings.audiobooksOnly
this.epubsAllowScriptedContent = !!settings.epubsAllowScriptedContent
this.hideSingleBookSeries = !!settings.hideSingleBookSeries
this.onlyShowLaterBooksInContinueSeries = !!settings.onlyShowLaterBooksInContinueSeries
if (settings.metadataPrecedence) {
@ -44,6 +46,7 @@ class LibrarySettings {
skipMatchingMediaWithIsbn: this.skipMatchingMediaWithIsbn,
autoScanCronExpression: this.autoScanCronExpression,
audiobooksOnly: this.audiobooksOnly,
epubsAllowScriptedContent: this.epubsAllowScriptedContent,
hideSingleBookSeries: this.hideSingleBookSeries,
onlyShowLaterBooksInContinueSeries: this.onlyShowLaterBooksInContinueSeries,
metadataPrecedence: [...this.metadataPrecedence],
@ -67,4 +70,4 @@ class LibrarySettings {
return hasUpdates
}
}
module.exports = LibrarySettings
module.exports = LibrarySettings

View file

@ -1,145 +1,176 @@
const axios = require('axios')
const axios = require('axios').default
const htmlSanitizer = require('../utils/htmlSanitizer')
const Logger = require('../Logger')
class Audible {
constructor() {
this.regionMap = {
'us': '.com',
'ca': '.ca',
'uk': '.co.uk',
'au': '.com.au',
'fr': '.fr',
'de': '.de',
'jp': '.co.jp',
'it': '.it',
'in': '.in',
'es': '.es'
}
#responseTimeout = 30000
constructor() {
this.regionMap = {
us: '.com',
ca: '.ca',
uk: '.co.uk',
au: '.com.au',
fr: '.fr',
de: '.de',
jp: '.co.jp',
it: '.it',
in: '.in',
es: '.es'
}
}
/**
* Audible will sometimes send sequences with "Book 1" or "2, Dramatized Adaptation"
* @see https://github.com/advplyr/audiobookshelf/issues/2380
* @see https://github.com/advplyr/audiobookshelf/issues/1339
*
* @param {string} seriesName
* @param {string} sequence
* @returns {string}
*/
cleanSeriesSequence(seriesName, sequence) {
if (!sequence) return ''
// match any number with optional decimal (e.g, 1 or 1.5 or .5)
let numberFound = sequence.match(/\.\d+|\d+(?:\.\d+)?/)
let updatedSequence = numberFound ? numberFound[0] : sequence
if (sequence !== updatedSequence) {
Logger.debug(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`)
}
return updatedSequence
}
cleanResult(item) {
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
const series = []
if (seriesPrimary) {
series.push({
series: seriesPrimary.name,
sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
})
}
if (seriesSecondary) {
series.push({
series: seriesSecondary.name,
sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
})
}
/**
* Audible will sometimes send sequences with "Book 1" or "2, Dramatized Adaptation"
* @see https://github.com/advplyr/audiobookshelf/issues/2380
* @see https://github.com/advplyr/audiobookshelf/issues/1339
*
* @param {string} seriesName
* @param {string} sequence
* @returns {string}
*/
cleanSeriesSequence(seriesName, sequence) {
if (!sequence) return ''
// match any number with optional decimal (e.g, 1 or 1.5 or .5)
let numberFound = sequence.match(/\.\d+|\d+(?:\.\d+)?/)
let updatedSequence = numberFound ? numberFound[0] : sequence
if (sequence !== updatedSequence) {
Logger.debug(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`)
}
return updatedSequence
const genresFiltered = genres ? genres.filter((g) => g.type == 'genre').map((g) => g.name) : []
const tagsFiltered = genres ? genres.filter((g) => g.type == 'tag').map((g) => g.name) : []
return {
title,
subtitle: subtitle || null,
author: authors ? authors.map(({ name }) => name).join(', ') : null,
narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null,
publisher: publisherName,
publishedYear: releaseDate ? releaseDate.split('-')[0] : null,
description: summary ? htmlSanitizer.stripAllTags(summary) : null,
cover: image,
asin,
genres: genresFiltered.length ? genresFiltered : null,
tags: tagsFiltered.length ? tagsFiltered.join(', ') : null,
series: series.length ? series : null,
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
region: item.region || null,
rating: item.rating || null,
abridged: formatType === 'abridged'
}
}
/**
* Test if a search title matches an ASIN. Supports lowercase letters
*
* @param {string} title
* @returns {boolean}
*/
isProbablyAsin(title) {
return /^[0-9A-Za-z]{10}$/.test(title)
}
/**
*
* @param {string} asin
* @param {string} region
* @param {number} [timeout] response timeout in ms
* @returns {Promise<Object[]>}
*/
asinSearch(asin, region, timeout = this.#responseTimeout) {
if (!asin) return []
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
asin = encodeURIComponent(asin.toUpperCase())
var regionQuery = region ? `?region=${region}` : ''
var url = `https://api.audnex.us/books/${asin}${regionQuery}`
Logger.debug(`[Audible] ASIN url: ${url}`)
return axios
.get(url, {
timeout
})
.then((res) => {
if (!res || !res.data || !res.data.asin) return null
return res.data
})
.catch((error) => {
Logger.error('[Audible] ASIN search error', error)
return []
})
}
/**
*
* @param {string} title
* @param {string} author
* @param {string} asin
* @param {string} region
* @param {number} [timeout] response timeout in ms
* @returns {Promise<Object[]>}
*/
async search(title, author, asin, region, timeout = this.#responseTimeout) {
if (region && !this.regionMap[region]) {
Logger.error(`[Audible] search: Invalid region ${region}`)
region = ''
}
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
let items
if (asin) {
items = [await this.asinSearch(asin, region, timeout)]
}
cleanResult(item) {
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
const series = []
if (seriesPrimary) {
series.push({
series: seriesPrimary.name,
sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
})
}
if (seriesSecondary) {
series.push({
series: seriesSecondary.name,
sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
})
}
const genresFiltered = genres ? genres.filter(g => g.type == "genre").map(g => g.name) : []
const tagsFiltered = genres ? genres.filter(g => g.type == "tag").map(g => g.name) : []
return {
title,
subtitle: subtitle || null,
author: authors ? authors.map(({ name }) => name).join(', ') : null,
narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null,
publisher: publisherName,
publishedYear: releaseDate ? releaseDate.split('-')[0] : null,
description: summary ? htmlSanitizer.stripAllTags(summary) : null,
cover: image,
asin,
genres: genresFiltered.length ? genresFiltered : null,
tags: tagsFiltered.length ? tagsFiltered.join(', ') : null,
series: series.length ? series : null,
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
region: item.region || null,
rating: item.rating || null,
abridged: formatType === 'abridged'
}
if (!items && this.isProbablyAsin(title)) {
items = [await this.asinSearch(title, region, timeout)]
}
/**
* Test if a search title matches an ASIN. Supports lowercase letters
*
* @param {string} title
* @returns {boolean}
*/
isProbablyAsin(title) {
return /^[0-9A-Za-z]{10}$/.test(title)
}
asinSearch(asin, region) {
if (!asin) return []
asin = encodeURIComponent(asin.toUpperCase())
var regionQuery = region ? `?region=${region}` : ''
var url = `https://api.audnex.us/books/${asin}${regionQuery}`
Logger.debug(`[Audible] ASIN url: ${url}`)
return axios.get(url).then((res) => {
if (!res || !res.data || !res.data.asin) return null
return res.data
}).catch(error => {
Logger.error('[Audible] ASIN search error', error)
return []
if (!items) {
const queryObj = {
num_results: '10',
products_sort_by: 'Relevance',
title: title
}
if (author) queryObj.author = author
const queryString = new URLSearchParams(queryObj).toString()
const tld = region ? this.regionMap[region] : '.com'
const url = `https://api.audible${tld}/1.0/catalog/products?${queryString}`
Logger.debug(`[Audible] Search url: ${url}`)
items = await axios
.get(url, {
timeout
})
.then((res) => {
if (!res?.data?.products) return null
return Promise.all(res.data.products.map((result) => this.asinSearch(result.asin, region, timeout)))
})
.catch((error) => {
Logger.error('[Audible] query search error', error)
return []
})
}
async search(title, author, asin, region) {
if (region && !this.regionMap[region]) {
Logger.error(`[Audible] search: Invalid region ${region}`)
region = ''
}
let items
if (asin) {
items = [await this.asinSearch(asin, region)]
}
if (!items && this.isProbablyAsin(title)) {
items = [await this.asinSearch(title, region)]
}
if (!items) {
const queryObj = {
num_results: '10',
products_sort_by: 'Relevance',
title: title
}
if (author) queryObj.author = author
const queryString = (new URLSearchParams(queryObj)).toString()
const tld = region ? this.regionMap[region] : '.com'
const url = `https://api.audible${tld}/1.0/catalog/products?${queryString}`
Logger.debug(`[Audible] Search url: ${url}`)
items = await axios.get(url).then((res) => {
if (!res?.data?.products) return null
return Promise.all(res.data.products.map(result => this.asinSearch(result.asin, region)))
}).catch(error => {
Logger.error('[Audible] query search error', error)
return []
})
}
return items ? items.map(item => this.cleanResult(item)) : []
}
return items?.map((item) => this.cleanResult(item)) || []
}
}
module.exports = Audible
module.exports = Audible

View file

@ -2,22 +2,32 @@ const axios = require('axios')
const Logger = require('../Logger')
class AudiobookCovers {
constructor() { }
#responseTimeout = 30000
constructor() {}
/**
*
* @param {string} search
* @param {number} [timeout]
* @returns {Promise<{cover: string}[]>}
*/
async search(search, timeout = this.#responseTimeout) {
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
async search(search) {
const url = `https://api.audiobookcovers.com/cover/bytext/`
const params = new URLSearchParams([['q', search]])
const items = await axios.get(url, { params }).then((res) => {
if (!res || !res.data) return []
return res.data
}).catch(error => {
Logger.error('[AudiobookCovers] Cover search error', error)
return []
})
return items.map(item => ({ cover: item.versions.png.original }))
const items = await axios
.get(url, {
params,
timeout
})
.then((res) => res?.data || [])
.catch((error) => {
Logger.error('[AudiobookCovers] Cover search error', error)
return []
})
return items.map((item) => ({ cover: item.versions.png.original }))
}
}
module.exports = AudiobookCovers

View file

@ -1,4 +1,4 @@
const axios = require('axios')
const axios = require('axios').default
const { levenshteinDistance } = require('../utils/index')
const Logger = require('../Logger')
@ -16,10 +16,10 @@ class Audnexus {
}
/**
*
* @param {string} name
* @param {string} region
* @returns {Promise<{asin:string, name:string}[]>}
*
* @param {string} name
* @param {string} region
* @returns {Promise<{asin:string, name:string}[]>}
*/
authorASINsRequest(name, region) {
const searchParams = new URLSearchParams()
@ -27,18 +27,21 @@ class Audnexus {
if (region) searchParams.set('region', region)
const authorRequestUrl = `${this.baseUrl}/authors?${searchParams.toString()}`
Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
return axios.get(authorRequestUrl).then((res) => {
return res.data || []
}).catch((error) => {
Logger.error(`[Audnexus] Author ASIN request failed for ${name}`, error)
return []
})
return axios
.get(authorRequestUrl)
.then((res) => {
return res.data || []
})
.catch((error) => {
Logger.error(`[Audnexus] Author ASIN request failed for ${name}`, error)
return []
})
}
/**
*
* @param {string} asin
* @param {string} region
*
* @param {string} asin
* @param {string} region
* @returns {Promise<AuthorSearchObj>}
*/
authorRequest(asin, region) {
@ -46,18 +49,21 @@ class Audnexus {
const regionQuery = region ? `?region=${region}` : ''
const authorRequestUrl = `${this.baseUrl}/authors/${asin}${regionQuery}`
Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
return axios.get(authorRequestUrl).then((res) => {
return res.data
}).catch((error) => {
Logger.error(`[Audnexus] Author request failed for ${asin}`, error)
return null
})
return axios
.get(authorRequestUrl)
.then((res) => {
return res.data
})
.catch((error) => {
Logger.error(`[Audnexus] Author request failed for ${asin}`, error)
return null
})
}
/**
*
* @param {string} asin
* @param {string} region
*
* @param {string} asin
* @param {string} region
* @returns {Promise<AuthorSearchObj>}
*/
async findAuthorByASIN(asin, region) {
@ -74,10 +80,10 @@ class Audnexus {
}
/**
*
* @param {string} name
* @param {string} region
* @param {number} maxLevenshtein
*
* @param {string} name
* @param {string} region
* @param {number} maxLevenshtein
* @returns {Promise<AuthorSearchObj>}
*/
async findAuthorByName(name, region, maxLevenshtein = 3) {
@ -108,12 +114,15 @@ class Audnexus {
getChaptersByASIN(asin, region) {
Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}/${region}`)
return axios.get(`${this.baseUrl}/books/${asin}/chapters?region=${region}`).then((res) => {
return res.data
}).catch((error) => {
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}/${region}`, error)
return null
})
return axios
.get(`${this.baseUrl}/books/${asin}/chapters?region=${region}`)
.then((res) => {
return res.data
})
.catch((error) => {
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}/${region}`, error)
return null
})
}
}
module.exports = Audnexus
module.exports = Audnexus

View file

@ -1,97 +1,91 @@
const axios = require('axios').default
const Database = require('../Database')
const axios = require('axios')
const Logger = require('../Logger')
class CustomProviderAdapter {
constructor() { }
#responseTimeout = 30000
/**
*
* @param {string} title
* @param {string} author
* @param {string} isbn
* @param {string} providerSlug
* @param {string} mediaType
* @returns {Promise<Object[]>}
*/
async search(title, author, isbn, providerSlug, mediaType) {
const providerId = providerSlug.split('custom-')[1]
const provider = await Database.customMetadataProviderModel.findByPk(providerId)
constructor() {}
if (!provider) {
throw new Error("Custom provider not found for the given id")
}
/**
*
* @param {string} title
* @param {string} author
* @param {string} isbn
* @param {string} providerSlug
* @param {string} mediaType
* @param {number} [timeout] response timeout in ms
* @returns {Promise<Object[]>}
*/
async search(title, author, isbn, providerSlug, mediaType, timeout = this.#responseTimeout) {
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
// Setup query params
const queryObj = {
mediaType,
query: title
}
if (author) {
queryObj.author = author
}
if (isbn) {
queryObj.isbn = isbn
}
const queryString = (new URLSearchParams(queryObj)).toString()
const providerId = providerSlug.split('custom-')[1]
const provider = await Database.customMetadataProviderModel.findByPk(providerId)
// Setup headers
const axiosOptions = {}
if (provider.authHeaderValue) {
axiosOptions.headers = {
'Authorization': provider.authHeaderValue
}
}
const matches = await axios.get(`${provider.url}/search?${queryString}`, axiosOptions).then((res) => {
if (!res?.data || !Array.isArray(res.data.matches)) return null
return res.data.matches
}).catch(error => {
Logger.error('[CustomMetadataProvider] Search error', error)
return []
})
if (!matches) {
throw new Error("Custom provider returned malformed response")
}
// re-map keys to throw out
return matches.map(({
title,
subtitle,
author,
narrator,
publisher,
publishedYear,
description,
cover,
isbn,
asin,
genres,
tags,
series,
language,
duration
}) => {
return {
title,
subtitle,
author,
narrator,
publisher,
publishedYear,
description,
cover,
isbn,
asin,
genres,
tags: tags?.join(',') || null,
series: series?.length ? series : null,
language,
duration
}
})
if (!provider) {
throw new Error('Custom provider not found for the given id')
}
// Setup query params
const queryObj = {
mediaType,
query: title
}
if (author) {
queryObj.author = author
}
if (isbn) {
queryObj.isbn = isbn
}
const queryString = new URLSearchParams(queryObj).toString()
// Setup headers
const axiosOptions = {
timeout
}
if (provider.authHeaderValue) {
axiosOptions.headers = {
Authorization: provider.authHeaderValue
}
}
const matches = await axios
.get(`${provider.url}/search?${queryString}`, axiosOptions)
.then((res) => {
if (!res?.data || !Array.isArray(res.data.matches)) return null
return res.data.matches
})
.catch((error) => {
Logger.error('[CustomMetadataProvider] Search error', error)
return []
})
if (!matches) {
throw new Error('Custom provider returned malformed response')
}
// re-map keys to throw out
return matches.map(({ title, subtitle, author, narrator, publisher, publishedYear, description, cover, isbn, asin, genres, tags, series, language, duration }) => {
return {
title,
subtitle,
author,
narrator,
publisher,
publishedYear,
description,
cover,
isbn,
asin,
genres,
tags: tags?.join(',') || null,
series: series?.length ? series : null,
language,
duration
}
})
}
}
module.exports = CustomProviderAdapter
module.exports = CustomProviderAdapter

View file

@ -2,6 +2,7 @@ const axios = require('axios')
const Logger = require('../Logger')
class FantLab {
#responseTimeout = 30000
// 7 - other
// 11 - essay
// 12 - article
@ -22,28 +23,47 @@ class FantLab {
_filterWorkType = [7, 11, 12, 22, 23, 24, 25, 26, 46, 47, 49, 51, 52, 55, 56, 57]
_baseUrl = 'https://api.fantlab.ru'
constructor() { }
constructor() {}
/**
* @param {string} title
* @param {string} author'
* @param {number} [timeout] response timeout in ms
* @returns {Promise<Object[]>}
**/
async search(title, author, timeout = this.#responseTimeout) {
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
async search(title, author) {
let searchString = encodeURIComponent(title)
if (author) {
searchString += encodeURIComponent(' ' + author)
}
const url = `${this._baseUrl}/search-works?q=${searchString}&page=1&onlymatches=1`
Logger.debug(`[FantLab] Search url: ${url}`)
const items = await axios.get(url).then((res) => {
return res.data || []
}).catch(error => {
Logger.error('[FantLab] search error', error)
return []
})
const items = await axios
.get(url, {
timeout
})
.then((res) => {
return res.data || []
})
.catch((error) => {
Logger.error('[FantLab] search error', error)
return []
})
return Promise.all(items.map(async item => await this.getWork(item))).then(resArray => {
return resArray.filter(res => res)
return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => {
return resArray.filter((res) => res)
})
}
async getWork(item) {
/**
* @param {Object} item
* @param {number} [timeout] response timeout in ms
* @returns {Promise<Object>}
**/
async getWork(item, timeout = this.#responseTimeout) {
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
const { work_id, work_type_id } = item
if (this._filterWorkType.includes(work_type_id)) {
@ -51,23 +71,34 @@ class FantLab {
}
const url = `${this._baseUrl}/work/${work_id}/extended`
const bookData = await axios.get(url).then((resp) => {
return resp.data || null
}).catch((error) => {
Logger.error(`[FantLab] work info request for url "${url}" error`, error)
return null
})
const bookData = await axios
.get(url, {
timeout
})
.then((resp) => {
return resp.data || null
})
.catch((error) => {
Logger.error(`[FantLab] work info request for url "${url}" error`, error)
return null
})
return this.cleanBookData(bookData)
return this.cleanBookData(bookData, timeout)
}
async cleanBookData(bookData) {
/**
*
* @param {Object} bookData
* @param {number} [timeout]
* @returns {Promise<Object>}
*/
async cleanBookData(bookData, timeout = this.#responseTimeout) {
let { authors, work_name_alts, work_id, work_name, work_year, work_description, image, classificatory, editions_blocks } = bookData
const subtitle = Array.isArray(work_name_alts) ? work_name_alts[0] : null
const authorNames = authors.map(au => (au.name || '').trim()).filter(au => au)
const authorNames = authors.map((au) => (au.name || '').trim()).filter((au) => au)
const imageAndIsbn = await this.tryGetCoverFromEditions(editions_blocks)
const imageAndIsbn = await this.tryGetCoverFromEditions(editions_blocks, timeout)
const imageToUse = imageAndIsbn?.imageUrl || image
@ -88,7 +119,7 @@ class FantLab {
tryGetGenres(classificatory) {
if (!classificatory || !classificatory.genre_group) return []
const genresGroup = classificatory.genre_group.find(group => group.genre_group_id == 1) // genres and subgenres
const genresGroup = classificatory.genre_group.find((group) => group.genre_group_id == 1) // genres and subgenres
// genre_group_id=2 - General Characteristics
// genre_group_id=3 - Arena
@ -108,10 +139,16 @@ class FantLab {
tryGetSubGenres(rootGenre) {
if (!rootGenre.genre || !rootGenre.genre.length) return []
return rootGenre.genre.map(g => g.label).filter(g => g)
return rootGenre.genre.map((g) => g.label).filter((g) => g)
}
async tryGetCoverFromEditions(editions) {
/**
*
* @param {Object} editions
* @param {number} [timeout]
* @returns {Promise<{imageUrl: string, isbn: string}>
*/
async tryGetCoverFromEditions(editions, timeout = this.#responseTimeout) {
if (!editions) {
return null
}
@ -129,24 +166,37 @@ class FantLab {
const isbn = lastEdition['isbn'] || null // get only from paper edition
return {
imageUrl: await this.getCoverFromEdition(editionId),
imageUrl: await this.getCoverFromEdition(editionId, timeout),
isbn
}
}
async getCoverFromEdition(editionId) {
/**
*
* @param {number} editionId
* @param {number} [timeout]
* @returns {Promise<string>}
*/
async getCoverFromEdition(editionId, timeout = this.#responseTimeout) {
if (!editionId) return null
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
const url = `${this._baseUrl}/edition/${editionId}`
const editionInfo = await axios.get(url).then((resp) => {
return resp.data || null
}).catch(error => {
Logger.error(`[FantLab] search cover from edition with url "${url}" error`, error)
return null
})
const editionInfo = await axios
.get(url, {
timeout
})
.then((resp) => {
return resp.data || null
})
.catch((error) => {
Logger.error(`[FantLab] search cover from edition with url "${url}" error`, error)
return null
})
return editionInfo?.image || null
}
}
module.exports = FantLab
module.exports = FantLab

View file

@ -2,12 +2,14 @@ const axios = require('axios')
const Logger = require('../Logger')
class GoogleBooks {
constructor() { }
#responseTimeout = 30000
constructor() {}
extractIsbn(industryIdentifiers) {
if (!industryIdentifiers || !industryIdentifiers.length) return null
var isbnObj = industryIdentifiers.find(i => i.type === 'ISBN_13') || industryIdentifiers.find(i => i.type === 'ISBN_10')
var isbnObj = industryIdentifiers.find((i) => i.type === 'ISBN_13') || industryIdentifiers.find((i) => i.type === 'ISBN_10')
if (isbnObj && isbnObj.identifier) return isbnObj.identifier
return null
}
@ -38,24 +40,38 @@ class GoogleBooks {
}
}
async search(title, author) {
/**
* Search for a book by title and author
* @param {string} title
* @param {string} author
* @param {number} [timeout] response timeout in ms
* @returns {Promise<Object[]>}
**/
async search(title, author, timeout = this.#responseTimeout) {
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
title = encodeURIComponent(title)
var queryString = `q=intitle:${title}`
let queryString = `q=intitle:${title}`
if (author) {
author = encodeURIComponent(author)
queryString += `+inauthor:${author}`
}
var url = `https://www.googleapis.com/books/v1/volumes?${queryString}`
const url = `https://www.googleapis.com/books/v1/volumes?${queryString}`
Logger.debug(`[GoogleBooks] Search url: ${url}`)
var items = await axios.get(url).then((res) => {
if (!res || !res.data || !res.data.items) return []
return res.data.items
}).catch(error => {
Logger.error('[GoogleBooks] Volume search error', error)
return []
})
return items.map(item => this.cleanResult(item))
const items = await axios
.get(url, {
timeout
})
.then((res) => {
if (!res || !res.data || !res.data.items) return []
return res.data.items
})
.catch((error) => {
Logger.error('[GoogleBooks] Volume search error', error)
return []
})
return items.map((item) => this.cleanResult(item))
}
}
module.exports = GoogleBooks
module.exports = GoogleBooks

View file

@ -1,17 +1,31 @@
var axios = require('axios')
const axios = require('axios').default
class OpenLibrary {
#responseTimeout = 30000
constructor() {
this.baseUrl = 'https://openlibrary.org'
}
get(uri) {
return axios.get(`${this.baseUrl}/${uri}`).then((res) => {
return res.data
}).catch((error) => {
console.error('Failed', error)
return false
})
/**
*
* @param {string} uri
* @param {number} timeout
* @returns {Promise<Object>}
*/
get(uri, timeout = this.#responseTimeout) {
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
return axios
.get(`${this.baseUrl}/${uri}`, {
timeout
})
.then((res) => {
return res.data
})
.catch((error) => {
console.error('Failed', error)
return null
})
}
async isbnLookup(isbn) {
@ -33,7 +47,7 @@ class OpenLibrary {
}
}
if (!worksData.covers) worksData.covers = []
var coverImages = worksData.covers.filter(c => c > 0).map(c => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)
var coverImages = worksData.covers.filter((c) => c > 0).map((c) => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)
var description = null
if (worksData.description) {
if (typeof worksData.description === 'string') {
@ -73,27 +87,35 @@ class OpenLibrary {
}
async search(query) {
var queryString = Object.keys(query).map(key => key + '=' + query[key]).join('&')
var queryString = Object.keys(query)
.map((key) => key + '=' + query[key])
.join('&')
var lookupData = await this.get(`/search.json?${queryString}`)
if (!lookupData) {
return {
errorCode: 404
}
}
var searchDocs = await Promise.all(lookupData.docs.map(d => this.cleanSearchDoc(d)))
var searchDocs = await Promise.all(lookupData.docs.map((d) => this.cleanSearchDoc(d)))
return searchDocs
}
async searchTitle(title) {
title = encodeURIComponent(title);
var lookupData = await this.get(`/search.json?title=${title}`)
/**
*
* @param {string} title
* @param {number} timeout
* @returns {Promise<Object[]>}
*/
async searchTitle(title, timeout = this.#responseTimeout) {
title = encodeURIComponent(title)
var lookupData = await this.get(`/search.json?title=${title}`, timeout)
if (!lookupData) {
return {
errorCode: 404
}
}
var searchDocs = await Promise.all(lookupData.docs.map(d => this.cleanSearchDoc(d)))
var searchDocs = await Promise.all(lookupData.docs.map((d) => this.cleanSearchDoc(d)))
return searchDocs
}
}
module.exports = OpenLibrary
module.exports = OpenLibrary

View file

@ -28,19 +28,24 @@ const htmlSanitizer = require('../utils/htmlSanitizer')
*/
class iTunes {
constructor() { }
#responseTimeout = 30000
constructor() {}
/**
* @see https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/Searching.html
*
* @param {iTunesSearchParams} options
*
* @param {iTunesSearchParams} options
* @param {number} [timeout] response timeout in ms
* @returns {Promise<Object[]>}
*/
search(options) {
search(options, timeout = this.#responseTimeout) {
if (!options.term) {
Logger.error('[iTunes] Invalid search options - no term')
return []
}
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
const query = {
term: options.term,
media: options.media,
@ -49,12 +54,18 @@ class iTunes {
limit: options.limit,
country: options.country
}
return axios.get('https://itunes.apple.com/search', { params: query }).then((response) => {
return response.data.results || []
}).catch((error) => {
Logger.error(`[iTunes] search request error`, error)
return []
})
return axios
.get('https://itunes.apple.com/search', {
params: query,
timeout
})
.then((response) => {
return response.data.results || []
})
.catch((error) => {
Logger.error(`[iTunes] search request error`, error)
return []
})
}
// Example cover art: https://is1-ssl.mzstatic.com/image/thumb/Music118/v4/cb/ea/73/cbea739b-ff3b-11c4-fb93-7889fbec7390/9781598874983_cover.jpg/100x100bb.jpg
@ -65,20 +76,22 @@ class iTunes {
return data.artworkUrl600
}
// Should already be sorted from small to large
var artworkSizes = Object.keys(data).filter(key => key.startsWith('artworkUrl')).map(key => {
return {
url: data[key],
size: Number(key.replace('artworkUrl', ''))
}
})
var artworkSizes = Object.keys(data)
.filter((key) => key.startsWith('artworkUrl'))
.map((key) => {
return {
url: data[key],
size: Number(key.replace('artworkUrl', ''))
}
})
if (!artworkSizes.length) return null
// Return next biggest size > 600
var nextBestSize = artworkSizes.find(size => size.size > 600)
var nextBestSize = artworkSizes.find((size) => size.size > 600)
if (nextBestSize) return nextBestSize.url
// Find square artwork
var squareArtwork = artworkSizes.find(size => size.url.includes(`${size.size}x${size.size}bb`))
var squareArtwork = artworkSizes.find((size) => size.url.includes(`${size.size}x${size.size}bb`))
// Square cover replace with 600x600bb
if (squareArtwork) {
@ -106,15 +119,21 @@ class iTunes {
}
}
searchAudiobooks(term) {
return this.search({ term, entity: 'audiobook', media: 'audiobook' }).then((results) => {
/**
*
* @param {string} term
* @param {number} [timeout] response timeout in ms
* @returns {Promise<Object[]>}
*/
searchAudiobooks(term, timeout = this.#responseTimeout) {
return this.search({ term, entity: 'audiobook', media: 'audiobook' }, timeout).then((results) => {
return results.map(this.cleanAudiobook.bind(this))
})
}
/**
*
* @param {Object} data
*
* @param {Object} data
* @returns {iTunesPodcastSearchResult}
*/
cleanPodcast(data) {
@ -136,13 +155,14 @@ class iTunes {
}
/**
*
* @param {string} term
* @param {{country:string}} options
*
* @param {string} term
* @param {{country:string}} options
* @param {number} [timeout] response timeout in ms
* @returns {Promise<iTunesPodcastSearchResult[]>}
*/
searchPodcasts(term, options = {}) {
return this.search({ term, entity: 'podcast', media: 'podcast', ...options }).then((results) => {
searchPodcasts(term, options = {}, timeout = this.#responseTimeout) {
return this.search({ term, entity: 'podcast', media: 'podcast', ...options }, timeout).then((results) => {
return results.map(this.cleanPodcast.bind(this))
})
}

View file

@ -166,6 +166,7 @@ class ApiRouter {
//
this.router.get('/me', MeController.getCurrentUser.bind(this))
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
this.router.get('/me/progress/:id/remove-from-continue-listening', MeController.removeItemFromContinueListening.bind(this))
this.router.get('/me/progress/:id/:episodeId?', MeController.getMediaProgress.bind(this))
@ -477,6 +478,11 @@ class ApiRouter {
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
}
async getUserItemListeningSessionsHelper(userId, mediaItemId) {
const userSessions = await Database.getPlaybackSessions({ userId, mediaItemId })
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
}
async getUserListeningStatsHelpers(userId) {
const today = date.format(new Date(), 'YYYY-MM-DD')

View file

@ -8,11 +8,11 @@ const LibraryItem = require('../models/LibraryItem')
const AudioFile = require('../objects/files/AudioFile')
class AudioFileScanner {
constructor() { }
constructor() {}
/**
* Is array of numbers sequential, i.e. 1, 2, 3, 4
* @param {number[]} nums
* @param {number[]} nums
* @returns {boolean}
*/
isSequential(nums) {
@ -27,8 +27,8 @@ class AudioFileScanner {
}
/**
* Remove
* @param {number[]} nums
* Remove
* @param {number[]} nums
* @returns {number[]}
*/
removeDupes(nums) {
@ -44,8 +44,8 @@ class AudioFileScanner {
/**
* Order audio files by track/disc number
* @param {string} libraryItemRelPath
* @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {string} libraryItemRelPath
* @param {import('../models/Book').AudioFileObject[]} audioFiles
* @returns {import('../models/Book').AudioFileObject[]}
*/
runSmartTrackOrder(libraryItemRelPath, audioFiles) {
@ -103,8 +103,8 @@ class AudioFileScanner {
/**
* Get track and disc number from audio filename
* @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan
* @param {LibraryItem.LibraryFileObject} audioLibraryFile
* @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan
* @param {LibraryItem.LibraryFileObject} audioLibraryFile
* @returns {{trackNumber:number, discNumber:number}}
*/
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
@ -146,10 +146,10 @@ class AudioFileScanner {
}
/**
*
* @param {string} mediaType
* @param {LibraryItem.LibraryFileObject} libraryFile
* @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan
*
* @param {string} mediaType
* @param {LibraryItem.LibraryFileObject} libraryFile
* @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan
* @returns {Promise<AudioFile>}
*/
async scan(mediaType, libraryFile, mediaMetadataFromScan) {
@ -181,7 +181,7 @@ class AudioFileScanner {
/**
* Scan LibraryFiles and return AudioFiles
* @param {string} mediaType
* @param {import('./LibraryItemScanData')} libraryItemScanData
* @param {import('./LibraryItemScanData')} libraryItemScanData
* @param {LibraryItem.LibraryFileObject[]} audioLibraryFiles
* @returns {Promise<AudioFile[]>}
*/
@ -193,15 +193,15 @@ class AudioFileScanner {
for (let i = batch; i < Math.min(batch + batchSize, audioLibraryFiles.length); i++) {
proms.push(this.scan(mediaType, audioLibraryFiles[i], libraryItemScanData.mediaMetadata))
}
results.push(...await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr)))
results.push(...(await Promise.all(proms).then((scanResults) => scanResults.filter((sr) => sr))))
}
return results
}
/**
*
* @param {AudioFile} audioFile
*
* @param {AudioFile} audioFile
* @returns {object}
*/
probeAudioFile(audioFile) {
@ -211,10 +211,10 @@ class AudioFileScanner {
/**
* Set book metadata & chapters from audio file meta tags
*
*
* @param {string} bookTitle
* @param {import('../models/Book').AudioFileObject} audioFile
* @param {Object} bookMetadata
* @param {import('../models/Book').AudioFileObject} audioFile
* @param {Object} bookMetadata
* @param {import('./LibraryScan')} libraryScan
*/
setBookMetadataFromAudioMetaTags(bookTitle, audioFiles, bookMetadata, libraryScan) {
@ -243,7 +243,7 @@ class AudioFileScanner {
{
tag: 'tagAlbum',
altTag: 'tagTitle',
key: 'title',
key: 'title'
},
{
tag: 'tagArtist',
@ -311,9 +311,9 @@ class AudioFileScanner {
/**
* Set podcast metadata from first audio file
*
* @param {import('../models/Book').AudioFileObject} audioFile
* @param {Object} podcastMetadata
*
* @param {import('../models/Book').AudioFileObject} audioFile
* @param {Object} podcastMetadata
* @param {import('./LibraryScan')} libraryScan
*/
setPodcastMetadataFromAudioMetaTags(audioFile, podcastMetadata, libraryScan) {
@ -343,7 +343,7 @@ class AudioFileScanner {
},
{
tag: 'tagPodcastType',
key: 'podcastType',
key: 'podcastType'
}
]
@ -370,7 +370,7 @@ class AudioFileScanner {
}
/**
*
*
* @param {import('../models/PodcastEpisode')} podcastEpisode Not the model when creating new podcast
* @param {import('./ScanLogger')} scanLogger
*/
@ -391,7 +391,7 @@ class AudioFileScanner {
},
{
tag: 'tagDisc',
key: 'season',
key: 'season'
},
{
tag: 'tagTrack',
@ -446,7 +446,7 @@ class AudioFileScanner {
/**
* @param {string} bookTitle
* @param {AudioFile[]} audioFiles
* @param {AudioFile[]} audioFiles
* @param {import('./LibraryScan')} libraryScan
* @returns {import('../models/Book').ChapterObject[]}
*/
@ -464,12 +464,7 @@ class AudioFileScanner {
// If first audio file has embedded chapters then use embedded chapters
if (audioFiles[0].chapters?.length) {
// If all files chapters are the same, then only make chapters for the first file
if (
audioFiles.length === 1 ||
audioFiles.length > 1 &&
audioFiles[0].chapters.length === audioFiles[1].chapters?.length &&
audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title && c.start === audioFiles[1].chapters[i].start)
) {
if (audioFiles.length === 1 || (audioFiles.length > 1 && audioFiles[0].chapters.length === audioFiles[1].chapters?.length && audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title && c.start === audioFiles[1].chapters[i].start))) {
libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters in first audio file ${audioFiles[0].metadata?.path}`)
chapters = audioFiles[0].chapters.map((c) => ({ ...c }))
} else {
@ -479,12 +474,13 @@ class AudioFileScanner {
audioFiles.forEach((file) => {
if (file.duration) {
const afChapters = file.chapters?.map((c) => ({
...c,
id: c.id + currChapterId,
start: c.start + currStartTime,
end: c.end + currStartTime,
})) ?? []
const afChapters =
file.chapters?.map((c) => ({
...c,
id: c.id + currChapterId,
start: c.start + currStartTime,
end: c.end + currStartTime
})) ?? []
chapters = chapters.concat(afChapters)
currChapterId += file.chapters?.length ?? 0
@ -494,12 +490,11 @@ class AudioFileScanner {
return chapters
}
} else if (audioFiles.length > 1) {
// In some cases the ID3 title tag for each file is the chapter title, the criteria to determine if this will be used
// 1. Every audio file has an ID3 title tag set
// 2. None of the title tags are the same as the book title
// 3. Every ID3 title tag is unique
const metaTagTitlesFound = [...new Set(audioFiles.map(af => af.metaTags?.tagTitle).filter(tagTitle => !!tagTitle && tagTitle !== bookTitle))]
const metaTagTitlesFound = [...new Set(audioFiles.map((af) => af.metaTags?.tagTitle).filter((tagTitle) => !!tagTitle && tagTitle !== bookTitle))]
const useMetaTagAsTitle = metaTagTitlesFound.length === audioFiles.length
// Build chapters from audio files
@ -528,8 +523,8 @@ class AudioFileScanner {
/**
* Parse a genre string into multiple genres
* @example "Fantasy;Sci-Fi;History" => ["Fantasy", "Sci-Fi", "History"]
*
* @param {string} genreTag
*
* @param {string} genreTag
* @returns {string[]}
*/
parseGenresString(genreTag) {
@ -537,10 +532,13 @@ class AudioFileScanner {
const separators = ['/', '//', ';']
for (let i = 0; i < separators.length; i++) {
if (genreTag.includes(separators[i])) {
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
return genreTag
.split(separators[i])
.map((genre) => genre.trim())
.filter((g) => !!g)
}
}
return [genreTag]
}
}
module.exports = new AudioFileScanner()
module.exports = new AudioFileScanner()

View file

@ -20,7 +20,7 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
var tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
var tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
if (tagDuration && tagBytes && !isNaN(tagDuration) && !isNaN(tagBytes)) {
var bps = Math.floor(Number(tagBytes) * 8 / Number(tagDuration))
var bps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))
if (bps && !isNaN(bps)) {
return bps
}
@ -33,7 +33,7 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
estimated_bit_rate -= Number(stream.bit_rate)
}
})
if (!all_streams.find(s => s.codec_type === 'audio' && s.bit_rate && Number(s.bit_rate) > estimated_bit_rate)) {
if (!all_streams.find((s) => s.codec_type === 'audio' && s.bit_rate && Number(s.bit_rate) > estimated_bit_rate)) {
return estimated_bit_rate
} else {
return total_bit_rate
@ -73,7 +73,7 @@ function tryGrabChannelLayout(stream) {
function tryGrabTags(stream, ...tags) {
if (!stream.tags) return null
for (let i = 0; i < tags.length; i++) {
const tagKey = Object.keys(stream.tags).find(t => t.toLowerCase() === tags[i].toLowerCase())
const tagKey = Object.keys(stream.tags).find((t) => t.toLowerCase() === tags[i].toLowerCase())
const value = stream.tags[tagKey]
if (value && value.trim()) return value.trim()
}
@ -101,7 +101,7 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
if (info.type === 'video') {
info.profile = stream.profile || null
info.is_avc = (stream.is_avc !== '0' && stream.is_avc !== 'false')
info.is_avc = stream.is_avc !== '0' && stream.is_avc !== 'false'
info.pix_fmt = stream.pix_fmt || null
info.frame_rate = tryGrabFrameRate(stream)
info.width = !isNaN(stream.width) ? Number(stream.width) : null
@ -123,7 +123,6 @@ function isNullOrNaN(val) {
return val === null || isNaN(val)
}
/* Example chapter object
* {
"id": 71,
@ -137,23 +136,28 @@ function isNullOrNaN(val) {
}
* }
*/
function parseChapters(chapters) {
if (!chapters) return []
let index = 0
return chapters.map(chap => {
let title = chap['TAG:title'] || chap.title || ''
if (!title && chap.tags?.title) title = chap.tags.title
function parseChapters(_chapters) {
if (!_chapters) return []
const timebase = chap.time_base?.includes('/') ? Number(chap.time_base.split('/')[1]) : 1
const start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0
const end = !isNullOrNaN(chap.end_time) ? Number(chap.end_time) : !isNullOrNaN(chap.end) ? Number(chap.end) / timebase : 0
return {
id: index++,
start,
end,
title
}
})
return _chapters
.map((chap) => {
let title = chap['TAG:title'] || chap.title || ''
if (!title && chap.tags?.title) title = chap.tags.title
const timebase = chap.time_base?.includes('/') ? Number(chap.time_base.split('/')[1]) : 1
const start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0
const end = !isNullOrNaN(chap.end_time) ? Number(chap.end_time) : !isNullOrNaN(chap.end) ? Number(chap.end) / timebase : 0
return {
start,
end,
title
}
})
.sort((a, b) => a.start - b.start)
.map((chap, index) => {
chap.id = index
return chap
})
}
function parseTags(format, verbose) {
@ -210,7 +214,7 @@ function parseTags(format, verbose) {
file_tag_movement: tryGrabTags(format, 'movement', 'mvin'),
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'),
file_tag_overdrive_media_marker: tryGrabTags(format, 'OverDrive MediaMarkers'),
file_tag_overdrive_media_marker: tryGrabTags(format, 'OverDrive MediaMarkers')
}
for (const key in tags) {
if (!tags[key]) {
@ -224,7 +228,7 @@ function parseTags(format, verbose) {
function getDefaultAudioStream(audioStreams) {
if (!audioStreams || !audioStreams.length) return null
if (audioStreams.length === 1) return audioStreams[0]
var defaultStream = audioStreams.find(a => a.is_default)
var defaultStream = audioStreams.find((a) => a.is_default)
if (!defaultStream) return audioStreams[0]
return defaultStream
}
@ -248,9 +252,9 @@ function parseProbeData(data, verbose = false) {
cleanedData.rawTags = format.tags
}
const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))
cleanedData.video_stream = cleaned_streams.find(s => s.type === 'video')
const audioStreams = cleaned_streams.filter(s => s.type === 'audio')
const cleaned_streams = streams.map((s) => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))
cleanedData.video_stream = cleaned_streams.find((s) => s.type === 'video')
const audioStreams = cleaned_streams.filter((s) => s.type === 'audio')
cleanedData.audio_stream = getDefaultAudioStream(audioStreams)
if (cleanedData.audio_stream && cleanedData.video_stream) {
@ -280,8 +284,8 @@ function parseProbeData(data, verbose = false) {
/**
* Run ffprobe on audio filepath
* @param {string} filepath
* @param {boolean} [verbose=false]
* @param {string} filepath
* @param {boolean} [verbose=false]
* @returns {import('../scanner/MediaProbeData')|{error:string}}
*/
function probe(filepath, verbose = false) {
@ -290,7 +294,7 @@ function probe(filepath, verbose = false) {
}
return ffprobe(filepath)
.then(raw => {
.then((raw) => {
if (raw.error) {
return {
error: raw.error.string
@ -318,7 +322,7 @@ module.exports.probe = probe
/**
* Ffprobe for audio file path
*
*
* @param {string} filepath
* @returns {Object} ffprobe json output
*/
@ -327,11 +331,10 @@ function rawProbe(filepath) {
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
}
return ffprobe(filepath)
.catch((err) => {
return {
error: err
}
})
return ffprobe(filepath).catch((err) => {
return {
error: err
}
})
}
module.exports.rawProbe = rawProbe
module.exports.rawProbe = rawProbe