mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-02 00:40:39 +02:00
Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fea28351f9 | |||
| bb124d3274 | |||
| 6cd1b82ada | |||
| c701617fbb | |||
| 5d84c426fe | |||
| 083ba2fe19 | |||
| 1024bc5a75 | |||
| 9553c19b33 | |||
| 2cbc9a07cb | |||
| ab97a9d613 | |||
| f1a7fd0d50 | |||
| e9d7efbc5c | |||
| 6e5d334874 | |||
| 6822628994 | |||
| 98d9fd8c32 | |||
| e2cca60853 | |||
| e80b313a7b | |||
| b09b95ef24 | |||
| aec45d04f7 | |||
| 87d037cb0a | |||
| f6baf06164 | |||
| 7e75845851 | |||
| 2a11932822 | |||
| 80fee92037 | |||
| d0c02a801a | |||
| 9e13c64408 | |||
| 826963bf00 | |||
| 39b6ede1e9 | |||
| 066d853156 | |||
| efae529fac | |||
| 934c0b9093 | |||
| f02992dd4d | |||
| 10011bd6a3 | |||
| a44ee913c4 | |||
| adccccbd7a | |||
| 05b1b2be36 | |||
| 7cc35a2cbe | |||
| 8d479b6e34 | |||
| 74d300f048 | |||
| 1dd1fe8994 | |||
| 03115e5e53 | |||
| b1c07834be | |||
| b9da3fa30e | |||
| 42ff3d8314 | |||
| e63aab95d8 | |||
| 9123dcb365 | |||
| 7567e91878 | |||
| 1b1bdea3c8 | |||
| 2df95c1712 | |||
| 4ad1cd2968 | |||
| 0ecfdab463 | |||
| 75276f5a44 | |||
| 4585d2816b | |||
| f8f94f2a6d | |||
| 2c8448d147 | |||
| ea1d051cfb | |||
| a38e43213d | |||
| 6cac8fcd6e | |||
| 8e65c78869 | |||
| a3899b68e1 | |||
| 1187f91063 | |||
| 7c288a5ff9 | |||
| e0dae44c7d | |||
| 754498958d | |||
| ec15978e26 | |||
| 469167df66 | |||
| e7c43a3f32 | |||
| 24989e73ae | |||
| 13427b9f70 | |||
| adafefecd4 | |||
| 6f96b069b5 | |||
| 6c1b4e3a36 | |||
| 21343ffbd1 | |||
| 4f94deefa0 | |||
| 332078e6c1 | |||
| ff0d6326d3 | |||
| 8d451217a3 | |||
| f21d69339f | |||
| c77cead9ae | |||
| b334d40998 | |||
| 4e4a976050 | |||
| 9d7d4c6902 | |||
| 7222171c5b | |||
| 361732a463 | |||
| 1ebe8a6f4c | |||
| a98942a361 | |||
| 0bc89cd40f | |||
| 2ae86ab5bb | |||
| c707bcf0f6 | |||
| 10040ba9fa | |||
| 7afda1295b | |||
| 6d6e8613cf | |||
| 3651fffbee | |||
| 8d03b23f46 | |||
| fc44c801f2 | |||
| 6056c14926 | |||
| f465193b9c | |||
| 09c9c28028 | |||
| f1130eb63a | |||
| db80cec168 | |||
| 38029d1202 | |||
| aac2879652 | |||
| 8c9fc3ddb5 | |||
| 33e04d0cbb | |||
| fbb5fd41fb | |||
| 43a5296dd7 | |||
| 345ff1aa66 | |||
| 56e3449db6 | |||
| 1372c24535 | |||
| 409c5f7b75 | |||
| 83d0db0607 | |||
| 91b6c4412d | |||
| 09eefae808 | |||
| 80b3bfea51 | |||
| 516298b5b2 | |||
| 8edab98163 | |||
| 58da095bcf | |||
| b9633691f4 | |||
| 7ec1d8ee5f | |||
| 83a1374e79 | |||
| 5ef00bac92 | |||
| 95c4b3862b | |||
| eeaf012cdc | |||
| 11120a3765 | |||
| 4d0acb30ba | |||
| 4dbe8d29d9 | |||
| 0ca4ff4fca | |||
| 8be1651c6b | |||
| af2db86d1a | |||
| 57c834f88d | |||
| 65fdebde20 | |||
| b58e42ebf3 | |||
| b2d45f598b | |||
| 09c4e690c6 | |||
| 67ba481dca | |||
| 710a62c2af | |||
| 5a9eed0a5a | |||
| 354e16e462 | |||
| 1d974375a0 | |||
| 1c40af3eef | |||
| daa8c4cd67 | |||
| d5da4441cd | |||
| 80aea0c82d | |||
| 14836eeb0d | |||
| 85e9883d3e | |||
| 80ca73e491 | |||
| 22323f606d | |||
| 01b65eb678 | |||
| d1d94c37a7 | |||
| 838a24c8a5 | |||
| 3f380b0839 | |||
| 7fdf1a1d7f | |||
| c2793fe29b | |||
| 38596d017f | |||
| 24b9ac6a68 | |||
| 9a5ed64fae | |||
| c2af96e7cd | |||
| 104cadb0b3 | |||
| 6814adffcc | |||
| 20c11e381e | |||
| b5952f16eb | |||
| 5b6878e5de | |||
| 89a25bcf39 | |||
| d0cd512be8 | |||
| 3543dea0fb | |||
| 1949e25ccb | |||
| b715ef3bfc | |||
| 954050df81 | |||
| e4aa7f10fa | |||
| a31df5ff81 |
@@ -15,3 +15,4 @@ test/
|
|||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
|
.idea/*
|
||||||
|
|||||||
+2
-5
@@ -10,6 +10,7 @@ FROM sandreas/tone:v0.1.5 AS tone
|
|||||||
FROM node:16-alpine
|
FROM node:16-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add --no-cache --update \
|
apk add --no-cache --update \
|
||||||
curl \
|
curl \
|
||||||
@@ -29,9 +30,5 @@ RUN npm ci --only=production
|
|||||||
RUN apk del make python3 g++
|
RUN apk del make python3 g++
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
HEALTHCHECK \
|
|
||||||
--interval=30s \
|
|
||||||
--timeout=3s \
|
|
||||||
--start-period=10s \
|
|
||||||
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ install_ffmpeg() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
$WGET
|
$WGET
|
||||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
|
||||||
rm ffmpeg-git-amd64-static.tar.xz
|
rm ffmpeg-git-amd64-static.tar.xz
|
||||||
|
|
||||||
# Temp downloading tone library to the ffmpeg dir
|
# Temp downloading tone library to the ffmpeg dir
|
||||||
echo "Getting tone.."
|
echo "Getting tone.."
|
||||||
$WGET_TONE
|
$WGET_TONE
|
||||||
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
|
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 --no-same-owner
|
||||||
rm tone-0.1.5-linux-x64.tar.gz
|
rm tone-0.1.5-linux-x64.tar.gz
|
||||||
|
|
||||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
currentLibraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
libraryName() {
|
libraryName() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
},
|
},
|
||||||
@@ -346,8 +349,6 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
episodeAdded(episodeWithLibraryItem) {
|
episodeAdded(episodeWithLibraryItem) {
|
||||||
console.log('Podcast episode added', episodeWithLibraryItem)
|
|
||||||
|
|
||||||
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
||||||
if (!this.search && isThisLibrary) {
|
if (!this.search && isThisLibrary) {
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ export default {
|
|||||||
id: 'config-item-metadata-utils',
|
id: 'config-item-metadata-utils',
|
||||||
title: this.$strings.HeaderItemMetadataUtils,
|
title: this.$strings.HeaderItemMetadataUtils,
|
||||||
path: '/config/item-metadata-utils'
|
path: '/config/item-metadata-utils'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-rss-feeds',
|
||||||
|
title: this.$strings.HeaderRSSFeeds,
|
||||||
|
path: '/config/rss-feeds'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ export default {
|
|||||||
this.currentSFQueryString = this.buildSearchParams()
|
this.currentSFQueryString = this.buildSearchParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
|
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
|
||||||
|
|
||||||
@@ -623,6 +623,11 @@ export default {
|
|||||||
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
||||||
},
|
},
|
||||||
async init(bookshelf) {
|
async init(bookshelf) {
|
||||||
|
if (this.entityName === 'series') {
|
||||||
|
this.booksPerFetch = 50
|
||||||
|
} else {
|
||||||
|
this.booksPerFetch = 100
|
||||||
|
}
|
||||||
this.checkUpdateSearchParams()
|
this.checkUpdateSearchParams()
|
||||||
this.initSizeData(bookshelf)
|
this.initSizeData(bookshelf)
|
||||||
|
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export default {
|
|||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
seriesSequence() {
|
seriesSequence() {
|
||||||
return this.series ? this.series.sequence : null
|
return this.series?.sequence || null
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this._libraryItem.libraryId
|
return this._libraryItem.libraryId
|
||||||
@@ -318,6 +318,7 @@ export default {
|
|||||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||||
|
if (this.orderBy === 'media.metadata.publishedYear' && this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
episodeProgress() {
|
episodeProgress() {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default {
|
|||||||
return this.narrator?.name || ''
|
return this.narrator?.name || ''
|
||||||
},
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this.narrator?.books?.length || 0
|
return this.narrator?.numBooks || this.narrator?.books?.length || 0
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
totalResults() {
|
totalResults() {
|
||||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -348,6 +348,10 @@ export default {
|
|||||||
},
|
},
|
||||||
tracks() {
|
tracks() {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
id: 'none',
|
||||||
|
name: this.$strings.LabelTracksNone
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'single',
|
id: 'single',
|
||||||
name: this.$strings.LabelTracksSingleTrack
|
name: this.$strings.LabelTracksSingleTrack
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
|
|
||||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||||
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
<img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
|
||||||
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
<p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -58,6 +58,9 @@ export default {
|
|||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.width / 120
|
return this.width / 120
|
||||||
},
|
},
|
||||||
|
invalidCoverFontSize() {
|
||||||
|
return Math.max(this.sizeMultiplier * 0.8, 0.5)
|
||||||
|
},
|
||||||
placeholderCoverPadding() {
|
placeholderCoverPadding() {
|
||||||
return 0.8 * this.sizeMultiplier
|
return 0.8 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<div class="w-full p-8">
|
<div class="w-full p-8">
|
||||||
<div class="flex py-2">
|
<div class="flex py-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
@@ -103,7 +103,6 @@
|
|||||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
|
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -353,7 +352,8 @@ export default {
|
|||||||
accessAllTags: true,
|
accessAllTags: true,
|
||||||
selectedTagsNotAccessible: false
|
selectedTagsNotAccessible: false
|
||||||
},
|
},
|
||||||
librariesAccessible: []
|
librariesAccessible: [],
|
||||||
|
itemTagsSelected: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,9 +283,8 @@ export default {
|
|||||||
}
|
}
|
||||||
if (success) {
|
if (success) {
|
||||||
this.$toast.success('Update Successful')
|
this.$toast.success('Update Successful')
|
||||||
// this.$emit('close')
|
} else if (this.media.coverPath) {
|
||||||
} else {
|
this.imageUrl = this.media.coverPath
|
||||||
this.imageUrl = this.media.coverPath || ''
|
|
||||||
}
|
}
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,18 +20,14 @@
|
|||||||
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
|
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
|
||||||
<table v-else class="text-sm tracksTable">
|
<table v-else class="text-sm tracksTable">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-left">Sort #</th>
|
<th class="text-center w-20 min-w-20">{{ $strings.LabelEpisode }}</th>
|
||||||
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
|
<th class="text-left">{{ $strings.LabelEpisodeTitle }}</th>
|
||||||
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
|
<th class="text-center w-28">{{ $strings.LabelEpisodeDuration }}</th>
|
||||||
<th class="text-center w-28">{{ $strings.EpisodeDuration }}</th>
|
<th class="text-center w-28">{{ $strings.LabelEpisodeSize }}</th>
|
||||||
<th class="text-center w-28">{{ $strings.EpisodeSize }}</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="episode in episodes" :key="episode.id">
|
<tr v-for="episode in episodes" :key="episode.id">
|
||||||
<td class="text-left">
|
<td class="text-center w-20 min-w-20">
|
||||||
<p class="px-4">{{ episode.index }}</p>
|
<p>{{ episode.episode }}</p>
|
||||||
</td>
|
|
||||||
<td class="text-left">
|
|
||||||
<p class="px-4">{{ episode.episode }}</p>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ episode.title }}
|
{{ episode.title }}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
|
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" @input="formUpdated" />
|
||||||
<ui-toggle-switch v-else disabled :value="false" />
|
<ui-toggle-switch v-else disabled :value="false" />
|
||||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsDisableWatcherForLibrary }}</p>
|
<p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +65,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
provider: null,
|
provider: null,
|
||||||
useSquareBookCovers: false,
|
useSquareBookCovers: false,
|
||||||
disableWatcher: false,
|
enableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false,
|
||||||
audiobooksOnly: false,
|
audiobooksOnly: false,
|
||||||
@@ -95,7 +95,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
||||||
disableWatcher: !!this.disableWatcher,
|
disableWatcher: !this.enableWatcher,
|
||||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
||||||
audiobooksOnly: !!this.audiobooksOnly,
|
audiobooksOnly: !!this.audiobooksOnly,
|
||||||
@@ -108,7 +108,7 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||||
this.disableWatcher = !!this.librarySettings.disableWatcher
|
this.enableWatcher = !this.librarySettings.disableWatcher
|
||||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
serverAddress: window.origin,
|
serverAddress: window.origin,
|
||||||
slug: this.newFeedSlug,
|
slug: this.newFeedSlug,
|
||||||
@@ -151,6 +153,9 @@ export default {
|
|||||||
const errorMsg = error.response ? error.response.data : null
|
const errorMsg = error.response ? error.response.data : null
|
||||||
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
|
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
copyToClipboard(str) {
|
copyToClipboard(str) {
|
||||||
this.$copyToClipboard(str, this)
|
this.$copyToClipboard(str, this)
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="rss-feed-view-modal" :processing="processing" :width="700" :height="'unset'">
|
||||||
|
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div v-if="feed" class="w-full">
|
||||||
|
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||||
|
|
||||||
|
<div class="w-full relative">
|
||||||
|
<ui-text-input v-model="feed.feedUrl" readonly />
|
||||||
|
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="feed.meta" class="mt-5">
|
||||||
|
<div class="flex py-0.5">
|
||||||
|
<div class="w-48">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
|
||||||
|
</div>
|
||||||
|
<div>{{ feed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="feed.meta.ownerName" class="flex py-0.5">
|
||||||
|
<div class="w-48">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
|
||||||
|
</div>
|
||||||
|
<div>{{ feed.meta.ownerName }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="feed.meta.ownerEmail" class="flex py-0.5">
|
||||||
|
<div class="w-48">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
|
||||||
|
</div>
|
||||||
|
<div>{{ feed.meta.ownerEmail }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- -->
|
||||||
|
<div class="episodesTable mt-2">
|
||||||
|
<div class="bg-primary bg-opacity-40 h-12 header">
|
||||||
|
{{ $strings.LabelEpisodeTitle }}
|
||||||
|
</div>
|
||||||
|
<div class="scroller">
|
||||||
|
<div v-for="episode in feed.episodes" :key="episode.id" class="h-8 text-xs truncate">
|
||||||
|
{{ episode.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
feed: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_feed() {
|
||||||
|
return this.feed || {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
copyToClipboard(str) {
|
||||||
|
this.$copyToClipboard(str, this)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.episodesTable {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodesTable div.header {
|
||||||
|
background-color: #272727;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodesTable .scroller {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodesTable .scroller div {
|
||||||
|
background-color: #373838;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodesTable .scroller div:nth-child(even) {
|
||||||
|
background-color: #2f2f2f;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -303,8 +303,8 @@ export default {
|
|||||||
},
|
},
|
||||||
parseImageFilename(filename) {
|
parseImageFilename(filename) {
|
||||||
var basename = Path.basename(filename, Path.extname(filename))
|
var basename = Path.basename(filename, Path.extname(filename))
|
||||||
var numbersinpath = basename.match(/\d{1,5}/g)
|
var numbersinpath = basename.match(/\d+/g)
|
||||||
if (!numbersinpath || !numbersinpath.length) {
|
if (!numbersinpath?.length) {
|
||||||
return {
|
return {
|
||||||
index: -1,
|
index: -1,
|
||||||
filename
|
filename
|
||||||
|
|||||||
@@ -133,12 +133,15 @@ export default {
|
|||||||
this.rendition.spread(settings.spread || 'auto')
|
this.rendition.spread(settings.spread || 'auto')
|
||||||
},
|
},
|
||||||
prev() {
|
prev() {
|
||||||
|
if (!this.rendition?.manager) return
|
||||||
return this.rendition?.prev()
|
return this.rendition?.prev()
|
||||||
},
|
},
|
||||||
next() {
|
next() {
|
||||||
|
if (!this.rendition?.manager) return
|
||||||
return this.rendition?.next()
|
return this.rendition?.next()
|
||||||
},
|
},
|
||||||
goToChapter(href) {
|
goToChapter(href) {
|
||||||
|
if (!this.rendition?.manager) return
|
||||||
return this.rendition?.display(href)
|
return this.rendition?.display(href)
|
||||||
},
|
},
|
||||||
keyUp(e) {
|
keyUp(e) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex px-4">
|
<div v-if="isBookLibrary" class="flex px-4">
|
||||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -58,26 +58,32 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
currentLibraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isBookLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'book'
|
||||||
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
totalItems() {
|
totalItems() {
|
||||||
return this.libraryStats ? this.libraryStats.totalItems : 0
|
return this.libraryStats?.totalItems || 0
|
||||||
},
|
},
|
||||||
totalAuthors() {
|
totalAuthors() {
|
||||||
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
return this.libraryStats?.totalAuthors || 0
|
||||||
},
|
},
|
||||||
numAudioTracks() {
|
numAudioTracks() {
|
||||||
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
return this.libraryStats?.numAudioTracks || 0
|
||||||
},
|
},
|
||||||
totalDuration() {
|
totalDuration() {
|
||||||
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
return this.libraryStats?.totalDuration || 0
|
||||||
},
|
},
|
||||||
totalHours() {
|
totalHours() {
|
||||||
return Math.round(this.totalDuration / (60 * 60))
|
return Math.round(this.totalDuration / (60 * 60))
|
||||||
},
|
},
|
||||||
totalSizePretty() {
|
totalSizePretty() {
|
||||||
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
var totalSize = this.libraryStats?.totalSize || 0
|
||||||
return this.$bytesPretty(totalSize, 1)
|
return this.$bytesPretty(totalSize, 1)
|
||||||
},
|
},
|
||||||
totalSizeNum() {
|
totalSizeNum() {
|
||||||
|
|||||||
@@ -11,10 +11,6 @@
|
|||||||
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
|
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">
|
|
||||||
*<strong>{{ $strings.ButtonForceReScan }}</strong> {{ $strings.MessageForceReScanDescription }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">
|
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">
|
||||||
**<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}
|
**<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -71,11 +71,6 @@ export default {
|
|||||||
text: this.$strings.ButtonScan,
|
text: this.$strings.ButtonScan,
|
||||||
action: 'scan',
|
action: 'scan',
|
||||||
value: 'scan'
|
value: 'scan'
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.ButtonForceReScan,
|
|
||||||
action: 'force-scan',
|
|
||||||
value: 'force-scan'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
if (this.isBookLibrary) {
|
if (this.isBookLibrary) {
|
||||||
@@ -137,26 +132,6 @@ export default {
|
|||||||
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
forceScan() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$strings.MessageConfirmForceReScan,
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.$store
|
|
||||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success(this.$strings.ToastLibraryScanStarted)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to start scan', error)
|
|
||||||
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
deleteClick() {
|
deleteClick() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),
|
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
endpoint: String,
|
filterKey: String,
|
||||||
label: String,
|
label: String,
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
readonly: Boolean,
|
readonly: Boolean,
|
||||||
@@ -60,7 +60,6 @@ export default {
|
|||||||
return {
|
return {
|
||||||
textInput: null,
|
textInput: null,
|
||||||
currentSearch: null,
|
currentSearch: null,
|
||||||
searching: false,
|
|
||||||
typingTimeout: null,
|
typingTimeout: null,
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
menu: null,
|
menu: null,
|
||||||
@@ -97,6 +96,9 @@ export default {
|
|||||||
},
|
},
|
||||||
itemsToShow() {
|
itemsToShow() {
|
||||||
return this.items
|
return this.items
|
||||||
|
},
|
||||||
|
filterData() {
|
||||||
|
return this.$store.state.libraries.filterData || {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -109,20 +111,15 @@ export default {
|
|||||||
getIsSelected(itemValue) {
|
getIsSelected(itemValue) {
|
||||||
return !!this.selected.find((i) => i.id === itemValue)
|
return !!this.selected.find((i) => i.id === itemValue)
|
||||||
},
|
},
|
||||||
async search() {
|
search() {
|
||||||
if (this.searching) return
|
|
||||||
this.currentSearch = this.textInput
|
this.currentSearch = this.textInput
|
||||||
this.searching = true
|
const dataToSearch = this.filterData[this.filterKey] || []
|
||||||
const results = await this.$axios
|
|
||||||
.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`)
|
const results = dataToSearch.filter((au) => {
|
||||||
.then((res) => res.results || res)
|
return au.name.toLowerCase().includes(this.currentSearch.toLowerCase().trim())
|
||||||
.catch((error) => {
|
})
|
||||||
console.error('Failed to get search results', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
|
|
||||||
this.items = results || []
|
this.items = results || []
|
||||||
this.searching = false
|
|
||||||
},
|
},
|
||||||
keydownInput() {
|
keydownInput() {
|
||||||
clearTimeout(this.typingTimeout)
|
clearTimeout(this.typingTimeout)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap mt-2 -mx-1">
|
<div class="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-full md:w-3/4 px-1">
|
<div class="w-full md:w-3/4 px-1">
|
||||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
<!-- Authors filter only contains authors in this library, uses filter data -->
|
||||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" :label="$strings.LabelAuthors" endpoint="authors/search" />
|
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" :label="$strings.LabelAuthors" filter-key="authors" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-1 mt-2 md:mt-0">
|
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" :label="$strings.LabelPublishYear" />
|
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" :label="$strings.LabelPublishYear" />
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ export default {
|
|||||||
}
|
}
|
||||||
this.$store.commit('libraries/removeCollection', collection)
|
this.$store.commit('libraries/removeCollection', collection)
|
||||||
},
|
},
|
||||||
|
seriesRemoved({ id, libraryId }) {
|
||||||
|
if (this.currentLibraryId !== libraryId) return
|
||||||
|
this.$store.commit('libraries/removeSeriesFromFilterData', id)
|
||||||
|
},
|
||||||
playlistAdded(playlist) {
|
playlistAdded(playlist) {
|
||||||
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
|
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
|
||||||
this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
|
this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
|
||||||
@@ -442,6 +446,9 @@ export default {
|
|||||||
this.socket.on('collection_updated', this.collectionUpdated)
|
this.socket.on('collection_updated', this.collectionUpdated)
|
||||||
this.socket.on('collection_removed', this.collectionRemoved)
|
this.socket.on('collection_removed', this.collectionRemoved)
|
||||||
|
|
||||||
|
// Series Listeners
|
||||||
|
this.socket.on('series_removed', this.seriesRemoved)
|
||||||
|
|
||||||
// User Playlist Listeners
|
// User Playlist Listeners
|
||||||
this.socket.on('playlist_added', this.playlistAdded)
|
this.socket.on('playlist_added', this.playlistAdded)
|
||||||
this.socket.on('playlist_updated', this.playlistUpdated)
|
this.socket.on('playlist_updated', this.playlistUpdated)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.3.1",
|
"version": "2.4.2",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.3.1",
|
"version": "2.4.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.3.1",
|
"version": "2.4.2",
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
|
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
|
||||||
<ui-checkbox v-model="selectedBatchUsage.authors" />
|
<ui-checkbox v-model="selectedBatchUsage.authors" />
|
||||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
<!-- Authors filter only contains authors in this library, uses filter data -->
|
||||||
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" endpoint="authors/search" class="mb-4 ml-4" />
|
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" filter-key="authors" class="mb-4 ml-4" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
|
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
|
||||||
<ui-checkbox v-model="selectedBatchUsage.publishedYear" />
|
<ui-checkbox v-model="selectedBatchUsage.publishedYear" />
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export default {
|
|||||||
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
|
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
|
||||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||||
|
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||||
}
|
}
|
||||||
return this.$strings.HeaderSettings
|
return this.$strings.HeaderSettings
|
||||||
|
|||||||
@@ -36,7 +36,10 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
|
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
|
||||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="sortingPrefixesUpdated" :disabled="savingPrefixes" />
|
||||||
|
<div class="flex justify-end py-1">
|
||||||
|
<ui-btn v-if="hasPrefixesChanged" color="success" :loading="savingPrefixes" small @click="updateSortingPrefixes">Save</ui-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2 mb-2">
|
<div class="flex items-center py-2 mb-2">
|
||||||
@@ -157,10 +160,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||||
<ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp">
|
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
<span id="settings-disable-watcher">{{ $strings.LabelSettingsDisableWatcher }}</span>
|
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -259,9 +262,12 @@ export default {
|
|||||||
updatingServerSettings: false,
|
updatingServerSettings: false,
|
||||||
homepageUseBookshelfView: false,
|
homepageUseBookshelfView: false,
|
||||||
useBookshelfView: false,
|
useBookshelfView: false,
|
||||||
|
scannerEnableWatcher: false,
|
||||||
isPurgingCache: false,
|
isPurgingCache: false,
|
||||||
|
hasPrefixesChanged: false,
|
||||||
newServerSettings: {},
|
newServerSettings: {},
|
||||||
showConfirmPurgeCache: false,
|
showConfirmPurgeCache: false,
|
||||||
|
savingPrefixes: false,
|
||||||
metadataFileFormats: [
|
metadataFileFormats: [
|
||||||
{
|
{
|
||||||
text: '.json',
|
text: '.json',
|
||||||
@@ -304,15 +310,36 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateSortingPrefixes(val) {
|
sortingPrefixesUpdated(val) {
|
||||||
if (!val || !val.length) {
|
const prefixes = [...new Set(val?.map((prefix) => prefix.trim().toLowerCase()) || [])]
|
||||||
|
this.newServerSettings.sortingPrefixes = prefixes
|
||||||
|
const serverPrefixes = this.serverSettings.sortingPrefixes || []
|
||||||
|
this.hasPrefixesChanged = prefixes.some((p) => !serverPrefixes.includes(p)) || serverPrefixes.some((p) => !prefixes.includes(p))
|
||||||
|
},
|
||||||
|
updateSortingPrefixes() {
|
||||||
|
const prefixes = [...new Set(this.newServerSettings.sortingPrefixes.map((prefix) => prefix.trim().toLowerCase()) || [])]
|
||||||
|
if (!prefixes.length) {
|
||||||
this.$toast.error('Must have at least 1 prefix')
|
this.$toast.error('Must have at least 1 prefix')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var prefixes = val.map((prefix) => prefix.trim().toLowerCase())
|
|
||||||
this.updateServerSettings({
|
this.savingPrefixes = true
|
||||||
sortingPrefixes: prefixes
|
this.$axios
|
||||||
})
|
.$patch(`/api/sorting-prefixes`, { sortingPrefixes: prefixes })
|
||||||
|
.then((data) => {
|
||||||
|
this.$toast.success(`Sorting prefixes updated. ${data.rowsUpdated} rows`)
|
||||||
|
if (data.serverSettings) {
|
||||||
|
this.$store.commit('setServerSettings', data.serverSettings)
|
||||||
|
}
|
||||||
|
this.hasPrefixesChanged = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update prefixes', error)
|
||||||
|
this.$toast.error('Failed to update sorting prefixes')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.savingPrefixes = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
updateScannerCoverProvider(val) {
|
updateScannerCoverProvider(val) {
|
||||||
this.updateServerSettings({
|
this.updateServerSettings({
|
||||||
@@ -337,6 +364,9 @@ export default {
|
|||||||
this.updateSettingsKey('metadataFileFormat', val)
|
this.updateSettingsKey('metadataFileFormat', val)
|
||||||
},
|
},
|
||||||
updateSettingsKey(key, val) {
|
updateSettingsKey(key, val) {
|
||||||
|
if (key === 'scannerDisableWatcher') {
|
||||||
|
this.newServerSettings.scannerDisableWatcher = val
|
||||||
|
}
|
||||||
this.updateServerSettings({
|
this.updateServerSettings({
|
||||||
[key]: val
|
[key]: val
|
||||||
})
|
})
|
||||||
@@ -363,6 +393,7 @@ export default {
|
|||||||
initServerSettings() {
|
initServerSettings() {
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
||||||
|
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
|
||||||
|
|
||||||
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
||||||
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-80 my-6 mx-auto">
|
<div v-if="isBookLibrary" class="w-80 my-6 mx-auto">
|
||||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
|
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
|
||||||
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
|
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
|
||||||
<template v-for="(author, index) in top10Authors">
|
<template v-for="(author, index) in top10Authors">
|
||||||
@@ -114,43 +114,49 @@ export default {
|
|||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
totalItems() {
|
totalItems() {
|
||||||
return this.libraryStats ? this.libraryStats.totalItems : 0
|
return this.libraryStats?.totalItems || 0
|
||||||
},
|
},
|
||||||
genresWithCount() {
|
genresWithCount() {
|
||||||
return this.libraryStats ? this.libraryStats.genresWithCount : []
|
return this.libraryStats?.genresWithCount || []
|
||||||
},
|
},
|
||||||
top5Genres() {
|
top5Genres() {
|
||||||
return this.genresWithCount.slice(0, 5)
|
return this.genresWithCount?.slice(0, 5) || []
|
||||||
},
|
},
|
||||||
top10LongestItems() {
|
top10LongestItems() {
|
||||||
return this.libraryStats ? this.libraryStats.longestItems || [] : []
|
return this.libraryStats?.longestItems || []
|
||||||
},
|
},
|
||||||
longestItemDuration() {
|
longestItemDuration() {
|
||||||
if (!this.top10LongestItems.length) return 0
|
if (!this.top10LongestItems.length) return 0
|
||||||
return this.top10LongestItems[0].duration
|
return this.top10LongestItems[0].duration
|
||||||
},
|
},
|
||||||
top10LargestItems() {
|
top10LargestItems() {
|
||||||
return this.libraryStats ? this.libraryStats.largestItems || [] : []
|
return this.libraryStats?.largestItems || []
|
||||||
},
|
},
|
||||||
largestItemSize() {
|
largestItemSize() {
|
||||||
if (!this.top10LargestItems.length) return 0
|
if (!this.top10LargestItems.length) return 0
|
||||||
return this.top10LargestItems[0].size
|
return this.top10LargestItems[0].size
|
||||||
},
|
},
|
||||||
authorsWithCount() {
|
authorsWithCount() {
|
||||||
return this.libraryStats ? this.libraryStats.authorsWithCount : []
|
return this.libraryStats?.authorsWithCount || []
|
||||||
},
|
},
|
||||||
mostUsedAuthorCount() {
|
mostUsedAuthorCount() {
|
||||||
if (!this.authorsWithCount.length) return 0
|
if (!this.authorsWithCount.length) return 0
|
||||||
return this.authorsWithCount[0].count
|
return this.authorsWithCount[0].count
|
||||||
},
|
},
|
||||||
top10Authors() {
|
top10Authors() {
|
||||||
return this.authorsWithCount.slice(0, 10)
|
return this.authorsWithCount?.slice(0, 10) || []
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
currentLibraryName() {
|
currentLibraryName() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
|
},
|
||||||
|
currentLibraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isBookLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'book'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
|
||||||
|
<div v-if="feeds.length" class="block max-w-full">
|
||||||
|
<table class="rssFeedsTable text-xs">
|
||||||
|
<tr class="bg-primary bg-opacity-40 h-12">
|
||||||
|
<th class="w-16 min-w-16"></th>
|
||||||
|
<th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}</th>
|
||||||
|
<th class="w-48 min-w-24 text-left hidden xl:table-cell">{{ $strings.LabelSlug }}</th>
|
||||||
|
<th class="w-24 min-w-16 text-left hidden md:table-cell">{{ $strings.LabelType }}</th>
|
||||||
|
<th class="w-16 min-w-16 text-center">{{ $strings.HeaderEpisodes }}</th>
|
||||||
|
<th class="w-16 min-w-16 text-center hidden lg:table-cell">{{ $strings.LabelRSSFeedPreventIndexing }}</th>
|
||||||
|
<th class="w-48 min-w-24 flex-grow hidden md:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||||
|
<th class="w-16 text-left"></th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
|
||||||
|
<!-- -->
|
||||||
|
<td>
|
||||||
|
<img :src="coverUrl(feed)" class="h-full w-full" />
|
||||||
|
</td>
|
||||||
|
<!-- -->
|
||||||
|
<td class="w-48 max-w-64 min-w-24 text-left truncate">
|
||||||
|
<p class="truncate">{{ feed.meta.title }}</p>
|
||||||
|
</td>
|
||||||
|
<!-- -->
|
||||||
|
<td class="hidden xl:table-cell">
|
||||||
|
<p class="truncate">{{ feed.slug }}</p>
|
||||||
|
</td>
|
||||||
|
<!-- -->
|
||||||
|
<td class="hidden md:table-cell">
|
||||||
|
<p class="">{{ getEntityType(feed.entityType) }}</p>
|
||||||
|
</td>
|
||||||
|
<!-- -->
|
||||||
|
<td class="text-center">
|
||||||
|
<p class="">{{ feed.episodes.length }}</p>
|
||||||
|
</td>
|
||||||
|
<!-- -->
|
||||||
|
<td class="text-center leading-none hidden lg:table-cell">
|
||||||
|
<p v-if="feed.meta.preventIndexing" class="">
|
||||||
|
<span class="material-icons text-2xl">check</span>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<!-- -->
|
||||||
|
<td class="text-center hidden md:table-cell">
|
||||||
|
<ui-tooltip v-if="feed.updatedAt" direction="top" :text="$formatDatetime(feed.updatedAt, dateFormat, timeFormat)">
|
||||||
|
<p class="text-gray-200">{{ $dateDistanceFromNow(feed.updatedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
<!-- -->
|
||||||
|
<td class="text-center">
|
||||||
|
<ui-icon-btn icon="delete" class="mx-0.5" :size="7" bg-color="error" outlined @click.stop="deleteFeedClick(feed)" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</app-settings-content>
|
||||||
|
<modals-rssfeed-view-feed-modal v-model="showFeedModal" :feed="selectedFeed" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showFeedModal: false,
|
||||||
|
selectedFeed: null,
|
||||||
|
feeds: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
|
timeFormat() {
|
||||||
|
return this.$store.state.serverSettings.timeFormat
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
showFeed(feed) {
|
||||||
|
this.selectedFeed = feed
|
||||||
|
this.showFeedModal = true
|
||||||
|
},
|
||||||
|
deleteFeedClick(feed) {
|
||||||
|
const payload = {
|
||||||
|
message: this.$strings.MessageConfirmCloseFeed,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.deleteFeed(feed)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
deleteFeed(feed) {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/feeds/${feed.id}/close`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
|
||||||
|
this.show = false
|
||||||
|
this.loadFeeds()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to close RSS feed', error)
|
||||||
|
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getEntityType(entityType) {
|
||||||
|
if (entityType === 'libraryItem') return this.$strings.LabelItem
|
||||||
|
else if (entityType === 'series') return this.$strings.LabelSeries
|
||||||
|
else if (entityType === 'collection') return this.$strings.LabelCollection
|
||||||
|
return this.$strings.LabelUnknown
|
||||||
|
},
|
||||||
|
coverUrl(feed) {
|
||||||
|
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
|
||||||
|
return `${feed.feedUrl}/cover`
|
||||||
|
},
|
||||||
|
async loadFeeds() {
|
||||||
|
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
|
||||||
|
console.error('Failed to load RSS feeds', err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!data) {
|
||||||
|
this.$toast.error('Failed to load RSS feeds')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.feeds = data.feeds
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.loadFeeds()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rssFeedsTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssFeedsTable tr:first-child {
|
||||||
|
background-color: #272727;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssFeedsTable tr:not(:first-child) {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssFeedsTable tr:not(:first-child):nth-child(odd) {
|
||||||
|
background-color: #2f2f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssFeedsTable tr:hover:not(:first-child) {
|
||||||
|
background-color: #474747;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssFeedsTable td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssFeedsTable th {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
|
||||||
|
|
||||||
<table v-if="mediaProgressWithMedia.length" class="userAudiobooksTable">
|
<table v-if="mediaProgress.length" class="userAudiobooksTable">
|
||||||
<tr class="bg-primary bg-opacity-40">
|
<tr class="bg-primary bg-opacity-40">
|
||||||
<th class="w-16 text-left">{{ $strings.LabelItem }}</th>
|
<th class="w-16 text-left">{{ $strings.LabelItem }}</th>
|
||||||
<th class="text-left"></th>
|
<th class="text-left"></th>
|
||||||
@@ -55,19 +55,14 @@
|
|||||||
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelStartedAt }}</th>
|
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelStartedAt }}</th>
|
||||||
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="item in mediaProgressWithMedia" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'">
|
<tr v-for="item in mediaProgress" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'">
|
||||||
<td>
|
<td>
|
||||||
<covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover v-if="item.coverPath" :width="50" :src="$store.getters['globals/getLibraryItemCoverSrcById'](item.libraryItemId, item.mediaUpdatedAt)" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
|
||||||
|
<div v-else class="bg-primary flex items-center justify-center text-center text-xs text-gray-400 p-1" :style="{ width: '50px', height: 50 * bookCoverAspectRatio + 'px' }">No Cover</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="item.media && item.media.metadata && item.episode">
|
<p>{{ item.displayTitle || 'Unknown' }}</p>
|
||||||
<p>{{ item.episode.title || 'Unknown' }}</p>
|
<p v-if="item.displaySubtitle" class="text-white text-opacity-50 text-sm font-sans">{{ item.displaySubtitle }}</p>
|
||||||
<p class="text-white text-opacity-50 text-sm font-sans">{{ item.media.metadata.title }}</p>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.media && item.media.metadata">
|
|
||||||
<p>{{ item.media.metadata.title || 'Unknown' }}</p>
|
|
||||||
<p v-if="item.media.metadata.authorName" class="text-white text-opacity-50 text-sm font-sans">by {{ item.media.metadata.authorName }}</p>
|
|
||||||
</template>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
||||||
@@ -124,9 +119,6 @@ export default {
|
|||||||
mediaProgress() {
|
mediaProgress() {
|
||||||
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||||
},
|
},
|
||||||
mediaProgressWithMedia() {
|
|
||||||
return this.mediaProgress.filter((mp) => mp.media)
|
|
||||||
},
|
|
||||||
totalListeningTime() {
|
totalListeningTime() {
|
||||||
return this.listeningStats.totalTime || 0
|
return this.listeningStats.totalTime || 0
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Include episode downloads for podcasts
|
// Include episode downloads for podcasts
|
||||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads,rssfeed`).catch((error) => {
|
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed`).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -761,6 +761,7 @@ export default {
|
|||||||
if (this.libraryId) {
|
if (this.libraryId) {
|
||||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||||
}
|
}
|
||||||
|
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||||
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||||
@@ -769,6 +770,7 @@ export default {
|
|||||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||||
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const languageCodeMap = {
|
|||||||
'fr': { label: 'Français', dateFnsLocale: 'fr' },
|
'fr': { label: 'Français', dateFnsLocale: 'fr' },
|
||||||
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||||
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
||||||
|
'lt': { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
||||||
'nl': { label: 'Nederlands', dateFnsLocale: 'nl' },
|
'nl': { label: 'Nederlands', dateFnsLocale: 'nl' },
|
||||||
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
||||||
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
||||||
|
|||||||
@@ -234,6 +234,10 @@ export const mutations = {
|
|||||||
setNumUserPlaylists(state, numUserPlaylists) {
|
setNumUserPlaylists(state, numUserPlaylists) {
|
||||||
state.numUserPlaylists = numUserPlaylists
|
state.numUserPlaylists = numUserPlaylists
|
||||||
},
|
},
|
||||||
|
removeSeriesFromFilterData(state, seriesId) {
|
||||||
|
if (!seriesId || !state.filterData) return
|
||||||
|
state.filterData.series = state.filterData.series.filter(se => se.id !== seriesId)
|
||||||
|
},
|
||||||
updateFilterDataWithItem(state, libraryItem) {
|
updateFilterDataWithItem(state, libraryItem) {
|
||||||
if (!libraryItem || !state.filterData) return
|
if (!libraryItem || !state.filterData) return
|
||||||
if (state.currentLibraryId !== libraryItem.libraryId) return
|
if (state.currentLibraryId !== libraryItem.libraryId) return
|
||||||
|
|||||||
+59
-50
@@ -3,7 +3,7 @@
|
|||||||
"ButtonAddChapters": "Kapitel hinzufügen",
|
"ButtonAddChapters": "Kapitel hinzufügen",
|
||||||
"ButtonAddPodcasts": "Podcasts hinzufügen",
|
"ButtonAddPodcasts": "Podcasts hinzufügen",
|
||||||
"ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek",
|
"ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek",
|
||||||
"ButtonApply": "Anwenden",
|
"ButtonApply": "Übernehmen",
|
||||||
"ButtonApplyChapters": "Kapitel anwenden",
|
"ButtonApplyChapters": "Kapitel anwenden",
|
||||||
"ButtonAuthors": "Autoren",
|
"ButtonAuthors": "Autoren",
|
||||||
"ButtonBrowseForFolder": "Ordnersuche",
|
"ButtonBrowseForFolder": "Ordnersuche",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
|
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
|
||||||
"ButtonMatchAllAuthors": "Online Metadaten-Abgleich (alle Autoren)",
|
"ButtonMatchAllAuthors": "Online Metadaten-Abgleich (alle Autoren)",
|
||||||
"ButtonMatchBooks": "Online Metadaten-Abgleich (alle Medien)",
|
"ButtonMatchBooks": "Online Metadaten-Abgleich (alle Medien)",
|
||||||
"ButtonNevermind": "Vergiss es",
|
"ButtonNevermind": "Abbrechen",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Feed öffnen",
|
"ButtonOpenFeed": "Feed öffnen",
|
||||||
"ButtonOpenManager": "Manager öffnen",
|
"ButtonOpenManager": "Manager öffnen",
|
||||||
@@ -98,12 +98,12 @@
|
|||||||
"HeaderCurrentDownloads": "Aktuelle Downloads",
|
"HeaderCurrentDownloads": "Aktuelle Downloads",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "Details",
|
||||||
"HeaderDownloadQueue": "Download Warteschlange",
|
"HeaderDownloadQueue": "Download Warteschlange",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "E-Book Dateien",
|
||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Einstellungen",
|
||||||
"HeaderEpisodes": "Episoden",
|
"HeaderEpisodes": "Episoden",
|
||||||
"HeaderEreaderDevices": "Ereader Devices",
|
"HeaderEreaderDevices": "Ereader Geräte",
|
||||||
"HeaderEreaderSettings": "Ereader Settings",
|
"HeaderEreaderSettings": "Ereader Einstellungen",
|
||||||
"HeaderFiles": "Dateien",
|
"HeaderFiles": "Dateien",
|
||||||
"HeaderFindChapters": "Kapitel suchen",
|
"HeaderFindChapters": "Kapitel suchen",
|
||||||
"HeaderIgnoredFiles": "Ignorierte Dateien",
|
"HeaderIgnoredFiles": "Ignorierte Dateien",
|
||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Lösche {0} Episoden",
|
"HeaderRemoveEpisodes": "Lösche {0} Episoden",
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "RSS Details",
|
||||||
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
|
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
|
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
|
||||||
"HeaderSchedule": "Zeitplan",
|
"HeaderSchedule": "Zeitplan",
|
||||||
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
|
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
|
||||||
@@ -155,7 +156,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Neueste Ereignisse",
|
"HeaderStatsRecentSessions": "Neueste Ereignisse",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Autoren",
|
"HeaderStatsTop10Authors": "Top 10 Autoren",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Kategorien",
|
"HeaderStatsTop5Genres": "Top 5 Kategorien",
|
||||||
"HeaderTableOfContents": "Table of Contents",
|
"HeaderTableOfContents": "Inhaltsverzeichnis",
|
||||||
"HeaderTools": "Werkzeuge",
|
"HeaderTools": "Werkzeuge",
|
||||||
"HeaderUpdateAccount": "Konto aktualisieren",
|
"HeaderUpdateAccount": "Konto aktualisieren",
|
||||||
"HeaderUpdateAuthor": "Autor aktualisieren",
|
"HeaderUpdateAuthor": "Autor aktualisieren",
|
||||||
@@ -195,17 +196,18 @@
|
|||||||
"LabelBooks": "Bücher",
|
"LabelBooks": "Bücher",
|
||||||
"LabelChangePassword": "Passwort ändern",
|
"LabelChangePassword": "Passwort ändern",
|
||||||
"LabelChannels": "Kanäle",
|
"LabelChannels": "Kanäle",
|
||||||
"LabelChapters": "Chapters",
|
"LabelChapters": "Kapitel",
|
||||||
"LabelChaptersFound": "gefundene Kapitel",
|
"LabelChaptersFound": "gefundene Kapitel",
|
||||||
"LabelChapterTitle": "Kapitelüberschrift",
|
"LabelChapterTitle": "Kapitelüberschrift",
|
||||||
"LabelClosePlayer": "Player schließen",
|
"LabelClosePlayer": "Player schließen",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Serien zusammenfassen",
|
"LabelCollapseSeries": "Serien zusammenfassen",
|
||||||
|
"LabelCollection": "Sammlung",
|
||||||
"LabelCollections": "Sammlungen",
|
"LabelCollections": "Sammlungen",
|
||||||
"LabelComplete": "Vollständig",
|
"LabelComplete": "Vollständig",
|
||||||
"LabelConfirmPassword": "Passwort bestätigen",
|
"LabelConfirmPassword": "Passwort bestätigen",
|
||||||
"LabelContinueListening": "Weiterhören",
|
"LabelContinueListening": "Weiterhören",
|
||||||
"LabelContinueReading": "Continue Reading",
|
"LabelContinueReading": "Lesen fortsetzen",
|
||||||
"LabelContinueSeries": "Serien fortsetzen",
|
"LabelContinueSeries": "Serien fortsetzen",
|
||||||
"LabelCover": "Titelbild",
|
"LabelCover": "Titelbild",
|
||||||
"LabelCoverImageURL": "URL des Titelbildes",
|
"LabelCoverImageURL": "URL des Titelbildes",
|
||||||
@@ -222,18 +224,19 @@
|
|||||||
"LabelDirectory": "Verzeichnis",
|
"LabelDirectory": "Verzeichnis",
|
||||||
"LabelDiscFromFilename": "CD aus dem Dateinamen",
|
"LabelDiscFromFilename": "CD aus dem Dateinamen",
|
||||||
"LabelDiscFromMetadata": "CD aus den Metadaten",
|
"LabelDiscFromMetadata": "CD aus den Metadaten",
|
||||||
|
"LabelDiscover": "Entdecken",
|
||||||
"LabelDownload": "Herunterladen",
|
"LabelDownload": "Herunterladen",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Laufzeit",
|
"LabelDuration": "Laufzeit",
|
||||||
"LabelDurationFound": "Gefundene Laufzeit:",
|
"LabelDurationFound": "Gefundene Laufzeit:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "E-Book",
|
||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "E-Books",
|
||||||
"LabelEdit": "Bearbeiten",
|
"LabelEdit": "Bearbeiten",
|
||||||
"LabelEmail": "Email",
|
"LabelEmail": "Email",
|
||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "Von Address",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Sicherheit",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "Wenn \"true\", verwendet die Verbindung TLS, wenn sie eine Verbindung zum Server herstellt. Bei \"false\" wird TLS verwendet, wenn der Server die STARTTLS-Erweiterung unterstützt. In den meisten Fällen setzen Sie diesen Wert auf \"true\", wenn Sie eine Verbindung zu Port 465 herstellen. Für Port 587 oder 25 behalten Sie den Wert \"false\" bei. (von nodemailer.com/smtp/#authentication)",
|
||||||
"LabelEmailSettingsTestAddress": "Test Address",
|
"LabelEmailSettingsTestAddress": "Test Addresse",
|
||||||
"LabelEmbeddedCover": "Eingebettetes Cover",
|
"LabelEmbeddedCover": "Eingebettetes Cover",
|
||||||
"LabelEnable": "Aktivieren",
|
"LabelEnable": "Aktivieren",
|
||||||
"LabelEnd": "Ende",
|
"LabelEnd": "Ende",
|
||||||
@@ -244,7 +247,7 @@
|
|||||||
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
"LabelFile": "Datei",
|
"LabelFile": "Datei",
|
||||||
"LabelFileBirthtime": "Datei Geburtsdatum",
|
"LabelFileBirthtime": "Datei erstellt",
|
||||||
"LabelFileModified": "Datei geändert",
|
"LabelFileModified": "Datei geändert",
|
||||||
"LabelFilename": "Dateiname",
|
"LabelFilename": "Dateiname",
|
||||||
"LabelFilterByUser": "Nach Benutzern filtern",
|
"LabelFilterByUser": "Nach Benutzern filtern",
|
||||||
@@ -252,13 +255,13 @@
|
|||||||
"LabelFinished": "beendet",
|
"LabelFinished": "beendet",
|
||||||
"LabelFolder": "Ordner",
|
"LabelFolder": "Ordner",
|
||||||
"LabelFolders": "Verzeichnisse",
|
"LabelFolders": "Verzeichnisse",
|
||||||
"LabelFontScale": "Font scale",
|
"LabelFontScale": "Schriftgröße",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Kategorie",
|
"LabelGenre": "Kategorie",
|
||||||
"LabelGenres": "Kategorien",
|
"LabelGenres": "Kategorien",
|
||||||
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
||||||
"LabelHasEbook": "Has ebook",
|
"LabelHasEbook": "mit E-Book",
|
||||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
"LabelHasSupplementaryEbook": "mit zusätlichem E-Book",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Stunde",
|
"LabelHour": "Stunde",
|
||||||
"LabelIcon": "Symbol",
|
"LabelIcon": "Symbol",
|
||||||
@@ -275,7 +278,7 @@
|
|||||||
"LabelIntervalEveryDay": "Jeden Tag",
|
"LabelIntervalEveryDay": "Jeden Tag",
|
||||||
"LabelIntervalEveryHour": "Jede Stunde",
|
"LabelIntervalEveryHour": "Jede Stunde",
|
||||||
"LabelInvalidParts": "Ungültige Teile",
|
"LabelInvalidParts": "Ungültige Teile",
|
||||||
"LabelInvert": "Invert",
|
"LabelInvert": "Umkehren",
|
||||||
"LabelItem": "Medium",
|
"LabelItem": "Medium",
|
||||||
"LabelLanguage": "Sprache",
|
"LabelLanguage": "Sprache",
|
||||||
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
|
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
|
||||||
@@ -285,15 +288,15 @@
|
|||||||
"LabelLastTime": "Letztes Mal",
|
"LabelLastTime": "Letztes Mal",
|
||||||
"LabelLastUpdate": "Letzte Aktualisierung",
|
"LabelLastUpdate": "Letzte Aktualisierung",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Layout",
|
||||||
"LabelLayoutSinglePage": "Single page",
|
"LabelLayoutSinglePage": "Eine Seite",
|
||||||
"LabelLayoutSplitPage": "Split page",
|
"LabelLayoutSplitPage": "Geteilte Seite",
|
||||||
"LabelLess": "Weniger",
|
"LabelLess": "Weniger",
|
||||||
"LabelLibrariesAccessibleToUser": "Für Benutzer zugängliche Bibliotheken",
|
"LabelLibrariesAccessibleToUser": "Für Benutzer zugängliche Bibliotheken",
|
||||||
"LabelLibrary": "Bibliothek",
|
"LabelLibrary": "Bibliothek",
|
||||||
"LabelLibraryItem": "Bibliothekseintrag",
|
"LabelLibraryItem": "Bibliothekseintrag",
|
||||||
"LabelLibraryName": "Bibliotheksname",
|
"LabelLibraryName": "Bibliotheksname",
|
||||||
"LabelLimit": "Begrenzung",
|
"LabelLimit": "Begrenzung",
|
||||||
"LabelLineSpacing": "Line spacing",
|
"LabelLineSpacing": "Zeilenabstand",
|
||||||
"LabelListenAgain": "Erneut anhören",
|
"LabelListenAgain": "Erneut anhören",
|
||||||
"LabelLogLevelDebug": "Fehlersuche",
|
"LabelLogLevelDebug": "Fehlersuche",
|
||||||
"LabelLogLevelInfo": "Informationen",
|
"LabelLogLevelInfo": "Informationen",
|
||||||
@@ -308,7 +311,7 @@
|
|||||||
"LabelMissing": "Fehlend",
|
"LabelMissing": "Fehlend",
|
||||||
"LabelMissingParts": "Fehlende Teile",
|
"LabelMissingParts": "Fehlende Teile",
|
||||||
"LabelMore": "Mehr",
|
"LabelMore": "Mehr",
|
||||||
"LabelMoreInfo": "More Info",
|
"LabelMoreInfo": "Mehr Info",
|
||||||
"LabelName": "Name",
|
"LabelName": "Name",
|
||||||
"LabelNarrator": "Erzähler",
|
"LabelNarrator": "Erzähler",
|
||||||
"LabelNarrators": "Erzähler",
|
"LabelNarrators": "Erzähler",
|
||||||
@@ -318,7 +321,7 @@
|
|||||||
"LabelNewPassword": "Neues Passwort",
|
"LabelNewPassword": "Neues Passwort",
|
||||||
"LabelNextBackupDate": "Nächstes Sicherungsdatum",
|
"LabelNextBackupDate": "Nächstes Sicherungsdatum",
|
||||||
"LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
|
"LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
|
||||||
"LabelNoEpisodesSelected": "No episodes selected",
|
"LabelNoEpisodesSelected": "Keine Episoden ausgewählt",
|
||||||
"LabelNotes": "Hinweise",
|
"LabelNotes": "Hinweise",
|
||||||
"LabelNotFinished": "nicht beendet",
|
"LabelNotFinished": "nicht beendet",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||||
@@ -353,15 +356,15 @@
|
|||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
|
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
|
||||||
"LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
|
"LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
|
||||||
"LabelPrimaryEbook": "Primary ebook",
|
"LabelPrimaryEbook": "Haupt-E-Book",
|
||||||
"LabelProgress": "Fortschritt",
|
"LabelProgress": "Fortschritt",
|
||||||
"LabelProvider": "Anbieter",
|
"LabelProvider": "Anbieter",
|
||||||
"LabelPubDate": "Veröffentlichungsdatum",
|
"LabelPubDate": "Veröffentlichungsdatum",
|
||||||
"LabelPublisher": "Herausgeber",
|
"LabelPublisher": "Herausgeber",
|
||||||
"LabelPublishYear": "Jahr",
|
"LabelPublishYear": "Jahr",
|
||||||
"LabelRead": "Read",
|
"LabelRead": "Lesen",
|
||||||
"LabelReadAgain": "Read Again",
|
"LabelReadAgain": "Nocheinmal Lesen",
|
||||||
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
|
"LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen",
|
||||||
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
|
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
|
||||||
"LabelRecentSeries": "Aktuelle Serien",
|
"LabelRecentSeries": "Aktuelle Serien",
|
||||||
"LabelRecommended": "Empfohlen",
|
"LabelRecommended": "Empfohlen",
|
||||||
@@ -378,29 +381,32 @@
|
|||||||
"LabelSearchTitle": "Titel",
|
"LabelSearchTitle": "Titel",
|
||||||
"LabelSearchTitleOrASIN": "Titel oder ASIN",
|
"LabelSearchTitleOrASIN": "Titel oder ASIN",
|
||||||
"LabelSeason": "Staffel",
|
"LabelSeason": "Staffel",
|
||||||
"LabelSelectAllEpisodes": "Select all episodes",
|
"LabelSelectAllEpisodes": "Alle Episoden auswählen",
|
||||||
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
"LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "E-Book senden an...",
|
||||||
"LabelSequence": "Reihenfolge",
|
"LabelSequence": "Reihenfolge",
|
||||||
"LabelSeries": "Serien",
|
"LabelSeries": "Serien",
|
||||||
"LabelSeriesName": "Serienname",
|
"LabelSeriesName": "Serienname",
|
||||||
"LabelSeriesProgress": "Serienfortschritt",
|
"LabelSeriesProgress": "Serienfortschritt",
|
||||||
"LabelSetEbookAsPrimary": "Set as primary",
|
"LabelSetEbookAsPrimary": "Setzen als Hauptbuch",
|
||||||
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
"LabelSetEbookAsSupplementary": "Setzen als Ergänzung",
|
||||||
"LabelSettingsAudiobooksOnly": "Audiobooks only",
|
"LabelSettingsAudiobooksOnly": "nur Hörbücher",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
|
"LabelSettingsAudiobooksOnlyHelp": "Wenn Sie diese Einstellung aktivieren, werden E-Book-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Books festgelegt",
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
|
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
|
||||||
"LabelSettingsChromecastSupport": "Chromecastunterstützung",
|
"LabelSettingsChromecastSupport": "Chromecastunterstützung",
|
||||||
"LabelSettingsDateFormat": "Datumsformat",
|
"LabelSettingsDateFormat": "Datumsformat",
|
||||||
"LabelSettingsDisableWatcher": "Überwachung deaktivieren",
|
"LabelSettingsDisableWatcher": "Überwachung deaktivieren",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren",
|
"LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren",
|
||||||
"LabelSettingsDisableWatcherHelp": "Deaktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart",
|
"LabelSettingsDisableWatcherHelp": "Deaktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Experimentelle Funktionen",
|
"LabelSettingsExperimentalFeatures": "Experimentelle Funktionen",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
|
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
|
||||||
"LabelSettingsFindCovers": "Suche Titelbilder",
|
"LabelSettingsFindCovers": "Suche Titelbilder",
|
||||||
"LabelSettingsFindCoversHelp": "Wenn Ihr Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
|
"LabelSettingsFindCoversHelp": "Wenn Ihr Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
|
||||||
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
"LabelSettingsHideSingleBookSeries": "Ausblenden einzelzne Bücher",
|
||||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
"LabelSettingsHideSingleBookSeriesHelp": "Serien, die ein einzelnes Buch enthalten, werden in den Regalen der Serienseite und der Startseite ausgeblendet.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht",
|
"LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht",
|
||||||
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
|
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
|
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Alles anzeigen",
|
"LabelShowAll": "Alles anzeigen",
|
||||||
"LabelSize": "Größe",
|
"LabelSize": "Größe",
|
||||||
"LabelSleepTimer": "Einschlaf-Timer",
|
"LabelSleepTimer": "Einschlaf-Timer",
|
||||||
|
"LabelSlug": "URL Teil",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStarted": "Gestartet",
|
"LabelStarted": "Gestartet",
|
||||||
"LabelStartedAt": "Gestartet am",
|
"LabelStartedAt": "Gestartet am",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Titel aus Metadaten",
|
"LabelTrackFromMetadata": "Titel aus Metadaten",
|
||||||
"LabelTracks": "Dateien",
|
"LabelTracks": "Dateien",
|
||||||
"LabelTracksMultiTrack": "Mehrfachdatei",
|
"LabelTracksMultiTrack": "Mehrfachdatei",
|
||||||
|
"LabelTracksNone": "Keine Dateien",
|
||||||
"LabelTracksSingleTrack": "Einzeldatei",
|
"LabelTracksSingleTrack": "Einzeldatei",
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
"LabelUnabridged": "Ungekürzt",
|
"LabelUnabridged": "Ungekürzt",
|
||||||
@@ -494,7 +502,7 @@
|
|||||||
"LabelViewBookmarks": "Lesezeichen anzeigen",
|
"LabelViewBookmarks": "Lesezeichen anzeigen",
|
||||||
"LabelViewChapters": "Kapitel anzeigen",
|
"LabelViewChapters": "Kapitel anzeigen",
|
||||||
"LabelViewQueue": "Spieler-Warteschlange anzeigen",
|
"LabelViewQueue": "Spieler-Warteschlange anzeigen",
|
||||||
"LabelVolume": "Volume",
|
"LabelVolume": "Volumen",
|
||||||
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
||||||
"LabelYourAudiobookDuration": "Laufzeit Ihres Mediums",
|
"LabelYourAudiobookDuration": "Laufzeit Ihres Mediums",
|
||||||
"LabelYourBookmarks": "Lesezeichen",
|
"LabelYourBookmarks": "Lesezeichen",
|
||||||
@@ -514,13 +522,14 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
|
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
|
||||||
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
|
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
|
||||||
"MessageCheckingCron": "Überprüfe Cron...",
|
"MessageCheckingCron": "Überprüfe Cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Sind Sie sicher, dass Sie diesen Feed schließen wollen?",
|
||||||
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
|
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "Es wird die Datei vom System löschen. Sind Sie sicher?",
|
||||||
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
|
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
|
||||||
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
|
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
|
||||||
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
|
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
|
"MessageConfirmMarkAllEpisodesFinished": "Sind Sie sicher, dass Sie alle Episoden als abgeschlossen markieren möchten?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Sind Sie sicher, dass Sie alle Episoden als nicht abgeschlossen markieren möchten?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
|
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
|
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
|
||||||
"MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?",
|
"MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?",
|
||||||
@@ -535,7 +544,7 @@
|
|||||||
"MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
|
"MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
|
"MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
|
||||||
"MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".",
|
||||||
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Sind Sie sicher, dass sie {0} ebook \"{1}\" auf das Gerät \"{2}\" senden wollen?",
|
||||||
"MessageDownloadingEpisode": "Episode herunterladen",
|
"MessageDownloadingEpisode": "Episode herunterladen",
|
||||||
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
|
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
|
||||||
"MessageEmbedFinished": "Einbettung abgeschlossen!",
|
"MessageEmbedFinished": "Einbettung abgeschlossen!",
|
||||||
@@ -554,11 +563,11 @@
|
|||||||
"MessageM4BFailed": "M4B fehlgeschlagen!",
|
"MessageM4BFailed": "M4B fehlgeschlagen!",
|
||||||
"MessageM4BFinished": "M4B beendet!",
|
"MessageM4BFinished": "M4B beendet!",
|
||||||
"MessageMapChapterTitles": "Zuordnen von Kapiteltiteln zu Ihren vorhandenen Medienkapiteln ohne Anpassung der Zeitangaben",
|
"MessageMapChapterTitles": "Zuordnen von Kapiteltiteln zu Ihren vorhandenen Medienkapiteln ohne Anpassung der Zeitangaben",
|
||||||
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
|
"MessageMarkAllEpisodesFinished": "Alle Episoden als beendet markieren",
|
||||||
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
|
"MessageMarkAllEpisodesNotFinished": "Alle Episoden als ungehört markieren",
|
||||||
"MessageMarkAsFinished": "Als beendet markieren",
|
"MessageMarkAsFinished": "Als beendet markieren",
|
||||||
"MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren",
|
"MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren",
|
||||||
"MessageMatchBooksDescription": "versucht, Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen und leere Details und das Titelbild auszufüllen. Details werden nicht überschrieben.",
|
"MessageMatchBooksDescription": "Es wird versucht die Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen um leere Details und das Titelbild auszufüllen. Vorhandene Details werden nicht überschrieben.",
|
||||||
"MessageNoAudioTracks": "Keine Audiodateien",
|
"MessageNoAudioTracks": "Keine Audiodateien",
|
||||||
"MessageNoAuthors": "Keine Autoren",
|
"MessageNoAuthors": "Keine Autoren",
|
||||||
"MessageNoBackups": "Keine Sicherungen",
|
"MessageNoBackups": "Keine Sicherungen",
|
||||||
@@ -594,7 +603,7 @@
|
|||||||
"MessagePauseChapter": "Kapitelwiedergabe pausieren",
|
"MessagePauseChapter": "Kapitelwiedergabe pausieren",
|
||||||
"MessagePlayChapter": "Kapitelanfang anhören",
|
"MessagePlayChapter": "Kapitelanfang anhören",
|
||||||
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
|
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
|
"MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
|
||||||
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
|
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
|
||||||
"MessageRemoveChapter": "Kapitel löschen",
|
"MessageRemoveChapter": "Kapitel löschen",
|
||||||
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
|
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
|
||||||
@@ -691,8 +700,8 @@
|
|||||||
"ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
|
"ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
|
||||||
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
|
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
|
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
|
||||||
"ToastSendEbookToDeviceFailed": "Failed to send ebook to device",
|
"ToastSendEbookToDeviceFailed": "E-Book konnte nicht auf Gerät übertragen werden",
|
||||||
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "E-Book an Gerät senden \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen",
|
"ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen",
|
||||||
"ToastSeriesUpdateSuccess": "Serien aktualisiert",
|
"ToastSeriesUpdateSuccess": "Serien aktualisiert",
|
||||||
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
|
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
|
||||||
@@ -702,4 +711,4 @@
|
|||||||
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
|
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
|
||||||
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
|
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
|
||||||
"ToastUserDeleteSuccess": "Benutzer gelöscht"
|
"ToastUserDeleteSuccess": "Benutzer gelöscht"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "RSS Details",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||||
"HeaderSchedule": "Schedule",
|
"HeaderSchedule": "Schedule",
|
||||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"LabelClosePlayer": "Close player",
|
"LabelClosePlayer": "Close player",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Collapse Series",
|
"LabelCollapseSeries": "Collapse Series",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "Collections",
|
"LabelCollections": "Collections",
|
||||||
"LabelComplete": "Complete",
|
"LabelComplete": "Complete",
|
||||||
"LabelConfirmPassword": "Confirm Password",
|
"LabelConfirmPassword": "Confirm Password",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"LabelDirectory": "Directory",
|
"LabelDirectory": "Directory",
|
||||||
"LabelDiscFromFilename": "Disc from Filename",
|
"LabelDiscFromFilename": "Disc from Filename",
|
||||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duration",
|
"LabelDuration": "Duration",
|
||||||
@@ -395,6 +398,9 @@
|
|||||||
"LabelSettingsDisableWatcher": "Disable Watcher",
|
"LabelSettingsDisableWatcher": "Disable Watcher",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
||||||
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Experimental features",
|
"LabelSettingsExperimentalFeatures": "Experimental features",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||||
"LabelSettingsFindCovers": "Find covers",
|
"LabelSettingsFindCovers": "Find covers",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Show All",
|
"LabelShowAll": "Show All",
|
||||||
"LabelSize": "Size",
|
"LabelSize": "Size",
|
||||||
"LabelSleepTimer": "Sleep timer",
|
"LabelSleepTimer": "Sleep timer",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStarted": "Started",
|
"LabelStarted": "Started",
|
||||||
"LabelStartedAt": "Started At",
|
"LabelStartedAt": "Started At",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Track from Metadata",
|
"LabelTrackFromMetadata": "Track from Metadata",
|
||||||
"LabelTracks": "Tracks",
|
"LabelTracks": "Tracks",
|
||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Type",
|
"LabelType": "Type",
|
||||||
"LabelUnabridged": "Unabridged",
|
"LabelUnabridged": "Unabridged",
|
||||||
@@ -514,6 +522,7 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||||
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
||||||
"MessageCheckingCron": "Checking cron...",
|
"MessageCheckingCron": "Checking cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||||
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Remover {0} Episodios",
|
"HeaderRemoveEpisodes": "Remover {0} Episodios",
|
||||||
"HeaderRSSFeedGeneral": "Detalles RSS",
|
"HeaderRSSFeedGeneral": "Detalles RSS",
|
||||||
"HeaderRSSFeedIsOpen": "Fuente RSS esta abierta",
|
"HeaderRSSFeedIsOpen": "Fuente RSS esta abierta",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "Guardar Progreso de multimedia",
|
"HeaderSavedMediaProgress": "Guardar Progreso de multimedia",
|
||||||
"HeaderSchedule": "Horario",
|
"HeaderSchedule": "Horario",
|
||||||
"HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca",
|
"HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca",
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"LabelClosePlayer": "Close player",
|
"LabelClosePlayer": "Close player",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Colapsar Series",
|
"LabelCollapseSeries": "Colapsar Series",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "Colecciones",
|
"LabelCollections": "Colecciones",
|
||||||
"LabelComplete": "Completo",
|
"LabelComplete": "Completo",
|
||||||
"LabelConfirmPassword": "Confirmar Contraseña",
|
"LabelConfirmPassword": "Confirmar Contraseña",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"LabelDirectory": "Directorio",
|
"LabelDirectory": "Directorio",
|
||||||
"LabelDiscFromFilename": "Disco a partir del Nombre del Archivo",
|
"LabelDiscFromFilename": "Disco a partir del Nombre del Archivo",
|
||||||
"LabelDiscFromMetadata": "Disco a partir de Metadata",
|
"LabelDiscFromMetadata": "Disco a partir de Metadata",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
"LabelDownload": "Descargar",
|
"LabelDownload": "Descargar",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duración",
|
"LabelDuration": "Duración",
|
||||||
@@ -395,6 +398,9 @@
|
|||||||
"LabelSettingsDisableWatcher": "Deshabilitar Watcher",
|
"LabelSettingsDisableWatcher": "Deshabilitar Watcher",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Deshabilitar Watcher de Carpetas para esta biblioteca",
|
"LabelSettingsDisableWatcherForLibrary": "Deshabilitar Watcher de Carpetas para esta biblioteca",
|
||||||
"LabelSettingsDisableWatcherHelp": "Deshabilitar la función automática de agregar/actualizar los elementos, cuando se detecta cambio en los archivos. *Require Reiniciar el Servidor",
|
"LabelSettingsDisableWatcherHelp": "Deshabilitar la función automática de agregar/actualizar los elementos, cuando se detecta cambio en los archivos. *Require Reiniciar el Servidor",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Funciones Experimentales",
|
"LabelSettingsExperimentalFeatures": "Funciones Experimentales",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.",
|
"LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.",
|
||||||
"LabelSettingsFindCovers": "Buscar Portadas",
|
"LabelSettingsFindCovers": "Buscar Portadas",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Mostrar Todos",
|
"LabelShowAll": "Mostrar Todos",
|
||||||
"LabelSize": "Tamaño",
|
"LabelSize": "Tamaño",
|
||||||
"LabelSleepTimer": "Temporizador para Dormir",
|
"LabelSleepTimer": "Temporizador para Dormir",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "Iniciar",
|
"LabelStart": "Iniciar",
|
||||||
"LabelStarted": "Indiciado",
|
"LabelStarted": "Indiciado",
|
||||||
"LabelStartedAt": "Iniciado En",
|
"LabelStartedAt": "Iniciado En",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Pista desde Metadata",
|
"LabelTrackFromMetadata": "Pista desde Metadata",
|
||||||
"LabelTracks": "Pistas",
|
"LabelTracks": "Pistas",
|
||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Tipo",
|
"LabelType": "Tipo",
|
||||||
"LabelUnabridged": "Unabridged",
|
"LabelUnabridged": "Unabridged",
|
||||||
@@ -514,6 +522,7 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "El tiempo de inicio no es válida debe ser mayor o igual que la hora de inicio del capítulo anterior",
|
"MessageChapterErrorStartLtPrev": "El tiempo de inicio no es válida debe ser mayor o igual que la hora de inicio del capítulo anterior",
|
||||||
"MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro",
|
"MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro",
|
||||||
"MessageCheckingCron": "Checking cron...",
|
"MessageCheckingCron": "Checking cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?",
|
"MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||||
"MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?",
|
||||||
|
|||||||
+76
-67
@@ -97,13 +97,13 @@
|
|||||||
"HeaderCover": "Couverture",
|
"HeaderCover": "Couverture",
|
||||||
"HeaderCurrentDownloads": "Téléchargements en cours",
|
"HeaderCurrentDownloads": "Téléchargements en cours",
|
||||||
"HeaderDetails": "Détails",
|
"HeaderDetails": "Détails",
|
||||||
"HeaderDownloadQueue": "File d'attente de téléchargements",
|
"HeaderDownloadQueue": "File d’attente de téléchargements",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Fichier des livres numériques",
|
||||||
"HeaderEmail": "E-mails",
|
"HeaderEmail": "Courriels",
|
||||||
"HeaderEmailSettings": "Configuration des e-mails",
|
"HeaderEmailSettings": "Configuration des courriels",
|
||||||
"HeaderEpisodes": "Épisodes",
|
"HeaderEpisodes": "Épisodes",
|
||||||
"HeaderEreaderDevices": "Lecteurs d'e-books",
|
"HeaderEreaderDevices": "Lecteur de livres numériques",
|
||||||
"HeaderEreaderSettings": "Ereader Settings",
|
"HeaderEreaderSettings": "Options Ereader",
|
||||||
"HeaderFiles": "Fichiers",
|
"HeaderFiles": "Fichiers",
|
||||||
"HeaderFindChapters": "Trouver les chapitres",
|
"HeaderFindChapters": "Trouver les chapitres",
|
||||||
"HeaderIgnoredFiles": "Fichiers Ignorés",
|
"HeaderIgnoredFiles": "Fichiers Ignorés",
|
||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
|
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
|
||||||
"HeaderRSSFeedGeneral": "Détails de flux RSS",
|
"HeaderRSSFeedGeneral": "Détails de flux RSS",
|
||||||
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
|
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
|
||||||
|
"HeaderRSSFeeds": "Flux RSS",
|
||||||
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
|
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
|
||||||
"HeaderSchedule": "Programmation",
|
"HeaderSchedule": "Programmation",
|
||||||
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
|
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
|
||||||
@@ -147,7 +148,7 @@
|
|||||||
"HeaderSettingsDisplay": "Affichage",
|
"HeaderSettingsDisplay": "Affichage",
|
||||||
"HeaderSettingsExperimental": "Fonctionnalités expérimentales",
|
"HeaderSettingsExperimental": "Fonctionnalités expérimentales",
|
||||||
"HeaderSettingsGeneral": "Général",
|
"HeaderSettingsGeneral": "Général",
|
||||||
"HeaderSettingsScanner": "Scanneur",
|
"HeaderSettingsScanner": "Analyseur",
|
||||||
"HeaderSleepTimer": "Minuterie",
|
"HeaderSleepTimer": "Minuterie",
|
||||||
"HeaderStatsLargestItems": "Articles les plus lourd",
|
"HeaderStatsLargestItems": "Articles les plus lourd",
|
||||||
"HeaderStatsLongestItems": "Articles les plus long (heures)",
|
"HeaderStatsLongestItems": "Articles les plus long (heures)",
|
||||||
@@ -155,7 +156,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Sessions récentes",
|
"HeaderStatsRecentSessions": "Sessions récentes",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Auteurs",
|
"HeaderStatsTop10Authors": "Top 10 Auteurs",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||||
"HeaderTableOfContents": "Table of Contents",
|
"HeaderTableOfContents": "Table des matières",
|
||||||
"HeaderTools": "Outils",
|
"HeaderTools": "Outils",
|
||||||
"HeaderUpdateAccount": "Mettre à jour le compte",
|
"HeaderUpdateAccount": "Mettre à jour le compte",
|
||||||
"HeaderUpdateAuthor": "Mettre à jour l’auteur",
|
"HeaderUpdateAuthor": "Mettre à jour l’auteur",
|
||||||
@@ -201,11 +202,12 @@
|
|||||||
"LabelClosePlayer": "Fermer le lecteur",
|
"LabelClosePlayer": "Fermer le lecteur",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Réduire les séries",
|
"LabelCollapseSeries": "Réduire les séries",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "Collections",
|
"LabelCollections": "Collections",
|
||||||
"LabelComplete": "Complet",
|
"LabelComplete": "Complet",
|
||||||
"LabelConfirmPassword": "Confirmer le mot de passe",
|
"LabelConfirmPassword": "Confirmer le mot de passe",
|
||||||
"LabelContinueListening": "Continuer la lecture",
|
"LabelContinueListening": "Continuer la lecture",
|
||||||
"LabelContinueReading": "Continue Reading",
|
"LabelContinueReading": "Continuer la lecture",
|
||||||
"LabelContinueSeries": "Continuer la série",
|
"LabelContinueSeries": "Continuer la série",
|
||||||
"LabelCover": "Couverture",
|
"LabelCover": "Couverture",
|
||||||
"LabelCoverImageURL": "URL vers l’image de couverture",
|
"LabelCoverImageURL": "URL vers l’image de couverture",
|
||||||
@@ -222,18 +224,19 @@
|
|||||||
"LabelDirectory": "Répertoire",
|
"LabelDirectory": "Répertoire",
|
||||||
"LabelDiscFromFilename": "Disque depuis le fichier",
|
"LabelDiscFromFilename": "Disque depuis le fichier",
|
||||||
"LabelDiscFromMetadata": "Disque depuis les métadonnées",
|
"LabelDiscFromMetadata": "Disque depuis les métadonnées",
|
||||||
|
"LabelDiscover": "Découvrir",
|
||||||
"LabelDownload": "Téléchargement",
|
"LabelDownload": "Téléchargement",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Télécharger {0} épisode(s)",
|
||||||
"LabelDuration": "Durée",
|
"LabelDuration": "Durée",
|
||||||
"LabelDurationFound": "Durée trouvée :",
|
"LabelDurationFound": "Durée trouvée :",
|
||||||
"LabelEbook": "E-book",
|
"LabelEbook": "Livre numérique",
|
||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "Livres numériques",
|
||||||
"LabelEdit": "Modifier",
|
"LabelEdit": "Modifier",
|
||||||
"LabelEmail": "E-mail",
|
"LabelEmail": "Courriel",
|
||||||
"LabelEmailSettingsFromAddress": "Expéditeur",
|
"LabelEmailSettingsFromAddress": "Expéditeur",
|
||||||
"LabelEmailSettingsSecure": "Sécurisé",
|
"LabelEmailSettingsSecure": "Sécurisé",
|
||||||
"LabelEmailSettingsSecureHelp": "Si coché, la connexion utilisera TLS lors de la connexion au serveur. Sinon TLS est utilisé si le serveur prend en charge l'extension STARTTLS. Dans la plupart des cas, cochez si vous vous connectez au port 465. Décochez pour le port 587 ou 25. (source: nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "Utiliser TLS lors de la connexion au serveur, autrement TLS sera utilisé si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, actviez l’option si vous vous connectez au port 465. Désactivez l’option pour utiliser port 587 ou 25. (source: nodemailer.com/smtp/#authentication)",
|
||||||
"LabelEmailSettingsTestAddress": "Test Address",
|
"LabelEmailSettingsTestAddress": "Adresse de test",
|
||||||
"LabelEmbeddedCover": "Couverture du livre intégrée",
|
"LabelEmbeddedCover": "Couverture du livre intégrée",
|
||||||
"LabelEnable": "Activer",
|
"LabelEnable": "Activer",
|
||||||
"LabelEnd": "Fin",
|
"LabelEnd": "Fin",
|
||||||
@@ -252,13 +255,13 @@
|
|||||||
"LabelFinished": "Fini(e)",
|
"LabelFinished": "Fini(e)",
|
||||||
"LabelFolder": "Dossier",
|
"LabelFolder": "Dossier",
|
||||||
"LabelFolders": "Dossiers",
|
"LabelFolders": "Dossiers",
|
||||||
"LabelFontScale": "Font scale",
|
"LabelFontScale": "Taille de la police de caractère",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genres",
|
"LabelGenres": "Genres",
|
||||||
"LabelHardDeleteFile": "Suppression du fichier",
|
"LabelHardDeleteFile": "Suppression du fichier",
|
||||||
"LabelHasEbook": "Has ebook",
|
"LabelHasEbook": "Dispose d’un livre numérique",
|
||||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
"LabelHasSupplementaryEbook": "Dispose d’un livre numérique supplémentaire",
|
||||||
"LabelHost": "Hôte",
|
"LabelHost": "Hôte",
|
||||||
"LabelHour": "Heure",
|
"LabelHour": "Heure",
|
||||||
"LabelIcon": "Icone",
|
"LabelIcon": "Icone",
|
||||||
@@ -284,16 +287,16 @@
|
|||||||
"LabelLastSeen": "Vu dernièrement",
|
"LabelLastSeen": "Vu dernièrement",
|
||||||
"LabelLastTime": "Progression",
|
"LabelLastTime": "Progression",
|
||||||
"LabelLastUpdate": "Dernière mise à jour",
|
"LabelLastUpdate": "Dernière mise à jour",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Disposition",
|
||||||
"LabelLayoutSinglePage": "Single page",
|
"LabelLayoutSinglePage": "Vue unique",
|
||||||
"LabelLayoutSplitPage": "Split page",
|
"LabelLayoutSplitPage": "Vue partagée",
|
||||||
"LabelLess": "Moins",
|
"LabelLess": "Moins",
|
||||||
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l’utilisateur",
|
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l’utilisateur",
|
||||||
"LabelLibrary": "Bibliothèque",
|
"LabelLibrary": "Bibliothèque",
|
||||||
"LabelLibraryItem": "Article de bibliothèque",
|
"LabelLibraryItem": "Article de bibliothèque",
|
||||||
"LabelLibraryName": "Nom de la bibliothèque",
|
"LabelLibraryName": "Nom de la bibliothèque",
|
||||||
"LabelLimit": "Limite",
|
"LabelLimit": "Limite",
|
||||||
"LabelLineSpacing": "Line spacing",
|
"LabelLineSpacing": "Interligne",
|
||||||
"LabelListenAgain": "Écouter à nouveau",
|
"LabelListenAgain": "Écouter à nouveau",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -318,7 +321,7 @@
|
|||||||
"LabelNewPassword": "Nouveau mot de passe",
|
"LabelNewPassword": "Nouveau mot de passe",
|
||||||
"LabelNextBackupDate": "Date de la prochaine sauvegarde",
|
"LabelNextBackupDate": "Date de la prochaine sauvegarde",
|
||||||
"LabelNextScheduledRun": "Prochain lancement prévu",
|
"LabelNextScheduledRun": "Prochain lancement prévu",
|
||||||
"LabelNoEpisodesSelected": "No episodes selected",
|
"LabelNoEpisodesSelected": "Aucun épisode sélectionné",
|
||||||
"LabelNotes": "Notes",
|
"LabelNotes": "Notes",
|
||||||
"LabelNotFinished": "Non terminé(e)",
|
"LabelNotFinished": "Non terminé(e)",
|
||||||
"LabelNotificationAppriseURL": "URL(s) d’Apprise",
|
"LabelNotificationAppriseURL": "URL(s) d’Apprise",
|
||||||
@@ -353,22 +356,22 @@
|
|||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
|
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
|
||||||
"LabelPreventIndexing": "Empêcher l’indexation de votre flux par les bases de données iTunes et Google podcast",
|
"LabelPreventIndexing": "Empêcher l’indexation de votre flux par les bases de données iTunes et Google podcast",
|
||||||
"LabelPrimaryEbook": "Primary ebook",
|
"LabelPrimaryEbook": "Premier livre numérique",
|
||||||
"LabelProgress": "Progression",
|
"LabelProgress": "Progression",
|
||||||
"LabelProvider": "Fournisseur",
|
"LabelProvider": "Fournisseur",
|
||||||
"LabelPubDate": "Date de publication",
|
"LabelPubDate": "Date de publication",
|
||||||
"LabelPublisher": "Éditeur",
|
"LabelPublisher": "Éditeur",
|
||||||
"LabelPublishYear": "Année d’édition",
|
"LabelPublishYear": "Année d’édition",
|
||||||
"LabelRead": "Read",
|
"LabelRead": "Lire",
|
||||||
"LabelReadAgain": "Read Again",
|
"LabelReadAgain": "Lire à nouveau",
|
||||||
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
|
"LabelReadEbookWithoutProgress": "Lire le livre numérique sans sauvegarder la progression",
|
||||||
"LabelRecentlyAdded": "Derniers ajouts",
|
"LabelRecentlyAdded": "Derniers ajouts",
|
||||||
"LabelRecentSeries": "Séries récentes",
|
"LabelRecentSeries": "Séries récentes",
|
||||||
"LabelRecommended": "Recommandé",
|
"LabelRecommended": "Recommandé",
|
||||||
"LabelRegion": "Région",
|
"LabelRegion": "Région",
|
||||||
"LabelReleaseDate": "Date de parution",
|
"LabelReleaseDate": "Date de parution",
|
||||||
"LabelRemoveCover": "Supprimer la couverture",
|
"LabelRemoveCover": "Supprimer la couverture",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "E-mail propriétaire personnalisé",
|
"LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé",
|
||||||
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
|
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
|
||||||
"LabelRSSFeedOpen": "Flux RSS ouvert",
|
"LabelRSSFeedOpen": "Flux RSS ouvert",
|
||||||
"LabelRSSFeedPreventIndexing": "Empêcher l’indexation",
|
"LabelRSSFeedPreventIndexing": "Empêcher l’indexation",
|
||||||
@@ -378,47 +381,50 @@
|
|||||||
"LabelSearchTitle": "Titre de recherche",
|
"LabelSearchTitle": "Titre de recherche",
|
||||||
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
|
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
|
||||||
"LabelSeason": "Saison",
|
"LabelSeason": "Saison",
|
||||||
"LabelSelectAllEpisodes": "Select all episodes",
|
"LabelSelectAllEpisodes": "Sélectionner tous les épisodes",
|
||||||
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
"LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours",
|
||||||
"LabelSendEbookToDevice": "Envoyer l'e-book à...",
|
"LabelSendEbookToDevice": "Envoyer le livre numérique à...",
|
||||||
"LabelSequence": "Séquence",
|
"LabelSequence": "Séquence",
|
||||||
"LabelSeries": "Séries",
|
"LabelSeries": "Séries",
|
||||||
"LabelSeriesName": "Nom de la série",
|
"LabelSeriesName": "Nom de la série",
|
||||||
"LabelSeriesProgress": "Progression de séries",
|
"LabelSeriesProgress": "Progression de séries",
|
||||||
"LabelSetEbookAsPrimary": "Set as primary",
|
"LabelSetEbookAsPrimary": "Définir comme principale",
|
||||||
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
"LabelSetEbookAsSupplementary": "Définir comme supplémentaire",
|
||||||
"LabelSettingsAudiobooksOnly": "Audiobooks only",
|
"LabelSettingsAudiobooksOnly": "Livres audios seulement",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
|
"LabelSettingsAudiobooksOnlyHelp": "L’activation de ce paramètre ignorera les fichiers “ ebook ”, à moins qu’ils ne se trouvent dans un dossier de livres audio, auquel cas ils seront définis comme des livres numériques supplémentaires.",
|
||||||
"LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois",
|
"LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois",
|
||||||
"LabelSettingsChromecastSupport": "Support du Chromecast",
|
"LabelSettingsChromecastSupport": "Support du Chromecast",
|
||||||
"LabelSettingsDateFormat": "Format de date",
|
"LabelSettingsDateFormat": "Format de date",
|
||||||
"LabelSettingsDisableWatcher": "Désactiver la surveillance",
|
"LabelSettingsDisableWatcher": "Désactiver la surveillance",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
|
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
|
||||||
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*",
|
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque des modifications de fichiers sont détectées. *Nécessite le redémarrage du serveur",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
|
"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.",
|
"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",
|
"LabelSettingsFindCovers": "Chercher des couvertures de livre",
|
||||||
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d’analyse.",
|
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d’analyse.",
|
||||||
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
"LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques",
|
||||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
"LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent qu’un seul livre seront masquées sur la page de la série et sur les étagères de la page d’accueil.",
|
||||||
"LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère",
|
"LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère",
|
||||||
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
|
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
|
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
|
||||||
"LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 d’Overdrive viennent avec les minutages des chapitres intégrés en métadonnées. Activer ce paramètre utilisera ces minutages pour les chapitres automatiquement.",
|
"LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 d’Overdrive viennent avec les minutages des chapitres intégrés en métadonnées. Activer ce paramètre utilisera ces minutages pour les chapitres automatiquement.",
|
||||||
"LabelSettingsParseSubtitles": "Analyse des sous-titres",
|
"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>i.e. « 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 « - »<br>i.e. « Titre du Livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »",
|
||||||
"LabelSettingsPreferAudioMetadata": "Préférer les Métadonnées audio",
|
"LabelSettingsPreferAudioMetadata": "Préférer les métadonnées audio",
|
||||||
"LabelSettingsPreferAudioMetadataHelp": "Les méta étiquettes ID3 des fichiers audios seront utilisés à la place des noms de dossier pour les détails du livre audio",
|
"LabelSettingsPreferAudioMetadataHelp": "Les méta étiquettes ID3 des fichiers audios seront utilisés à la place des noms de dossier pour les détails du livre audio",
|
||||||
"LabelSettingsPreferMatchedMetadata": "Préférer les Métadonnées par correspondance",
|
"LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance",
|
||||||
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de l’article lors d’une recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
|
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de l’article lors d’une recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
|
||||||
"LabelSettingsPreferOPFMetadata": "Préférer les Métadonnées OPF",
|
"LabelSettingsPreferOPFMetadata": "Préférer les métadonnées OPF",
|
||||||
"LabelSettingsPreferOPFMetadataHelp": "Les fichiers de métadonnées OPF seront utilisés à la place des noms de dossier pour les détails du Livre Audio",
|
"LabelSettingsPreferOPFMetadataHelp": "Les fichiers de métadonnées OPF seront utilisés à la place des noms de dossier pour les détails du Livre Audio",
|
||||||
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN",
|
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN",
|
||||||
"LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN",
|
"LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN",
|
||||||
"LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri",
|
"LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri",
|
||||||
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »",
|
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »",
|
||||||
"LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées",
|
"LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées",
|
||||||
"LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standardes de 1.6:1.",
|
"LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standards de ratio 1.6:1.",
|
||||||
"LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles",
|
"LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles",
|
||||||
"LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de l’article. Seul un fichier nommé « cover » sera conservé.",
|
"LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de l’article. Seul un fichier nommé « cover » sera conservé.",
|
||||||
"LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles",
|
"LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Afficher Tout",
|
"LabelShowAll": "Afficher Tout",
|
||||||
"LabelSize": "Taille",
|
"LabelSize": "Taille",
|
||||||
"LabelSleepTimer": "Minuterie",
|
"LabelSleepTimer": "Minuterie",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "Démarrer",
|
"LabelStart": "Démarrer",
|
||||||
"LabelStarted": "Démarré",
|
"LabelStarted": "Démarré",
|
||||||
"LabelStartedAt": "Démarré à",
|
"LabelStartedAt": "Démarré à",
|
||||||
@@ -451,11 +458,11 @@
|
|||||||
"LabelTag": "Étiquette",
|
"LabelTag": "Étiquette",
|
||||||
"LabelTags": "Étiquettes",
|
"LabelTags": "Étiquettes",
|
||||||
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l’utilisateur",
|
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l’utilisateur",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Étiquettes non accessibles à l’utilisateur",
|
||||||
"LabelTasks": "Tâches en cours",
|
"LabelTasks": "Tâches en cours",
|
||||||
"LabelTheme": "Theme",
|
"LabelTheme": "Thème",
|
||||||
"LabelThemeDark": "Dark",
|
"LabelThemeDark": "Sombre",
|
||||||
"LabelThemeLight": "Light",
|
"LabelThemeLight": "Clair",
|
||||||
"LabelTimeBase": "Base de temps",
|
"LabelTimeBase": "Base de temps",
|
||||||
"LabelTimeListened": "Temps d’écoute",
|
"LabelTimeListened": "Temps d’écoute",
|
||||||
"LabelTimeListenedToday": "Nombres d’écoutes Aujourd’hui",
|
"LabelTimeListenedToday": "Nombres d’écoutes Aujourd’hui",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Piste depuis les métadonnées",
|
"LabelTrackFromMetadata": "Piste depuis les métadonnées",
|
||||||
"LabelTracks": "Pistes",
|
"LabelTracks": "Pistes",
|
||||||
"LabelTracksMultiTrack": "Piste multiple",
|
"LabelTracksMultiTrack": "Piste multiple",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Piste simple",
|
"LabelTracksSingleTrack": "Piste simple",
|
||||||
"LabelType": "Type",
|
"LabelType": "Type",
|
||||||
"LabelUnabridged": "Version intégrale",
|
"LabelUnabridged": "Version intégrale",
|
||||||
@@ -512,22 +520,23 @@
|
|||||||
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
|
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
|
||||||
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
|
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
|
||||||
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
|
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
|
||||||
"MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio",
|
"MessageChapterStartIsAfter": "Le premier chapitre est situé au début de votre livre audio",
|
||||||
"MessageCheckingCron": "Vérification du cron…",
|
"MessageCheckingCron": "Vérification du cron…",
|
||||||
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?",
|
"MessageConfirmCloseFeed": "Êtes-vous sûr de vouloir fermer ce flux ?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la sauvegarde de « {0} » ?",
|
||||||
|
"MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?",
|
||||||
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",
|
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",
|
||||||
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
|
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
|
||||||
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?",
|
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
|
"MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer comme terminé tous les livres de cette série ?",
|
"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 comme non terminé tous les livres de cette série ?",
|
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?",
|
||||||
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
|
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
|
||||||
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
|
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
|
||||||
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?",
|
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?",
|
||||||
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
|
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
|
||||||
"MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur « {0} » ?",
|
||||||
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?",
|
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?",
|
||||||
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?",
|
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?",
|
||||||
"MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
|
"MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
|
||||||
@@ -535,12 +544,12 @@
|
|||||||
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les articles ?",
|
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les articles ?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
|
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
|
||||||
"MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».",
|
"MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».",
|
||||||
"MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer l'ebook {0} \"{1}\" à l'appareil \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à l’appareil « {2} »?",
|
||||||
"MessageDownloadingEpisode": "Téléchargement de l’épisode",
|
"MessageDownloadingEpisode": "Téléchargement de l’épisode",
|
||||||
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l’ordre correct",
|
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l’ordre correct",
|
||||||
"MessageEmbedFinished": "Intégration Terminée !",
|
"MessageEmbedFinished": "Intégration terminée !",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement",
|
"MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement",
|
||||||
"MessageFeedURLWillBe": "l’URL du Flux sera {0}",
|
"MessageFeedURLWillBe": "l’URL du flux sera {0}",
|
||||||
"MessageFetching": "Récupération…",
|
"MessageFetching": "Récupération…",
|
||||||
"MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme s’ils étaient nouveaux.",
|
"MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme s’ils étaient nouveaux.",
|
||||||
"MessageImportantNotice": "Information Importante !",
|
"MessageImportantNotice": "Information Importante !",
|
||||||
@@ -551,13 +560,13 @@
|
|||||||
"MessageListeningSessionsInTheLastYear": "{0} sessions d’écoute l’an dernier",
|
"MessageListeningSessionsInTheLastYear": "{0} sessions d’écoute l’an dernier",
|
||||||
"MessageLoading": "Chargement…",
|
"MessageLoading": "Chargement…",
|
||||||
"MessageLoadingFolders": "Chargement des dossiers…",
|
"MessageLoadingFolders": "Chargement des dossiers…",
|
||||||
"MessageM4BFailed": "M4B en échec !",
|
"MessageM4BFailed": "M4B échec",
|
||||||
"MessageM4BFinished": "M4B terminé !",
|
"MessageM4BFinished": "M4B terminé",
|
||||||
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster l’horodatage.",
|
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster l’horodatage.",
|
||||||
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
|
"MessageMarkAllEpisodesFinished": "Marquer tous les épisodes terminés",
|
||||||
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
|
"MessageMarkAllEpisodesNotFinished": "Marquer tous les épisodes non terminés",
|
||||||
"MessageMarkAsFinished": "Marquer comme terminé",
|
"MessageMarkAsFinished": "Marquer comme terminé",
|
||||||
"MessageMarkAsNotFinished": "Marquer comme non Terminé",
|
"MessageMarkAsNotFinished": "Marquer comme non terminé",
|
||||||
"MessageMatchBooksDescription": "tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. N’écrase pas les données existantes.",
|
"MessageMatchBooksDescription": "tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. N’écrase pas les données existantes.",
|
||||||
"MessageNoAudioTracks": "Aucune piste audio",
|
"MessageNoAudioTracks": "Aucune piste audio",
|
||||||
"MessageNoAuthors": "Aucun auteur",
|
"MessageNoAuthors": "Aucun auteur",
|
||||||
@@ -691,8 +700,8 @@
|
|||||||
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
|
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
|
||||||
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
|
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
|
||||||
"ToastRSSFeedCloseSuccess": "Flux RSS fermé",
|
"ToastRSSFeedCloseSuccess": "Flux RSS fermé",
|
||||||
"ToastSendEbookToDeviceFailed": "Échec de l'envoi de l'e-book à l'appareil",
|
"ToastSendEbookToDeviceFailed": "Échec de l’envoi du livre numérique à l’appareil",
|
||||||
"ToastSendEbookToDeviceSuccess": "E-book envoyé à l'appareil \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "Livre numérique envoyé à l’appareil : {0}",
|
||||||
"ToastSeriesUpdateFailed": "Échec de la mise à jour de la série",
|
"ToastSeriesUpdateFailed": "Échec de la mise à jour de la série",
|
||||||
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
|
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
|
||||||
"ToastSessionDeleteFailed": "Échec de la suppression de session",
|
"ToastSessionDeleteFailed": "Échec de la suppression de session",
|
||||||
@@ -702,4 +711,4 @@
|
|||||||
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
|
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
|
||||||
"ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur",
|
"ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur",
|
||||||
"ToastUserDeleteSuccess": "Utilisateur supprimé"
|
"ToastUserDeleteSuccess": "Utilisateur supprimé"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "RSS Details",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||||
"HeaderSchedule": "Schedule",
|
"HeaderSchedule": "Schedule",
|
||||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"LabelClosePlayer": "Close player",
|
"LabelClosePlayer": "Close player",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Collapse Series",
|
"LabelCollapseSeries": "Collapse Series",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "Collections",
|
"LabelCollections": "Collections",
|
||||||
"LabelComplete": "Complete",
|
"LabelComplete": "Complete",
|
||||||
"LabelConfirmPassword": "Confirm Password",
|
"LabelConfirmPassword": "Confirm Password",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"LabelDirectory": "Directory",
|
"LabelDirectory": "Directory",
|
||||||
"LabelDiscFromFilename": "Disc from Filename",
|
"LabelDiscFromFilename": "Disc from Filename",
|
||||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duration",
|
"LabelDuration": "Duration",
|
||||||
@@ -395,6 +398,9 @@
|
|||||||
"LabelSettingsDisableWatcher": "Disable Watcher",
|
"LabelSettingsDisableWatcher": "Disable Watcher",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
||||||
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Experimental features",
|
"LabelSettingsExperimentalFeatures": "Experimental features",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||||
"LabelSettingsFindCovers": "Find covers",
|
"LabelSettingsFindCovers": "Find covers",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Show All",
|
"LabelShowAll": "Show All",
|
||||||
"LabelSize": "Size",
|
"LabelSize": "Size",
|
||||||
"LabelSleepTimer": "Sleep timer",
|
"LabelSleepTimer": "Sleep timer",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStarted": "Started",
|
"LabelStarted": "Started",
|
||||||
"LabelStartedAt": "Started At",
|
"LabelStartedAt": "Started At",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Track from Metadata",
|
"LabelTrackFromMetadata": "Track from Metadata",
|
||||||
"LabelTracks": "Tracks",
|
"LabelTracks": "Tracks",
|
||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Type",
|
"LabelType": "Type",
|
||||||
"LabelUnabridged": "Unabridged",
|
"LabelUnabridged": "Unabridged",
|
||||||
@@ -514,6 +522,7 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||||
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
||||||
"MessageCheckingCron": "Checking cron...",
|
"MessageCheckingCron": "Checking cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||||
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "RSS Details",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||||
"HeaderSchedule": "Schedule",
|
"HeaderSchedule": "Schedule",
|
||||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"LabelClosePlayer": "Close player",
|
"LabelClosePlayer": "Close player",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Collapse Series",
|
"LabelCollapseSeries": "Collapse Series",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "Collections",
|
"LabelCollections": "Collections",
|
||||||
"LabelComplete": "Complete",
|
"LabelComplete": "Complete",
|
||||||
"LabelConfirmPassword": "Confirm Password",
|
"LabelConfirmPassword": "Confirm Password",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"LabelDirectory": "Directory",
|
"LabelDirectory": "Directory",
|
||||||
"LabelDiscFromFilename": "Disc from Filename",
|
"LabelDiscFromFilename": "Disc from Filename",
|
||||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duration",
|
"LabelDuration": "Duration",
|
||||||
@@ -395,6 +398,9 @@
|
|||||||
"LabelSettingsDisableWatcher": "Disable Watcher",
|
"LabelSettingsDisableWatcher": "Disable Watcher",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
||||||
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Experimental features",
|
"LabelSettingsExperimentalFeatures": "Experimental features",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||||
"LabelSettingsFindCovers": "Find covers",
|
"LabelSettingsFindCovers": "Find covers",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Show All",
|
"LabelShowAll": "Show All",
|
||||||
"LabelSize": "Size",
|
"LabelSize": "Size",
|
||||||
"LabelSleepTimer": "Sleep timer",
|
"LabelSleepTimer": "Sleep timer",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStarted": "Started",
|
"LabelStarted": "Started",
|
||||||
"LabelStartedAt": "Started At",
|
"LabelStartedAt": "Started At",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Track from Metadata",
|
"LabelTrackFromMetadata": "Track from Metadata",
|
||||||
"LabelTracks": "Tracks",
|
"LabelTracks": "Tracks",
|
||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Type",
|
"LabelType": "Type",
|
||||||
"LabelUnabridged": "Unabridged",
|
"LabelUnabridged": "Unabridged",
|
||||||
@@ -514,6 +522,7 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||||
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
||||||
"MessageCheckingCron": "Checking cron...",
|
"MessageCheckingCron": "Checking cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||||
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e",
|
"HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e",
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "RSS Details",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
|
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "Spremljen Media Progress",
|
"HeaderSavedMediaProgress": "Spremljen Media Progress",
|
||||||
"HeaderSchedule": "Schedule",
|
"HeaderSchedule": "Schedule",
|
||||||
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke",
|
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke",
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"LabelClosePlayer": "Close player",
|
"LabelClosePlayer": "Close player",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Collapse Series",
|
"LabelCollapseSeries": "Collapse Series",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "Kolekcije",
|
"LabelCollections": "Kolekcije",
|
||||||
"LabelComplete": "Complete",
|
"LabelComplete": "Complete",
|
||||||
"LabelConfirmPassword": "Potvrdi lozinku",
|
"LabelConfirmPassword": "Potvrdi lozinku",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"LabelDirectory": "Direktorij",
|
"LabelDirectory": "Direktorij",
|
||||||
"LabelDiscFromFilename": "CD iz imena datoteke",
|
"LabelDiscFromFilename": "CD iz imena datoteke",
|
||||||
"LabelDiscFromMetadata": "CD iz metapodataka",
|
"LabelDiscFromMetadata": "CD iz metapodataka",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
"LabelDownload": "Preuzmi",
|
"LabelDownload": "Preuzmi",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Trajanje",
|
"LabelDuration": "Trajanje",
|
||||||
@@ -395,6 +398,9 @@
|
|||||||
"LabelSettingsDisableWatcher": "Isključi Watchera",
|
"LabelSettingsDisableWatcher": "Isključi Watchera",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku",
|
"LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku",
|
||||||
"LabelSettingsDisableWatcherHelp": "Isključi automatsko dodavanje/aktualiziranje stavci ako su promjene prepoznate. *Potreban restart servera",
|
"LabelSettingsDisableWatcherHelp": "Isključi automatsko dodavanje/aktualiziranje stavci ako su promjene prepoznate. *Potreban restart servera",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Eksperimentalni features",
|
"LabelSettingsExperimentalFeatures": "Eksperimentalni features",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
|
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
|
||||||
"LabelSettingsFindCovers": "Pronađi covers",
|
"LabelSettingsFindCovers": "Pronađi covers",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Prikaži sve",
|
"LabelShowAll": "Prikaži sve",
|
||||||
"LabelSize": "Veličina",
|
"LabelSize": "Veličina",
|
||||||
"LabelSleepTimer": "Sleep timer",
|
"LabelSleepTimer": "Sleep timer",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "Pokreni",
|
"LabelStart": "Pokreni",
|
||||||
"LabelStarted": "Pokrenuto",
|
"LabelStarted": "Pokrenuto",
|
||||||
"LabelStartedAt": "Pokrenuto",
|
"LabelStartedAt": "Pokrenuto",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Track iz metapodataka",
|
"LabelTrackFromMetadata": "Track iz metapodataka",
|
||||||
"LabelTracks": "Tracks",
|
"LabelTracks": "Tracks",
|
||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Tip",
|
"LabelType": "Tip",
|
||||||
"LabelUnabridged": "Unabridged",
|
"LabelUnabridged": "Unabridged",
|
||||||
@@ -514,6 +522,7 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||||
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
|
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
|
||||||
"MessageCheckingCron": "Provjeravam cron...",
|
"MessageCheckingCron": "Provjeravam cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
|
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||||
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",
|
||||||
|
|||||||
+62
-53
@@ -55,7 +55,7 @@
|
|||||||
"ButtonRemoveAll": "Rimuovi Tutto",
|
"ButtonRemoveAll": "Rimuovi Tutto",
|
||||||
"ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria",
|
"ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria",
|
||||||
"ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto",
|
"ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto",
|
||||||
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
|
"ButtonRemoveFromContinueReading": "Rimuovi per proseguire la lettura",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
|
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
|
||||||
"ButtonReScan": "Ri-scansiona",
|
"ButtonReScan": "Ri-scansiona",
|
||||||
"ButtonReset": "Reset",
|
"ButtonReset": "Reset",
|
||||||
@@ -95,15 +95,15 @@
|
|||||||
"HeaderCollection": "Raccolta",
|
"HeaderCollection": "Raccolta",
|
||||||
"HeaderCollectionItems": "Elementi della Raccolta",
|
"HeaderCollectionItems": "Elementi della Raccolta",
|
||||||
"HeaderCover": "Cover",
|
"HeaderCover": "Cover",
|
||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "Download Correnti",
|
||||||
"HeaderDetails": "Dettagli",
|
"HeaderDetails": "Dettagli",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Download Queue",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Ebook Files",
|
||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Episodi",
|
"HeaderEpisodes": "Episodi",
|
||||||
"HeaderEreaderDevices": "Ereader Devices",
|
"HeaderEreaderDevices": "Dispositivo Ereader",
|
||||||
"HeaderEreaderSettings": "Ereader Settings",
|
"HeaderEreaderSettings": "Impostazioni Ereader",
|
||||||
"HeaderFiles": "File",
|
"HeaderFiles": "File",
|
||||||
"HeaderFindChapters": "Trova Capitoli",
|
"HeaderFindChapters": "Trova Capitoli",
|
||||||
"HeaderIgnoredFiles": "File Ignorati",
|
"HeaderIgnoredFiles": "File Ignorati",
|
||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
|
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "RSS Details",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
|
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "Progressi salvati",
|
"HeaderSavedMediaProgress": "Progressi salvati",
|
||||||
"HeaderSchedule": "Schedula",
|
"HeaderSchedule": "Schedula",
|
||||||
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
|
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
|
||||||
@@ -149,13 +150,13 @@
|
|||||||
"HeaderSettingsGeneral": "Generale",
|
"HeaderSettingsGeneral": "Generale",
|
||||||
"HeaderSettingsScanner": "Scanner",
|
"HeaderSettingsScanner": "Scanner",
|
||||||
"HeaderSleepTimer": "Sveglia",
|
"HeaderSleepTimer": "Sveglia",
|
||||||
"HeaderStatsLargestItems": "Largest Items",
|
"HeaderStatsLargestItems": "Oggetti Grandi",
|
||||||
"HeaderStatsLongestItems": "libri più lunghi (ore)",
|
"HeaderStatsLongestItems": "libri più lunghi (ore)",
|
||||||
"HeaderStatsMinutesListeningChart": "Minuti ascoltati (Ultimi 7 Giorni)",
|
"HeaderStatsMinutesListeningChart": "Minuti ascoltati (Ultimi 7 Giorni)",
|
||||||
"HeaderStatsRecentSessions": "Sessioni Recenti",
|
"HeaderStatsRecentSessions": "Sessioni Recenti",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Autori",
|
"HeaderStatsTop10Authors": "Top 10 Autori",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Generi",
|
"HeaderStatsTop5Genres": "Top 5 Generi",
|
||||||
"HeaderTableOfContents": "Table of Contents",
|
"HeaderTableOfContents": "Tabellla dei Contenuti",
|
||||||
"HeaderTools": "Strumenti",
|
"HeaderTools": "Strumenti",
|
||||||
"HeaderUpdateAccount": "Aggiorna Account",
|
"HeaderUpdateAccount": "Aggiorna Account",
|
||||||
"HeaderUpdateAuthor": "Aggiorna Autore",
|
"HeaderUpdateAuthor": "Aggiorna Autore",
|
||||||
@@ -163,13 +164,13 @@
|
|||||||
"HeaderUpdateLibrary": "Aggiorna Libreria",
|
"HeaderUpdateLibrary": "Aggiorna Libreria",
|
||||||
"HeaderUsers": "Utenti",
|
"HeaderUsers": "Utenti",
|
||||||
"HeaderYourStats": "Statistiche Personali",
|
"HeaderYourStats": "Statistiche Personali",
|
||||||
"LabelAbridged": "Abridged",
|
"LabelAbridged": "Abbreviato",
|
||||||
"LabelAccountType": "Tipo di Account",
|
"LabelAccountType": "Tipo di Account",
|
||||||
"LabelAccountTypeAdmin": "Admin",
|
"LabelAccountTypeAdmin": "Admin",
|
||||||
"LabelAccountTypeGuest": "Ospite",
|
"LabelAccountTypeGuest": "Ospite",
|
||||||
"LabelAccountTypeUser": "Utente",
|
"LabelAccountTypeUser": "Utente",
|
||||||
"LabelActivity": "Attività",
|
"LabelActivity": "Attività",
|
||||||
"LabelAdded": "Added",
|
"LabelAdded": "Aggiunto",
|
||||||
"LabelAddedAt": "Aggiunto il",
|
"LabelAddedAt": "Aggiunto il",
|
||||||
"LabelAddToCollection": "Aggiungi alla Raccolta",
|
"LabelAddToCollection": "Aggiungi alla Raccolta",
|
||||||
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
|
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
|
||||||
@@ -194,18 +195,19 @@
|
|||||||
"LabelBitrate": "Bitrate",
|
"LabelBitrate": "Bitrate",
|
||||||
"LabelBooks": "Libri",
|
"LabelBooks": "Libri",
|
||||||
"LabelChangePassword": "Cambia Password",
|
"LabelChangePassword": "Cambia Password",
|
||||||
"LabelChannels": "Channels",
|
"LabelChannels": "Canali",
|
||||||
"LabelChapters": "Chapters",
|
"LabelChapters": "Capitoli",
|
||||||
"LabelChaptersFound": "Capitoli Trovati",
|
"LabelChaptersFound": "Capitoli Trovati",
|
||||||
"LabelChapterTitle": "Titoli dei Capitoli",
|
"LabelChapterTitle": "Titoli dei Capitoli",
|
||||||
"LabelClosePlayer": "Chiudi player",
|
"LabelClosePlayer": "Chiudi player",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Comprimi Serie",
|
"LabelCollapseSeries": "Comprimi Serie",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "Raccolte",
|
"LabelCollections": "Raccolte",
|
||||||
"LabelComplete": "Completo",
|
"LabelComplete": "Completo",
|
||||||
"LabelConfirmPassword": "Conferma Password",
|
"LabelConfirmPassword": "Conferma Password",
|
||||||
"LabelContinueListening": "Continua ad Ascoltare",
|
"LabelContinueListening": "Continua ad Ascoltare",
|
||||||
"LabelContinueReading": "Continue Reading",
|
"LabelContinueReading": "Continua la Lettura",
|
||||||
"LabelContinueSeries": "Continua Serie",
|
"LabelContinueSeries": "Continua Serie",
|
||||||
"LabelCover": "Cover",
|
"LabelCover": "Cover",
|
||||||
"LabelCoverImageURL": "Cover Image URL",
|
"LabelCoverImageURL": "Cover Image URL",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"LabelDirectory": "Elenco",
|
"LabelDirectory": "Elenco",
|
||||||
"LabelDiscFromFilename": "Disco dal nome file",
|
"LabelDiscFromFilename": "Disco dal nome file",
|
||||||
"LabelDiscFromMetadata": "Disco dal Metadata",
|
"LabelDiscFromMetadata": "Disco dal Metadata",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Durata",
|
"LabelDuration": "Durata",
|
||||||
@@ -230,17 +233,17 @@
|
|||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "Ebooks",
|
||||||
"LabelEdit": "Modifica",
|
"LabelEdit": "Modifica",
|
||||||
"LabelEmail": "Email",
|
"LabelEmail": "Email",
|
||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "Da Indirizzo",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "Se vero, la connessione utilizzerà TLS durante la connessione al server. Se false, viene utilizzato TLS se il server supporta l'estensione STARTTLS. Nella maggior parte dei casi impostare questo valore su true se ci si connette alla porta 465. Per la porta 587 o 25 mantenerlo false. (da nodemailer.com/smtp/#authentication)",
|
||||||
"LabelEmailSettingsTestAddress": "Test Address",
|
"LabelEmailSettingsTestAddress": "Test Indirizzo",
|
||||||
"LabelEmbeddedCover": "Embedded Cover",
|
"LabelEmbeddedCover": "Cover Integrata",
|
||||||
"LabelEnable": "Abilita",
|
"LabelEnable": "Abilita",
|
||||||
"LabelEnd": "Fine",
|
"LabelEnd": "Fine",
|
||||||
"LabelEpisode": "Episodio",
|
"LabelEpisode": "Episodio",
|
||||||
"LabelEpisodeTitle": "Titolo Episodio",
|
"LabelEpisodeTitle": "Titolo Episodio",
|
||||||
"LabelEpisodeType": "Tipo Episodio",
|
"LabelEpisodeType": "Tipo Episodio",
|
||||||
"LabelExample": "Example",
|
"LabelExample": "Esempio",
|
||||||
"LabelExplicit": "Esplicito",
|
"LabelExplicit": "Esplicito",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
"LabelFile": "File",
|
"LabelFile": "File",
|
||||||
@@ -252,13 +255,13 @@
|
|||||||
"LabelFinished": "Finita",
|
"LabelFinished": "Finita",
|
||||||
"LabelFolder": "Cartella",
|
"LabelFolder": "Cartella",
|
||||||
"LabelFolders": "Cartelle",
|
"LabelFolders": "Cartelle",
|
||||||
"LabelFontScale": "Font scale",
|
"LabelFontScale": "Dimensione Font",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Formato",
|
||||||
"LabelGenre": "Genere",
|
"LabelGenre": "Genere",
|
||||||
"LabelGenres": "Generi",
|
"LabelGenres": "Generi",
|
||||||
"LabelHardDeleteFile": "Elimina Definitivamente",
|
"LabelHardDeleteFile": "Elimina Definitivamente",
|
||||||
"LabelHasEbook": "Has ebook",
|
"LabelHasEbook": "Un ebook",
|
||||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
"LabelHasSupplementaryEbook": "Un ebook Supplementare",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Ora",
|
"LabelHour": "Ora",
|
||||||
"LabelIcon": "Icona",
|
"LabelIcon": "Icona",
|
||||||
@@ -275,18 +278,18 @@
|
|||||||
"LabelIntervalEveryDay": "Ogni Giorno",
|
"LabelIntervalEveryDay": "Ogni Giorno",
|
||||||
"LabelIntervalEveryHour": "Ogni ora",
|
"LabelIntervalEveryHour": "Ogni ora",
|
||||||
"LabelInvalidParts": "Parti Invalide",
|
"LabelInvalidParts": "Parti Invalide",
|
||||||
"LabelInvert": "Invert",
|
"LabelInvert": "Inverti",
|
||||||
"LabelItem": "Oggetti",
|
"LabelItem": "Oggetti",
|
||||||
"LabelLanguage": "Lingua",
|
"LabelLanguage": "Lingua",
|
||||||
"LabelLanguageDefaultServer": "Lingua di Default",
|
"LabelLanguageDefaultServer": "Lingua di Default",
|
||||||
"LabelLastBookAdded": "Last Book Added",
|
"LabelLastBookAdded": "Ultimo Libro Aggiunto",
|
||||||
"LabelLastBookUpdated": "Last Book Updated",
|
"LabelLastBookUpdated": "Ultimo Libro Aggiornato",
|
||||||
"LabelLastSeen": "Ultimi Visti",
|
"LabelLastSeen": "Ultimi Visti",
|
||||||
"LabelLastTime": "Ultima Volta",
|
"LabelLastTime": "Ultima Volta",
|
||||||
"LabelLastUpdate": "Ultimo Aggiornamento",
|
"LabelLastUpdate": "Ultimo Aggiornamento",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Layout",
|
||||||
"LabelLayoutSinglePage": "Single page",
|
"LabelLayoutSinglePage": "Pagina Singola",
|
||||||
"LabelLayoutSplitPage": "Split page",
|
"LabelLayoutSplitPage": "DIvidi Pagina",
|
||||||
"LabelLess": "Poco",
|
"LabelLess": "Poco",
|
||||||
"LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti",
|
"LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti",
|
||||||
"LabelLibrary": "Libreria",
|
"LabelLibrary": "Libreria",
|
||||||
@@ -308,7 +311,7 @@
|
|||||||
"LabelMissing": "Altro",
|
"LabelMissing": "Altro",
|
||||||
"LabelMissingParts": "Parti rimantenti",
|
"LabelMissingParts": "Parti rimantenti",
|
||||||
"LabelMore": "Molto",
|
"LabelMore": "Molto",
|
||||||
"LabelMoreInfo": "More Info",
|
"LabelMoreInfo": "Più Info",
|
||||||
"LabelName": "Nome",
|
"LabelName": "Nome",
|
||||||
"LabelNarrator": "Narratore",
|
"LabelNarrator": "Narratore",
|
||||||
"LabelNarrators": "Narratori",
|
"LabelNarrators": "Narratori",
|
||||||
@@ -318,7 +321,7 @@
|
|||||||
"LabelNewPassword": "Nuova Password",
|
"LabelNewPassword": "Nuova Password",
|
||||||
"LabelNextBackupDate": "Data Prossimo Backup",
|
"LabelNextBackupDate": "Data Prossimo Backup",
|
||||||
"LabelNextScheduledRun": "Data prossima esecuzione schedulata",
|
"LabelNextScheduledRun": "Data prossima esecuzione schedulata",
|
||||||
"LabelNoEpisodesSelected": "No episodes selected",
|
"LabelNoEpisodesSelected": "Nessun Episodio Selezionato",
|
||||||
"LabelNotes": "Note",
|
"LabelNotes": "Note",
|
||||||
"LabelNotFinished": "Da Completare",
|
"LabelNotFinished": "Da Completare",
|
||||||
"LabelNotificationAppriseURL": "Apprendi URL(s)",
|
"LabelNotificationAppriseURL": "Apprendi URL(s)",
|
||||||
@@ -349,19 +352,19 @@
|
|||||||
"LabelPlayMethod": "Metodo di riproduzione",
|
"LabelPlayMethod": "Metodo di riproduzione",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
"LabelPodcastType": "Timo di Podcast",
|
"LabelPodcastType": "Tipo di Podcast",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
|
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
|
||||||
"LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google",
|
"LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google",
|
||||||
"LabelPrimaryEbook": "Primary ebook",
|
"LabelPrimaryEbook": "Libri Principlae",
|
||||||
"LabelProgress": "Cominciati",
|
"LabelProgress": "Cominciati",
|
||||||
"LabelProvider": "Provider",
|
"LabelProvider": "Provider",
|
||||||
"LabelPubDate": "Data Pubblicazione",
|
"LabelPubDate": "Data Pubblicazione",
|
||||||
"LabelPublisher": "Editore",
|
"LabelPublisher": "Editore",
|
||||||
"LabelPublishYear": "Anno Pubblicazione",
|
"LabelPublishYear": "Anno Pubblicazione",
|
||||||
"LabelRead": "Read",
|
"LabelRead": "Leggi",
|
||||||
"LabelReadAgain": "Read Again",
|
"LabelReadAgain": "Leggi Ancora",
|
||||||
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
|
"LabelReadEbookWithoutProgress": "Leggi l'ebook senza mantenere i progressi",
|
||||||
"LabelRecentlyAdded": "Aggiunti Recentemente",
|
"LabelRecentlyAdded": "Aggiunti Recentemente",
|
||||||
"LabelRecentSeries": "Serie Recenti",
|
"LabelRecentSeries": "Serie Recenti",
|
||||||
"LabelRecommended": "Raccomandati",
|
"LabelRecommended": "Raccomandati",
|
||||||
@@ -378,29 +381,32 @@
|
|||||||
"LabelSearchTitle": "Cerca Titolo",
|
"LabelSearchTitle": "Cerca Titolo",
|
||||||
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
|
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
|
||||||
"LabelSeason": "Stagione",
|
"LabelSeason": "Stagione",
|
||||||
"LabelSelectAllEpisodes": "Select all episodes",
|
"LabelSelectAllEpisodes": "Seleziona tutti gli Episodi",
|
||||||
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
"LabelSelectEpisodesShowing": "Episodi {0} selezionati ",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Invia ebook a...",
|
||||||
"LabelSequence": "Sequenza",
|
"LabelSequence": "Sequenza",
|
||||||
"LabelSeries": "Serie",
|
"LabelSeries": "Serie",
|
||||||
"LabelSeriesName": "Nome Serie",
|
"LabelSeriesName": "Nome Serie",
|
||||||
"LabelSeriesProgress": "Cominciato",
|
"LabelSeriesProgress": "Cominciato",
|
||||||
"LabelSetEbookAsPrimary": "Set as primary",
|
"LabelSetEbookAsPrimary": "Immposta come Primario",
|
||||||
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
"LabelSetEbookAsSupplementary": "Imposta come Suplementare",
|
||||||
"LabelSettingsAudiobooksOnly": "Audiobooks only",
|
"LabelSettingsAudiobooksOnly": "Solo Audiolibri",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
|
"LabelSettingsAudiobooksOnlyHelp": "L'abilitazione di questa impostazione ignorerà i file di ebook a meno che non si trovino all'interno di una cartella di audiolibri, nel qual caso verranno impostati come ebook supplementari",
|
||||||
"LabelSettingsBookshelfViewHelp": "Design con scaffali in legno",
|
"LabelSettingsBookshelfViewHelp": "Design con scaffali in legno",
|
||||||
"LabelSettingsChromecastSupport": "Supporto a Chromecast",
|
"LabelSettingsChromecastSupport": "Supporto a Chromecast",
|
||||||
"LabelSettingsDateFormat": "Formato Data",
|
"LabelSettingsDateFormat": "Formato Data",
|
||||||
"LabelSettingsDisableWatcher": "Disattiva Watcher",
|
"LabelSettingsDisableWatcher": "Disattiva Watcher",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
|
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
|
||||||
"LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server",
|
"LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Opzioni Sperimentali",
|
"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.",
|
"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",
|
"LabelSettingsFindCovers": "Trova covers",
|
||||||
"LabelSettingsFindCoversHelp": "Se il tuo audiolibro non ha una copertina incorporata o un'immagine di copertina all'interno della cartella, questa funzione tenterà di trovare una copertina.<br>Nota: aumenta il tempo di scansione",
|
"LabelSettingsFindCoversHelp": "Se il tuo audiolibro non ha una copertina incorporata o un'immagine di copertina all'interno della cartella, questa funzione tenterà di trovare una copertina.<br>Nota: aumenta il tempo di scansione",
|
||||||
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
"LabelSettingsHideSingleBookSeries": "Nascondi una singola serie di libri",
|
||||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
"LabelSettingsHideSingleBookSeriesHelp": "Le serie che hanno un solo libro saranno nascoste dalla pagina della serie e dagli scaffali della home page.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Home page con sfondo legno",
|
"LabelSettingsHomePageBookshelfView": "Home page con sfondo legno",
|
||||||
"LabelSettingsLibraryBookshelfView": "Libreria con sfondo legno",
|
"LabelSettingsLibraryBookshelfView": "Libreria con sfondo legno",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Usa Overdrive Media Markers per i capitoli",
|
"LabelSettingsOverdriveMediaMarkers": "Usa Overdrive Media Markers per i capitoli",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Mostra Tutto",
|
"LabelShowAll": "Mostra Tutto",
|
||||||
"LabelSize": "Dimensione",
|
"LabelSize": "Dimensione",
|
||||||
"LabelSleepTimer": "Sleep timer",
|
"LabelSleepTimer": "Sleep timer",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "Inizo",
|
"LabelStart": "Inizo",
|
||||||
"LabelStarted": "Iniziato",
|
"LabelStarted": "Iniziato",
|
||||||
"LabelStartedAt": "Iniziato al",
|
"LabelStartedAt": "Iniziato al",
|
||||||
@@ -451,9 +458,9 @@
|
|||||||
"LabelTag": "Tag",
|
"LabelTag": "Tag",
|
||||||
"LabelTags": "Tags",
|
"LabelTags": "Tags",
|
||||||
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
"LabelTagsNotAccessibleToUser": "Tags non accessibile agli Utenti",
|
||||||
"LabelTasks": "Processi in esecuzione",
|
"LabelTasks": "Processi in esecuzione",
|
||||||
"LabelTheme": "Theme",
|
"LabelTheme": "Tema",
|
||||||
"LabelThemeDark": "Dark",
|
"LabelThemeDark": "Dark",
|
||||||
"LabelThemeLight": "Light",
|
"LabelThemeLight": "Light",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Time Base",
|
||||||
@@ -474,9 +481,10 @@
|
|||||||
"LabelTrackFromMetadata": "Traccia da Metadata",
|
"LabelTrackFromMetadata": "Traccia da Metadata",
|
||||||
"LabelTracks": "Traccia",
|
"LabelTracks": "Traccia",
|
||||||
"LabelTracksMultiTrack": "Multi-traccia",
|
"LabelTracksMultiTrack": "Multi-traccia",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Traccia-singola",
|
"LabelTracksSingleTrack": "Traccia-singola",
|
||||||
"LabelType": "Tipo",
|
"LabelType": "Tipo",
|
||||||
"LabelUnabridged": "Unabridged",
|
"LabelUnabridged": "Integrale",
|
||||||
"LabelUnknown": "Sconosciuto",
|
"LabelUnknown": "Sconosciuto",
|
||||||
"LabelUpdateCover": "Aggiornamento Cover",
|
"LabelUpdateCover": "Aggiornamento Cover",
|
||||||
"LabelUpdateCoverHelp": "Consenti la sovrascrittura delle copertine esistenti per i libri selezionati quando viene trovata una corrispondenza",
|
"LabelUpdateCoverHelp": "Consenti la sovrascrittura delle copertine esistenti per i libri selezionati quando viene trovata una corrispondenza",
|
||||||
@@ -514,20 +522,21 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente",
|
"MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente",
|
||||||
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
|
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
|
||||||
"MessageCheckingCron": "Controllo cron...",
|
"MessageCheckingCron": "Controllo cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
|
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?",
|
||||||
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
|
||||||
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
|
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
|
||||||
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
|
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
|
"MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?",
|
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?",
|
"MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?",
|
||||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
"MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?",
|
||||||
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
|
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?",
|
"MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?",
|
||||||
"MessageConfirmRenameGenreMergeNote": "Note: Questo genere esiste già quindi verra unito.",
|
"MessageConfirmRenameGenreMergeNote": "Note: Questo genere esiste già quindi verra unito.",
|
||||||
@@ -535,7 +544,7 @@
|
|||||||
"MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?",
|
"MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.",
|
"MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.",
|
||||||
"MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".",
|
||||||
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Sei sicuro di voler inviare {0} ebook \"{1}\" al Device \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "Download episodio in corso",
|
"MessageDownloadingEpisode": "Download episodio in corso",
|
||||||
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
|
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
|
||||||
"MessageEmbedFinished": "Incorporamento finito!",
|
"MessageEmbedFinished": "Incorporamento finito!",
|
||||||
@@ -554,8 +563,8 @@
|
|||||||
"MessageM4BFailed": "M4B Fallito!",
|
"MessageM4BFailed": "M4B Fallito!",
|
||||||
"MessageM4BFinished": "M4B Finito!",
|
"MessageM4BFinished": "M4B Finito!",
|
||||||
"MessageMapChapterTitles": "Associa i titoli dei capitoli ai capitoli dell'audiolibro esistente senza modificare i timestamp",
|
"MessageMapChapterTitles": "Associa i titoli dei capitoli ai capitoli dell'audiolibro esistente senza modificare i timestamp",
|
||||||
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
|
"MessageMarkAllEpisodesFinished": "Segna tutti gli episodi come finiti",
|
||||||
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
|
"MessageMarkAllEpisodesNotFinished": "Segna tutti gli episodi come non finiti",
|
||||||
"MessageMarkAsFinished": "Segna come finito",
|
"MessageMarkAsFinished": "Segna come finito",
|
||||||
"MessageMarkAsNotFinished": "Segna come da completare",
|
"MessageMarkAsNotFinished": "Segna come da completare",
|
||||||
"MessageMatchBooksDescription": "tenterà di abbinare i libri nella biblioteca con un libro del provider di ricerca selezionato e inserirà i dettagli vuoti e la copertina. Non sovrascrive i dettagli.",
|
"MessageMatchBooksDescription": "tenterà di abbinare i libri nella biblioteca con un libro del provider di ricerca selezionato e inserirà i dettagli vuoti e la copertina. Non sovrascrive i dettagli.",
|
||||||
@@ -691,8 +700,8 @@
|
|||||||
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
|
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
|
||||||
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
|
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
|
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
|
||||||
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
|
"ToastSendEbookToDeviceFailed": "Impossibile inviare l'ebook al dispositivo",
|
||||||
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "Ebook inviato al dispositivo \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito",
|
"ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito",
|
||||||
"ToastSeriesUpdateSuccess": "Serie Aggornate",
|
"ToastSeriesUpdateSuccess": "Serie Aggornate",
|
||||||
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
|
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
|
||||||
|
|||||||
@@ -0,0 +1,714 @@
|
|||||||
|
{
|
||||||
|
"ButtonAdd": "Pridėti",
|
||||||
|
"ButtonAddChapters": "Pridėti skyrius",
|
||||||
|
"ButtonAddPodcasts": "Pridėti tinklalaides",
|
||||||
|
"ButtonAddYourFirstLibrary": "Pridėkite savo pirmąją biblioteką",
|
||||||
|
"ButtonApply": "Taikyti",
|
||||||
|
"ButtonApplyChapters": "Taikyti skyrius",
|
||||||
|
"ButtonAuthors": "Autoriai",
|
||||||
|
"ButtonBrowseForFolder": "Naršyti aplanko",
|
||||||
|
"ButtonCancel": "Atšaukti",
|
||||||
|
"ButtonCancelEncode": "Atšaukti kodavimą",
|
||||||
|
"ButtonChangeRootPassword": "Keisti root slaptažodį",
|
||||||
|
"ButtonCheckAndDownloadNewEpisodes": "Patikrinti ir parsiųsti naujus epizodus",
|
||||||
|
"ButtonChooseAFolder": "Pasirinkite aplanką",
|
||||||
|
"ButtonChooseFiles": "Pasirinkite failus",
|
||||||
|
"ButtonClearFilter": "Valyti filtrą",
|
||||||
|
"ButtonCloseFeed": "Uždaryti srautą",
|
||||||
|
"ButtonCollections": "Kolekcijos",
|
||||||
|
"ButtonConfigureScanner": "Konfigūruoti skenerį",
|
||||||
|
"ButtonCreate": "Kurti",
|
||||||
|
"ButtonCreateBackup": "Kurti atsarginę kopiją",
|
||||||
|
"ButtonDelete": "Ištrinti",
|
||||||
|
"ButtonDownloadQueue": "Parsisiuntimų eilė",
|
||||||
|
"ButtonEdit": "Redaguoti",
|
||||||
|
"ButtonEditChapters": "Redaguoti skyrius",
|
||||||
|
"ButtonEditPodcast": "Redaguoti tinklalaidę",
|
||||||
|
"ButtonForceReScan": "Priverstinai nuskaityti iš naujo",
|
||||||
|
"ButtonFullPath": "Visas kelias",
|
||||||
|
"ButtonHide": "Slėpti",
|
||||||
|
"ButtonHome": "Pradžia",
|
||||||
|
"ButtonIssues": "Problemos",
|
||||||
|
"ButtonLatest": "Naujausias",
|
||||||
|
"ButtonLibrary": "Biblioteka",
|
||||||
|
"ButtonLogout": "Atsijungti",
|
||||||
|
"ButtonLookup": "Ieškoti",
|
||||||
|
"ButtonManageTracks": "Tvarkyti takelius",
|
||||||
|
"ButtonMapChapterTitles": "Suderinti skyrių pavadinimus",
|
||||||
|
"ButtonMatchAllAuthors": "Pritaikyti visus autorius",
|
||||||
|
"ButtonMatchBooks": "Pritaikyti knygas",
|
||||||
|
"ButtonNevermind": "Nesvarbu",
|
||||||
|
"ButtonOk": "Ok",
|
||||||
|
"ButtonOpenFeed": "Atidaryti srautą",
|
||||||
|
"ButtonOpenManager": "Atidaryti tvarkyklę",
|
||||||
|
"ButtonPlay": "Groti",
|
||||||
|
"ButtonPlaying": "Grojama",
|
||||||
|
"ButtonPlaylists": "Grojaraščiai",
|
||||||
|
"ButtonPurgeAllCache": "Valyti visą saugyklą",
|
||||||
|
"ButtonPurgeItemsCache": "Valyti elementų saugyklą",
|
||||||
|
"ButtonPurgeMediaProgress": "Valyti medijos progresą",
|
||||||
|
"ButtonQueueAddItem": "Pridėti į eilę",
|
||||||
|
"ButtonQueueRemoveItem": "Pašalinti iš eilės",
|
||||||
|
"ButtonQuickMatch": "Greitas pritaikymas",
|
||||||
|
"ButtonRead": "Skaityti",
|
||||||
|
"ButtonRemove": "Pašalinti",
|
||||||
|
"ButtonRemoveAll": "Pašalinti viską",
|
||||||
|
"ButtonRemoveAllLibraryItems": "Pašalinti visus bibliotekos elementus",
|
||||||
|
"ButtonRemoveFromContinueListening": "Pašalinti iš Tęsti Klausimą",
|
||||||
|
"ButtonRemoveFromContinueReading": "Pašalinti iš Tęsti Skaitymą",
|
||||||
|
"ButtonRemoveSeriesFromContinueSeries": "Pašalinti seriją iš Tęsti Seriją",
|
||||||
|
"ButtonReScan": "Iš naujo nuskaityti",
|
||||||
|
"ButtonReset": "Atstatyti",
|
||||||
|
"ButtonRestore": "Atkurti",
|
||||||
|
"ButtonSave": "Išsaugoti",
|
||||||
|
"ButtonSaveAndClose": "Išsaugoti ir uždaryti",
|
||||||
|
"ButtonSaveTracklist": "Išsaugoti takelių sąrašą",
|
||||||
|
"ButtonScan": "Nuskaityti",
|
||||||
|
"ButtonScanLibrary": "Nuskaityti biblioteką",
|
||||||
|
"ButtonSearch": "Ieškoti",
|
||||||
|
"ButtonSelectFolderPath": "Pasirinkti aplanko kelią",
|
||||||
|
"ButtonSeries": "Serijos",
|
||||||
|
"ButtonSetChaptersFromTracks": "Nustatyti skyrius iš takelių",
|
||||||
|
"ButtonShiftTimes": "Perstumti laikus",
|
||||||
|
"ButtonShow": "Rodyti",
|
||||||
|
"ButtonStartM4BEncode": "Pradėti M4B kodavimą",
|
||||||
|
"ButtonStartMetadataEmbed": "Pradėti metaduomenų įterpimą",
|
||||||
|
"ButtonSubmit": "Pateikti",
|
||||||
|
"ButtonTest": "Testuoti",
|
||||||
|
"ButtonUpload": "Įkelti",
|
||||||
|
"ButtonUploadBackup": "Įkelti atsarginę kopiją",
|
||||||
|
"ButtonUploadCover": "Įkelti viršelį",
|
||||||
|
"ButtonUploadOPMLFile": "Įkelti OPML failą",
|
||||||
|
"ButtonUserDelete": "Ištrinti naudotoją {0}",
|
||||||
|
"ButtonUserEdit": "Redaguoti naudotoją {0}",
|
||||||
|
"ButtonViewAll": "Peržiūrėti visus",
|
||||||
|
"ButtonYes": "Taip",
|
||||||
|
"HeaderAccount": "Paskyra",
|
||||||
|
"HeaderAdvanced": "Papildomi",
|
||||||
|
"HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai",
|
||||||
|
"HeaderAudiobookTools": "Audioknygų failų valdymo įrankiai",
|
||||||
|
"HeaderAudioTracks": "Garso takeliai",
|
||||||
|
"HeaderBackups": "Atsarginės kopijos",
|
||||||
|
"HeaderChangePassword": "Pakeisti slaptažodį",
|
||||||
|
"HeaderChapters": "Skyriai",
|
||||||
|
"HeaderChooseAFolder": "Pasirinkti aplanką",
|
||||||
|
"HeaderCollection": "Kolekcija",
|
||||||
|
"HeaderCollectionItems": "Kolekcijos elementai",
|
||||||
|
"HeaderCover": "Viršelis",
|
||||||
|
"HeaderCurrentDownloads": "Dabartiniai parsisiuntimai",
|
||||||
|
"HeaderDetails": "Detalės",
|
||||||
|
"HeaderDownloadQueue": "Parsisiuntimo eilė",
|
||||||
|
"HeaderEbookFiles": "Eknygos failai",
|
||||||
|
"HeaderEmail": "El. paštas",
|
||||||
|
"HeaderEmailSettings": "El. pašto nustatymai",
|
||||||
|
"HeaderEpisodes": "Epizodai",
|
||||||
|
"HeaderEreaderDevices": "Elektroniniai skaitytuvai",
|
||||||
|
"HeaderEreaderSettings": "Elektroninių skaitytuvų nustatymai",
|
||||||
|
"HeaderFiles": "Failai",
|
||||||
|
"HeaderFindChapters": "Rasti skyrius",
|
||||||
|
"HeaderIgnoredFiles": "Ignoruojami failai",
|
||||||
|
"HeaderItemFiles": "Elemento failai",
|
||||||
|
"HeaderItemMetadataUtils": "Elemento metaduomenų įrankiai",
|
||||||
|
"HeaderLastListeningSession": "Paskutinė klausymosi sesija",
|
||||||
|
"HeaderLatestEpisodes": "Naujausi epizodai",
|
||||||
|
"HeaderLibraries": "Bibliotekos",
|
||||||
|
"HeaderLibraryFiles": "Bibliotekos failai",
|
||||||
|
"HeaderLibraryStats": "Bibliotekos statistika",
|
||||||
|
"HeaderListeningSessions": "Klausymosi sesijos",
|
||||||
|
"HeaderListeningStats": "Klausymosi statistika",
|
||||||
|
"HeaderLogin": "Prisijungti",
|
||||||
|
"HeaderLogs": "Žurnalai",
|
||||||
|
"HeaderManageGenres": "Tvarkyti žanrus",
|
||||||
|
"HeaderManageTags": "Tvarkyti žymas",
|
||||||
|
"HeaderMapDetails": "Susieti detales",
|
||||||
|
"HeaderMatch": "Atitaikyti",
|
||||||
|
"HeaderMetadataToEmbed": "Metaduomenys įterpimui",
|
||||||
|
"HeaderNewAccount": "Nauja paskyra",
|
||||||
|
"HeaderNewLibrary": "Nauja biblioteka",
|
||||||
|
"HeaderNotifications": "Pranešimai",
|
||||||
|
"HeaderOpenRSSFeed": "Atidaryti RSS srautą",
|
||||||
|
"HeaderOtherFiles": "Kiti failai",
|
||||||
|
"HeaderPermissions": "Leidimai",
|
||||||
|
"HeaderPlayerQueue": "Grotuvo eilė",
|
||||||
|
"HeaderPlaylist": "Grojaraštis",
|
||||||
|
"HeaderPlaylistItems": "Grojaraščio elementai",
|
||||||
|
"HeaderPodcastsToAdd": "Pridėti tinklalaides",
|
||||||
|
"HeaderPreviewCover": "Peržiūrėti viršelį",
|
||||||
|
"HeaderRemoveEpisode": "Pašalinti epizodą",
|
||||||
|
"HeaderRemoveEpisodes": "Pašalinti {0} epizodus",
|
||||||
|
"HeaderRSSFeedGeneral": "RSS informacija",
|
||||||
|
"HeaderRSSFeedIsOpen": "RSS srautas yra atidarytas",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
|
"HeaderSavedMediaProgress": "Išsaugota medijos pažanga",
|
||||||
|
"HeaderSchedule": "Tvarkaraštis",
|
||||||
|
"HeaderScheduleLibraryScans": "Nustatyti bibliotekų nuskaitymo tvarkaraštį",
|
||||||
|
"HeaderSession": "Sesija",
|
||||||
|
"HeaderSetBackupSchedule": "Nustatyti atsarginių kopijų tvarkaraštį",
|
||||||
|
"HeaderSettings": "Nustatymai",
|
||||||
|
"HeaderSettingsDisplay": "Rodymas",
|
||||||
|
"HeaderSettingsExperimental": "Eksperimentinės funkcijos",
|
||||||
|
"HeaderSettingsGeneral": "Bendra",
|
||||||
|
"HeaderSettingsScanner": "Skaitytuvas",
|
||||||
|
"HeaderSleepTimer": "Miego laikmatis",
|
||||||
|
"HeaderStatsLargestItems": "Didžiausi elementai",
|
||||||
|
"HeaderStatsLongestItems": "Ilgiausi elementai (val.)",
|
||||||
|
"HeaderStatsMinutesListeningChart": "Klausymo minutės (paskutinės 7 dienos)",
|
||||||
|
"HeaderStatsRecentSessions": "Naujausios sesijos",
|
||||||
|
"HeaderStatsTop10Authors": "Top 10 autorių",
|
||||||
|
"HeaderStatsTop5Genres": "Top 5 žanrai",
|
||||||
|
"HeaderTableOfContents": "Turinys",
|
||||||
|
"HeaderTools": "Įrankiai",
|
||||||
|
"HeaderUpdateAccount": "Atnaujinti paskyrą",
|
||||||
|
"HeaderUpdateAuthor": "Atnaujinti autorių",
|
||||||
|
"HeaderUpdateDetails": "Atnaujinti informaciją",
|
||||||
|
"HeaderUpdateLibrary": "Atnaujinti biblioteką",
|
||||||
|
"HeaderUsers": "Naudotojai",
|
||||||
|
"HeaderYourStats": "Jūsų statistika",
|
||||||
|
"LabelAbridged": "Santrauka",
|
||||||
|
"LabelAccountType": "Paskyros tipas",
|
||||||
|
"LabelAccountTypeAdmin": "Administratorius",
|
||||||
|
"LabelAccountTypeGuest": "Svečias",
|
||||||
|
"LabelAccountTypeUser": "Naudotojas",
|
||||||
|
"LabelActivity": "Veikla",
|
||||||
|
"LabelAdded": "Pridėta",
|
||||||
|
"LabelAddedAt": "Pridėta {0}",
|
||||||
|
"LabelAddToCollection": "Pridėti į kolekciją",
|
||||||
|
"LabelAddToCollectionBatch": "Pridėti {0} knygas į kolekciją",
|
||||||
|
"LabelAddToPlaylist": "Pridėti į grojaraštį",
|
||||||
|
"LabelAddToPlaylistBatch": "Pridėti {0} elementus į grojaraštį",
|
||||||
|
"LabelAll": "Visi",
|
||||||
|
"LabelAllUsers": "Visi naudotojai",
|
||||||
|
"LabelAlreadyInYourLibrary": "Jau yra jūsų bibliotekoje",
|
||||||
|
"LabelAppend": "Pridėti",
|
||||||
|
"LabelAuthor": "Autorius",
|
||||||
|
"LabelAuthorFirstLast": "Autorius (Vardas Pavardė)",
|
||||||
|
"LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)",
|
||||||
|
"LabelAuthors": "Autoriai",
|
||||||
|
"LabelAutoDownloadEpisodes": "Automatiškai atsisiųsti epizodus",
|
||||||
|
"LabelBackToUser": "Grįžti į naudotoją",
|
||||||
|
"LabelBackupsEnableAutomaticBackups": "Įjungti automatinį atsarginių kopijų kūrimą",
|
||||||
|
"LabelBackupsEnableAutomaticBackupsHelp": "Atsarginės kopijos bus išsaugotos /metadata/backups aplanke",
|
||||||
|
"LabelBackupsMaxBackupSize": "Maksimalus atsarginių kopijų dydis (GB)",
|
||||||
|
"LabelBackupsMaxBackupSizeHelp": "Jei konfigūruotas dydis viršijamas, atsarginės kopijos nebus sukurtos, kad būtų išvengta klaidingų konfigūracijų.",
|
||||||
|
"LabelBackupsNumberToKeep": "Laikytinų atsarginių kopijų skaičius",
|
||||||
|
"LabelBackupsNumberToKeepHelp": "Tik viena atsarginė kopija bus pašalinta vienu metu, todėl jei jau turite daugiau atsarginių kopijų nei nurodyta, turite jas pašalinti rankiniu būdu.",
|
||||||
|
"LabelBitrate": "Bitų sparta",
|
||||||
|
"LabelBooks": "Knygos",
|
||||||
|
"LabelChangePassword": "Pakeisti slaptažodį",
|
||||||
|
"LabelChannels": "Kanalai",
|
||||||
|
"LabelChapters": "Skyriai",
|
||||||
|
"LabelChaptersFound": "rasti skyriai",
|
||||||
|
"LabelChapterTitle": "Skyriaus pavadinimas",
|
||||||
|
"LabelClosePlayer": "Uždaryti grotuvą",
|
||||||
|
"LabelCodec": "Kodekas",
|
||||||
|
"LabelCollapseSeries": "Suskleisti seriją",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
|
"LabelCollections": "Kolekcijos",
|
||||||
|
"LabelComplete": "Baigta",
|
||||||
|
"LabelConfirmPassword": "Patvirtinkite slaptažodį",
|
||||||
|
"LabelContinueListening": "Tęsti klausymąsi",
|
||||||
|
"LabelContinueReading": "Tęsti skaitymą",
|
||||||
|
"LabelContinueSeries": "Tęsti seriją",
|
||||||
|
"LabelCover": "Viršelis",
|
||||||
|
"LabelCoverImageURL": "Viršelio paveikslėlio URL",
|
||||||
|
"LabelCreatedAt": "Sukurta",
|
||||||
|
"LabelCronExpression": "Cron išraiška",
|
||||||
|
"LabelCurrent": "Dabartinė",
|
||||||
|
"LabelCurrently": "Šiuo metu:",
|
||||||
|
"LabelCustomCronExpression": "Nestandartinė Cron išraiška:",
|
||||||
|
"LabelDatetime": "Data ir laikas",
|
||||||
|
"LabelDescription": "Aprašymas",
|
||||||
|
"LabelDeselectAll": "Išvalyti pasirinktus",
|
||||||
|
"LabelDevice": "Įrenginys",
|
||||||
|
"LabelDeviceInfo": "Įrenginio informacija",
|
||||||
|
"LabelDirectory": "Katalogas",
|
||||||
|
"LabelDiscFromFilename": "Diskas pagal failo pavadinimą",
|
||||||
|
"LabelDiscFromMetadata": "Diskas pagal metaduomenis",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
|
"LabelDownload": "Atsisiųsti",
|
||||||
|
"LabelDownloadNEpisodes": "Atsisiųsti {0} epizodų",
|
||||||
|
"LabelDuration": "Trukmė",
|
||||||
|
"LabelDurationFound": "Rasta trukmė:",
|
||||||
|
"LabelEbook": "Elektroninė knyga",
|
||||||
|
"LabelEbooks": "Elektroninės knygos",
|
||||||
|
"LabelEdit": "Redaguoti",
|
||||||
|
"LabelEmail": "El. paštas",
|
||||||
|
"LabelEmailSettingsFromAddress": "Siuntėjo adresas",
|
||||||
|
"LabelEmailSettingsSecure": "Apsaugota",
|
||||||
|
"LabelEmailSettingsSecureHelp": "Jei ši reikšmė yra \"true\", ryšys naudos TLS protokolą. Jei \"false\", TLS bus naudojamas tik tada, jei serveris palaiko STARTTLS plėtinį. Daugumos atveju, jei jungiamasi prie 465 prievado, šią reikšmę turėtumėte nustatyti kaip \"true\". Jei jungiamasi prie 587 arba 25 prievado, turi būti nustatyta \"false\". (iš nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Testinis adresas",
|
||||||
|
"LabelEmbeddedCover": "Įterptas viršelis",
|
||||||
|
"LabelEnable": "Įjungti",
|
||||||
|
"LabelEnd": "Pabaiga",
|
||||||
|
"LabelEpisode": "Epizodas",
|
||||||
|
"LabelEpisodeTitle": "Epizodo pavadinimas",
|
||||||
|
"LabelEpisodeType": "Epizodo tipas",
|
||||||
|
"LabelExample": "Pavyzdys",
|
||||||
|
"LabelExplicit": "Suaugusiems",
|
||||||
|
"LabelFeedURL": "Srauto URL",
|
||||||
|
"LabelFile": "Failas",
|
||||||
|
"LabelFileBirthtime": "Failo kūrimo laikas",
|
||||||
|
"LabelFileModified": "Failo keitimo laikas",
|
||||||
|
"LabelFilename": "Failo pavadinimas",
|
||||||
|
"LabelFilterByUser": "Filtruoti pagal naudotoją",
|
||||||
|
"LabelFindEpisodes": "Rasti epizodus",
|
||||||
|
"LabelFinished": "Baigta",
|
||||||
|
"LabelFolder": "Aplankas",
|
||||||
|
"LabelFolders": "Aplankai",
|
||||||
|
"LabelFontScale": "Šrifto mastelis",
|
||||||
|
"LabelFormat": "Formatas",
|
||||||
|
"LabelGenre": "Žanras",
|
||||||
|
"LabelGenres": "Žanrai",
|
||||||
|
"LabelHardDeleteFile": "Galutinai ištrinti failą",
|
||||||
|
"LabelHasEbook": "Turi e-knygą",
|
||||||
|
"LabelHasSupplementaryEbook": "Turi papildomą e-knygą",
|
||||||
|
"LabelHost": "Serveris",
|
||||||
|
"LabelHour": "Valanda",
|
||||||
|
"LabelIcon": "Piktograma",
|
||||||
|
"LabelIncludeInTracklist": "Įtraukti į takelių sąrašą",
|
||||||
|
"LabelIncomplete": "Nebaigta",
|
||||||
|
"LabelInProgress": "Vyksta",
|
||||||
|
"LabelInterval": "Intervalas",
|
||||||
|
"LabelIntervalCustomDailyWeekly": "Pasirinktinis kasdieninės/savaitinės periodiškumas",
|
||||||
|
"LabelIntervalEvery12Hours": "Kas 12 valandų",
|
||||||
|
"LabelIntervalEvery15Minutes": "Kas 15 minučių",
|
||||||
|
"LabelIntervalEvery2Hours": "Kas 2 valandas",
|
||||||
|
"LabelIntervalEvery30Minutes": "Kas 30 minučių",
|
||||||
|
"LabelIntervalEvery6Hours": "Kas 6 valandas",
|
||||||
|
"LabelIntervalEveryDay": "Kasdien",
|
||||||
|
"LabelIntervalEveryHour": "Kiekvieną valandą",
|
||||||
|
"LabelInvalidParts": "Netinkamos dalys",
|
||||||
|
"LabelInvert": "Apversti",
|
||||||
|
"LabelItem": "Elementas",
|
||||||
|
"LabelLanguage": "Kalba",
|
||||||
|
"LabelLanguageDefaultServer": "Numatytoji serverio kalba",
|
||||||
|
"LabelLastBookAdded": "Paskutinė pridėta knyga",
|
||||||
|
"LabelLastBookUpdated": "Paskutinė atnaujinta knyga",
|
||||||
|
"LabelLastSeen": "Paskutinį kartą matyta",
|
||||||
|
"LabelLastTime": "Paskutinį kartą",
|
||||||
|
"LabelLastUpdate": "Paskutinė atnaujinimo data",
|
||||||
|
"LabelLayout": "Išdėstymas",
|
||||||
|
"LabelLayoutSinglePage": "Vieno puslapio",
|
||||||
|
"LabelLayoutSplitPage": "Padalinto puslapio",
|
||||||
|
"LabelLess": "Mažiau",
|
||||||
|
"LabelLibrariesAccessibleToUser": "Naudotojui pasiekiamos bibliotekos",
|
||||||
|
"LabelLibrary": "Biblioteka",
|
||||||
|
"LabelLibraryItem": "Bibliotekos elementas",
|
||||||
|
"LabelLibraryName": "Bibliotekos pavadinimas",
|
||||||
|
"LabelLimit": "Limitas",
|
||||||
|
"LabelLineSpacing": "Tarpas tarp eilučių",
|
||||||
|
"LabelListenAgain": "Klausytis iš naujo",
|
||||||
|
"LabelLogLevelDebug": "Debug",
|
||||||
|
"LabelLogLevelInfo": "Info",
|
||||||
|
"LabelLogLevelWarn": "Warn",
|
||||||
|
"LabelLookForNewEpisodesAfterDate": "Ieškoti naujų epizodų po šios datos",
|
||||||
|
"LabelMediaPlayer": "Grotuvas",
|
||||||
|
"LabelMediaType": "Medijos tipas",
|
||||||
|
"LabelMetadataProvider": "Metaduomenų tiekėjas",
|
||||||
|
"LabelMetaTag": "Meta žymė",
|
||||||
|
"LabelMetaTags": "Meta žymos",
|
||||||
|
"LabelMinute": "Minutė",
|
||||||
|
"LabelMissing": "Trūksta",
|
||||||
|
"LabelMissingParts": "Trūkstamos dalys",
|
||||||
|
"LabelMore": "Daugiau",
|
||||||
|
"LabelMoreInfo": "Daugiau informacijos",
|
||||||
|
"LabelName": "Pavadinimas",
|
||||||
|
"LabelNarrator": "Skaitytojas",
|
||||||
|
"LabelNarrators": "Skaitytojai",
|
||||||
|
"LabelNew": "Nauja",
|
||||||
|
"LabelNewestAuthors": "Naujausi autoriai",
|
||||||
|
"LabelNewestEpisodes": "Naujausi epizodai",
|
||||||
|
"LabelNewPassword": "Naujas slaptažodis",
|
||||||
|
"LabelNextBackupDate": "Kitos atsarginės kopijos data",
|
||||||
|
"LabelNextScheduledRun": "Kito planuoto vykdymo data",
|
||||||
|
"LabelNoEpisodesSelected": "Nepasirinkti jokie epizodai",
|
||||||
|
"LabelNotes": "Užrašai",
|
||||||
|
"LabelNotFinished": "Nebaigta",
|
||||||
|
"LabelNotificationAppriseURL": "Pranešimo (Apprise) URL",
|
||||||
|
"LabelNotificationAvailableVariables": "Galimi kintamieji",
|
||||||
|
"LabelNotificationBodyTemplate": "Turinio šablonas",
|
||||||
|
"LabelNotificationEvent": "Pranešimo įvykis",
|
||||||
|
"LabelNotificationsMaxFailedAttempts": "Maksimalus nesėkmingų bandymų skaičius",
|
||||||
|
"LabelNotificationsMaxFailedAttemptsHelp": "Pranešimai bus išjungti, jei nepavyks jų išsiųsti nurodytą kartų",
|
||||||
|
"LabelNotificationsMaxQueueSize": "Maksimalus pranešimų eilių dydis",
|
||||||
|
"LabelNotificationsMaxQueueSizeHelp": "Įvykiai yra apriboti vienu įvykiu per sekundę. Įvykiai bus ignoruojami, jei eilė yra maksimalaus dydžio. Tai apsaugo nuo pranešimų šlamšto.",
|
||||||
|
"LabelNotificationTitleTemplate": "Pavadinimo šablonas",
|
||||||
|
"LabelNotStarted": "Nepasileista",
|
||||||
|
"LabelNumberOfBooks": "Knygų skaičius",
|
||||||
|
"LabelNumberOfEpisodes": "Epizodų skaičius",
|
||||||
|
"LabelOpenRSSFeed": "Atidaryti RSS srautą",
|
||||||
|
"LabelOverwrite": "Perrašyti",
|
||||||
|
"LabelPassword": "Slaptažodis",
|
||||||
|
"LabelPath": "Kelias",
|
||||||
|
"LabelPermissionsAccessAllLibraries": "Gali pasiekti visas bibliotekas",
|
||||||
|
"LabelPermissionsAccessAllTags": "Gali pasiekti visas žymes",
|
||||||
|
"LabelPermissionsAccessExplicitContent": "Gali pasiekti turinį suaugusiems",
|
||||||
|
"LabelPermissionsDelete": "Gali trinti",
|
||||||
|
"LabelPermissionsDownload": "Gali atsisiųsti",
|
||||||
|
"LabelPermissionsUpdate": "Gali atnaujinti",
|
||||||
|
"LabelPermissionsUpload": "Gali įkelti",
|
||||||
|
"LabelPhotoPathURL": "Nuotraukos kelias/URL",
|
||||||
|
"LabelPlaylists": "Grojaraščiai",
|
||||||
|
"LabelPlayMethod": "Grojimo metodas",
|
||||||
|
"LabelPodcast": "Tinklalaidė",
|
||||||
|
"LabelPodcasts": "Tinklalaidės",
|
||||||
|
"LabelPodcastType": "Tinklalaidės tipas",
|
||||||
|
"LabelPort": "Prievadas",
|
||||||
|
"LabelPrefixesToIgnore": "Ignoruojami priešdėliai (didžiosios/mažosios nesvarbu)",
|
||||||
|
"LabelPreventIndexing": "Neleisti indeksuoti jūsų srauto „iTunes“ ir Google podcast kataloguose",
|
||||||
|
"LabelPrimaryEbook": "Pagrindinė e-knyga",
|
||||||
|
"LabelProgress": "Progresas",
|
||||||
|
"LabelProvider": "Tiekėjas",
|
||||||
|
"LabelPubDate": "Publikavimo data",
|
||||||
|
"LabelPublisher": "Leidėjas",
|
||||||
|
"LabelPublishYear": "Leidimo metai",
|
||||||
|
"LabelRead": "Skaityta",
|
||||||
|
"LabelReadAgain": "Skaityti dar kartą",
|
||||||
|
"LabelReadEbookWithoutProgress": "Skaityti e-knygą be pažangos saugojimo",
|
||||||
|
"LabelRecentlyAdded": "Neseniai pridėta",
|
||||||
|
"LabelRecentSeries": "Naujausios serijos",
|
||||||
|
"LabelRecommended": "Rekomenduojama",
|
||||||
|
"LabelRegion": "Regionas",
|
||||||
|
"LabelReleaseDate": "Išleidimo data",
|
||||||
|
"LabelRemoveCover": "Pašalinti viršelį",
|
||||||
|
"LabelRSSFeedCustomOwnerEmail": "Pasirinktinis savininko el. paštas",
|
||||||
|
"LabelRSSFeedCustomOwnerName": "Pasirinktinis savininko vardas",
|
||||||
|
"LabelRSSFeedOpen": "Atidarytas RSS srautas",
|
||||||
|
"LabelRSSFeedPreventIndexing": "Neleisti indeksuoti",
|
||||||
|
"LabelRSSFeedSlug": "RSS srauto identifikatorius",
|
||||||
|
"LabelRSSFeedURL": "RSS srauto URL",
|
||||||
|
"LabelSearchTerm": "Paieškos žodis",
|
||||||
|
"LabelSearchTitle": "Ieškoti pavadinimo",
|
||||||
|
"LabelSearchTitleOrASIN": "Ieškoti pavadinimo arba ASIN",
|
||||||
|
"LabelSeason": "Sezonas",
|
||||||
|
"LabelSelectAllEpisodes": "Pažymėti visus epizodus",
|
||||||
|
"LabelSelectEpisodesShowing": "Pažymėti {0} rodomus epizodus",
|
||||||
|
"LabelSendEbookToDevice": "Siųsti e-knygą į...",
|
||||||
|
"LabelSequence": "Seka",
|
||||||
|
"LabelSeries": "Serija",
|
||||||
|
"LabelSeriesName": "Serijos pavadinimas",
|
||||||
|
"LabelSeriesProgress": "Serijos progresas",
|
||||||
|
"LabelSetEbookAsPrimary": "Nustatyti kaip pagrindinę",
|
||||||
|
"LabelSetEbookAsSupplementary": "Nustatyti kaip papildomą",
|
||||||
|
"LabelSettingsAudiobooksOnly": "Tik garso knygos",
|
||||||
|
"LabelSettingsAudiobooksOnlyHelp": "Įjungus šią parinktį, e-knygų failai bus ignoruojami, nebent jie būtų audioknygų aplankuose, kurie tada būtų rodomi kaip papildomos e-knygos",
|
||||||
|
"LabelSettingsBookshelfViewHelp": "Knygų lentynos dizainas su medinėmis lentynomis",
|
||||||
|
"LabelSettingsChromecastSupport": "„Chromecast“ palaikymas",
|
||||||
|
"LabelSettingsDateFormat": "Datos formatas",
|
||||||
|
"LabelSettingsDisableWatcher": "Išjungti stebėtoją",
|
||||||
|
"LabelSettingsDisableWatcherForLibrary": "Išjungti aplankų stebėtoją bibliotekai",
|
||||||
|
"LabelSettingsDisableWatcherHelp": "Išjungia automatinį elementų pridėjimą/atnaujinimą, jei pastebėti failų pokyčiai. *Reikalingas serverio paleidimas iš naujo",
|
||||||
|
"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",
|
||||||
|
"LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai",
|
||||||
|
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
|
||||||
|
"LabelSettingsFindCovers": "Rasti viršelius",
|
||||||
|
"LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanko, skeneris bandys rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę.",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Slėpti serijas, turinčias tik vieną knygą",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.",
|
||||||
|
"LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą",
|
||||||
|
"LabelSettingsLibraryBookshelfView": "Naudoti bibliotekos knygų lentynų vaizdą",
|
||||||
|
"LabelSettingsOverdriveMediaMarkers": "Naudoti Overdrive žymeklius skyriams",
|
||||||
|
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 failai iš Overdrive turi įterptus skyrių laikus kaip papildomą metaduomenį. Įjungus šią funkciją, skyrių laikai bus automatiškai naudojami.",
|
||||||
|
"LabelSettingsParseSubtitles": "Analizuoti subtitrus",
|
||||||
|
"LabelSettingsParseSubtitlesHelp": "Išskleisti subtitrus iš audioknygos aplanko pavadinimų.<br>Subtitrai turi būti atskirti brūkšniu \"-\"<br>pavyzdžiui, \"Knygos pavadinimas - Čia yra subtitrai\" turi subtitrą \"Čia yra subtitrai\"",
|
||||||
|
"LabelSettingsPreferAudioMetadata": "Pirmenybė failo metaduomenis",
|
||||||
|
"LabelSettingsPreferAudioMetadataHelp": "Garso failo ID3 metaduomenys bus naudojami knygos informacijai (vietoj aplankų pavadinimų)",
|
||||||
|
"LabelSettingsPreferMatchedMetadata": "Pirmenybė atitaikytiems metaduomenis",
|
||||||
|
"LabelSettingsPreferMatchedMetadataHelp": "Atitaikyti duomenys pakeis elementų informaciją naudojant Greitą atitikimą. Pagal nutylėjimą Greitas atitaikymas užpildys tik trūkstamas detales.",
|
||||||
|
"LabelSettingsPreferOPFMetadata": "Pirmenybė OPF metaduomenis",
|
||||||
|
"LabelSettingsPreferOPFMetadataHelp": "OPF failo metaduomenys bus naudojami knygos informacijai (vietoj aplankų pavadinimų)",
|
||||||
|
"LabelSettingsSkipMatchingBooksWithASIN": "Praleisti knygas, kurios jau turi ASIN",
|
||||||
|
"LabelSettingsSkipMatchingBooksWithISBN": "Praleisti knygas, kurios jau turi ISBN",
|
||||||
|
"LabelSettingsSortingIgnorePrefixes": "Ignoruoti priešdėlius rūšiuojant",
|
||||||
|
"LabelSettingsSortingIgnorePrefixesHelp": "pvz., su priešdėliu \"the\" knygos pavadinimas \"The Book Title\" bus rūšiuojamas kaip \"Book Title, The\"",
|
||||||
|
"LabelSettingsSquareBookCovers": "Naudoti kvadratinius knygos viršelius",
|
||||||
|
"LabelSettingsSquareBookCoversHelp": "Naudoti kvadratinius viršelius vietoj standartinių 1.6:1 knygų viršelių",
|
||||||
|
"LabelSettingsStoreCoversWithItem": "Saugoti viršelius su elementu",
|
||||||
|
"LabelSettingsStoreCoversWithItemHelp": "Pagal nutylėjimą viršeliai saugomi /metadata/items aplanke, įjungus šią parinktį viršeliai bus saugomi jūsų bibliotekos elemento aplanke. Bus išsaugotas tik vienas „cover“ pavadinimo failas.",
|
||||||
|
"LabelSettingsStoreMetadataWithItem": "Saugoti metaduomenis su elementu",
|
||||||
|
"LabelSettingsStoreMetadataWithItemHelp": "Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke. Naudojamas .abs plėtinys.",
|
||||||
|
"LabelSettingsTimeFormat": "Laiko formatas",
|
||||||
|
"LabelShowAll": "Rodyti viską",
|
||||||
|
"LabelSize": "Dydis",
|
||||||
|
"LabelSleepTimer": "Miego laikmatis",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
|
"LabelStart": "Pradėti",
|
||||||
|
"LabelStarted": "Pradėta",
|
||||||
|
"LabelStartedAt": "Pradėta",
|
||||||
|
"LabelStartTime": "Pradžios laikas",
|
||||||
|
"LabelStatsAudioTracks": "Garsiniai takeliai",
|
||||||
|
"LabelStatsAuthors": "Autoriai",
|
||||||
|
"LabelStatsBestDay": "Geriausia diena",
|
||||||
|
"LabelStatsDailyAverage": "Vidutiniškai per dieną",
|
||||||
|
"LabelStatsDays": "Dienos",
|
||||||
|
"LabelStatsDaysListened": "Klausyta dienų",
|
||||||
|
"LabelStatsHours": "Valandos",
|
||||||
|
"LabelStatsInARow": "iš eilės",
|
||||||
|
"LabelStatsItemsFinished": "Baigti elementai",
|
||||||
|
"LabelStatsItemsInLibrary": "Elementai bibliotekoje",
|
||||||
|
"LabelStatsMinutes": "minutės",
|
||||||
|
"LabelStatsMinutesListening": "Klausyta minučių",
|
||||||
|
"LabelStatsOverallDays": "Iš viso dienų",
|
||||||
|
"LabelStatsOverallHours": "Iš viso valandų",
|
||||||
|
"LabelStatsWeekListening": "Savaitės klausymas",
|
||||||
|
"LabelSubtitle": "Subtitrai",
|
||||||
|
"LabelSupportedFileTypes": "Palaikomi failų tipai",
|
||||||
|
"LabelTag": "Žyma",
|
||||||
|
"LabelTags": "Žymos",
|
||||||
|
"LabelTagsAccessibleToUser": "Žymos, pasiekiamos vartotojui",
|
||||||
|
"LabelTagsNotAccessibleToUser": "Žymos, nepasiekiamos vartotojui",
|
||||||
|
"LabelTasks": "Vykdomos užduotys",
|
||||||
|
"LabelTheme": "Tema",
|
||||||
|
"LabelThemeDark": "Tamsi",
|
||||||
|
"LabelThemeLight": "Šviesi",
|
||||||
|
"LabelTimeBase": "Laiko pagrindas",
|
||||||
|
"LabelTimeListened": "Klausytas laikas",
|
||||||
|
"LabelTimeListenedToday": "Klausytas laikas šiandien",
|
||||||
|
"LabelTimeRemaining": "{0} likę",
|
||||||
|
"LabelTimeToShift": "Laiko perkėlimas sekundėmis",
|
||||||
|
"LabelTitle": "Pavadinimas",
|
||||||
|
"LabelToolsEmbedMetadata": "Įterpti metaduomenis",
|
||||||
|
"LabelToolsEmbedMetadataDescription": "Įterpti metaduomenis į garso failus, įskaitant viršelio paveikslu ir skyrius.",
|
||||||
|
"LabelToolsMakeM4b": "Sukurti M4B garso knygų failą",
|
||||||
|
"LabelToolsMakeM4bDescription": "Sukurti .M4B garso knygų failą su įterptais metaduomenimis, viršelio paveikslu ir skyriais.",
|
||||||
|
"LabelToolsSplitM4b": "Skaidyti M4B į MP3 failus",
|
||||||
|
"LabelToolsSplitM4bDescription": "Sukurti MP3 failus iš M4B su skyrių skaldymu ir įterptais metaduomenimis, viršelio paveikslu ir skyriais.",
|
||||||
|
"LabelTotalDuration": "Viso trukmė",
|
||||||
|
"LabelTotalTimeListened": "Iš viso klausyta laiko",
|
||||||
|
"LabelTrackFromFilename": "Takelis iš failo pavadinimo",
|
||||||
|
"LabelTrackFromMetadata": "Takelis iš metaduomenų",
|
||||||
|
"LabelTracks": "Takeliai",
|
||||||
|
"LabelTracksMultiTrack": "Keli takeliai",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
|
"LabelTracksSingleTrack": "Vienas takelis",
|
||||||
|
"LabelType": "Tipas",
|
||||||
|
"LabelUnabridged": "Neprikurptas",
|
||||||
|
"LabelUnknown": "Nežinoma",
|
||||||
|
"LabelUpdateCover": "Atnaujinti viršelį",
|
||||||
|
"LabelUpdateCoverHelp": "Leisti perrašyti esamus viršelius pasirinktoms knygoms, kai yra rasta atitikmenų",
|
||||||
|
"LabelUpdatedAt": "Atnaujinta",
|
||||||
|
"LabelUpdateDetails": "Atnaujinti duomenis",
|
||||||
|
"LabelUpdateDetailsHelp": "Leisti perrašyti esamus duomenis pasirinktoms knygoms, kai yra rasta atitikmenų",
|
||||||
|
"LabelUploaderDragAndDrop": "Tempkite ir paleiskite failus ar aplankus",
|
||||||
|
"LabelUploaderDropFiles": "Nutempti failus",
|
||||||
|
"LabelUseChapterTrack": "Naudoti skyrių takelį",
|
||||||
|
"LabelUseFullTrack": "Naudoti visą takelį",
|
||||||
|
"LabelUser": "Vartotojas",
|
||||||
|
"LabelUsername": "Vartotojo vardas",
|
||||||
|
"LabelValue": "Reikšmė",
|
||||||
|
"LabelVersion": "Versija",
|
||||||
|
"LabelViewBookmarks": "Peržiūrėti skirtukus",
|
||||||
|
"LabelViewChapters": "Peržiūrėti skyrius",
|
||||||
|
"LabelViewQueue": "Peržiūrėti grotuvo eilę",
|
||||||
|
"LabelVolume": "Garsumas",
|
||||||
|
"LabelWeekdaysToRun": "Dienos, kuriomis vykdyti",
|
||||||
|
"LabelYourAudiobookDuration": "Jūsų garso knygos trukmė",
|
||||||
|
"LabelYourBookmarks": "Jūsų skirtukai",
|
||||||
|
"LabelYourPlaylists": "Jūsų grojaraščiai",
|
||||||
|
"LabelYourProgress": "Jūsų pažanga",
|
||||||
|
"MessageAddToPlayerQueue": "Pridėti į grotuvo eilę",
|
||||||
|
"MessageAppriseDescription": "Norint naudoti šią funkciją, reikės turėti <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> veikiantį arba API, kuris tvarkys tas pačias užklausas.<br />Apprise API URL turėtų būti visi kelio takai iki pranešimo siuntimo, pvz., jei jūsų API pasiekiamas adresu <code>http://192.168.1.1:8337</code>, tada įveskite <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
|
"MessageBackupsDescription": "Atsarginės kopijos apima vartotojus, vartotojų pažangą, bibliotekos elemento informaciją, serverio nustatymus ir vaizdus, saugomus <code>/metadata/items</code> ir <code>/metadata/authors</code>. Atsarginės kopijos <strong>neįtraukia</strong> jokių failų, saugomų jūsų bibliotekos aplankuose.",
|
||||||
|
"MessageBatchQuickMatchDescription": "Greitas atitikmens rasti bandys pridėti trūkstamus viršelius ir metaduomenis pasirinktiems elementams. Įjunkite žemiau esančias parinktis, kad leistumėte Greitajam atitikmeniui perrašyti esamus viršelius ir/ar metaduomenis.",
|
||||||
|
"MessageBookshelfNoCollections": "Dar nepridėjote jokių kolekcijų",
|
||||||
|
"MessageBookshelfNoResultsForFilter": "Rezultatų pagal filtrą \"{0}: {1}\" nėra",
|
||||||
|
"MessageBookshelfNoRSSFeeds": "Nėra atvertų RSS srautų",
|
||||||
|
"MessageBookshelfNoSeries": "Neturite jokių serijų",
|
||||||
|
"MessageChapterEndIsAfter": "Skyriaus pabaiga yra po jūsų garso knygos pabaigos",
|
||||||
|
"MessageChapterErrorFirstNotZero": "Pirmasis skyrius turi prasidėti nuo 0",
|
||||||
|
"MessageChapterErrorStartGteDuration": "Netinkamas pradžios laikas. Turi būti mažesnis nei garso knygos trukmė",
|
||||||
|
"MessageChapterErrorStartLtPrev": "Netinkamas pradžios laikas. Turi būti didesnis arba lygus ankstesnio skyriaus pradžios laikui",
|
||||||
|
"MessageChapterStartIsAfter": "Skyriaus pradžia yra po jūsų garso knygos pabaigos",
|
||||||
|
"MessageCheckingCron": "Tikrinamas cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
|
"MessageConfirmDeleteBackup": "Ar tikrai norite ištrinti atsarginę kopiją, skirtą {0}?",
|
||||||
|
"MessageConfirmDeleteFile": "Tai ištrins failą iš jūsų failų sistemos. Ar tikrai?",
|
||||||
|
"MessageConfirmDeleteLibrary": "Ar tikrai norite visam laikui ištrinti biblioteką \"{0}\"?",
|
||||||
|
"MessageConfirmDeleteSession": "Ar tikrai norite ištrinti šią sesiją?",
|
||||||
|
"MessageConfirmForceReScan": "Ar tikrai norite priversti perskenavimą?",
|
||||||
|
"MessageConfirmMarkAllEpisodesFinished": "Ar tikrai norite pažymėti visus epizodus kaip užbaigtus?",
|
||||||
|
"MessageConfirmMarkAllEpisodesNotFinished": "Ar tikrai norite pažymėti visus epizodus kaip nebaigtus?",
|
||||||
|
"MessageConfirmMarkSeriesFinished": "Ar tikrai norite pažymėti visas knygas šioje serijoje kaip užbaigtas?",
|
||||||
|
"MessageConfirmMarkSeriesNotFinished": "Ar tikrai norite pažymėti visas knygas šioje serijoje kaip nebaigtas?",
|
||||||
|
"MessageConfirmRemoveAllChapters": "Ar tikrai norite pašalinti visus skyrius?",
|
||||||
|
"MessageConfirmRemoveCollection": "Ar tikrai norite pašalinti kolekciją \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisode": "Ar tikrai norite pašalinti epizodą \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisodes": "Ar tikrai norite pašalinti {0} epizodus?",
|
||||||
|
"MessageConfirmRemoveNarrator": "Ar tikrai norite pašalinti skaitytoją \"{0}\"?",
|
||||||
|
"MessageConfirmRemovePlaylist": "Ar tikrai norite pašalinti savo grojaraštį \"{0}\"?",
|
||||||
|
"MessageConfirmRenameGenre": "Ar tikrai norite pervadinti žanrą \"{0}\" į \"{1}\" visiems elementams?",
|
||||||
|
"MessageConfirmRenameGenreMergeNote": "Pastaba: šis žanras jau yra, todėl jie bus sujungti.",
|
||||||
|
"MessageConfirmRenameGenreWarning": "Įspėjimas! Panašus žanras jau yra \"{0}\".",
|
||||||
|
"MessageConfirmRenameTag": "Ar tikrai norite pervadinti žymą \"{0}\" į \"{1}\" visiems elementams?",
|
||||||
|
"MessageConfirmRenameTagMergeNote": "Pastaba: ši žyma jau egzistuoja, todėl jos bus sujungtos.",
|
||||||
|
"MessageConfirmRenameTagWarning": "Įspėjimas! Panaši žyma jau egzistuoja \"{0}\".",
|
||||||
|
"MessageConfirmSendEbookToDevice": "Ar tikrai norite nusiųsti {0} el. knygą \"{1}\" į įrenginį \"{2}\"?",
|
||||||
|
"MessageDownloadingEpisode": "Epizodas atsisiunčiamas",
|
||||||
|
"MessageDragFilesIntoTrackOrder": "Surikiuokite takelius vilkdami failus",
|
||||||
|
"MessageEmbedFinished": "Įterpimas baigtas!",
|
||||||
|
"MessageEpisodesQueuedForDownload": "{0} epizodai laukia atsisiuntimo",
|
||||||
|
"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.",
|
||||||
|
"MessageImportantNotice": "Svarbus pranešimas!",
|
||||||
|
"MessageInsertChapterBelow": "Įterpti skyrių žemiau",
|
||||||
|
"MessageItemsSelected": "Pasirinkti {0} elementai (-ų)",
|
||||||
|
"MessageItemsUpdated": "Atnaujinti {0} elementai (-ų)",
|
||||||
|
"MessageJoinUsOn": "Prisijunkite prie mūsų",
|
||||||
|
"MessageListeningSessionsInTheLastYear": "{0} klausymo sesijų per paskutinius metus",
|
||||||
|
"MessageLoading": "Kraunama...",
|
||||||
|
"MessageLoadingFolders": "Kraunami aplankai...",
|
||||||
|
"MessageM4BFailed": "M4B Nepavyko!",
|
||||||
|
"MessageM4BFinished": "M4B Baigta!",
|
||||||
|
"MessageMapChapterTitles": "Susieti skyriaus pavadinimus su jūsų esamais garso knygos skyriais, neredaguojant laiko žymų",
|
||||||
|
"MessageMarkAllEpisodesFinished": "Pažymėti visus epizodus kaip užbaigtus",
|
||||||
|
"MessageMarkAllEpisodesNotFinished": "Pažymėti visus epizodus kaip nebaigtus",
|
||||||
|
"MessageMarkAsFinished": "Pažymėti kaip užbaigtą",
|
||||||
|
"MessageMarkAsNotFinished": "Pažymėti kaip nebaigtą",
|
||||||
|
"MessageMatchBooksDescription": "bandys suderinti bibliotekos knygas su knyga iš pasirinkto paieškos tiekėjo ir užpildys tuščius duomenis ir viršelius. Neperrašo detalių.",
|
||||||
|
"MessageNoAudioTracks": "Nėra garso takelių",
|
||||||
|
"MessageNoAuthors": "Nėra autorių",
|
||||||
|
"MessageNoBackups": "Nėra atsarginių kopijų",
|
||||||
|
"MessageNoBookmarks": "Nėra žymų",
|
||||||
|
"MessageNoChapters": "Nėra skyrių",
|
||||||
|
"MessageNoCollections": "Nėra kolekcijų",
|
||||||
|
"MessageNoCoversFound": "Nerasta viršelių",
|
||||||
|
"MessageNoDescription": "Nėra aprašymo",
|
||||||
|
"MessageNoDownloadsInProgress": "Nėra vykstančių atsisiuntimų",
|
||||||
|
"MessageNoDownloadsQueued": "Nėra eilėje esančių atsisiuntimų",
|
||||||
|
"MessageNoEpisodeMatchesFound": "Nerasta epizodo atitikmenų",
|
||||||
|
"MessageNoEpisodes": "Nėra epizodų",
|
||||||
|
"MessageNoFoldersAvailable": "Nėra prieinamų aplankų",
|
||||||
|
"MessageNoGenres": "Nėra žanrų",
|
||||||
|
"MessageNoIssues": "Nėra problemų",
|
||||||
|
"MessageNoItems": "Nėra elementų",
|
||||||
|
"MessageNoItemsFound": "Elementų nerasta",
|
||||||
|
"MessageNoListeningSessions": "Klausymo sesijų nėra",
|
||||||
|
"MessageNoLogs": "Žurnalo įrašų nėra",
|
||||||
|
"MessageNoMediaProgress": "Nėra medijos pažangos",
|
||||||
|
"MessageNoNotifications": "Nėra pranešimų",
|
||||||
|
"MessageNoPodcastsFound": "Tinklalaidžių nerasta",
|
||||||
|
"MessageNoResults": "Rezultatų nėra",
|
||||||
|
"MessageNoSearchResultsFor": "Paieškos rezultatų nėra „{0}“",
|
||||||
|
"MessageNoSeries": "Serijų nėra",
|
||||||
|
"MessageNoTags": "Žymų nėra",
|
||||||
|
"MessageNoTasksRunning": "Nėra vykstančių užduočių",
|
||||||
|
"MessageNotYetImplemented": "Dar neįgyvendinta",
|
||||||
|
"MessageNoUpdateNecessary": "Atnaujinimai nereikalingi",
|
||||||
|
"MessageNoUpdatesWereNecessary": "Nereikalingi jokie atnaujinimai",
|
||||||
|
"MessageNoUserPlaylists": "Neturite grojaraščių",
|
||||||
|
"MessageOr": "arba",
|
||||||
|
"MessagePauseChapter": "Pristabdyti skyriaus grojimą",
|
||||||
|
"MessagePlayChapter": "Paklausyti skyriaus pradžios",
|
||||||
|
"MessagePlaylistCreateFromCollection": "Sukurti grojaraštį iš kolekcijos",
|
||||||
|
"MessagePodcastHasNoRSSFeedForMatching": "Tinklalidė neturi RSS srauto URL kuriuo būtų galima sulyginti",
|
||||||
|
"MessageQuickMatchDescription": "Užpildykite tuščius elementų duomenis ir viršelius su pirmuoju atitikimo rezultatu iš „{0}“. Neneperrašo detalių, nebent įgalintas serverio nustatymas „Pirmenybė atitaikytiems metaduomenis“.",
|
||||||
|
"MessageRemoveChapter": "Pašalinti skyrių",
|
||||||
|
"MessageRemoveEpisodes": "Pašalinti {0} epizodų (-ą)",
|
||||||
|
"MessageRemoveFromPlayerQueue": "Pašalinti iš grojaraščio",
|
||||||
|
"MessageRemoveUserWarning": "Ar tikrai norite visam laikui ištrinti naudotoją „{0}“?",
|
||||||
|
"MessageReportBugsAndContribute": "Praneškite apie klaidas, prašykite naujovių ir prisidėkite",
|
||||||
|
"MessageResetChaptersConfirm": "Ar tikrai norite atkurti skyrius ir atšaukti pakeitimus, kuriuos atlikote?",
|
||||||
|
"MessageRestoreBackupConfirm": "Ar tikrai norite atkurti atsarginę kopiją, sukurtą",
|
||||||
|
"MessageRestoreBackupWarning": "Atkurdami atsarginę kopiją perrašysite visą duomenų bazę, esančią /config ir viršelių vaizdus /metadata/items ir /metadata/authors.<br /><br />Atsarginės kopijos nekeičia jokių failų jūsų bibliotekos aplankuose. Jei esate įgalinę serverio nustatymus, kad viršelio meną ir metaduomenis saugotumėte savo bibliotekos aplankuose, šie neperrašomi ar atkuriami.<br /><br />Visi klientai, naudojantys jūsų serverį, bus automatiškai atnaujinti.",
|
||||||
|
"MessageSearchResultsFor": "Paieškos rezultatai „{0}“",
|
||||||
|
"MessageServerCouldNotBeReached": "Nepavyko pasiekti serverio",
|
||||||
|
"MessageSetChaptersFromTracksDescription": "Nustatyti skyrius, naudojant kiekvieną garso failą kaip skyrių ir skyriaus pavadinimą kaip garso failo pavadinimą",
|
||||||
|
"MessageStartPlaybackAtTime": "Paleisti klausymą „{0}“ nuo {1}?",
|
||||||
|
"MessageThinking": "Mąstau...",
|
||||||
|
"MessageUploaderItemFailed": "Įkelti nepavyko",
|
||||||
|
"MessageUploaderItemSuccess": "Sėkmingai įkelta!",
|
||||||
|
"MessageUploading": "Įkeliama...",
|
||||||
|
"MessageValidCronExpression": "Galiojanti cron išraiška",
|
||||||
|
"MessageWatcherIsDisabledGlobally": "Serverio nustatymuose stebėtojas išjungtas visuotinai",
|
||||||
|
"MessageXLibraryIsEmpty": "{0} biblioteka tuščia!",
|
||||||
|
"MessageYourAudiobookDurationIsLonger": "Jūsų garso knygos trukmė yra ilgesnė nei rasta trukmė",
|
||||||
|
"MessageYourAudiobookDurationIsShorter": "Jūsų garso knygos trukmė yra trumpesnė nei rasta trukmė",
|
||||||
|
"NoteChangeRootPassword": "Tik root vartotojas gali turėti tuščią slaptažodį",
|
||||||
|
"NoteChapterEditorTimes": "Pastaba: Pirmasis skyriaus pradžios laikas turi likti 0:00, o paskutinio skyriaus pradžios laikas negali viršyti šios garso knygos trukmės.",
|
||||||
|
"NoteFolderPicker": "Pastaba: jau susieti aplankai nebus rodomi",
|
||||||
|
"NoteFolderPickerDebian": "Pastaba: Aplanko pasirinkimo įrankis „Debian“ sistemoje nėra visiškai įgyvendintas. Turėtumėte tiesiogiai įvesti kelią į savo biblioteką.",
|
||||||
|
"NoteRSSFeedPodcastAppsHttps": "Įspėjimas: Dauguma tinklalaidžių programų reikalauja, kad RSS kanalo URL būtų naudojamas su HTTPS",
|
||||||
|
"NoteRSSFeedPodcastAppsPubDate": "Įspėjimas: Vienas ar daugiau jūsų epizodų neturi publikavimo datos. Kai kurios tinklalaidžių programos to reikalauja.",
|
||||||
|
"NoteUploaderFoldersWithMediaFiles": "Aplankai su medijos failais bus tvarkomi kaip atskiri bibliotekos elementai.",
|
||||||
|
"NoteUploaderOnlyAudioFiles": "Jei įkeliami tik garso failai, kiekvienas garso failas bus tvarkomas kaip atskira garso knyga.",
|
||||||
|
"NoteUploaderUnsupportedFiles": "Nepalaikomi failai yra ignoruojami. Pasirinkus ar atidarant aplanką, kiti failai, nesantys elementų aplankuose, yra ignoruojami.",
|
||||||
|
"PlaceholderNewCollection": "Naujas kolekcijos pavadinimas",
|
||||||
|
"PlaceholderNewFolderPath": "Naujas aplanko kelias",
|
||||||
|
"PlaceholderNewPlaylist": "Naujas grojaraščio pavadinimas",
|
||||||
|
"PlaceholderSearch": "Ieškoti..",
|
||||||
|
"PlaceholderSearchEpisode": "Ieškoti epizodo..",
|
||||||
|
"ToastAccountUpdateFailed": "Paskyros atnaujinimas nepavyko",
|
||||||
|
"ToastAccountUpdateSuccess": "Paskyra atnaujinta",
|
||||||
|
"ToastAuthorImageRemoveFailed": "Nepavyko pašalinti autoriaus paveiksliuko",
|
||||||
|
"ToastAuthorImageRemoveSuccess": "Autoriaus paveiksliukas pašalintas",
|
||||||
|
"ToastAuthorUpdateFailed": "Nepavyko atnaujinti autoriaus",
|
||||||
|
"ToastAuthorUpdateMerged": "Autorius sujungtas",
|
||||||
|
"ToastAuthorUpdateSuccess": "Autorius atnaujintas",
|
||||||
|
"ToastAuthorUpdateSuccessNoImageFound": "Autorius atnaujintas (paveiksliukas nerastas)",
|
||||||
|
"ToastBackupCreateFailed": "Atsarginės kopijos sukurti nepavyko",
|
||||||
|
"ToastBackupCreateSuccess": "Atsarginė kopija sukurta",
|
||||||
|
"ToastBackupDeleteFailed": "Atsarginės kopijos ištrinti nepavyko",
|
||||||
|
"ToastBackupDeleteSuccess": "Atsarginė kopija ištrinta",
|
||||||
|
"ToastBackupRestoreFailed": "Atsarginės kopijos atkurti nepavyko",
|
||||||
|
"ToastBackupUploadFailed": "Atsarginės kopijos įkelti nepavyko",
|
||||||
|
"ToastBackupUploadSuccess": "Atsarginė kopija įkelta",
|
||||||
|
"ToastBatchUpdateFailed": "Masinis atnaujinimas nepavyko",
|
||||||
|
"ToastBatchUpdateSuccess": "Masinis atnaujinimas sėkmingas",
|
||||||
|
"ToastBookmarkCreateFailed": "Žymos sukurti nepavyko",
|
||||||
|
"ToastBookmarkCreateSuccess": "Žyma pridėta",
|
||||||
|
"ToastBookmarkRemoveFailed": "Žymos pašalinti nepavyko",
|
||||||
|
"ToastBookmarkRemoveSuccess": "Žyma pašalinta",
|
||||||
|
"ToastBookmarkUpdateFailed": "Žymos atnaujinti nepavyko",
|
||||||
|
"ToastBookmarkUpdateSuccess": "Žyma atnaujinta",
|
||||||
|
"ToastChaptersHaveErrors": "Skyriai turi klaidų",
|
||||||
|
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
|
||||||
|
"ToastCollectionItemsRemoveFailed": "Elementų pašalinti iš kolekcijos nepavyko",
|
||||||
|
"ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos",
|
||||||
|
"ToastCollectionRemoveFailed": "Kolekcijos pašalinti nepavyko",
|
||||||
|
"ToastCollectionRemoveSuccess": "Kolekcija pašalinta",
|
||||||
|
"ToastCollectionUpdateFailed": "Kolekcijos atnaujinti nepavyko",
|
||||||
|
"ToastCollectionUpdateSuccess": "Kolekcija atnaujinta",
|
||||||
|
"ToastItemCoverUpdateFailed": "Elemento viršelio atnaujinti nepavyko",
|
||||||
|
"ToastItemCoverUpdateSuccess": "Elemento viršelis atnaujintas",
|
||||||
|
"ToastItemDetailsUpdateFailed": "Elemento detalių atnaujinti nepavyko",
|
||||||
|
"ToastItemDetailsUpdateSuccess": "Elemento detalės atnaujintos",
|
||||||
|
"ToastItemDetailsUpdateUnneeded": "Elemento detalės atnaujinimas nereikalingas",
|
||||||
|
"ToastItemMarkedAsFinishedFailed": "Pažymėti kaip Baigta nepavyko",
|
||||||
|
"ToastItemMarkedAsFinishedSuccess": "Elementas pažymėtas kaip Baigta",
|
||||||
|
"ToastItemMarkedAsNotFinishedFailed": "Pažymėti kaip Nebaigta nepavyko",
|
||||||
|
"ToastItemMarkedAsNotFinishedSuccess": "Elementas pažymėtas kaip Nebaigta",
|
||||||
|
"ToastLibraryCreateFailed": "Bibliotekos sukurti nepavyko",
|
||||||
|
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" sukurta",
|
||||||
|
"ToastLibraryDeleteFailed": "Bibliotekos ištrinti nepavyko",
|
||||||
|
"ToastLibraryDeleteSuccess": "Biblioteka ištrinta",
|
||||||
|
"ToastLibraryScanFailedToStart": "Nepavyko pradėti bibliotekos skenavimo",
|
||||||
|
"ToastLibraryScanStarted": "Bibliotekos skenavimas pradėtas",
|
||||||
|
"ToastLibraryUpdateFailed": "Bibliotekos atnaujinti nepavyko",
|
||||||
|
"ToastLibraryUpdateSuccess": "Biblioteka \"{0}\" atnaujinta",
|
||||||
|
"ToastPlaylistCreateFailed": "Grojaraščio sukurti nepavyko",
|
||||||
|
"ToastPlaylistCreateSuccess": "Grojaraštis sukurtas",
|
||||||
|
"ToastPlaylistRemoveFailed": "Grojaraščio pašalinti nepavyko",
|
||||||
|
"ToastPlaylistRemoveSuccess": "Grojaraštis pašalintas",
|
||||||
|
"ToastPlaylistUpdateFailed": "Grojaraščio atnaujinti nepavyko",
|
||||||
|
"ToastPlaylistUpdateSuccess": "Grojaraštis atnaujintas",
|
||||||
|
"ToastPodcastCreateFailed": "Tinklalaidės sukurti nepavyko",
|
||||||
|
"ToastPodcastCreateSuccess": "Tinklalaidė sėkmingai sukurta",
|
||||||
|
"ToastRemoveItemFromCollectionFailed": "Elemento pašalinti iš kolekcijos nepavyko",
|
||||||
|
"ToastRemoveItemFromCollectionSuccess": "Elementas pašalintas iš kolekcijos",
|
||||||
|
"ToastRSSFeedCloseFailed": "RSS srauto uždaryti nepavyko",
|
||||||
|
"ToastRSSFeedCloseSuccess": "RSS srautas uždarytas",
|
||||||
|
"ToastSendEbookToDeviceFailed": "Nepavyko nusiųsti e-knygos į įrenginį",
|
||||||
|
"ToastSendEbookToDeviceSuccess": "E-knyga išsiųsta į įrenginį \"{0}\"",
|
||||||
|
"ToastSeriesUpdateFailed": "Serijos atnaujinti nepavyko",
|
||||||
|
"ToastSeriesUpdateSuccess": "Serijos atnaujintos",
|
||||||
|
"ToastSessionDeleteFailed": "Sesijos ištrinti nepavyko",
|
||||||
|
"ToastSessionDeleteSuccess": "Sesija ištrinta",
|
||||||
|
"ToastSocketConnected": "Serveris prijungtas",
|
||||||
|
"ToastSocketDisconnected": "Severis atjungtas",
|
||||||
|
"ToastSocketFailedToConnect": "Nepavyko prisijungti prie serverio",
|
||||||
|
"ToastUserDeleteFailed": "Nepavyko ištrinti naudotojo",
|
||||||
|
"ToastUserDeleteSuccess": "Naudotojas ištrintas"
|
||||||
|
}
|
||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Verwijder {0} afleveringen",
|
"HeaderRemoveEpisodes": "Verwijder {0} afleveringen",
|
||||||
"HeaderRSSFeedGeneral": "RSS-details",
|
"HeaderRSSFeedGeneral": "RSS-details",
|
||||||
"HeaderRSSFeedIsOpen": "RSS-feed is open",
|
"HeaderRSSFeedIsOpen": "RSS-feed is open",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "Opgeslagen mediavoortgang",
|
"HeaderSavedMediaProgress": "Opgeslagen mediavoortgang",
|
||||||
"HeaderSchedule": "Schema",
|
"HeaderSchedule": "Schema",
|
||||||
"HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans",
|
"HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans",
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"LabelClosePlayer": "Sluit speler",
|
"LabelClosePlayer": "Sluit speler",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Series inklappen",
|
"LabelCollapseSeries": "Series inklappen",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "Collecties",
|
"LabelCollections": "Collecties",
|
||||||
"LabelComplete": "Compleet",
|
"LabelComplete": "Compleet",
|
||||||
"LabelConfirmPassword": "Bevestig wachtwoord",
|
"LabelConfirmPassword": "Bevestig wachtwoord",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"LabelDirectory": "Map",
|
"LabelDirectory": "Map",
|
||||||
"LabelDiscFromFilename": "Schijf uit bestandsnaam",
|
"LabelDiscFromFilename": "Schijf uit bestandsnaam",
|
||||||
"LabelDiscFromMetadata": "Schijf uit metadata",
|
"LabelDiscFromMetadata": "Schijf uit metadata",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Duur",
|
"LabelDuration": "Duur",
|
||||||
@@ -395,6 +398,9 @@
|
|||||||
"LabelSettingsDisableWatcher": "Watcher uitschakelen",
|
"LabelSettingsDisableWatcher": "Watcher uitschakelen",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
|
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
|
||||||
"LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/bijwerken van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server",
|
"LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/bijwerken van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Experimentele functies",
|
"LabelSettingsExperimentalFeatures": "Experimentele functies",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
|
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
|
||||||
"LabelSettingsFindCovers": "Zoek covers",
|
"LabelSettingsFindCovers": "Zoek covers",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Toon alle",
|
"LabelShowAll": "Toon alle",
|
||||||
"LabelSize": "Grootte",
|
"LabelSize": "Grootte",
|
||||||
"LabelSleepTimer": "Slaaptimer",
|
"LabelSleepTimer": "Slaaptimer",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStarted": "Gestart",
|
"LabelStarted": "Gestart",
|
||||||
"LabelStartedAt": "Gestart op",
|
"LabelStartedAt": "Gestart op",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Track vanuit metadata",
|
"LabelTrackFromMetadata": "Track vanuit metadata",
|
||||||
"LabelTracks": "Tracks",
|
"LabelTracks": "Tracks",
|
||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Type",
|
"LabelType": "Type",
|
||||||
"LabelUnabridged": "Onverkort",
|
"LabelUnabridged": "Onverkort",
|
||||||
@@ -514,6 +522,7 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk",
|
"MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk",
|
||||||
"MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek",
|
"MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek",
|
||||||
"MessageCheckingCron": "Cron aan het checken...",
|
"MessageCheckingCron": "Cron aan het checken...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?",
|
"MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||||
"MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?",
|
"MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?",
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Usuń {0} odcinków",
|
"HeaderRemoveEpisodes": "Usuń {0} odcinków",
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "RSS Details",
|
||||||
"HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty",
|
"HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "Zapisany postęp",
|
"HeaderSavedMediaProgress": "Zapisany postęp",
|
||||||
"HeaderSchedule": "Harmonogram",
|
"HeaderSchedule": "Harmonogram",
|
||||||
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
|
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"LabelClosePlayer": "Zamknij odtwarzacz",
|
"LabelClosePlayer": "Zamknij odtwarzacz",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Podsumuj serię",
|
"LabelCollapseSeries": "Podsumuj serię",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "Kolekcje",
|
"LabelCollections": "Kolekcje",
|
||||||
"LabelComplete": "Ukończone",
|
"LabelComplete": "Ukończone",
|
||||||
"LabelConfirmPassword": "Potwierdź hasło",
|
"LabelConfirmPassword": "Potwierdź hasło",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"LabelDirectory": "Katalog",
|
"LabelDirectory": "Katalog",
|
||||||
"LabelDiscFromFilename": "Oznaczenie dysku z nazwy pliku",
|
"LabelDiscFromFilename": "Oznaczenie dysku z nazwy pliku",
|
||||||
"LabelDiscFromMetadata": "Oznaczenie dysku z metadanych",
|
"LabelDiscFromMetadata": "Oznaczenie dysku z metadanych",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
"LabelDownload": "Pobierz",
|
"LabelDownload": "Pobierz",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "Czas trwania",
|
"LabelDuration": "Czas trwania",
|
||||||
@@ -395,6 +398,9 @@
|
|||||||
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
|
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
|
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
|
||||||
"LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera",
|
"LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne",
|
"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.",
|
"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",
|
"LabelSettingsFindCovers": "Szukanie okładek",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Pokaż wszystko",
|
"LabelShowAll": "Pokaż wszystko",
|
||||||
"LabelSize": "Rozmiar",
|
"LabelSize": "Rozmiar",
|
||||||
"LabelSleepTimer": "Wyłącznik czasowy",
|
"LabelSleepTimer": "Wyłącznik czasowy",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "Rozpocznij",
|
"LabelStart": "Rozpocznij",
|
||||||
"LabelStarted": "Rozpoczęty",
|
"LabelStarted": "Rozpoczęty",
|
||||||
"LabelStartedAt": "Rozpoczęto",
|
"LabelStartedAt": "Rozpoczęto",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Ścieżka z metadanych",
|
"LabelTrackFromMetadata": "Ścieżka z metadanych",
|
||||||
"LabelTracks": "Tracks",
|
"LabelTracks": "Tracks",
|
||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
"LabelUnabridged": "Unabridged",
|
"LabelUnabridged": "Unabridged",
|
||||||
@@ -514,6 +522,7 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||||
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
|
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
|
||||||
"MessageCheckingCron": "Sprawdzanie cron...",
|
"MessageCheckingCron": "Sprawdzanie cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
|
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||||
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
|
||||||
|
|||||||
+31
-22
@@ -103,7 +103,7 @@
|
|||||||
"HeaderEmailSettings": "Настройки Email",
|
"HeaderEmailSettings": "Настройки Email",
|
||||||
"HeaderEpisodes": "Эпизоды",
|
"HeaderEpisodes": "Эпизоды",
|
||||||
"HeaderEreaderDevices": "Устройства E-книга",
|
"HeaderEreaderDevices": "Устройства E-книга",
|
||||||
"HeaderEreaderSettings": "Ereader Settings",
|
"HeaderEreaderSettings": "Настройки E-ридера",
|
||||||
"HeaderFiles": "Файлы",
|
"HeaderFiles": "Файлы",
|
||||||
"HeaderFindChapters": "Найти главы",
|
"HeaderFindChapters": "Найти главы",
|
||||||
"HeaderIgnoredFiles": "Игнорируемые Файлы",
|
"HeaderIgnoredFiles": "Игнорируемые Файлы",
|
||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "Удалить {0} эпизодов",
|
"HeaderRemoveEpisodes": "Удалить {0} эпизодов",
|
||||||
"HeaderRSSFeedGeneral": "Сведения о RSS",
|
"HeaderRSSFeedGeneral": "Сведения о RSS",
|
||||||
"HeaderRSSFeedIsOpen": "RSS-канал открыт",
|
"HeaderRSSFeedIsOpen": "RSS-канал открыт",
|
||||||
|
"HeaderRSSFeeds": "RSS-каналы",
|
||||||
"HeaderSavedMediaProgress": "Прогресс медиа сохранен",
|
"HeaderSavedMediaProgress": "Прогресс медиа сохранен",
|
||||||
"HeaderSchedule": "Планировщик",
|
"HeaderSchedule": "Планировщик",
|
||||||
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
|
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
|
||||||
@@ -155,7 +156,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Последние сеансы",
|
"HeaderStatsRecentSessions": "Последние сеансы",
|
||||||
"HeaderStatsTop10Authors": "Топ 10 авторов",
|
"HeaderStatsTop10Authors": "Топ 10 авторов",
|
||||||
"HeaderStatsTop5Genres": "Топ 5 жанров",
|
"HeaderStatsTop5Genres": "Топ 5 жанров",
|
||||||
"HeaderTableOfContents": "Table of Contents",
|
"HeaderTableOfContents": "Содержание",
|
||||||
"HeaderTools": "Инструменты",
|
"HeaderTools": "Инструменты",
|
||||||
"HeaderUpdateAccount": "Обновить учетную запись",
|
"HeaderUpdateAccount": "Обновить учетную запись",
|
||||||
"HeaderUpdateAuthor": "Обновить автора",
|
"HeaderUpdateAuthor": "Обновить автора",
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"LabelClosePlayer": "Закрыть проигрыватель",
|
"LabelClosePlayer": "Закрыть проигрыватель",
|
||||||
"LabelCodec": "Кодек",
|
"LabelCodec": "Кодек",
|
||||||
"LabelCollapseSeries": "Свернуть серии",
|
"LabelCollapseSeries": "Свернуть серии",
|
||||||
|
"LabelCollection": "Коллекция",
|
||||||
"LabelCollections": "Коллекции",
|
"LabelCollections": "Коллекции",
|
||||||
"LabelComplete": "Завершить",
|
"LabelComplete": "Завершить",
|
||||||
"LabelConfirmPassword": "Подтвердить пароль",
|
"LabelConfirmPassword": "Подтвердить пароль",
|
||||||
@@ -222,8 +224,9 @@
|
|||||||
"LabelDirectory": "Каталог",
|
"LabelDirectory": "Каталог",
|
||||||
"LabelDiscFromFilename": "Диск из Имени файла",
|
"LabelDiscFromFilename": "Диск из Имени файла",
|
||||||
"LabelDiscFromMetadata": "Диск из Метаданных",
|
"LabelDiscFromMetadata": "Диск из Метаданных",
|
||||||
|
"LabelDiscover": "Не начато",
|
||||||
"LabelDownload": "Скачать",
|
"LabelDownload": "Скачать",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Скачать {0} эпизодов",
|
||||||
"LabelDuration": "Длина",
|
"LabelDuration": "Длина",
|
||||||
"LabelDurationFound": "Найденная длина:",
|
"LabelDurationFound": "Найденная длина:",
|
||||||
"LabelEbook": "E-книга",
|
"LabelEbook": "E-книга",
|
||||||
@@ -252,7 +255,7 @@
|
|||||||
"LabelFinished": "Закончен",
|
"LabelFinished": "Закончен",
|
||||||
"LabelFolder": "Папка",
|
"LabelFolder": "Папка",
|
||||||
"LabelFolders": "Папки",
|
"LabelFolders": "Папки",
|
||||||
"LabelFontScale": "Font scale",
|
"LabelFontScale": "Масштаб шрифта",
|
||||||
"LabelFormat": "Формат",
|
"LabelFormat": "Формат",
|
||||||
"LabelGenre": "Жанр",
|
"LabelGenre": "Жанр",
|
||||||
"LabelGenres": "Жанры",
|
"LabelGenres": "Жанры",
|
||||||
@@ -284,16 +287,16 @@
|
|||||||
"LabelLastSeen": "Последнее сканирование",
|
"LabelLastSeen": "Последнее сканирование",
|
||||||
"LabelLastTime": "Последний по времени",
|
"LabelLastTime": "Последний по времени",
|
||||||
"LabelLastUpdate": "Последний обновленный",
|
"LabelLastUpdate": "Последний обновленный",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Макет",
|
||||||
"LabelLayoutSinglePage": "Single page",
|
"LabelLayoutSinglePage": "Одна страница",
|
||||||
"LabelLayoutSplitPage": "Split page",
|
"LabelLayoutSplitPage": "Разделенная страница",
|
||||||
"LabelLess": "Менее",
|
"LabelLess": "Менее",
|
||||||
"LabelLibrariesAccessibleToUser": "Библиотеки доступные для пользователя",
|
"LabelLibrariesAccessibleToUser": "Библиотеки доступные для пользователя",
|
||||||
"LabelLibrary": "Библиотека",
|
"LabelLibrary": "Библиотека",
|
||||||
"LabelLibraryItem": "Элемент библиотеки",
|
"LabelLibraryItem": "Элемент библиотеки",
|
||||||
"LabelLibraryName": "Имя библиотеки",
|
"LabelLibraryName": "Имя библиотеки",
|
||||||
"LabelLimit": "Лимит",
|
"LabelLimit": "Лимит",
|
||||||
"LabelLineSpacing": "Line spacing",
|
"LabelLineSpacing": "Межстрочный интервал",
|
||||||
"LabelListenAgain": "Послушать снова",
|
"LabelListenAgain": "Послушать снова",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
@@ -318,7 +321,7 @@
|
|||||||
"LabelNewPassword": "Новый пароль",
|
"LabelNewPassword": "Новый пароль",
|
||||||
"LabelNextBackupDate": "Следующая дата бэкапирования",
|
"LabelNextBackupDate": "Следующая дата бэкапирования",
|
||||||
"LabelNextScheduledRun": "Следущий запланированный запуск",
|
"LabelNextScheduledRun": "Следущий запланированный запуск",
|
||||||
"LabelNoEpisodesSelected": "No episodes selected",
|
"LabelNoEpisodesSelected": "Эпизоды не выбраны",
|
||||||
"LabelNotes": "Заметки",
|
"LabelNotes": "Заметки",
|
||||||
"LabelNotFinished": "Не завершено",
|
"LabelNotFinished": "Не завершено",
|
||||||
"LabelNotificationAppriseURL": "URL(ы) для извещений",
|
"LabelNotificationAppriseURL": "URL(ы) для извещений",
|
||||||
@@ -378,8 +381,8 @@
|
|||||||
"LabelSearchTitle": "Поиск по названию",
|
"LabelSearchTitle": "Поиск по названию",
|
||||||
"LabelSearchTitleOrASIN": "Поиск по названию или ASIN",
|
"LabelSearchTitleOrASIN": "Поиск по названию или ASIN",
|
||||||
"LabelSeason": "Сезон",
|
"LabelSeason": "Сезон",
|
||||||
"LabelSelectAllEpisodes": "Select all episodes",
|
"LabelSelectAllEpisodes": "Выбрать все эпизоды",
|
||||||
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
"LabelSelectEpisodesShowing": "Выберите {0} эпизодов для показа",
|
||||||
"LabelSendEbookToDevice": "Отправить e-книгу в...",
|
"LabelSendEbookToDevice": "Отправить e-книгу в...",
|
||||||
"LabelSequence": "Последовательность",
|
"LabelSequence": "Последовательность",
|
||||||
"LabelSeries": "Серия",
|
"LabelSeries": "Серия",
|
||||||
@@ -395,12 +398,15 @@
|
|||||||
"LabelSettingsDisableWatcher": "Отключить отслеживание",
|
"LabelSettingsDisableWatcher": "Отключить отслеживание",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
|
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
|
||||||
"LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера",
|
"LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера",
|
||||||
|
"LabelSettingsEnableWatcher": "Включить отслеживание",
|
||||||
|
"LabelSettingsEnableWatcherForLibrary": "Включить отслеживание за папками библиотеки",
|
||||||
|
"LabelSettingsEnableWatcherHelp": "Включает автоматическое добавление/обновление элементов при обнаружении изменений файлов. *Требуется перезапуск сервера",
|
||||||
"LabelSettingsExperimentalFeatures": "Экспериментальные функции",
|
"LabelSettingsExperimentalFeatures": "Экспериментальные функции",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
|
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
|
||||||
"LabelSettingsFindCovers": "Найти обложки",
|
"LabelSettingsFindCovers": "Найти обложки",
|
||||||
"LabelSettingsFindCoversHelp": "Если у Ваших аудиокниг нет встроенной обложки или файла обложки в папке книги, то сканер попробует найти обложку.<br>Примечание: Это увеличит время сканирования",
|
"LabelSettingsFindCoversHelp": "Если у Ваших аудиокниг нет встроенной обложки или файла обложки в папке книги, то сканер попробует найти обложку.<br>Примечание: Это увеличит время сканирования",
|
||||||
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
"LabelSettingsHideSingleBookSeries": "Скрыть серии с одной книгой",
|
||||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
"LabelSettingsHideSingleBookSeriesHelp": "Серии, в которых всего одна книга, будут скрыты со страницы серий и полок домашней страницы.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице",
|
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице",
|
||||||
"LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке",
|
"LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Overdrive Media Markers для глав",
|
"LabelSettingsOverdriveMediaMarkers": "Overdrive Media Markers для глав",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "Показать все",
|
"LabelShowAll": "Показать все",
|
||||||
"LabelSize": "Размер",
|
"LabelSize": "Размер",
|
||||||
"LabelSleepTimer": "Таймер сна",
|
"LabelSleepTimer": "Таймер сна",
|
||||||
|
"LabelSlug": "Слизень",
|
||||||
"LabelStart": "Начало",
|
"LabelStart": "Начало",
|
||||||
"LabelStarted": "Начат",
|
"LabelStarted": "Начат",
|
||||||
"LabelStartedAt": "Начато В",
|
"LabelStartedAt": "Начато В",
|
||||||
@@ -453,9 +460,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Теги доступные для пользователя",
|
"LabelTagsAccessibleToUser": "Теги доступные для пользователя",
|
||||||
"LabelTagsNotAccessibleToUser": "Теги не доступные для пользователя",
|
"LabelTagsNotAccessibleToUser": "Теги не доступные для пользователя",
|
||||||
"LabelTasks": "Запущенные задачи",
|
"LabelTasks": "Запущенные задачи",
|
||||||
"LabelTheme": "Theme",
|
"LabelTheme": "Тема",
|
||||||
"LabelThemeDark": "Dark",
|
"LabelThemeDark": "Темная",
|
||||||
"LabelThemeLight": "Light",
|
"LabelThemeLight": "Светлая",
|
||||||
"LabelTimeBase": "Временная база",
|
"LabelTimeBase": "Временная база",
|
||||||
"LabelTimeListened": "Время прослушивания",
|
"LabelTimeListened": "Время прослушивания",
|
||||||
"LabelTimeListenedToday": "Время прослушивания сегодня",
|
"LabelTimeListenedToday": "Время прослушивания сегодня",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "Трек из Метаданных",
|
"LabelTrackFromMetadata": "Трек из Метаданных",
|
||||||
"LabelTracks": "Треков",
|
"LabelTracks": "Треков",
|
||||||
"LabelTracksMultiTrack": "Мультитрек",
|
"LabelTracksMultiTrack": "Мультитрек",
|
||||||
|
"LabelTracksNone": "Нет треков",
|
||||||
"LabelTracksSingleTrack": "Один трек",
|
"LabelTracksSingleTrack": "Один трек",
|
||||||
"LabelType": "Тип",
|
"LabelType": "Тип",
|
||||||
"LabelUnabridged": "Полное издание",
|
"LabelUnabridged": "Полное издание",
|
||||||
@@ -514,15 +522,16 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "Неверное время начала, должно быть больше или равно времени начала предыдущей главы",
|
"MessageChapterErrorStartLtPrev": "Неверное время начала, должно быть больше или равно времени начала предыдущей главы",
|
||||||
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
|
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
|
||||||
"MessageCheckingCron": "Проверка cron...",
|
"MessageCheckingCron": "Проверка cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Вы уверены, что хотите закрыть этот канал?",
|
||||||
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
|
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
|
||||||
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
|
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
|
||||||
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
|
||||||
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
|
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
|
||||||
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
|
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
|
"MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?",
|
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как завершенные?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?",
|
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как не завершенные?",
|
||||||
"MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?",
|
"MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?",
|
||||||
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
|
||||||
@@ -554,8 +563,8 @@
|
|||||||
"MessageM4BFailed": "M4B Ошибка!",
|
"MessageM4BFailed": "M4B Ошибка!",
|
||||||
"MessageM4BFinished": "M4B Завершено!",
|
"MessageM4BFinished": "M4B Завершено!",
|
||||||
"MessageMapChapterTitles": "Сопоставление названий глав с существующими главами аудиокниги без корректировки временных меток",
|
"MessageMapChapterTitles": "Сопоставление названий глав с существующими главами аудиокниги без корректировки временных меток",
|
||||||
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
|
"MessageMarkAllEpisodesFinished": "Отметить все эпизоды как завершенные",
|
||||||
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
|
"MessageMarkAllEpisodesNotFinished": "Отметить все эпизоды как не завершенные",
|
||||||
"MessageMarkAsFinished": "Отметить, как завершенную",
|
"MessageMarkAsFinished": "Отметить, как завершенную",
|
||||||
"MessageMarkAsNotFinished": "Отметить, как не завершенную",
|
"MessageMarkAsNotFinished": "Отметить, как не завершенную",
|
||||||
"MessageMatchBooksDescription": "попытается сопоставить книги в библиотеке с книгой из выбранного поставщика поиска и заполнить пустые детали и обложку. Не перезаписывает сведения.",
|
"MessageMatchBooksDescription": "попытается сопоставить книги в библиотеке с книгой из выбранного поставщика поиска и заполнить пустые детали и обложку. Не перезаписывает сведения.",
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"HeaderRemoveEpisodes": "移除 {0} 剧集",
|
"HeaderRemoveEpisodes": "移除 {0} 剧集",
|
||||||
"HeaderRSSFeedGeneral": "RSS 详细信息",
|
"HeaderRSSFeedGeneral": "RSS 详细信息",
|
||||||
"HeaderRSSFeedIsOpen": "RSS 源已打开",
|
"HeaderRSSFeedIsOpen": "RSS 源已打开",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
"HeaderSavedMediaProgress": "保存媒体进度",
|
"HeaderSavedMediaProgress": "保存媒体进度",
|
||||||
"HeaderSchedule": "计划任务",
|
"HeaderSchedule": "计划任务",
|
||||||
"HeaderScheduleLibraryScans": "自动扫描媒体库",
|
"HeaderScheduleLibraryScans": "自动扫描媒体库",
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"LabelClosePlayer": "关闭播放器",
|
"LabelClosePlayer": "关闭播放器",
|
||||||
"LabelCodec": "编解码",
|
"LabelCodec": "编解码",
|
||||||
"LabelCollapseSeries": "折叠系列",
|
"LabelCollapseSeries": "折叠系列",
|
||||||
|
"LabelCollection": "Collection",
|
||||||
"LabelCollections": "收藏",
|
"LabelCollections": "收藏",
|
||||||
"LabelComplete": "已完成",
|
"LabelComplete": "已完成",
|
||||||
"LabelConfirmPassword": "确认密码",
|
"LabelConfirmPassword": "确认密码",
|
||||||
@@ -222,6 +224,7 @@
|
|||||||
"LabelDirectory": "目录",
|
"LabelDirectory": "目录",
|
||||||
"LabelDiscFromFilename": "从文件名获取光盘",
|
"LabelDiscFromFilename": "从文件名获取光盘",
|
||||||
"LabelDiscFromMetadata": "从元数据获取光盘",
|
"LabelDiscFromMetadata": "从元数据获取光盘",
|
||||||
|
"LabelDiscover": "Discover",
|
||||||
"LabelDownload": "下载",
|
"LabelDownload": "下载",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||||
"LabelDuration": "持续时间",
|
"LabelDuration": "持续时间",
|
||||||
@@ -395,6 +398,9 @@
|
|||||||
"LabelSettingsDisableWatcher": "禁用监视程序",
|
"LabelSettingsDisableWatcher": "禁用监视程序",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序",
|
"LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序",
|
||||||
"LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器",
|
"LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器",
|
||||||
|
"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",
|
||||||
"LabelSettingsExperimentalFeatures": "实验功能",
|
"LabelSettingsExperimentalFeatures": "实验功能",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
|
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
|
||||||
"LabelSettingsFindCovers": "查找封面",
|
"LabelSettingsFindCovers": "查找封面",
|
||||||
@@ -427,6 +433,7 @@
|
|||||||
"LabelShowAll": "全部显示",
|
"LabelShowAll": "全部显示",
|
||||||
"LabelSize": "文件大小",
|
"LabelSize": "文件大小",
|
||||||
"LabelSleepTimer": "睡眠定时",
|
"LabelSleepTimer": "睡眠定时",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
"LabelStart": "开始",
|
"LabelStart": "开始",
|
||||||
"LabelStarted": "开始于",
|
"LabelStarted": "开始于",
|
||||||
"LabelStartedAt": "从这开始",
|
"LabelStartedAt": "从这开始",
|
||||||
@@ -474,6 +481,7 @@
|
|||||||
"LabelTrackFromMetadata": "从源数据获取音轨",
|
"LabelTrackFromMetadata": "从源数据获取音轨",
|
||||||
"LabelTracks": "音轨",
|
"LabelTracks": "音轨",
|
||||||
"LabelTracksMultiTrack": "多轨",
|
"LabelTracksMultiTrack": "多轨",
|
||||||
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "单轨",
|
"LabelTracksSingleTrack": "单轨",
|
||||||
"LabelType": "类型",
|
"LabelType": "类型",
|
||||||
"LabelUnabridged": "未删节",
|
"LabelUnabridged": "未删节",
|
||||||
@@ -514,6 +522,7 @@
|
|||||||
"MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间",
|
"MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间",
|
||||||
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
|
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
|
||||||
"MessageCheckingCron": "检查计划任务...",
|
"MessageCheckingCron": "检查计划任务...",
|
||||||
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
|
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
|
||||||
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
|
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
|
||||||
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
- 13378:80
|
- 13378:80
|
||||||
volumes:
|
volumes:
|
||||||
- ./audiobooks:/audiobooks
|
- ./audiobooks:/audiobooks
|
||||||
|
- ./podcasts:/podcasts
|
||||||
- ./metadata:/metadata
|
- ./metadata:/metadata
|
||||||
- ./config:/config
|
- ./config:/config
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.3.1",
|
"version": "2.4.2",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.3.1",
|
"version": "2.4.2",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.3.1",
|
"version": "2.4.2",
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
|
|||||||
* Merge your audio files into a single m4b
|
* Merge your audio files into a single m4b
|
||||||
* Embed metadata and cover image into your audio files (using [Tone](https://github.com/sandreas/tone))
|
* Embed metadata and cover image into your audio files (using [Tone](https://github.com/sandreas/tone))
|
||||||
* Basic ebook support and ereader
|
* Basic ebook support and ereader
|
||||||
|
* Epub, pdf, cbr, cbz
|
||||||
|
* Send ebook to device (i.e. Kindle)
|
||||||
|
* Open RSS feeds for podcasts and audiobooks
|
||||||
|
|
||||||
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
|
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
|
||||||
|
|
||||||
|
|||||||
+26
-12
@@ -32,12 +32,13 @@ class Auth {
|
|||||||
await Database.updateServerSettings()
|
await Database.updateServerSettings()
|
||||||
|
|
||||||
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
||||||
if (Database.users.length) {
|
const users = await Database.userModel.getOldUsers()
|
||||||
for (const user of Database.users) {
|
if (users.length) {
|
||||||
|
for (const user of users) {
|
||||||
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
|
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
|
||||||
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
|
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
|
||||||
}
|
}
|
||||||
await Database.updateBulkUsers(Database.users)
|
await Database.updateBulkUsers(users)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,21 +94,32 @@ class Auth {
|
|||||||
|
|
||||||
verifyToken(token) {
|
verifyToken(token) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
jwt.verify(token, Database.serverSettings.tokenSecret, (err, payload) => {
|
jwt.verify(token, Database.serverSettings.tokenSecret, async (err, payload) => {
|
||||||
if (!payload || err) {
|
if (!payload || err) {
|
||||||
Logger.error('JWT Verify Token Failed', err)
|
Logger.error('JWT Verify Token Failed', err)
|
||||||
return resolve(null)
|
return resolve(null)
|
||||||
}
|
}
|
||||||
const user = Database.users.find(u => (u.id === payload.userId || u.oldUserId === payload.userId) && u.username === payload.username)
|
|
||||||
resolve(user || null)
|
const user = await Database.userModel.getUserByIdOrOldId(payload.userId)
|
||||||
|
if (user && user.username === payload.username) {
|
||||||
|
resolve(user)
|
||||||
|
} else {
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserLoginResponsePayload(user) {
|
/**
|
||||||
|
* Payload returned to a user after successful login
|
||||||
|
* @param {oldUser} user
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
async getUserLoginResponsePayload(user) {
|
||||||
|
const libraryIds = await Database.libraryModel.getAllLibraryIds()
|
||||||
return {
|
return {
|
||||||
user: user.toJSONForBrowser(),
|
user: user.toJSONForBrowser(),
|
||||||
userDefaultLibraryId: user.getDefaultLibraryId(Database.libraries),
|
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
|
||||||
serverSettings: Database.serverSettings.toJSONForBrowser(),
|
serverSettings: Database.serverSettings.toJSONForBrowser(),
|
||||||
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
|
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
|
||||||
Source: global.Source
|
Source: global.Source
|
||||||
@@ -119,7 +131,7 @@ class Auth {
|
|||||||
const username = (req.body.username || '').toLowerCase()
|
const username = (req.body.username || '').toLowerCase()
|
||||||
const password = req.body.password || ''
|
const password = req.body.password || ''
|
||||||
|
|
||||||
const user = Database.users.find(u => u.username.toLowerCase() === username)
|
const user = await Database.userModel.getUserByUsername(username)
|
||||||
|
|
||||||
if (!user?.isActive) {
|
if (!user?.isActive) {
|
||||||
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
||||||
@@ -136,7 +148,8 @@ class Auth {
|
|||||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||||
} else {
|
} else {
|
||||||
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
|
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
|
||||||
return res.json(this.getUserLoginResponsePayload(user))
|
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
|
||||||
|
return res.json(userLoginResponsePayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +157,8 @@ class Auth {
|
|||||||
const compare = await bcrypt.compare(password, user.pash)
|
const compare = await bcrypt.compare(password, user.pash)
|
||||||
if (compare) {
|
if (compare) {
|
||||||
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
|
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
|
||||||
res.json(this.getUserLoginResponsePayload(user))
|
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
|
||||||
|
res.json(userLoginResponsePayload)
|
||||||
} else {
|
} else {
|
||||||
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
||||||
if (req.rateLimit.remaining <= 2) {
|
if (req.rateLimit.remaining <= 2) {
|
||||||
@@ -164,7 +178,7 @@ class Auth {
|
|||||||
async userChangePassword(req, res) {
|
async userChangePassword(req, res) {
|
||||||
var { password, newPassword } = req.body
|
var { password, newPassword } = req.body
|
||||||
newPassword = newPassword || ''
|
newPassword = newPassword || ''
|
||||||
const matchingUser = Database.users.find(u => u.id === req.user.id)
|
const matchingUser = await Database.userModel.getUserById(req.user.id)
|
||||||
|
|
||||||
// Only root can have an empty password
|
// Only root can have an empty password
|
||||||
if (matchingUser.type !== 'root' && !newPassword) {
|
if (matchingUser.type !== 'root' && !newPassword) {
|
||||||
|
|||||||
+386
-219
@@ -6,27 +6,25 @@ const fs = require('./libs/fsExtra')
|
|||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
const dbMigration = require('./utils/migrations/dbMigration')
|
const dbMigration = require('./utils/migrations/dbMigration')
|
||||||
|
const Auth = require('./Auth')
|
||||||
|
|
||||||
class Database {
|
class Database {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sequelize = null
|
this.sequelize = null
|
||||||
this.dbPath = null
|
this.dbPath = null
|
||||||
this.isNew = false // New absdatabase.sqlite created
|
this.isNew = false // New absdatabase.sqlite created
|
||||||
|
this.hasRootUser = false // Used to show initialization page in web ui
|
||||||
|
|
||||||
// Temporarily using format of old DB
|
|
||||||
// TODO: below data should be loaded from the DB as needed
|
|
||||||
this.libraryItems = []
|
|
||||||
this.users = []
|
|
||||||
this.libraries = []
|
|
||||||
this.settings = []
|
this.settings = []
|
||||||
this.collections = []
|
|
||||||
this.playlists = []
|
|
||||||
this.authors = []
|
|
||||||
this.series = []
|
|
||||||
this.feeds = []
|
|
||||||
|
|
||||||
|
// Cached library filter data
|
||||||
|
this.libraryFilterData = {}
|
||||||
|
|
||||||
|
/** @type {import('./objects/settings/ServerSettings')} */
|
||||||
this.serverSettings = null
|
this.serverSettings = null
|
||||||
|
/** @type {import('./objects/settings/NotificationSettings')} */
|
||||||
this.notificationSettings = null
|
this.notificationSettings = null
|
||||||
|
/** @type {import('./objects/settings/EmailSettings')} */
|
||||||
this.emailSettings = null
|
this.emailSettings = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,10 +32,105 @@ class Database {
|
|||||||
return this.sequelize?.models || {}
|
return this.sequelize?.models || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasRootUser() {
|
/** @type {typeof import('./models/User')} */
|
||||||
return this.users.some(u => u.type === 'root')
|
get userModel() {
|
||||||
|
return this.models.user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Library')} */
|
||||||
|
get libraryModel() {
|
||||||
|
return this.models.library
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/LibraryFolder')} */
|
||||||
|
get libraryFolderModel() {
|
||||||
|
return this.models.libraryFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Author')} */
|
||||||
|
get authorModel() {
|
||||||
|
return this.models.author
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Series')} */
|
||||||
|
get seriesModel() {
|
||||||
|
return this.models.series
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Book')} */
|
||||||
|
get bookModel() {
|
||||||
|
return this.models.book
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/BookSeries')} */
|
||||||
|
get bookSeriesModel() {
|
||||||
|
return this.models.bookSeries
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/BookAuthor')} */
|
||||||
|
get bookAuthorModel() {
|
||||||
|
return this.models.bookAuthor
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Podcast')} */
|
||||||
|
get podcastModel() {
|
||||||
|
return this.models.podcast
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/PodcastEpisode')} */
|
||||||
|
get podcastEpisodeModel() {
|
||||||
|
return this.models.podcastEpisode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/LibraryItem')} */
|
||||||
|
get libraryItemModel() {
|
||||||
|
return this.models.libraryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/PodcastEpisode')} */
|
||||||
|
get podcastEpisodeModel() {
|
||||||
|
return this.models.podcastEpisode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/MediaProgress')} */
|
||||||
|
get mediaProgressModel() {
|
||||||
|
return this.models.mediaProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Collection')} */
|
||||||
|
get collectionModel() {
|
||||||
|
return this.models.collection
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/CollectionBook')} */
|
||||||
|
get collectionBookModel() {
|
||||||
|
return this.models.collectionBook
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Playlist')} */
|
||||||
|
get playlistModel() {
|
||||||
|
return this.models.playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/PlaylistMediaItem')} */
|
||||||
|
get playlistMediaItemModel() {
|
||||||
|
return this.models.playlistMediaItem
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Feed')} */
|
||||||
|
get feedModel() {
|
||||||
|
return this.models.feed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Feed')} */
|
||||||
|
get feedEpisodeModel() {
|
||||||
|
return this.models.feedEpisode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if db file exists
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
async checkHasDb() {
|
async checkHasDb() {
|
||||||
if (!await fs.pathExists(this.dbPath)) {
|
if (!await fs.pathExists(this.dbPath)) {
|
||||||
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
|
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
|
||||||
@@ -46,6 +139,10 @@ class Database {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to db, build models and run migrations
|
||||||
|
* @param {boolean} [force=false] Used for testing, drops & re-creates all tables
|
||||||
|
*/
|
||||||
async init(force = false) {
|
async init(force = false) {
|
||||||
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
|
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
|
||||||
|
|
||||||
@@ -59,15 +156,21 @@ class Database {
|
|||||||
await this.buildModels(force)
|
await this.buildModels(force)
|
||||||
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
||||||
|
|
||||||
|
|
||||||
await this.loadData()
|
await this.loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to db
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
async connect() {
|
async connect() {
|
||||||
Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
|
Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
|
||||||
this.sequelize = new Sequelize({
|
this.sequelize = new Sequelize({
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
storage: this.dbPath,
|
storage: this.dbPath,
|
||||||
logging: false
|
logging: false,
|
||||||
|
transactionType: 'IMMEDIATE'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper function
|
// Helper function
|
||||||
@@ -83,51 +186,73 @@ class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from db
|
||||||
|
*/
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
Logger.info(`[Database] Disconnecting sqlite db`)
|
Logger.info(`[Database] Disconnecting sqlite db`)
|
||||||
await this.sequelize.close()
|
await this.sequelize.close()
|
||||||
this.sequelize = null
|
this.sequelize = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to db and init
|
||||||
|
*/
|
||||||
async reconnect() {
|
async reconnect() {
|
||||||
Logger.info(`[Database] Reconnecting sqlite db`)
|
Logger.info(`[Database] Reconnecting sqlite db`)
|
||||||
await this.init()
|
await this.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
buildModels(force = false) {
|
buildModels(force = false) {
|
||||||
require('./models/User')(this.sequelize)
|
require('./models/User').init(this.sequelize)
|
||||||
require('./models/Library')(this.sequelize)
|
require('./models/Library').init(this.sequelize)
|
||||||
require('./models/LibraryFolder')(this.sequelize)
|
require('./models/LibraryFolder').init(this.sequelize)
|
||||||
require('./models/Book')(this.sequelize)
|
require('./models/Book').init(this.sequelize)
|
||||||
require('./models/Podcast')(this.sequelize)
|
require('./models/Podcast').init(this.sequelize)
|
||||||
require('./models/PodcastEpisode')(this.sequelize)
|
require('./models/PodcastEpisode').init(this.sequelize)
|
||||||
require('./models/LibraryItem')(this.sequelize)
|
require('./models/LibraryItem').init(this.sequelize)
|
||||||
require('./models/MediaProgress')(this.sequelize)
|
require('./models/MediaProgress').init(this.sequelize)
|
||||||
require('./models/Series')(this.sequelize)
|
require('./models/Series').init(this.sequelize)
|
||||||
require('./models/BookSeries')(this.sequelize)
|
require('./models/BookSeries').init(this.sequelize)
|
||||||
require('./models/Author')(this.sequelize)
|
require('./models/Author').init(this.sequelize)
|
||||||
require('./models/BookAuthor')(this.sequelize)
|
require('./models/BookAuthor').init(this.sequelize)
|
||||||
require('./models/Collection')(this.sequelize)
|
require('./models/Collection').init(this.sequelize)
|
||||||
require('./models/CollectionBook')(this.sequelize)
|
require('./models/CollectionBook').init(this.sequelize)
|
||||||
require('./models/Playlist')(this.sequelize)
|
require('./models/Playlist').init(this.sequelize)
|
||||||
require('./models/PlaylistMediaItem')(this.sequelize)
|
require('./models/PlaylistMediaItem').init(this.sequelize)
|
||||||
require('./models/Device')(this.sequelize)
|
require('./models/Device').init(this.sequelize)
|
||||||
require('./models/PlaybackSession')(this.sequelize)
|
require('./models/PlaybackSession').init(this.sequelize)
|
||||||
require('./models/Feed')(this.sequelize)
|
require('./models/Feed').init(this.sequelize)
|
||||||
require('./models/FeedEpisode')(this.sequelize)
|
require('./models/FeedEpisode').init(this.sequelize)
|
||||||
require('./models/Setting')(this.sequelize)
|
require('./models/Setting').init(this.sequelize)
|
||||||
|
|
||||||
return this.sequelize.sync({ force, alter: false })
|
return this.sequelize.sync({ force, alter: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two server versions
|
||||||
|
* @param {string} v1
|
||||||
|
* @param {string} v2
|
||||||
|
* @returns {-1|0|1} 1 if v1 > v2
|
||||||
|
*/
|
||||||
|
compareVersions(v1, v2) {
|
||||||
|
if (!v1 || !v2) return 0
|
||||||
|
return v1.localeCompare(v2, undefined, { numeric: true, sensitivity: "case", caseFirst: "upper" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if migration to sqlite db is necessary & runs migration.
|
||||||
|
*
|
||||||
|
* Check if version was upgraded and run any version specific migrations.
|
||||||
|
*
|
||||||
|
* Loads most of the data from the database. This is a temporary solution.
|
||||||
|
*/
|
||||||
async loadData() {
|
async loadData() {
|
||||||
if (this.isNew && await dbMigration.checkShouldMigrate()) {
|
if (this.isNew && await dbMigration.checkShouldMigrate()) {
|
||||||
Logger.info(`[Database] New database was created and old database was detected - migrating old to new`)
|
Logger.info(`[Database] New database was created and old database was detected - migrating old to new`)
|
||||||
await dbMigration.migrate(this.models)
|
await dbMigration.migrate(this.models)
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now()
|
|
||||||
|
|
||||||
const settingsData = await this.models.setting.getOldSettings()
|
const settingsData = await this.models.setting.getOldSettings()
|
||||||
this.settings = settingsData.settings
|
this.settings = settingsData.settings
|
||||||
this.emailSettings = settingsData.emailSettings
|
this.emailSettings = settingsData.emailSettings
|
||||||
@@ -136,20 +261,17 @@ class Database {
|
|||||||
global.ServerSettings = this.serverSettings.toJSON()
|
global.ServerSettings = this.serverSettings.toJSON()
|
||||||
|
|
||||||
// Version specific migrations
|
// Version specific migrations
|
||||||
if (this.serverSettings.version === '2.3.0' && packageJson.version !== '2.3.0') {
|
if (this.serverSettings.version === '2.3.0' && this.compareVersions(packageJson.version, '2.3.0') == 1) {
|
||||||
await dbMigration.migrationPatch(this)
|
await dbMigration.migrationPatch(this)
|
||||||
}
|
}
|
||||||
|
if (['2.3.0', '2.3.1', '2.3.2', '2.3.3'].includes(this.serverSettings.version) && this.compareVersions(packageJson.version, '2.3.3') >= 0) {
|
||||||
|
await dbMigration.migrationPatch2(this)
|
||||||
|
}
|
||||||
|
|
||||||
this.libraryItems = await this.models.libraryItem.getAllOldLibraryItems()
|
await this.cleanDatabase()
|
||||||
this.users = await this.models.user.getOldUsers()
|
|
||||||
this.libraries = await this.models.library.getAllOldLibraries()
|
|
||||||
this.collections = await this.models.collection.getOldCollections()
|
|
||||||
this.playlists = await this.models.playlist.getOldPlaylists()
|
|
||||||
this.authors = await this.models.author.getOldAuthors()
|
|
||||||
this.series = await this.models.series.getAllOldSeries()
|
|
||||||
this.feeds = await this.models.feed.getOldFeeds()
|
|
||||||
|
|
||||||
Logger.info(`[Database] Db data loaded in ${Date.now() - startTime}ms`)
|
// Set if root user has been created
|
||||||
|
this.hasRootUser = await this.models.user.getHasRootUser()
|
||||||
|
|
||||||
if (packageJson.version !== this.serverSettings.version) {
|
if (packageJson.version !== this.serverSettings.version) {
|
||||||
Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)
|
Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)
|
||||||
@@ -158,14 +280,18 @@ class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRootUser(username, pash, token) {
|
/**
|
||||||
|
* Create root user
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} pash
|
||||||
|
* @param {Auth} auth
|
||||||
|
* @returns {boolean} true if created
|
||||||
|
*/
|
||||||
|
async createRootUser(username, pash, auth) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
const newUser = await this.models.user.createRootUser(username, pash, token)
|
await this.models.user.createRootUser(username, pash, auth)
|
||||||
if (newUser) {
|
this.hasRootUser = true
|
||||||
this.users.push(newUser)
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateServerSettings() {
|
updateServerSettings() {
|
||||||
@@ -182,7 +308,6 @@ class Database {
|
|||||||
async createUser(oldUser) {
|
async createUser(oldUser) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.user.createFromOld(oldUser)
|
await this.models.user.createFromOld(oldUser)
|
||||||
this.users.push(oldUser)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +321,9 @@ class Database {
|
|||||||
return Promise.all(oldUsers.map(u => this.updateUser(u)))
|
return Promise.all(oldUsers.map(u => this.updateUser(u)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeUser(userId) {
|
removeUser(userId) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.user.removeById(userId)
|
return this.models.user.removeById(userId)
|
||||||
this.users = this.users.filter(u => u.id !== userId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertMediaProgress(oldMediaProgress) {
|
upsertMediaProgress(oldMediaProgress) {
|
||||||
@@ -217,10 +341,9 @@ class Database {
|
|||||||
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
|
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async createLibrary(oldLibrary) {
|
createLibrary(oldLibrary) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.library.createFromOld(oldLibrary)
|
return this.models.library.createFromOld(oldLibrary)
|
||||||
this.libraries.push(oldLibrary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLibrary(oldLibrary) {
|
updateLibrary(oldLibrary) {
|
||||||
@@ -228,59 +351,9 @@ class Database {
|
|||||||
return this.models.library.updateFromOld(oldLibrary)
|
return this.models.library.updateFromOld(oldLibrary)
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeLibrary(libraryId) {
|
removeLibrary(libraryId) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.library.removeById(libraryId)
|
return this.models.library.removeById(libraryId)
|
||||||
this.libraries = this.libraries.filter(lib => lib.id !== libraryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCollection(oldCollection) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
const newCollection = await this.models.collection.createFromOld(oldCollection)
|
|
||||||
// Create CollectionBooks
|
|
||||||
if (newCollection) {
|
|
||||||
const collectionBooks = []
|
|
||||||
oldCollection.books.forEach((libraryItemId) => {
|
|
||||||
const libraryItem = this.libraryItems.find(li => li.id === libraryItemId)
|
|
||||||
if (libraryItem) {
|
|
||||||
collectionBooks.push({
|
|
||||||
collectionId: newCollection.id,
|
|
||||||
bookId: libraryItem.media.id
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (collectionBooks.length) {
|
|
||||||
await this.createBulkCollectionBooks(collectionBooks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.collections.push(oldCollection)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCollection(oldCollection) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
const collectionBooks = []
|
|
||||||
let order = 1
|
|
||||||
oldCollection.books.forEach((libraryItemId) => {
|
|
||||||
const libraryItem = this.getLibraryItem(libraryItemId)
|
|
||||||
if (!libraryItem) return
|
|
||||||
collectionBooks.push({
|
|
||||||
collectionId: oldCollection.id,
|
|
||||||
bookId: libraryItem.media.id,
|
|
||||||
order: order++
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return this.models.collection.fullUpdateFromOld(oldCollection, collectionBooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeCollection(collectionId) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
await this.models.collection.removeById(collectionId)
|
|
||||||
this.collections = this.collections.filter(c => c.id !== collectionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
createCollectionBook(collectionBook) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
return this.models.collectionBook.create(collectionBook)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createBulkCollectionBooks(collectionBooks) {
|
createBulkCollectionBooks(collectionBooks) {
|
||||||
@@ -288,64 +361,6 @@ class Database {
|
|||||||
return this.models.collectionBook.bulkCreate(collectionBooks)
|
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
removeCollectionBook(collectionId, bookId) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
return this.models.collectionBook.removeByIds(collectionId, bookId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPlaylist(oldPlaylist) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
const newPlaylist = await this.models.playlist.createFromOld(oldPlaylist)
|
|
||||||
if (newPlaylist) {
|
|
||||||
const playlistMediaItems = []
|
|
||||||
let order = 1
|
|
||||||
for (const mediaItemObj of oldPlaylist.items) {
|
|
||||||
const libraryItem = this.libraryItems.find(li => li.id === mediaItemObj.libraryItemId)
|
|
||||||
if (!libraryItem) continue
|
|
||||||
|
|
||||||
let mediaItemId = libraryItem.media.id // bookId
|
|
||||||
let mediaItemType = 'book'
|
|
||||||
if (mediaItemObj.episodeId) {
|
|
||||||
mediaItemType = 'podcastEpisode'
|
|
||||||
mediaItemId = mediaItemObj.episodeId
|
|
||||||
}
|
|
||||||
playlistMediaItems.push({
|
|
||||||
playlistId: newPlaylist.id,
|
|
||||||
mediaItemId,
|
|
||||||
mediaItemType,
|
|
||||||
order: order++
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (playlistMediaItems.length) {
|
|
||||||
await this.createBulkPlaylistMediaItems(playlistMediaItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.playlists.push(oldPlaylist)
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePlaylist(oldPlaylist) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
const playlistMediaItems = []
|
|
||||||
let order = 1
|
|
||||||
oldPlaylist.items.forEach((item) => {
|
|
||||||
const libraryItem = this.getLibraryItem(item.libraryItemId)
|
|
||||||
if (!libraryItem) return
|
|
||||||
playlistMediaItems.push({
|
|
||||||
playlistId: oldPlaylist.id,
|
|
||||||
mediaItemId: item.episodeId || libraryItem.media.id,
|
|
||||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
|
||||||
order: order++
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return this.models.playlist.fullUpdateFromOld(oldPlaylist, playlistMediaItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
async removePlaylist(playlistId) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
await this.models.playlist.removeById(playlistId)
|
|
||||||
this.playlists = this.playlists.filter(p => p.id !== playlistId)
|
|
||||||
}
|
|
||||||
|
|
||||||
createPlaylistMediaItem(playlistMediaItem) {
|
createPlaylistMediaItem(playlistMediaItem) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
return this.models.playlistMediaItem.create(playlistMediaItem)
|
return this.models.playlistMediaItem.create(playlistMediaItem)
|
||||||
@@ -356,59 +371,26 @@ class Database {
|
|||||||
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
|
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
removePlaylistMediaItem(playlistId, mediaItemId) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
return this.models.playlistMediaItem.removeByIds(playlistId, mediaItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
getLibraryItem(libraryItemId) {
|
|
||||||
if (!this.sequelize || !libraryItemId) return false
|
|
||||||
|
|
||||||
// Temp support for old library item ids from mobile
|
|
||||||
if (libraryItemId.startsWith('li_')) return this.libraryItems.find(li => li.oldLibraryItemId === libraryItemId)
|
|
||||||
|
|
||||||
return this.libraryItems.find(li => li.id === libraryItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async createLibraryItem(oldLibraryItem) {
|
async createLibraryItem(oldLibraryItem) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
|
await oldLibraryItem.saveMetadata()
|
||||||
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
||||||
this.libraryItems.push(oldLibraryItem)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLibraryItem(oldLibraryItem) {
|
async updateLibraryItem(oldLibraryItem) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
|
await oldLibraryItem.saveMetadata()
|
||||||
return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateBulkLibraryItems(oldLibraryItems) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
let updatesMade = 0
|
|
||||||
for (const oldLibraryItem of oldLibraryItems) {
|
|
||||||
const hasUpdates = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
|
||||||
if (hasUpdates) updatesMade++
|
|
||||||
}
|
|
||||||
return updatesMade
|
|
||||||
}
|
|
||||||
|
|
||||||
async createBulkLibraryItems(oldLibraryItems) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
for (const oldLibraryItem of oldLibraryItems) {
|
|
||||||
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
|
||||||
this.libraryItems.push(oldLibraryItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeLibraryItem(libraryItemId) {
|
async removeLibraryItem(libraryItemId) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.libraryItem.removeById(libraryItemId)
|
await this.models.libraryItem.removeById(libraryItemId)
|
||||||
this.libraryItems = this.libraryItems.filter(li => li.id !== libraryItemId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFeed(oldFeed) {
|
async createFeed(oldFeed) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.feed.fullCreateFromOld(oldFeed)
|
await this.models.feed.fullCreateFromOld(oldFeed)
|
||||||
this.feeds.push(oldFeed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFeed(oldFeed) {
|
updateFeed(oldFeed) {
|
||||||
@@ -419,7 +401,6 @@ class Database {
|
|||||||
async removeFeed(feedId) {
|
async removeFeed(feedId) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.feed.removeById(feedId)
|
await this.models.feed.removeById(feedId)
|
||||||
this.feeds = this.feeds.filter(f => f.id !== feedId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSeries(oldSeries) {
|
updateSeries(oldSeries) {
|
||||||
@@ -430,31 +411,26 @@ class Database {
|
|||||||
async createSeries(oldSeries) {
|
async createSeries(oldSeries) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.series.createFromOld(oldSeries)
|
await this.models.series.createFromOld(oldSeries)
|
||||||
this.series.push(oldSeries)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBulkSeries(oldSeriesObjs) {
|
async createBulkSeries(oldSeriesObjs) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.series.createBulkFromOld(oldSeriesObjs)
|
await this.models.series.createBulkFromOld(oldSeriesObjs)
|
||||||
this.series.push(...oldSeriesObjs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeSeries(seriesId) {
|
async removeSeries(seriesId) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.series.removeById(seriesId)
|
await this.models.series.removeById(seriesId)
|
||||||
this.series = this.series.filter(se => se.id !== seriesId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAuthor(oldAuthor) {
|
async createAuthor(oldAuthor) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.author.createFromOld(oldAuthor)
|
await this.models.author.createFromOld(oldAuthor)
|
||||||
this.authors.push(oldAuthor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBulkAuthors(oldAuthors) {
|
async createBulkAuthors(oldAuthors) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.author.createBulkFromOld(oldAuthors)
|
await this.models.author.createBulkFromOld(oldAuthors)
|
||||||
this.authors.push(...oldAuthors)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAuthor(oldAuthor) {
|
updateAuthor(oldAuthor) {
|
||||||
@@ -465,24 +441,17 @@ class Database {
|
|||||||
async removeAuthor(authorId) {
|
async removeAuthor(authorId) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.author.removeById(authorId)
|
await this.models.author.removeById(authorId)
|
||||||
this.authors = this.authors.filter(au => au.id !== authorId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBulkBookAuthors(bookAuthors) {
|
async createBulkBookAuthors(bookAuthors) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||||
this.authors.push(...bookAuthors)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeBulkBookAuthors(authorId = null, bookId = null) {
|
async removeBulkBookAuthors(authorId = null, bookId = null) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
if (!authorId && !bookId) return
|
if (!authorId && !bookId) return
|
||||||
await this.models.bookAuthor.removeByIds(authorId, bookId)
|
await this.models.bookAuthor.removeByIds(authorId, bookId)
|
||||||
this.authors = this.authors.filter(au => {
|
|
||||||
if (authorId && au.authorId !== authorId) return true
|
|
||||||
if (bookId && au.bookId !== bookId) return true
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlaybackSessions(where = null) {
|
getPlaybackSessions(where = null) {
|
||||||
@@ -524,6 +493,204 @@ class Database {
|
|||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
return this.models.device.createFromOld(oldDevice)
|
return this.models.device.createFromOld(oldDevice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replaceTagInFilterData(oldTag, newTag) {
|
||||||
|
for (const libraryId in this.libraryFilterData) {
|
||||||
|
const indexOf = this.libraryFilterData[libraryId].tags.findIndex(n => n === oldTag)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.libraryFilterData[libraryId].tags.splice(indexOf, 1, newTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTagFromFilterData(tag) {
|
||||||
|
for (const libraryId in this.libraryFilterData) {
|
||||||
|
this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter(t => t !== tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addTagsToFilterData(libraryId, tags) {
|
||||||
|
if (!this.libraryFilterData[libraryId] || !tags?.length) return
|
||||||
|
tags.forEach((t) => {
|
||||||
|
if (!this.libraryFilterData[libraryId].tags.includes(t)) {
|
||||||
|
this.libraryFilterData[libraryId].tags.push(t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceGenreInFilterData(oldGenre, newGenre) {
|
||||||
|
for (const libraryId in this.libraryFilterData) {
|
||||||
|
const indexOf = this.libraryFilterData[libraryId].genres.findIndex(n => n === oldGenre)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.libraryFilterData[libraryId].genres.splice(indexOf, 1, newGenre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGenreFromFilterData(genre) {
|
||||||
|
for (const libraryId in this.libraryFilterData) {
|
||||||
|
this.libraryFilterData[libraryId].genres = this.libraryFilterData[libraryId].genres.filter(g => g !== genre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addGenresToFilterData(libraryId, genres) {
|
||||||
|
if (!this.libraryFilterData[libraryId] || !genres?.length) return
|
||||||
|
genres.forEach((g) => {
|
||||||
|
if (!this.libraryFilterData[libraryId].genres.includes(g)) {
|
||||||
|
this.libraryFilterData[libraryId].genres.push(g)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceNarratorInFilterData(oldNarrator, newNarrator) {
|
||||||
|
for (const libraryId in this.libraryFilterData) {
|
||||||
|
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex(n => n === oldNarrator)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeNarratorFromFilterData(narrator) {
|
||||||
|
for (const libraryId in this.libraryFilterData) {
|
||||||
|
this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter(n => n !== narrator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addNarratorsToFilterData(libraryId, narrators) {
|
||||||
|
if (!this.libraryFilterData[libraryId] || !narrators?.length) return
|
||||||
|
narrators.forEach((n) => {
|
||||||
|
if (!this.libraryFilterData[libraryId].narrators.includes(n)) {
|
||||||
|
this.libraryFilterData[libraryId].narrators.push(n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSeriesFromFilterData(libraryId, seriesId) {
|
||||||
|
if (!this.libraryFilterData[libraryId]) return
|
||||||
|
this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId)
|
||||||
|
}
|
||||||
|
|
||||||
|
addSeriesToFilterData(libraryId, seriesName, seriesId) {
|
||||||
|
if (!this.libraryFilterData[libraryId]) return
|
||||||
|
// Check if series is already added
|
||||||
|
if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return
|
||||||
|
this.libraryFilterData[libraryId].series.push({
|
||||||
|
id: seriesId,
|
||||||
|
name: seriesName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAuthorFromFilterData(libraryId, authorId) {
|
||||||
|
if (!this.libraryFilterData[libraryId]) return
|
||||||
|
this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
addAuthorToFilterData(libraryId, authorName, authorId) {
|
||||||
|
if (!this.libraryFilterData[libraryId]) return
|
||||||
|
// Check if author is already added
|
||||||
|
if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return
|
||||||
|
this.libraryFilterData[libraryId].authors.push({
|
||||||
|
id: authorId,
|
||||||
|
name: authorName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addPublisherToFilterData(libraryId, publisher) {
|
||||||
|
if (!this.libraryFilterData[libraryId] || !publisher || this.libraryFilterData[libraryId].publishers.includes(publisher)) return
|
||||||
|
this.libraryFilterData[libraryId].publishers.push(publisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
addLanguageToFilterData(libraryId, language) {
|
||||||
|
if (!this.libraryFilterData[libraryId] || !language || this.libraryFilterData[libraryId].languages.includes(language)) return
|
||||||
|
this.libraryFilterData[libraryId].languages.push(language)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used when updating items to make sure author id exists
|
||||||
|
* If library filter data is set then use that for check
|
||||||
|
* otherwise lookup in db
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @param {string} authorId
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async checkAuthorExists(libraryId, authorId) {
|
||||||
|
if (!this.libraryFilterData[libraryId]) {
|
||||||
|
return this.authorModel.checkExistsById(authorId)
|
||||||
|
}
|
||||||
|
return this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used when updating items to make sure series id exists
|
||||||
|
* If library filter data is set then use that for check
|
||||||
|
* otherwise lookup in db
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @param {string} seriesId
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async checkSeriesExists(libraryId, seriesId) {
|
||||||
|
if (!this.libraryFilterData[libraryId]) {
|
||||||
|
return this.seriesModel.checkExistsById(seriesId)
|
||||||
|
}
|
||||||
|
return this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset numIssues for library
|
||||||
|
* @param {string} libraryId
|
||||||
|
*/
|
||||||
|
async resetLibraryIssuesFilterData(libraryId) {
|
||||||
|
if (!this.libraryFilterData[libraryId]) return // Do nothing if filter data is not set
|
||||||
|
|
||||||
|
this.libraryFilterData[libraryId].numIssues = await this.libraryItemModel.count({
|
||||||
|
where: {
|
||||||
|
libraryId,
|
||||||
|
[Sequelize.Op.or]: [
|
||||||
|
{
|
||||||
|
isMissing: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isInvalid: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean invalid records in database
|
||||||
|
* Series should have atleast one Book
|
||||||
|
* Book and Podcast must have an associated LibraryItem
|
||||||
|
*/
|
||||||
|
async cleanDatabase() {
|
||||||
|
// Remove invalid Podcast records
|
||||||
|
const podcastsWithNoLibraryItem = await this.podcastModel.findAll({
|
||||||
|
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM libraryItems li WHERE li.mediaId = podcast.id)`), 0)
|
||||||
|
})
|
||||||
|
for (const podcast of podcastsWithNoLibraryItem) {
|
||||||
|
Logger.warn(`Found podcast "${podcast.title}" with no libraryItem - removing it`)
|
||||||
|
await podcast.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove invalid Book records
|
||||||
|
const booksWithNoLibraryItem = await this.bookModel.findAll({
|
||||||
|
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM libraryItems li WHERE li.mediaId = book.id)`), 0)
|
||||||
|
})
|
||||||
|
for (const book of booksWithNoLibraryItem) {
|
||||||
|
Logger.warn(`Found book "${book.title}" with no libraryItem - removing it`)
|
||||||
|
await book.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty series
|
||||||
|
const emptySeries = await this.seriesModel.findAll({
|
||||||
|
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)`), 0)
|
||||||
|
})
|
||||||
|
for (const series of emptySeries) {
|
||||||
|
Logger.warn(`Found series "${series.name}" with no books - removing it`)
|
||||||
|
await series.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new Database()
|
module.exports = new Database()
|
||||||
+64
-68
@@ -1,4 +1,5 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
const Sequelize = require('sequelize')
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
const fs = require('./libs/fsExtra')
|
const fs = require('./libs/fsExtra')
|
||||||
@@ -8,24 +9,19 @@ const rateLimit = require('./libs/expressRateLimit')
|
|||||||
const { version } = require('../package.json')
|
const { version } = require('../package.json')
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
const filePerms = require('./utils/filePerms')
|
|
||||||
const fileUtils = require('./utils/fileUtils')
|
const fileUtils = require('./utils/fileUtils')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
const Auth = require('./Auth')
|
const Auth = require('./Auth')
|
||||||
const Watcher = require('./Watcher')
|
const Watcher = require('./Watcher')
|
||||||
const Scanner = require('./scanner/Scanner')
|
|
||||||
const Database = require('./Database')
|
const Database = require('./Database')
|
||||||
const SocketAuthority = require('./SocketAuthority')
|
const SocketAuthority = require('./SocketAuthority')
|
||||||
|
|
||||||
const routes = require('./routes/index')
|
|
||||||
|
|
||||||
const ApiRouter = require('./routers/ApiRouter')
|
const ApiRouter = require('./routers/ApiRouter')
|
||||||
const HlsRouter = require('./routers/HlsRouter')
|
const HlsRouter = require('./routers/HlsRouter')
|
||||||
|
|
||||||
const NotificationManager = require('./managers/NotificationManager')
|
const NotificationManager = require('./managers/NotificationManager')
|
||||||
const EmailManager = require('./managers/EmailManager')
|
const EmailManager = require('./managers/EmailManager')
|
||||||
const CoverManager = require('./managers/CoverManager')
|
|
||||||
const AbMergeManager = require('./managers/AbMergeManager')
|
const AbMergeManager = require('./managers/AbMergeManager')
|
||||||
const CacheManager = require('./managers/CacheManager')
|
const CacheManager = require('./managers/CacheManager')
|
||||||
const LogManager = require('./managers/LogManager')
|
const LogManager = require('./managers/LogManager')
|
||||||
@@ -36,6 +32,7 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
|||||||
const RssFeedManager = require('./managers/RssFeedManager')
|
const RssFeedManager = require('./managers/RssFeedManager')
|
||||||
const CronManager = require('./managers/CronManager')
|
const CronManager = require('./managers/CronManager')
|
||||||
const TaskManager = require('./managers/TaskManager')
|
const TaskManager = require('./managers/TaskManager')
|
||||||
|
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||||
@@ -52,11 +49,9 @@ class Server {
|
|||||||
|
|
||||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||||
fs.mkdirSync(global.ConfigPath)
|
fs.mkdirSync(global.ConfigPath)
|
||||||
filePerms.setDefaultDirSync(global.ConfigPath, false)
|
|
||||||
}
|
}
|
||||||
if (!fs.pathExistsSync(global.MetadataPath)) {
|
if (!fs.pathExistsSync(global.MetadataPath)) {
|
||||||
fs.mkdirSync(global.MetadataPath)
|
fs.mkdirSync(global.MetadataPath)
|
||||||
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.watcher = new Watcher()
|
this.watcher = new Watcher()
|
||||||
@@ -68,16 +63,12 @@ class Server {
|
|||||||
this.emailManager = new EmailManager()
|
this.emailManager = new EmailManager()
|
||||||
this.backupManager = new BackupManager()
|
this.backupManager = new BackupManager()
|
||||||
this.logManager = new LogManager()
|
this.logManager = new LogManager()
|
||||||
this.cacheManager = new CacheManager()
|
|
||||||
this.abMergeManager = new AbMergeManager(this.taskManager)
|
this.abMergeManager = new AbMergeManager(this.taskManager)
|
||||||
this.playbackSessionManager = new PlaybackSessionManager()
|
this.playbackSessionManager = new PlaybackSessionManager()
|
||||||
this.coverManager = new CoverManager(this.cacheManager)
|
|
||||||
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager)
|
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager)
|
||||||
this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager)
|
this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager)
|
||||||
this.rssFeedManager = new RssFeedManager()
|
this.rssFeedManager = new RssFeedManager()
|
||||||
|
this.cronManager = new CronManager(this.podcastManager)
|
||||||
this.scanner = new Scanner(this.coverManager, this.taskManager)
|
|
||||||
this.cronManager = new CronManager(this.scanner, this.podcastManager)
|
|
||||||
|
|
||||||
// Routers
|
// Routers
|
||||||
this.apiRouter = new ApiRouter(this)
|
this.apiRouter = new ApiRouter(this)
|
||||||
@@ -93,6 +84,18 @@ class Server {
|
|||||||
this.auth.authMiddleware(req, res, next)
|
this.auth.authMiddleware(req, res, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelLibraryScan(libraryId) {
|
||||||
|
LibraryScanner.setCancelLibraryScan(libraryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getLibrariesScanning() {
|
||||||
|
return LibraryScanner.librariesScanning
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize database, backups, logs, rss feeds, cron jobs & watcher
|
||||||
|
* Cleanup stale/invalid data
|
||||||
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
Logger.info('[Server] Init v' + version)
|
Logger.info('[Server] Init v' + version)
|
||||||
await this.playbackSessionManager.removeOrphanStreams()
|
await this.playbackSessionManager.removeOrphanStreams()
|
||||||
@@ -105,21 +108,20 @@ class Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.cleanUserData() // Remove invalid user item progress
|
await this.cleanUserData() // Remove invalid user item progress
|
||||||
await this.purgeMetadata() // Remove metadata folders without library item
|
await CacheManager.ensureCachePaths()
|
||||||
await this.cacheManager.ensureCachePaths()
|
|
||||||
|
|
||||||
await this.backupManager.init()
|
await this.backupManager.init()
|
||||||
await this.logManager.init()
|
await this.logManager.init()
|
||||||
await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series
|
|
||||||
await this.rssFeedManager.init()
|
await this.rssFeedManager.init()
|
||||||
this.cronManager.init()
|
|
||||||
|
const libraries = await Database.libraryModel.getAllOldLibraries()
|
||||||
|
await this.cronManager.init(libraries)
|
||||||
|
|
||||||
if (Database.serverSettings.scannerDisableWatcher) {
|
if (Database.serverSettings.scannerDisableWatcher) {
|
||||||
Logger.info(`[Server] Watcher is disabled`)
|
Logger.info(`[Server] Watcher is disabled`)
|
||||||
this.watcher.disabled = true
|
this.watcher.disabled = true
|
||||||
} else {
|
} else {
|
||||||
this.watcher.initWatcher(Database.libraries)
|
this.watcher.initWatcher(libraries)
|
||||||
this.watcher.on('files', this.filesChanged.bind(this))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +184,7 @@ class Server {
|
|||||||
'/library/:library/series/:id?',
|
'/library/:library/series/:id?',
|
||||||
'/library/:library/podcast/search',
|
'/library/:library/podcast/search',
|
||||||
'/library/:library/podcast/latest',
|
'/library/:library/podcast/latest',
|
||||||
|
'/library/:library/podcast/download-queue',
|
||||||
'/config/users/:id',
|
'/config/users/:id',
|
||||||
'/config/users/:id/sessions',
|
'/config/users/:id/sessions',
|
||||||
'/config/item-metadata-utils/:id',
|
'/config/item-metadata-utils/:id',
|
||||||
@@ -238,63 +241,56 @@ class Server {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
async filesChanged(fileUpdates) {
|
/**
|
||||||
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
* Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist
|
||||||
await this.scanner.scanFilesChanged(fileUpdates)
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
// Remove unused /metadata/items/{id} folders
|
|
||||||
async purgeMetadata() {
|
|
||||||
const itemsMetadata = Path.join(global.MetadataPath, 'items')
|
|
||||||
if (!(await fs.pathExists(itemsMetadata))) return
|
|
||||||
const foldersInItemsMetadata = await fs.readdir(itemsMetadata)
|
|
||||||
|
|
||||||
let purged = 0
|
|
||||||
await Promise.all(foldersInItemsMetadata.map(async foldername => {
|
|
||||||
const itemFullPath = fileUtils.filePathToPOSIX(Path.join(itemsMetadata, foldername))
|
|
||||||
|
|
||||||
const hasMatchingItem = Database.libraryItems.find(li => {
|
|
||||||
if (!li.media.coverPath) return false
|
|
||||||
return itemFullPath === fileUtils.filePathToPOSIX(Path.dirname(li.media.coverPath))
|
|
||||||
})
|
|
||||||
if (!hasMatchingItem) {
|
|
||||||
Logger.debug(`[Server] Purging unused metadata ${itemFullPath}`)
|
|
||||||
|
|
||||||
await fs.remove(itemFullPath).then(() => {
|
|
||||||
purged++
|
|
||||||
}).catch((err) => {
|
|
||||||
Logger.error(`[Server] Failed to delete folder path ${itemFullPath}`, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
if (purged > 0) {
|
|
||||||
Logger.info(`[Server] Purged ${purged} unused library item metadata`)
|
|
||||||
}
|
|
||||||
return purged
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
|
|
||||||
async cleanUserData() {
|
async cleanUserData() {
|
||||||
for (const _user of Database.users) {
|
// Get all media progress without an associated media item
|
||||||
if (_user.mediaProgress.length) {
|
const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
|
||||||
for (const mediaProgress of _user.mediaProgress) {
|
where: {
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === mediaProgress.libraryItemId)
|
'$podcastEpisode.id$': null,
|
||||||
if (libraryItem && mediaProgress.episodeId) {
|
'$book.id$': null
|
||||||
const episode = libraryItem.media.checkHasEpisode?.(mediaProgress.episodeId)
|
},
|
||||||
if (episode) continue
|
attributes: ['id'],
|
||||||
} else {
|
include: [
|
||||||
continue
|
{
|
||||||
}
|
model: Database.bookModel,
|
||||||
|
attributes: ['id']
|
||||||
Logger.debug(`[Server] Removing media progress ${mediaProgress.id} data from user ${_user.username}`)
|
},
|
||||||
await Database.removeMediaProgress(mediaProgress.id)
|
{
|
||||||
|
model: Database.podcastEpisodeModel,
|
||||||
|
attributes: ['id']
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (mediaProgressToRemove.length) {
|
||||||
|
// Remove media progress
|
||||||
|
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Sequelize.Op.in]: mediaProgressToRemove.map(mp => mp.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (mediaProgressRemoved) {
|
||||||
|
Logger.info(`[Server] Removed ${mediaProgressRemoved} media progress for media items that no longer exist in db`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove series from hide from continue listening that no longer exist
|
||||||
|
const users = await Database.userModel.getOldUsers()
|
||||||
|
for (const _user of users) {
|
||||||
let hasUpdated = false
|
let hasUpdated = false
|
||||||
if (_user.seriesHideFromContinueListening.length) {
|
if (_user.seriesHideFromContinueListening.length) {
|
||||||
|
const seriesHiding = (await Database.seriesModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: _user.seriesHideFromContinueListening
|
||||||
|
},
|
||||||
|
attributes: ['id'],
|
||||||
|
raw: true
|
||||||
|
})).map(se => se.id)
|
||||||
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
|
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
|
||||||
if (!Database.series.some(se => se.id === seriesId)) { // Series removed
|
if (!seriesHiding.includes(seriesId)) { // Series removed
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-26
@@ -10,8 +10,11 @@ class SocketAuthority {
|
|||||||
this.clients = {}
|
this.clients = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns an array of User.toJSONForPublic with `connections` for the # of socket connections
|
/**
|
||||||
// a user can have many socket connections
|
* returns an array of User.toJSONForPublic with `connections` for the # of socket connections
|
||||||
|
* a user can have many socket connections
|
||||||
|
* @returns {object[]}
|
||||||
|
*/
|
||||||
getUsersOnline() {
|
getUsersOnline() {
|
||||||
const onlineUsersMap = {}
|
const onlineUsersMap = {}
|
||||||
Object.values(this.clients).filter(c => c.user).forEach(client => {
|
Object.values(this.clients).filter(c => c.user).forEach(client => {
|
||||||
@@ -19,7 +22,7 @@ class SocketAuthority {
|
|||||||
onlineUsersMap[client.user.id].connections++
|
onlineUsersMap[client.user.id].connections++
|
||||||
} else {
|
} else {
|
||||||
onlineUsersMap[client.user.id] = {
|
onlineUsersMap[client.user.id] = {
|
||||||
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems),
|
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions),
|
||||||
connections: 1
|
connections: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,9 +34,12 @@ class SocketAuthority {
|
|||||||
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
|
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emits event to all authorized clients
|
/**
|
||||||
// optional filter function to only send event to specific users
|
* Emits event to all authorized clients
|
||||||
// TODO: validate that filter is actually a function
|
* @param {string} evt
|
||||||
|
* @param {any} data
|
||||||
|
* @param {Function} [filter] optional filter function to only send event to specific users
|
||||||
|
*/
|
||||||
emitter(evt, data, filter = null) {
|
emitter(evt, data, filter = null) {
|
||||||
for (const socketId in this.clients) {
|
for (const socketId in this.clients) {
|
||||||
if (this.clients[socketId].user) {
|
if (this.clients[socketId].user) {
|
||||||
@@ -48,7 +54,7 @@ class SocketAuthority {
|
|||||||
clientEmitter(userId, evt, data) {
|
clientEmitter(userId, evt, data) {
|
||||||
const clients = this.getClientsForUser(userId)
|
const clients = this.getClientsForUser(userId)
|
||||||
if (!clients.length) {
|
if (!clients.length) {
|
||||||
return Logger.debug(`[Server] clientEmitter - no clients found for user ${userId}`)
|
return Logger.debug(`[SocketAuthority] clientEmitter - no clients found for user ${userId}`)
|
||||||
}
|
}
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
if (client.socket) {
|
if (client.socket) {
|
||||||
@@ -83,13 +89,13 @@ class SocketAuthority {
|
|||||||
}
|
}
|
||||||
socket.sheepClient = this.clients[socket.id]
|
socket.sheepClient = this.clients[socket.id]
|
||||||
|
|
||||||
Logger.info('[Server] Socket Connected', socket.id)
|
Logger.info('[SocketAuthority] Socket Connected', socket.id)
|
||||||
|
|
||||||
// Required for associating a User with a socket
|
// Required for associating a User with a socket
|
||||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||||
|
|
||||||
// Scanning
|
// Scanning
|
||||||
socket.on('cancel_scan', this.cancelScan.bind(this))
|
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||||
@@ -102,16 +108,16 @@ class SocketAuthority {
|
|||||||
|
|
||||||
const _client = this.clients[socket.id]
|
const _client = this.clients[socket.id]
|
||||||
if (!_client) {
|
if (!_client) {
|
||||||
Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
|
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
|
||||||
} else if (!_client.user) {
|
} else if (!_client.user) {
|
||||||
Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
|
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
|
||||||
delete this.clients[socket.id]
|
delete this.clients[socket.id]
|
||||||
} else {
|
} else {
|
||||||
Logger.debug('[Server] User Offline ' + _client.user.username)
|
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
|
||||||
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
|
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||||
|
|
||||||
const disconnectTime = Date.now() - _client.connected_at
|
const disconnectTime = Date.now() - _client.connected_at
|
||||||
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
||||||
delete this.clients[socket.id]
|
delete this.clients[socket.id]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -126,13 +132,13 @@ class SocketAuthority {
|
|||||||
if (client.user && client.user.isAdminOrUp) {
|
if (client.user && client.user.isAdminOrUp) {
|
||||||
this.emitter('admin_message', payload.message || '')
|
this.emitter('admin_message', payload.message || '')
|
||||||
} else {
|
} else {
|
||||||
Logger.error(`[Server] Non-admin user sent the message_all_users event`)
|
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
socket.on('ping', () => {
|
socket.on('ping', () => {
|
||||||
const client = this.clients[socket.id] || {}
|
const client = this.clients[socket.id] || {}
|
||||||
const user = client.user || {}
|
const user = client.user || {}
|
||||||
Logger.debug(`[Server] Received ping from socket ${user.username || 'No User'}`)
|
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
|
||||||
socket.emit('pong')
|
socket.emit('pong')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -147,9 +153,13 @@ class SocketAuthority {
|
|||||||
return socket.emit('invalid_token')
|
return socket.emit('invalid_token')
|
||||||
}
|
}
|
||||||
const client = this.clients[socket.id]
|
const client = this.clients[socket.id]
|
||||||
|
if (!client) {
|
||||||
|
Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (client.user !== undefined) {
|
if (client.user !== undefined) {
|
||||||
Logger.debug(`[Server] Authenticating socket client already has user`, client.user.username)
|
Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
client.user = user
|
client.user = user
|
||||||
@@ -159,9 +169,9 @@ class SocketAuthority {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(`[Server] User Online ${client.user.username}`)
|
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
|
||||||
|
|
||||||
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
|
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||||
|
|
||||||
// Update user lastSeen
|
// Update user lastSeen
|
||||||
user.lastSeen = Date.now()
|
user.lastSeen = Date.now()
|
||||||
@@ -170,7 +180,7 @@ class SocketAuthority {
|
|||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
userId: client.user.id,
|
userId: client.user.id,
|
||||||
username: client.user.username,
|
username: client.user.username,
|
||||||
librariesScanning: this.Server.scanner.librariesScanning
|
librariesScanning: this.Server.getLibrariesScanning()
|
||||||
}
|
}
|
||||||
if (user.isAdminOrUp) {
|
if (user.isAdminOrUp) {
|
||||||
initialPayload.usersOnline = this.getUsersOnline()
|
initialPayload.usersOnline = this.getUsersOnline()
|
||||||
@@ -183,23 +193,23 @@ class SocketAuthority {
|
|||||||
if (socketId && this.clients[socketId]) {
|
if (socketId && this.clients[socketId]) {
|
||||||
const client = this.clients[socketId]
|
const client = this.clients[socketId]
|
||||||
const clientSocket = client.socket
|
const clientSocket = client.socket
|
||||||
Logger.debug(`[Server] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
|
Logger.debug(`[SocketAuthority] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
|
||||||
|
|
||||||
if (client.user) {
|
if (client.user) {
|
||||||
Logger.debug('[Server] User Offline ' + client.user.username)
|
Logger.debug('[SocketAuthority] User Offline ' + client.user.username)
|
||||||
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, Database.libraryItems))
|
this.adminEmitter('user_offline', client.user.toJSONForPublic())
|
||||||
}
|
}
|
||||||
|
|
||||||
delete this.clients[socketId].user
|
delete this.clients[socketId].user
|
||||||
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
|
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
|
||||||
} else if (socketId) {
|
} else if (socketId) {
|
||||||
Logger.warn(`[Server] No client for socket ${socketId}`)
|
Logger.warn(`[SocketAuthority] No client for socket ${socketId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelScan(id) {
|
cancelScan(id) {
|
||||||
Logger.debug('[Server] Cancel scan', id)
|
Logger.debug('[SocketAuthority] Cancel scan', id)
|
||||||
this.Server.scanner.setCancelLibraryScan(id)
|
this.Server.cancelLibraryScan(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = new SocketAuthority()
|
module.exports = new SocketAuthority()
|
||||||
+64
-10
@@ -1,21 +1,34 @@
|
|||||||
|
const Path = require('path')
|
||||||
const EventEmitter = require('events')
|
const EventEmitter = require('events')
|
||||||
const Watcher = require('./libs/watcher/watcher')
|
const Watcher = require('./libs/watcher/watcher')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||||
|
|
||||||
const { filePathToPOSIX } = require('./utils/fileUtils')
|
const { filePathToPOSIX } = require('./utils/fileUtils')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef PendingFileUpdate
|
||||||
|
* @property {string} path
|
||||||
|
* @property {string} relPath
|
||||||
|
* @property {string} folderId
|
||||||
|
* @property {string} type
|
||||||
|
*/
|
||||||
class FolderWatcher extends EventEmitter {
|
class FolderWatcher extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.paths = [] // Not used
|
|
||||||
this.pendingFiles = [] // Not used
|
|
||||||
|
|
||||||
|
/** @type {{id:string, name:string, folders:import('./objects/Folder')[], paths:string[], watcher:Watcher[]}[]} */
|
||||||
this.libraryWatchers = []
|
this.libraryWatchers = []
|
||||||
|
/** @type {PendingFileUpdate[]} */
|
||||||
this.pendingFileUpdates = []
|
this.pendingFileUpdates = []
|
||||||
this.pendingDelay = 4000
|
this.pendingDelay = 4000
|
||||||
this.pendingTimeout = null
|
this.pendingTimeout = null
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
this.ignoreDirs = []
|
this.ignoreDirs = []
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.pendingDirsToRemoveFromIgnore = []
|
||||||
|
|
||||||
this.disabled = false
|
this.disabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,11 +42,12 @@ class FolderWatcher extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
Logger.info(`[Watcher] Initializing watcher for "${library.name}".`)
|
Logger.info(`[Watcher] Initializing watcher for "${library.name}".`)
|
||||||
var folderPaths = library.folderPaths
|
|
||||||
|
const folderPaths = library.folderPaths
|
||||||
folderPaths.forEach((fp) => {
|
folderPaths.forEach((fp) => {
|
||||||
Logger.debug(`[Watcher] Init watcher for library folder path "${fp}"`)
|
Logger.debug(`[Watcher] Init watcher for library folder path "${fp}"`)
|
||||||
})
|
})
|
||||||
var watcher = new Watcher(folderPaths, {
|
const watcher = new Watcher(folderPaths, {
|
||||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||||
renameDetection: true,
|
renameDetection: true,
|
||||||
renameTimeout: 2000,
|
renameTimeout: 2000,
|
||||||
@@ -144,6 +158,12 @@ class FolderWatcher extends EventEmitter {
|
|||||||
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File update detected from watcher
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @param {string} path
|
||||||
|
* @param {string} type
|
||||||
|
*/
|
||||||
addFileUpdate(libraryId, path, type) {
|
addFileUpdate(libraryId, path, type) {
|
||||||
path = filePathToPOSIX(path)
|
path = filePathToPOSIX(path)
|
||||||
if (this.pendingFilePaths.includes(path)) return
|
if (this.pendingFilePaths.includes(path)) return
|
||||||
@@ -161,11 +181,18 @@ class FolderWatcher extends EventEmitter {
|
|||||||
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
|
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderFullPath = filePathToPOSIX(folder.fullPath)
|
const folderFullPath = filePathToPOSIX(folder.fullPath)
|
||||||
|
|
||||||
var relPath = path.replace(folderFullPath, '')
|
const relPath = path.replace(folderFullPath, '')
|
||||||
|
|
||||||
var hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
|
if (Path.extname(relPath).toLowerCase() === '.part') {
|
||||||
|
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore files/folders starting with "."
|
||||||
|
const hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
|
||||||
if (hasDotPath) {
|
if (hasDotPath) {
|
||||||
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
|
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
|
||||||
return
|
return
|
||||||
@@ -184,7 +211,8 @@ class FolderWatcher extends EventEmitter {
|
|||||||
// Notify server of update after "pendingDelay"
|
// Notify server of update after "pendingDelay"
|
||||||
clearTimeout(this.pendingTimeout)
|
clearTimeout(this.pendingTimeout)
|
||||||
this.pendingTimeout = setTimeout(() => {
|
this.pendingTimeout = setTimeout(() => {
|
||||||
this.emit('files', this.pendingFileUpdates)
|
// this.emit('files', this.pendingFileUpdates)
|
||||||
|
LibraryScanner.scanFilesChanged(this.pendingFileUpdates)
|
||||||
this.pendingFileUpdates = []
|
this.pendingFileUpdates = []
|
||||||
}, this.pendingDelay)
|
}, this.pendingDelay)
|
||||||
}
|
}
|
||||||
@@ -195,24 +223,50 @@ class FolderWatcher extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to POSIX and remove trailing slash
|
||||||
|
* @param {string} path
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
cleanDirPath(path) {
|
cleanDirPath(path) {
|
||||||
path = filePathToPOSIX(path)
|
path = filePathToPOSIX(path)
|
||||||
if (path.endsWith('/')) path = path.slice(0, -1)
|
if (path.endsWith('/')) path = path.slice(0, -1)
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ignore this directory if files are picked up by watcher
|
||||||
|
* @param {string} path
|
||||||
|
*/
|
||||||
addIgnoreDir(path) {
|
addIgnoreDir(path) {
|
||||||
path = this.cleanDirPath(path)
|
path = this.cleanDirPath(path)
|
||||||
if (this.ignoreDirs.includes(path)) return
|
if (this.ignoreDirs.includes(path)) return
|
||||||
|
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
|
||||||
Logger.debug(`[Watcher] Ignoring directory "${path}"`)
|
Logger.debug(`[Watcher] Ignoring directory "${path}"`)
|
||||||
this.ignoreDirs.push(path)
|
this.ignoreDirs.push(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When downloading a podcast episode we dont want the scanner triggering for that podcast
|
||||||
|
* when the episode finishes the watcher may have a delayed response so a timeout is added
|
||||||
|
* to prevent the watcher from picking up the episode
|
||||||
|
*
|
||||||
|
* @param {string} path
|
||||||
|
*/
|
||||||
removeIgnoreDir(path) {
|
removeIgnoreDir(path) {
|
||||||
path = this.cleanDirPath(path)
|
path = this.cleanDirPath(path)
|
||||||
if (!this.ignoreDirs.includes(path)) return
|
if (!this.ignoreDirs.includes(path) || this.pendingDirsToRemoveFromIgnore.includes(path)) return
|
||||||
Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
|
|
||||||
this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
|
// Add a 5 second delay before removing the ignore from this dir
|
||||||
|
this.pendingDirsToRemoveFromIgnore.push(path)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.pendingDirsToRemoveFromIgnore.includes(path)) {
|
||||||
|
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
|
||||||
|
Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
|
||||||
|
this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = FolderWatcher
|
module.exports = FolderWatcher
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
|
const sequelize = require('sequelize')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const { createNewSortInstance } = require('../libs/fastSort')
|
const { createNewSortInstance } = require('../libs/fastSort')
|
||||||
|
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
const CacheManager = require('../managers/CacheManager')
|
||||||
|
const CoverManager = require('../managers/CoverManager')
|
||||||
|
const AuthorFinder = require('../finders/AuthorFinder')
|
||||||
|
|
||||||
const { reqSupportsWebp } = require('../utils/index')
|
const { reqSupportsWebp } = require('../utils/index')
|
||||||
|
|
||||||
@@ -15,18 +18,13 @@ class AuthorController {
|
|||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
async findOne(req, res) {
|
async findOne(req, res) {
|
||||||
const libraryId = req.query.library
|
|
||||||
const include = (req.query.include || '').split(',')
|
const include = (req.query.include || '').split(',')
|
||||||
|
|
||||||
const authorJson = req.author.toJSON()
|
const authorJson = req.author.toJSON()
|
||||||
|
|
||||||
// Used on author landing page to include library items and items grouped in series
|
// Used on author landing page to include library items and items grouped in series
|
||||||
if (include.includes('items')) {
|
if (include.includes('items')) {
|
||||||
authorJson.libraryItems = Database.libraryItems.filter(li => {
|
authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
|
||||||
if (libraryId && li.libraryId !== libraryId) return false
|
|
||||||
if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access
|
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (include.includes('series')) {
|
if (include.includes('series')) {
|
||||||
const seriesMap = {}
|
const seriesMap = {}
|
||||||
@@ -72,13 +70,13 @@ class AuthorController {
|
|||||||
// Updating/removing cover image
|
// Updating/removing cover image
|
||||||
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
|
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
|
||||||
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
|
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
|
||||||
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
|
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||||
await this.coverManager.removeFile(req.author.imagePath)
|
await CoverManager.removeFile(req.author.imagePath)
|
||||||
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
|
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
|
||||||
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
|
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath)
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
if (req.author.imagePath) {
|
if (req.author.imagePath) {
|
||||||
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
|
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||||
}
|
}
|
||||||
payload.imagePath = imageData.path
|
payload.imagePath = imageData.path
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
@@ -90,7 +88,7 @@ class AuthorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.author.imagePath) {
|
if (req.author.imagePath) {
|
||||||
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
|
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,10 +96,21 @@ class AuthorController {
|
|||||||
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
||||||
|
|
||||||
// Check if author name matches another author and merge the authors
|
// Check if author name matches another author and merge the authors
|
||||||
const existingAuthor = authorNameUpdate ? Database.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
|
let existingAuthor = null
|
||||||
|
if (authorNameUpdate) {
|
||||||
|
const author = await Database.authorModel.findOne({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[sequelize.Op.not]: req.author.id
|
||||||
|
},
|
||||||
|
name: payload.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
existingAuthor = author?.getOldAuthor()
|
||||||
|
}
|
||||||
if (existingAuthor) {
|
if (existingAuthor) {
|
||||||
const bookAuthorsToCreate = []
|
const bookAuthorsToCreate = []
|
||||||
const itemsWithAuthor = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
|
||||||
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
|
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
|
||||||
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
|
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
|
||||||
bookAuthorsToCreate.push({
|
bookAuthorsToCreate.push({
|
||||||
@@ -118,11 +127,11 @@ class AuthorController {
|
|||||||
// Remove old author
|
// Remove old author
|
||||||
await Database.removeAuthor(req.author.id)
|
await Database.removeAuthor(req.author.id)
|
||||||
SocketAuthority.emitter('author_removed', req.author.toJSON())
|
SocketAuthority.emitter('author_removed', req.author.toJSON())
|
||||||
|
// Update filter data
|
||||||
|
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
|
||||||
|
|
||||||
// Send updated num books for merged author
|
// Send updated num books for merged author
|
||||||
const numBooks = Database.libraryItems.filter(li => {
|
const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
|
|
||||||
}).length
|
|
||||||
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
|
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -137,8 +146,8 @@ class AuthorController {
|
|||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
req.author.updatedAt = Date.now()
|
req.author.updatedAt = Date.now()
|
||||||
|
|
||||||
|
const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
|
||||||
if (authorNameUpdate) { // Update author name on all books
|
if (authorNameUpdate) { // Update author name on all books
|
||||||
const itemsWithAuthor = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
|
||||||
itemsWithAuthor.forEach(libraryItem => {
|
itemsWithAuthor.forEach(libraryItem => {
|
||||||
libraryItem.media.metadata.updateAuthor(req.author)
|
libraryItem.media.metadata.updateAuthor(req.author)
|
||||||
})
|
})
|
||||||
@@ -148,10 +157,7 @@ class AuthorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Database.updateAuthor(req.author)
|
await Database.updateAuthor(req.author)
|
||||||
const numBooks = Database.libraryItems.filter(li => {
|
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(itemsWithAuthor.length))
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
|
||||||
}).length
|
|
||||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -161,24 +167,13 @@ class AuthorController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(req, res) {
|
|
||||||
var q = (req.query.q || '').toLowerCase()
|
|
||||||
if (!q) return res.json([])
|
|
||||||
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
|
|
||||||
var authors = Database.authors.filter(au => au.name.toLowerCase().includes(q))
|
|
||||||
authors = authors.slice(0, limit)
|
|
||||||
res.json({
|
|
||||||
results: authors
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async match(req, res) {
|
async match(req, res) {
|
||||||
let authorData = null
|
let authorData = null
|
||||||
const region = req.body.region || 'us'
|
const region = req.body.region || 'us'
|
||||||
if (req.body.asin) {
|
if (req.body.asin) {
|
||||||
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region)
|
authorData = await AuthorFinder.findAuthorByASIN(req.body.asin, region)
|
||||||
} else {
|
} else {
|
||||||
authorData = await this.authorFinder.findAuthorByName(req.body.q, region)
|
authorData = await AuthorFinder.findAuthorByName(req.body.q, region)
|
||||||
}
|
}
|
||||||
if (!authorData) {
|
if (!authorData) {
|
||||||
return res.status(404).send('Author not found')
|
return res.status(404).send('Author not found')
|
||||||
@@ -193,9 +188,9 @@ class AuthorController {
|
|||||||
|
|
||||||
// Only updates image if there was no image before or the author ASIN was updated
|
// Only updates image if there was no image before or the author ASIN was updated
|
||||||
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
||||||
this.cacheManager.purgeImageCache(req.author.id)
|
await CacheManager.purgeImageCache(req.author.id)
|
||||||
|
|
||||||
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
req.author.imagePath = imageData.path
|
req.author.imagePath = imageData.path
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
@@ -211,9 +206,8 @@ class AuthorController {
|
|||||||
req.author.updatedAt = Date.now()
|
req.author.updatedAt = Date.now()
|
||||||
|
|
||||||
await Database.updateAuthor(req.author)
|
await Database.updateAuthor(req.author)
|
||||||
const numBooks = Database.libraryItems.filter(li => {
|
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length
|
||||||
}).length
|
|
||||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,11 +234,11 @@ class AuthorController {
|
|||||||
height: height ? parseInt(height) : null,
|
height: height ? parseInt(height) : null,
|
||||||
width: width ? parseInt(width) : null
|
width: width ? parseInt(width) : null
|
||||||
}
|
}
|
||||||
return this.cacheManager.handleAuthorCache(res, author, options)
|
return CacheManager.handleAuthorCache(res, author, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
const author = Database.authors.find(au => au.id === req.params.id)
|
const author = await Database.authorModel.getOldById(req.params.id)
|
||||||
if (!author) return res.sendStatus(404)
|
if (!author) return res.sendStatus(404)
|
||||||
|
|
||||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const Logger = require('../Logger')
|
const CacheManager = require('../managers/CacheManager')
|
||||||
|
|
||||||
class CacheController {
|
class CacheController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -8,7 +8,7 @@ class CacheController {
|
|||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
await this.cacheManager.purgeAll()
|
await CacheManager.purgeAll()
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ class CacheController {
|
|||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
await this.cacheManager.purgeItems()
|
await CacheManager.purgeItems()
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const Sequelize = require('sequelize')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
@@ -7,162 +8,326 @@ const Collection = require('../objects/Collection')
|
|||||||
class CollectionController {
|
class CollectionController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/collections
|
||||||
|
* Create new collection
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async create(req, res) {
|
async create(req, res) {
|
||||||
var newCollection = new Collection()
|
const newCollection = new Collection()
|
||||||
req.body.userId = req.user.id
|
req.body.userId = req.user.id
|
||||||
var success = newCollection.setData(req.body)
|
if (!newCollection.setData(req.body)) {
|
||||||
if (!success) {
|
return res.status(400).send('Invalid collection data')
|
||||||
return res.status(500).send('Invalid collection data')
|
|
||||||
}
|
}
|
||||||
var jsonExpanded = newCollection.toJSONExpanded(Database.libraryItems)
|
|
||||||
await Database.createCollection(newCollection)
|
// Create collection record
|
||||||
|
await Database.collectionModel.createFromOld(newCollection)
|
||||||
|
|
||||||
|
// Get library items in collection
|
||||||
|
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
|
||||||
|
|
||||||
|
// Create collectionBook records
|
||||||
|
let order = 1
|
||||||
|
const collectionBooksToAdd = []
|
||||||
|
for (const libraryItemId of newCollection.books) {
|
||||||
|
const libraryItem = libraryItemsInCollection.find(li => li.id === libraryItemId)
|
||||||
|
if (libraryItem) {
|
||||||
|
collectionBooksToAdd.push({
|
||||||
|
collectionId: newCollection.id,
|
||||||
|
bookId: libraryItem.media.id,
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (collectionBooksToAdd.length) {
|
||||||
|
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
|
||||||
SocketAuthority.emitter('collection_added', jsonExpanded)
|
SocketAuthority.emitter('collection_added', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
findAll(req, res) {
|
async findAll(req, res) {
|
||||||
|
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
|
||||||
res.json({
|
res.json({
|
||||||
collections: Database.collections.map(c => c.toJSONExpanded(Database.libraryItems))
|
collections: collectionsExpanded
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(req, res) {
|
async findOne(req, res) {
|
||||||
const includeEntities = (req.query.include || '').split(',')
|
const includeEntities = (req.query.include || '').split(',')
|
||||||
|
|
||||||
const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems)
|
const collectionExpanded = await req.collection.getOldJsonExpanded(req.user, includeEntities)
|
||||||
|
if (!collectionExpanded) {
|
||||||
if (includeEntities.includes('rssfeed')) {
|
// This may happen if the user is restricted from all books
|
||||||
const feedData = this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
|
return res.sendStatus(404)
|
||||||
collectionExpanded.rssFeed = feedData ? feedData.toJSONMinified() : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(collectionExpanded)
|
res.json(collectionExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH: /api/collections/:id
|
||||||
|
* Update collection
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const collection = req.collection
|
let wasUpdated = false
|
||||||
const wasUpdated = collection.update(req.body)
|
|
||||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
// Update description and name if defined
|
||||||
|
const collectionUpdatePayload = {}
|
||||||
|
if (req.body.description !== undefined && req.body.description !== req.collection.description) {
|
||||||
|
collectionUpdatePayload.description = req.body.description
|
||||||
|
wasUpdated = true
|
||||||
|
}
|
||||||
|
if (req.body.name !== undefined && req.body.name !== req.collection.name) {
|
||||||
|
collectionUpdatePayload.name = req.body.name
|
||||||
|
wasUpdated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasUpdated) {
|
||||||
|
await req.collection.update(collectionUpdatePayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If books array is passed in then update order in collection
|
||||||
|
if (req.body.books?.length) {
|
||||||
|
const collectionBooks = await req.collection.getCollectionBooks({
|
||||||
|
include: {
|
||||||
|
model: Database.bookModel,
|
||||||
|
include: Database.libraryItemModel
|
||||||
|
},
|
||||||
|
order: [['order', 'ASC']]
|
||||||
|
})
|
||||||
|
collectionBooks.sort((a, b) => {
|
||||||
|
const aIndex = req.body.books.findIndex(lid => lid === a.book.libraryItem.id)
|
||||||
|
const bIndex = req.body.books.findIndex(lid => lid === b.book.libraryItem.id)
|
||||||
|
return aIndex - bIndex
|
||||||
|
})
|
||||||
|
for (let i = 0; i < collectionBooks.length; i++) {
|
||||||
|
if (collectionBooks[i].order !== i + 1) {
|
||||||
|
await collectionBooks[i].update({
|
||||||
|
order: i + 1
|
||||||
|
})
|
||||||
|
wasUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
await Database.updateCollection(collection)
|
|
||||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||||
}
|
}
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
const collection = req.collection
|
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
|
||||||
|
|
||||||
// Close rss feed - remove from db and emit socket event
|
// Close rss feed - remove from db and emit socket event
|
||||||
await this.rssFeedManager.closeFeedForEntityId(collection.id)
|
await this.rssFeedManager.closeFeedForEntityId(req.collection.id)
|
||||||
|
|
||||||
|
await req.collection.destroy()
|
||||||
|
|
||||||
await Database.removeCollection(collection.id)
|
|
||||||
SocketAuthority.emitter('collection_removed', jsonExpanded)
|
SocketAuthority.emitter('collection_removed', jsonExpanded)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/collections/:id/book
|
||||||
|
* Add a single book to a collection
|
||||||
|
* Req.body { id: <library item id> }
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async addBook(req, res) {
|
async addBook(req, res) {
|
||||||
const collection = req.collection
|
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === req.body.id)
|
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(500).send('Book not found')
|
return res.status(404).send('Book not found')
|
||||||
}
|
}
|
||||||
if (libraryItem.libraryId !== collection.libraryId) {
|
if (libraryItem.libraryId !== req.collection.libraryId) {
|
||||||
return res.status(500).send('Book in different library')
|
return res.status(400).send('Book in different library')
|
||||||
}
|
}
|
||||||
if (collection.books.includes(req.body.id)) {
|
|
||||||
return res.status(500).send('Book already in collection')
|
|
||||||
}
|
|
||||||
collection.addBook(req.body.id)
|
|
||||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
|
||||||
|
|
||||||
const collectionBook = {
|
// Check if book is already in collection
|
||||||
collectionId: collection.id,
|
const collectionBooks = await req.collection.getCollectionBooks()
|
||||||
bookId: libraryItem.media.id,
|
if (collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
|
||||||
order: collection.books.length
|
return res.status(400).send('Book already in collection')
|
||||||
}
|
}
|
||||||
await Database.createCollectionBook(collectionBook)
|
|
||||||
|
// Create collectionBook record
|
||||||
|
await Database.collectionBookModel.create({
|
||||||
|
collectionId: req.collection.id,
|
||||||
|
bookId: libraryItem.media.id,
|
||||||
|
order: collectionBooks.length + 1
|
||||||
|
})
|
||||||
|
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: api/collections/:id/book/:bookId
|
/**
|
||||||
|
* DELETE: /api/collections/:id/book/:bookId
|
||||||
|
* Remove a single book from a collection. Re-order books
|
||||||
|
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async removeBook(req, res) {
|
async removeBook(req, res) {
|
||||||
const collection = req.collection
|
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId)
|
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collection.books.includes(req.params.bookId)) {
|
// Get books in collection ordered
|
||||||
collection.removeBook(req.params.bookId)
|
const collectionBooks = await req.collection.getCollectionBooks({
|
||||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
order: [['order', 'ASC']]
|
||||||
|
})
|
||||||
|
|
||||||
|
let jsonExpanded = null
|
||||||
|
const collectionBookToRemove = collectionBooks.find(cb => cb.bookId === libraryItem.media.id)
|
||||||
|
if (collectionBookToRemove) {
|
||||||
|
// Remove collection book record
|
||||||
|
await collectionBookToRemove.destroy()
|
||||||
|
|
||||||
|
// Update order on collection books
|
||||||
|
let order = 1
|
||||||
|
for (const collectionBook of collectionBooks) {
|
||||||
|
if (collectionBook.bookId === libraryItem.media.id) continue
|
||||||
|
if (collectionBook.order !== order) {
|
||||||
|
await collectionBook.update({
|
||||||
|
order
|
||||||
|
})
|
||||||
|
}
|
||||||
|
order++
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||||
await Database.updateCollection(collection)
|
} else {
|
||||||
|
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
}
|
}
|
||||||
res.json(collection.toJSONExpanded(Database.libraryItems))
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/collections/:id/batch/add
|
/**
|
||||||
|
* POST: /api/collections/:id/batch/add
|
||||||
|
* Add multiple books to collection
|
||||||
|
* Req.body { books: <Array of library item ids> }
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async addBatch(req, res) {
|
async addBatch(req, res) {
|
||||||
const collection = req.collection
|
// filter out invalid libraryItemIds
|
||||||
if (!req.body.books || !req.body.books.length) {
|
const bookIdsToAdd = (req.body.books || []).filter(b => !!b && typeof b == 'string')
|
||||||
|
if (!bookIdsToAdd.length) {
|
||||||
return res.status(500).send('Invalid request body')
|
return res.status(500).send('Invalid request body')
|
||||||
}
|
}
|
||||||
const bookIdsToAdd = req.body.books
|
|
||||||
|
// Get library items associated with ids
|
||||||
|
const libraryItems = await Database.libraryItemModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Sequelize.Op.in]: bookIdsToAdd
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
model: Database.bookModel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get collection books already in collection
|
||||||
|
const collectionBooks = await req.collection.getCollectionBooks()
|
||||||
|
|
||||||
|
let order = collectionBooks.length + 1
|
||||||
const collectionBooksToAdd = []
|
const collectionBooksToAdd = []
|
||||||
let hasUpdated = false
|
let hasUpdated = false
|
||||||
|
|
||||||
let order = collection.books.length
|
// Check and set new collection books to add
|
||||||
for (const libraryItemId of bookIdsToAdd) {
|
for (const libraryItem of libraryItems) {
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
if (!collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
|
||||||
if (!libraryItem) continue
|
|
||||||
if (!collection.books.includes(libraryItemId)) {
|
|
||||||
collection.addBook(libraryItemId)
|
|
||||||
collectionBooksToAdd.push({
|
collectionBooksToAdd.push({
|
||||||
collectionId: collection.id,
|
collectionId: req.collection.id,
|
||||||
bookId: libraryItem.media.id,
|
bookId: libraryItem.media.id,
|
||||||
order: order++
|
order: order++
|
||||||
})
|
})
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
|
} else {
|
||||||
|
Logger.warn(`[CollectionController] addBatch: Library item ${libraryItem.id} already in collection`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let jsonExpanded = null
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||||
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
|
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
|
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||||
|
} else {
|
||||||
|
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
}
|
}
|
||||||
res.json(collection.toJSONExpanded(Database.libraryItems))
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/collections/:id/batch/remove
|
/**
|
||||||
|
* POST: /api/collections/:id/batch/remove
|
||||||
|
* Remove multiple books from collection
|
||||||
|
* Req.body { books: <Array of library item ids> }
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async removeBatch(req, res) {
|
async removeBatch(req, res) {
|
||||||
const collection = req.collection
|
// filter out invalid libraryItemIds
|
||||||
if (!req.body.books || !req.body.books.length) {
|
const bookIdsToRemove = (req.body.books || []).filter(b => !!b && typeof b == 'string')
|
||||||
|
if (!bookIdsToRemove.length) {
|
||||||
return res.status(500).send('Invalid request body')
|
return res.status(500).send('Invalid request body')
|
||||||
}
|
}
|
||||||
var bookIdsToRemove = req.body.books
|
|
||||||
let hasUpdated = false
|
|
||||||
for (const libraryItemId of bookIdsToRemove) {
|
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
|
||||||
if (!libraryItem) continue
|
|
||||||
|
|
||||||
if (collection.books.includes(libraryItemId)) {
|
// Get library items associated with ids
|
||||||
collection.removeBook(libraryItemId)
|
const libraryItems = await Database.libraryItemModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Sequelize.Op.in]: bookIdsToRemove
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
model: Database.bookModel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get collection books already in collection
|
||||||
|
const collectionBooks = await req.collection.getCollectionBooks({
|
||||||
|
order: [['order', 'ASC']]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove collection books and update order
|
||||||
|
let order = 1
|
||||||
|
let hasUpdated = false
|
||||||
|
for (const collectionBook of collectionBooks) {
|
||||||
|
if (libraryItems.some(li => li.media.id === collectionBook.bookId)) {
|
||||||
|
await collectionBook.destroy()
|
||||||
|
hasUpdated = true
|
||||||
|
continue
|
||||||
|
} else if (collectionBook.order !== order) {
|
||||||
|
await collectionBook.update({
|
||||||
|
order
|
||||||
|
})
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
|
order++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await Database.updateCollection(collection)
|
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||||
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
|
|
||||||
}
|
}
|
||||||
res.json(collection.toJSONExpanded(Database.libraryItems))
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
const collection = Database.collections.find(c => c.id === req.params.id)
|
const collection = await Database.collectionModel.findByPk(req.params.id)
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
return res.status(404).send('Collection not found')
|
return res.status(404).send('Collection not found')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class EmailController {
|
|||||||
async sendEBookToDevice(req, res) {
|
async sendEBookToDevice(req, res) {
|
||||||
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
|
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
|
||||||
|
|
||||||
const libraryItem = Database.getLibraryItem(req.body.libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(404).send('Library item not found')
|
return res.status(404).send('Library item not found')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,11 @@ class FileSystemController {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Do not include existing mapped library paths in response
|
// Do not include existing mapped library paths in response
|
||||||
Database.libraries.forEach(lib => {
|
const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths()
|
||||||
lib.folders.forEach((folder) => {
|
libraryFoldersPaths.forEach((path) => {
|
||||||
let dir = folder.fullPath
|
let dir = path || ''
|
||||||
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
||||||
excludedDirs.push(dir)
|
excludedDirs.push(dir)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,25 @@ const zipHelpers = require('../utils/zipHelpers')
|
|||||||
const { reqSupportsWebp } = require('../utils/index')
|
const { reqSupportsWebp } = require('../utils/index')
|
||||||
const { ScanResult } = require('../utils/constants')
|
const { ScanResult } = require('../utils/constants')
|
||||||
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||||
|
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||||
|
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
||||||
|
const Scanner = require('../scanner/Scanner')
|
||||||
|
const CacheManager = require('../managers/CacheManager')
|
||||||
|
const CoverManager = require('../managers/CoverManager')
|
||||||
|
|
||||||
class LibraryItemController {
|
class LibraryItemController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
// Example expand with authors: api/items/:id?expanded=1&include=authors
|
/**
|
||||||
findOne(req, res) {
|
* GET: /api/items/:id
|
||||||
|
* Optional query params:
|
||||||
|
* ?include=progress,rssfeed,downloads
|
||||||
|
* ?expanded=1
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async findOne(req, res) {
|
||||||
const includeEntities = (req.query.include || '').split(',')
|
const includeEntities = (req.query.include || '').split(',')
|
||||||
if (req.query.expanded == 1) {
|
if (req.query.expanded == 1) {
|
||||||
var item = req.libraryItem.toJSONExpanded()
|
var item = req.libraryItem.toJSONExpanded()
|
||||||
@@ -25,21 +38,11 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (includeEntities.includes('rssfeed')) {
|
if (includeEntities.includes('rssfeed')) {
|
||||||
const feedData = this.rssFeedManager.findFeedForEntityId(item.id)
|
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
|
||||||
item.rssFeed = feedData ? feedData.toJSONMinified() : null
|
item.rssFeed = feedData?.toJSONMinified() || null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.mediaType == 'book') {
|
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
|
||||||
if (includeEntities.includes('authors')) {
|
|
||||||
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
|
||||||
var author = Database.authors.find(_au => _au.id === au.id)
|
|
||||||
if (!author) return null
|
|
||||||
return {
|
|
||||||
...author
|
|
||||||
}
|
|
||||||
}).filter(au => au)
|
|
||||||
}
|
|
||||||
} else if (includeEntities.includes('downloads')) {
|
|
||||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||||
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
|
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
|
||||||
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
||||||
@@ -56,7 +59,7 @@ class LibraryItemController {
|
|||||||
var libraryItem = req.libraryItem
|
var libraryItem = req.libraryItem
|
||||||
// Item has cover and update is removing cover so purge it from cache
|
// Item has cover and update is removing cover so purge it from cache
|
||||||
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
|
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
|
||||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUpdates = libraryItem.update(req.body)
|
const hasUpdates = libraryItem.update(req.body)
|
||||||
@@ -71,13 +74,14 @@ class LibraryItemController {
|
|||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
const hardDelete = req.query.hard == 1 // Delete from file system
|
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||||
const libraryItemPath = req.libraryItem.path
|
const libraryItemPath = req.libraryItem.path
|
||||||
await this.handleDeleteLibraryItem(req.libraryItem)
|
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, [req.libraryItem.media.id])
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||||
await fs.remove(libraryItemPath).catch((error) => {
|
await fs.remove(libraryItemPath).catch((error) => {
|
||||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,9 +104,10 @@ class LibraryItemController {
|
|||||||
async updateMedia(req, res) {
|
async updateMedia(req, res) {
|
||||||
const libraryItem = req.libraryItem
|
const libraryItem = req.libraryItem
|
||||||
const mediaPayload = req.body
|
const mediaPayload = req.body
|
||||||
|
|
||||||
// Item has cover and update is removing cover so purge it from cache
|
// Item has cover and update is removing cover so purge it from cache
|
||||||
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
|
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
|
||||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Book specific
|
// Book specific
|
||||||
@@ -123,7 +128,7 @@ class LibraryItemController {
|
|||||||
// Book specific - Get all series being removed from this item
|
// Book specific - Get all series being removed from this item
|
||||||
let seriesRemoved = []
|
let seriesRemoved = []
|
||||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
|
const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || []
|
||||||
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
|
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +139,7 @@ class LibraryItemController {
|
|||||||
if (seriesRemoved.length) {
|
if (seriesRemoved.length) {
|
||||||
// Check remove empty series
|
// Check remove empty series
|
||||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||||
await this.checkRemoveEmptySeries(seriesRemoved)
|
await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPodcastAutoDownloadUpdated) {
|
if (isPodcastAutoDownloadUpdated) {
|
||||||
@@ -163,10 +168,10 @@ class LibraryItemController {
|
|||||||
var result = null
|
var result = null
|
||||||
if (req.body && req.body.url) {
|
if (req.body && req.body.url) {
|
||||||
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
|
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
|
||||||
result = await this.coverManager.downloadCoverFromUrl(libraryItem, req.body.url)
|
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
|
||||||
} else if (req.files && req.files.cover) {
|
} else if (req.files && req.files.cover) {
|
||||||
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
|
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
|
||||||
result = await this.coverManager.uploadCover(libraryItem, req.files.cover)
|
result = await CoverManager.uploadCover(libraryItem, req.files.cover)
|
||||||
} else {
|
} else {
|
||||||
return res.status(400).send('Invalid request no file or url')
|
return res.status(400).send('Invalid request no file or url')
|
||||||
}
|
}
|
||||||
@@ -192,7 +197,7 @@ class LibraryItemController {
|
|||||||
return res.status(400).send('Invalid request no cover path')
|
return res.status(400).send('Invalid request no cover path')
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
|
const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem)
|
||||||
if (validationResult.error) {
|
if (validationResult.error) {
|
||||||
return res.status(500).send(validationResult.error)
|
return res.status(500).send(validationResult.error)
|
||||||
}
|
}
|
||||||
@@ -212,7 +217,7 @@ class LibraryItemController {
|
|||||||
|
|
||||||
if (libraryItem.media.coverPath) {
|
if (libraryItem.media.coverPath) {
|
||||||
libraryItem.updateMediaCover('')
|
libraryItem.updateMediaCover('')
|
||||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||||
await Database.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
@@ -241,7 +246,7 @@ class LibraryItemController {
|
|||||||
height: height ? parseInt(height) : null,
|
height: height ? parseInt(height) : null,
|
||||||
width: width ? parseInt(width) : null
|
width: width ? parseInt(width) : null
|
||||||
}
|
}
|
||||||
return this.cacheManager.handleCoverCache(res, libraryItem, options)
|
return CacheManager.handleCoverCache(res, libraryItem, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/items/:id/stream
|
// GET: api/items/:id/stream
|
||||||
@@ -295,7 +300,7 @@ class LibraryItemController {
|
|||||||
var libraryItem = req.libraryItem
|
var libraryItem = req.libraryItem
|
||||||
|
|
||||||
var options = req.body || {}
|
var options = req.body || {}
|
||||||
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||||
res.json(matchResult)
|
res.json(matchResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,18 +313,23 @@ class LibraryItemController {
|
|||||||
const hardDelete = req.query.hard == 1 // Delete files from filesystem
|
const hardDelete = req.query.hard == 1 // Delete files from filesystem
|
||||||
|
|
||||||
const { libraryItemIds } = req.body
|
const { libraryItemIds } = req.body
|
||||||
if (!libraryItemIds || !libraryItemIds.length) {
|
if (!libraryItemIds?.length) {
|
||||||
return res.sendStatus(500)
|
return res.status(400).send('Invalid request body')
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsToDelete = Database.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||||
|
id: libraryItemIds
|
||||||
|
})
|
||||||
|
|
||||||
if (!itemsToDelete.length) {
|
if (!itemsToDelete.length) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
for (let i = 0; i < itemsToDelete.length; i++) {
|
|
||||||
const libraryItemPath = itemsToDelete[i].path
|
const libraryId = itemsToDelete[0].libraryId
|
||||||
Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`)
|
for (const libraryItem of itemsToDelete) {
|
||||||
await this.handleDeleteLibraryItem(itemsToDelete[i])
|
const libraryItemPath = libraryItem.path
|
||||||
|
Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}"`)
|
||||||
|
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, [libraryItem.media.id])
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||||
await fs.remove(libraryItemPath).catch((error) => {
|
await fs.remove(libraryItemPath).catch((error) => {
|
||||||
@@ -327,28 +337,42 @@ class LibraryItemController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/items/batch/update
|
// POST: api/items/batch/update
|
||||||
async batchUpdate(req, res) {
|
async batchUpdate(req, res) {
|
||||||
var updatePayloads = req.body
|
const updatePayloads = req.body
|
||||||
if (!updatePayloads || !updatePayloads.length) {
|
if (!updatePayloads?.length) {
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
var itemsUpdated = 0
|
let itemsUpdated = 0
|
||||||
|
|
||||||
for (let i = 0; i < updatePayloads.length; i++) {
|
for (const updatePayload of updatePayloads) {
|
||||||
var mediaPayload = updatePayloads[i].mediaPayload
|
const mediaPayload = updatePayload.mediaPayload
|
||||||
var libraryItem = Database.libraryItems.find(_li => _li.id === updatePayloads[i].id)
|
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
|
||||||
if (!libraryItem) return null
|
if (!libraryItem) return null
|
||||||
|
|
||||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||||
|
|
||||||
var hasUpdates = libraryItem.media.update(mediaPayload)
|
let seriesRemoved = []
|
||||||
if (hasUpdates) {
|
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||||
|
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
|
||||||
|
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItem.media.update(mediaPayload)) {
|
||||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||||
|
|
||||||
|
if (seriesRemoved.length) {
|
||||||
|
// Check remove empty series
|
||||||
|
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||||
|
await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
|
||||||
|
}
|
||||||
|
|
||||||
await Database.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
itemsUpdated++
|
itemsUpdated++
|
||||||
@@ -367,13 +391,11 @@ class LibraryItemController {
|
|||||||
if (!libraryItemIds.length) {
|
if (!libraryItemIds.length) {
|
||||||
return res.status(403).send('Invalid payload')
|
return res.status(403).send('Invalid payload')
|
||||||
}
|
}
|
||||||
const libraryItems = []
|
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||||
libraryItemIds.forEach((lid) => {
|
id: libraryItemIds
|
||||||
const li = Database.libraryItems.find(_li => _li.id === lid)
|
|
||||||
if (li) libraryItems.push(li.toJSONExpanded())
|
|
||||||
})
|
})
|
||||||
res.json({
|
res.json({
|
||||||
libraryItems
|
libraryItems: libraryItems.map(li => li.toJSONExpanded())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +414,9 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
|
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||||
|
id: req.body.libraryItemIds
|
||||||
|
})
|
||||||
if (!libraryItems?.length) {
|
if (!libraryItems?.length) {
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
@@ -400,7 +424,7 @@ class LibraryItemController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
|
|
||||||
for (const libraryItem of libraryItems) {
|
for (const libraryItem of libraryItems) {
|
||||||
const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||||
if (matchResult.updated) {
|
if (matchResult.updated) {
|
||||||
itemsUpdated++
|
itemsUpdated++
|
||||||
} else if (matchResult.warning) {
|
} else if (matchResult.warning) {
|
||||||
@@ -427,23 +451,31 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
|
const libraryItems = await Database.libraryItemModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: req.body.libraryItemIds
|
||||||
|
},
|
||||||
|
attributes: ['id', 'libraryId', 'isFile']
|
||||||
|
})
|
||||||
if (!libraryItems?.length) {
|
if (!libraryItems?.length) {
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
|
|
||||||
|
const libraryId = libraryItems[0].libraryId
|
||||||
for (const libraryItem of libraryItems) {
|
for (const libraryItem of libraryItems) {
|
||||||
if (libraryItem.isFile) {
|
if (libraryItem.isFile) {
|
||||||
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
|
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
|
||||||
} else {
|
} else {
|
||||||
await this.scanner.scanLibraryItemByRequest(libraryItem)
|
await LibraryItemScanner.scanLibraryItem(libraryItem.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/items/:id/scan (admin)
|
// POST: api/items/:id/scan
|
||||||
async scan(req, res) {
|
async scan(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
|
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
|
||||||
@@ -455,7 +487,8 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
|
const result = await LibraryItemScanner.scanLibraryItem(req.libraryItem.id)
|
||||||
|
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||||
res.json({
|
res.json({
|
||||||
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
|
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
|
||||||
})
|
})
|
||||||
@@ -528,7 +561,7 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ffprobeData = await this.scanner.probeAudioFile(audioFile)
|
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile)
|
||||||
res.json(ffprobeData)
|
res.json(ffprobeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,8 +711,8 @@ class LibraryItemController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
req.libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
|
||||||
if (!req.libraryItem?.media) return res.sendStatus(404)
|
if (!req.libraryItem?.media) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class MeController {
|
|||||||
|
|
||||||
// PATCH: api/me/progress/:id
|
// PATCH: api/me/progress/:id
|
||||||
async createUpdateMediaProgress(req, res) {
|
async createUpdateMediaProgress(req, res) {
|
||||||
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
|
const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(404).send('Item not found')
|
return res.status(404).send('Item not found')
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ class MeController {
|
|||||||
// PATCH: api/me/progress/:id/:episodeId
|
// PATCH: api/me/progress/:id/:episodeId
|
||||||
async createUpdateEpisodeMediaProgress(req, res) {
|
async createUpdateEpisodeMediaProgress(req, res) {
|
||||||
const episodeId = req.params.episodeId
|
const episodeId = req.params.episodeId
|
||||||
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
|
const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(404).send('Item not found')
|
return res.status(404).send('Item not found')
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ class MeController {
|
|||||||
|
|
||||||
let shouldUpdate = false
|
let shouldUpdate = false
|
||||||
for (const itemProgress of itemProgressPayloads) {
|
for (const itemProgress of itemProgressPayloads) {
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists
|
const libraryItem = await Database.libraryItemModel.getOldById(itemProgress.libraryItemId)
|
||||||
if (libraryItem) {
|
if (libraryItem) {
|
||||||
if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
|
if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
|
||||||
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
|
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
|
||||||
@@ -122,10 +122,10 @@ class MeController {
|
|||||||
|
|
||||||
// POST: api/me/item/:id/bookmark
|
// POST: api/me/item/:id/bookmark
|
||||||
async createBookmark(req, res) {
|
async createBookmark(req, res) {
|
||||||
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||||
if (!libraryItem) return res.sendStatus(404)
|
|
||||||
const { time, title } = req.body
|
const { time, title } = req.body
|
||||||
var bookmark = req.user.createBookmark(libraryItem.id, time, title)
|
const bookmark = req.user.createBookmark(req.params.id, time, title)
|
||||||
await Database.updateUser(req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.json(bookmark)
|
res.json(bookmark)
|
||||||
@@ -133,15 +133,17 @@ class MeController {
|
|||||||
|
|
||||||
// PATCH: api/me/item/:id/bookmark
|
// PATCH: api/me/item/:id/bookmark
|
||||||
async updateBookmark(req, res) {
|
async updateBookmark(req, res) {
|
||||||
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||||
if (!libraryItem) return res.sendStatus(404)
|
|
||||||
const { time, title } = req.body
|
const { time, title } = req.body
|
||||||
if (!req.user.findBookmark(libraryItem.id, time)) {
|
if (!req.user.findBookmark(req.params.id, time)) {
|
||||||
Logger.error(`[MeController] updateBookmark not found`)
|
Logger.error(`[MeController] updateBookmark not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
var bookmark = req.user.updateBookmark(libraryItem.id, time, title)
|
|
||||||
|
const bookmark = req.user.updateBookmark(req.params.id, time, title)
|
||||||
if (!bookmark) return res.sendStatus(500)
|
if (!bookmark) return res.sendStatus(500)
|
||||||
|
|
||||||
await Database.updateUser(req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.json(bookmark)
|
res.json(bookmark)
|
||||||
@@ -149,16 +151,17 @@ class MeController {
|
|||||||
|
|
||||||
// DELETE: api/me/item/:id/bookmark/:time
|
// DELETE: api/me/item/:id/bookmark/:time
|
||||||
async removeBookmark(req, res) {
|
async removeBookmark(req, res) {
|
||||||
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||||
if (!libraryItem) return res.sendStatus(404)
|
|
||||||
var time = Number(req.params.time)
|
const time = Number(req.params.time)
|
||||||
if (isNaN(time)) return res.sendStatus(500)
|
if (isNaN(time)) return res.sendStatus(500)
|
||||||
|
|
||||||
if (!req.user.findBookmark(libraryItem.id, time)) {
|
if (!req.user.findBookmark(req.params.id, time)) {
|
||||||
Logger.error(`[MeController] removeBookmark not found`)
|
Logger.error(`[MeController] removeBookmark not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
req.user.removeBookmark(libraryItem.id, time)
|
|
||||||
|
req.user.removeBookmark(req.params.id, time)
|
||||||
await Database.updateUser(req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
@@ -188,12 +191,13 @@ class MeController {
|
|||||||
for (const localProgress of localMediaProgress) {
|
for (const localProgress of localMediaProgress) {
|
||||||
if (!localProgress.libraryItemId) {
|
if (!localProgress.libraryItemId) {
|
||||||
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
|
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
const libraryItem = Database.getLibraryItem(localProgress.libraryItemId)
|
|
||||||
|
const libraryItem = await Database.libraryItemModel.getOldById(localProgress.libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
|
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
|
let mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
|
||||||
@@ -242,13 +246,15 @@ class MeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/me/items-in-progress
|
// GET: api/me/items-in-progress
|
||||||
getAllLibraryItemsInProgress(req, res) {
|
async getAllLibraryItemsInProgress(req, res) {
|
||||||
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
|
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
|
||||||
|
|
||||||
let itemsInProgress = []
|
let itemsInProgress = []
|
||||||
|
// TODO: More efficient to do this in a single query
|
||||||
for (const mediaProgress of req.user.mediaProgress) {
|
for (const mediaProgress of req.user.mediaProgress) {
|
||||||
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
|
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
|
||||||
const libraryItem = Database.getLibraryItem(mediaProgress.libraryItemId)
|
|
||||||
|
const libraryItem = await Database.libraryItemModel.getOldById(mediaProgress.libraryItemId)
|
||||||
if (libraryItem) {
|
if (libraryItem) {
|
||||||
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
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)
|
||||||
@@ -278,7 +284,7 @@ class MeController {
|
|||||||
|
|
||||||
// GET: api/me/series/:id/remove-from-continue-listening
|
// GET: api/me/series/:id/remove-from-continue-listening
|
||||||
async removeSeriesFromContinueListening(req, res) {
|
async removeSeriesFromContinueListening(req, res) {
|
||||||
const series = Database.series.find(se => se.id === req.params.id)
|
const series = await Database.seriesModel.getOldById(req.params.id)
|
||||||
if (!series) {
|
if (!series) {
|
||||||
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
|
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
@@ -294,7 +300,7 @@ class MeController {
|
|||||||
|
|
||||||
// GET: api/me/series/:id/readd-to-continue-listening
|
// GET: api/me/series/:id/readd-to-continue-listening
|
||||||
async readdSeriesFromContinueListening(req, res) {
|
async readdSeriesFromContinueListening(req, res) {
|
||||||
const series = Database.series.find(se => se.id === req.params.id)
|
const series = await Database.seriesModel.getOldById(req.params.id)
|
||||||
if (!series) {
|
if (!series) {
|
||||||
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
|
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
@@ -310,9 +316,19 @@ class MeController {
|
|||||||
|
|
||||||
// GET: api/me/progress/:id/remove-from-continue-listening
|
// GET: api/me/progress/:id/remove-from-continue-listening
|
||||||
async removeItemFromContinueListening(req, res) {
|
async removeItemFromContinueListening(req, res) {
|
||||||
|
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)
|
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
await Database.updateUser(req.user)
|
await Database.mediaProgressModel.update({
|
||||||
|
hideFromContinueListening: true
|
||||||
|
}, {
|
||||||
|
where: {
|
||||||
|
id: mediaProgress.id
|
||||||
|
}
|
||||||
|
})
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
res.json(req.user.toJSONForBrowser())
|
res.json(req.user.toJSONForBrowser())
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
const Sequelize = require('sequelize')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
|
||||||
const filePerms = require('../utils/filePerms')
|
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||||
const { isObject } = require('../utils/index')
|
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||||
|
|
||||||
//
|
//
|
||||||
// This is a controller for routes that don't have a home yet :(
|
// This is a controller for routes that don't have a home yet :(
|
||||||
@@ -14,7 +15,12 @@ const { isObject } = require('../utils/index')
|
|||||||
class MiscController {
|
class MiscController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
// POST: api/upload
|
/**
|
||||||
|
* POST: /api/upload
|
||||||
|
* Update library item
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async handleUpload(req, res) {
|
async handleUpload(req, res) {
|
||||||
if (!req.user.canUpload) {
|
if (!req.user.canUpload) {
|
||||||
Logger.warn('User attempted to upload without permission', req.user)
|
Logger.warn('User attempted to upload without permission', req.user)
|
||||||
@@ -24,18 +30,18 @@ class MiscController {
|
|||||||
Logger.error('Invalid request, no files')
|
Logger.error('Invalid request, no files')
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
var files = Object.values(req.files)
|
const files = Object.values(req.files)
|
||||||
var title = req.body.title
|
const title = req.body.title
|
||||||
var author = req.body.author
|
const author = req.body.author
|
||||||
var series = req.body.series
|
const series = req.body.series
|
||||||
var libraryId = req.body.library
|
const libraryId = req.body.library
|
||||||
var folderId = req.body.folder
|
const folderId = req.body.folder
|
||||||
|
|
||||||
var library = Database.libraries.find(lib => lib.id === libraryId)
|
const library = await Database.libraryModel.getOldById(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return res.status(404).send(`Library not found with id ${libraryId}`)
|
return res.status(404).send(`Library not found with id ${libraryId}`)
|
||||||
}
|
}
|
||||||
var folder = library.folders.find(fold => fold.id === folderId)
|
const folder = library.folders.find(fold => fold.id === folderId)
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`)
|
return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`)
|
||||||
}
|
}
|
||||||
@@ -45,8 +51,8 @@ class MiscController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For setting permissions recursively
|
// For setting permissions recursively
|
||||||
var outputDirectory = ''
|
let outputDirectory = ''
|
||||||
var firstDirPath = ''
|
let firstDirPath = ''
|
||||||
|
|
||||||
if (library.isPodcast) { // Podcasts only in 1 folder
|
if (library.isPodcast) { // Podcasts only in 1 folder
|
||||||
outputDirectory = Path.join(folder.fullPath, title)
|
outputDirectory = Path.join(folder.fullPath, title)
|
||||||
@@ -62,8 +68,7 @@ class MiscController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists = await fs.pathExists(outputDirectory)
|
if (await fs.pathExists(outputDirectory)) {
|
||||||
if (exists) {
|
|
||||||
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
||||||
return res.status(500).send(`Directory "${outputDirectory}" already exists`)
|
return res.status(500).send(`Directory "${outputDirectory}" already exists`)
|
||||||
}
|
}
|
||||||
@@ -84,12 +89,15 @@ class MiscController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await filePerms.setDefault(firstDirPath)
|
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/tasks
|
/**
|
||||||
|
* GET: /api/tasks
|
||||||
|
* Get tasks for task manager
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
getTasks(req, res) {
|
getTasks(req, res) {
|
||||||
const includeArray = (req.query.include || '').split(',')
|
const includeArray = (req.query.include || '').split(',')
|
||||||
|
|
||||||
@@ -106,7 +114,12 @@ class MiscController {
|
|||||||
res.json(data)
|
res.json(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/settings (admin)
|
/**
|
||||||
|
* PATCH: /api/settings
|
||||||
|
* Update server settings
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async updateServerSettings(req, res) {
|
async updateServerSettings(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error('User other than admin attempting to update server settings', req.user)
|
Logger.error('User other than admin attempting to update server settings', req.user)
|
||||||
@@ -114,7 +127,7 @@ class MiscController {
|
|||||||
}
|
}
|
||||||
const settingsUpdate = req.body
|
const settingsUpdate = req.body
|
||||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||||
return res.status(500).send('Invalid settings update object')
|
return res.status(400).send('Invalid settings update object')
|
||||||
}
|
}
|
||||||
|
|
||||||
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
||||||
@@ -132,35 +145,168 @@ class MiscController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
authorize(req, res) {
|
/**
|
||||||
|
* PATCH: /api/sorting-prefixes
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async updateSortingPrefixes(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error('User other than admin attempting to update server sorting prefixes', req.user)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
let sortingPrefixes = req.body.sortingPrefixes
|
||||||
|
if (!sortingPrefixes?.length || !Array.isArray(sortingPrefixes)) {
|
||||||
|
return res.status(400).send('Invalid request body')
|
||||||
|
}
|
||||||
|
sortingPrefixes = [...new Set(sortingPrefixes.map(p => p?.trim?.().toLowerCase()).filter(p => p))]
|
||||||
|
if (!sortingPrefixes.length) {
|
||||||
|
return res.status(400).send('Invalid sortingPrefixes in request body')
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`[MiscController] Updating sorting prefixes ${sortingPrefixes.join(', ')}`)
|
||||||
|
Database.serverSettings.sortingPrefixes = sortingPrefixes
|
||||||
|
await Database.updateServerSettings()
|
||||||
|
|
||||||
|
let rowsUpdated = 0
|
||||||
|
// Update titleIgnorePrefix column on books
|
||||||
|
const books = await Database.bookModel.findAll({
|
||||||
|
attributes: ['id', 'title', 'titleIgnorePrefix']
|
||||||
|
})
|
||||||
|
const bulkUpdateBooks = []
|
||||||
|
books.forEach((book) => {
|
||||||
|
const titleIgnorePrefix = getTitleIgnorePrefix(book.title)
|
||||||
|
if (titleIgnorePrefix !== book.titleIgnorePrefix) {
|
||||||
|
bulkUpdateBooks.push({
|
||||||
|
id: book.id,
|
||||||
|
titleIgnorePrefix
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (bulkUpdateBooks.length) {
|
||||||
|
Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdateBooks.length} books`)
|
||||||
|
rowsUpdated += bulkUpdateBooks.length
|
||||||
|
await Database.bookModel.bulkCreate(bulkUpdateBooks, {
|
||||||
|
updateOnDuplicate: ['titleIgnorePrefix']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update titleIgnorePrefix column on podcasts
|
||||||
|
const podcasts = await Database.podcastModel.findAll({
|
||||||
|
attributes: ['id', 'title', 'titleIgnorePrefix']
|
||||||
|
})
|
||||||
|
const bulkUpdatePodcasts = []
|
||||||
|
podcasts.forEach((podcast) => {
|
||||||
|
const titleIgnorePrefix = getTitleIgnorePrefix(podcast.title)
|
||||||
|
if (titleIgnorePrefix !== podcast.titleIgnorePrefix) {
|
||||||
|
bulkUpdatePodcasts.push({
|
||||||
|
id: podcast.id,
|
||||||
|
titleIgnorePrefix
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (bulkUpdatePodcasts.length) {
|
||||||
|
Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdatePodcasts.length} podcasts`)
|
||||||
|
rowsUpdated += bulkUpdatePodcasts.length
|
||||||
|
await Database.podcastModel.bulkCreate(bulkUpdatePodcasts, {
|
||||||
|
updateOnDuplicate: ['titleIgnorePrefix']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update nameIgnorePrefix column on series
|
||||||
|
const allSeries = await Database.seriesModel.findAll({
|
||||||
|
attributes: ['id', 'name', 'nameIgnorePrefix']
|
||||||
|
})
|
||||||
|
const bulkUpdateSeries = []
|
||||||
|
allSeries.forEach((series) => {
|
||||||
|
const nameIgnorePrefix = getTitleIgnorePrefix(series.name)
|
||||||
|
if (nameIgnorePrefix !== series.nameIgnorePrefix) {
|
||||||
|
bulkUpdateSeries.push({
|
||||||
|
id: series.id,
|
||||||
|
nameIgnorePrefix
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (bulkUpdateSeries.length) {
|
||||||
|
Logger.info(`[MiscController] Updating nameIgnorePrefix on ${bulkUpdateSeries.length} series`)
|
||||||
|
rowsUpdated += bulkUpdateSeries.length
|
||||||
|
await Database.seriesModel.bulkCreate(bulkUpdateSeries, {
|
||||||
|
updateOnDuplicate: ['nameIgnorePrefix']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
rowsUpdated,
|
||||||
|
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/authorize
|
||||||
|
* Used to authorize an API token
|
||||||
|
*
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
|
async authorize(req, res) {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
Logger.error('Invalid user in authorize')
|
Logger.error('Invalid user in authorize')
|
||||||
return res.sendStatus(401)
|
return res.sendStatus(401)
|
||||||
}
|
}
|
||||||
const userResponse = this.auth.getUserLoginResponsePayload(req.user)
|
const userResponse = await this.auth.getUserLoginResponsePayload(req.user)
|
||||||
res.json(userResponse)
|
res.json(userResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/tags
|
/**
|
||||||
getAllTags(req, res) {
|
* GET: /api/tags
|
||||||
|
* Get all tags
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
|
async getAllTags(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
|
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = []
|
const tags = []
|
||||||
Database.libraryItems.forEach((li) => {
|
const books = await Database.bookModel.findAll({
|
||||||
if (li.media.tags && li.media.tags.length) {
|
attributes: ['tags'],
|
||||||
li.media.tags.forEach((tag) => {
|
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
|
||||||
if (!tags.includes(tag)) tags.push(tag)
|
[Sequelize.Op.gt]: 0
|
||||||
})
|
})
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
for (const book of books) {
|
||||||
|
for (const tag of book.tags) {
|
||||||
|
if (!tags.includes(tag)) tags.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const podcasts = await Database.podcastModel.findAll({
|
||||||
|
attributes: ['tags'],
|
||||||
|
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
|
||||||
|
[Sequelize.Op.gt]: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
for (const podcast of podcasts) {
|
||||||
|
for (const tag of podcast.tags) {
|
||||||
|
if (!tags.includes(tag)) tags.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
tags: tags
|
tags: tags
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/tags/rename
|
/**
|
||||||
|
* POST: /api/tags/rename
|
||||||
|
* Rename tag
|
||||||
|
* Req.body { tag, newTag }
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async renameTag(req, res) {
|
async renameTag(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
|
Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
|
||||||
@@ -177,19 +323,26 @@ class MiscController {
|
|||||||
let tagMerged = false
|
let tagMerged = false
|
||||||
let numItemsUpdated = 0
|
let numItemsUpdated = 0
|
||||||
|
|
||||||
for (const li of Database.libraryItems) {
|
// Update filter data
|
||||||
if (!li.media.tags || !li.media.tags.length) continue
|
Database.replaceTagInFilterData(tag, newTag)
|
||||||
|
|
||||||
if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge
|
const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag, newTag])
|
||||||
|
for (const libraryItem of libraryItemsWithTag) {
|
||||||
|
if (libraryItem.media.tags.includes(newTag)) {
|
||||||
|
tagMerged = true // new tag is an existing tag so this is a merge
|
||||||
|
}
|
||||||
|
|
||||||
if (li.media.tags.includes(tag)) {
|
if (libraryItem.media.tags.includes(tag)) {
|
||||||
li.media.tags = li.media.tags.filter(t => t !== tag) // Remove old tag
|
libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag
|
||||||
if (!li.media.tags.includes(newTag)) {
|
if (!libraryItem.media.tags.includes(newTag)) {
|
||||||
li.media.tags.push(newTag) // Add new tag
|
libraryItem.media.tags.push(newTag)
|
||||||
}
|
}
|
||||||
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
|
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${libraryItem.media.title}"`)
|
||||||
await Database.updateLibraryItem(li)
|
await libraryItem.media.update({
|
||||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
tags: libraryItem.media.tags
|
||||||
|
})
|
||||||
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||||
|
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||||
numItemsUpdated++
|
numItemsUpdated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +353,13 @@ class MiscController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: api/tags/:tag
|
/**
|
||||||
|
* DELETE: /api/tags/:tag
|
||||||
|
* Remove a tag
|
||||||
|
* :tag param is base64 encoded
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async deleteTag(req, res) {
|
async deleteTag(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[MiscController] Non-admin user attempted to deleteTag`)
|
Logger.error(`[MiscController] Non-admin user attempted to deleteTag`)
|
||||||
@@ -209,17 +368,23 @@ class MiscController {
|
|||||||
|
|
||||||
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
|
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
|
||||||
|
|
||||||
let numItemsUpdated = 0
|
// Get all items with tag
|
||||||
for (const li of Database.libraryItems) {
|
const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag])
|
||||||
if (!li.media.tags || !li.media.tags.length) continue
|
|
||||||
|
|
||||||
if (li.media.tags.includes(tag)) {
|
// Update filterdata
|
||||||
li.media.tags = li.media.tags.filter(t => t !== tag)
|
Database.removeTagFromFilterData(tag)
|
||||||
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
|
|
||||||
await Database.updateLibraryItem(li)
|
let numItemsUpdated = 0
|
||||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
// Remove tag from items
|
||||||
numItemsUpdated++
|
for (const libraryItem of libraryItemsWithTag) {
|
||||||
}
|
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${libraryItem.media.title}"`)
|
||||||
|
libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag)
|
||||||
|
await libraryItem.media.update({
|
||||||
|
tags: libraryItem.media.tags
|
||||||
|
})
|
||||||
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||||
|
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||||
|
numItemsUpdated++
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -227,26 +392,54 @@ class MiscController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/genres
|
/**
|
||||||
getAllGenres(req, res) {
|
* GET: /api/genres
|
||||||
|
* Get all genres
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
|
async getAllGenres(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
|
Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
const genres = []
|
const genres = []
|
||||||
Database.libraryItems.forEach((li) => {
|
const books = await Database.bookModel.findAll({
|
||||||
if (li.media.metadata.genres && li.media.metadata.genres.length) {
|
attributes: ['genres'],
|
||||||
li.media.metadata.genres.forEach((genre) => {
|
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
|
||||||
if (!genres.includes(genre)) genres.push(genre)
|
[Sequelize.Op.gt]: 0
|
||||||
})
|
})
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
for (const book of books) {
|
||||||
|
for (const tag of book.genres) {
|
||||||
|
if (!genres.includes(tag)) genres.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const podcasts = await Database.podcastModel.findAll({
|
||||||
|
attributes: ['genres'],
|
||||||
|
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
|
||||||
|
[Sequelize.Op.gt]: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
for (const podcast of podcasts) {
|
||||||
|
for (const tag of podcast.genres) {
|
||||||
|
if (!genres.includes(tag)) genres.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
genres
|
genres
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/genres/rename
|
/**
|
||||||
|
* POST: /api/genres/rename
|
||||||
|
* Rename genres
|
||||||
|
* Req.body { genre, newGenre }
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async renameGenre(req, res) {
|
async renameGenre(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
|
Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
|
||||||
@@ -263,19 +456,26 @@ class MiscController {
|
|||||||
let genreMerged = false
|
let genreMerged = false
|
||||||
let numItemsUpdated = 0
|
let numItemsUpdated = 0
|
||||||
|
|
||||||
for (const li of Database.libraryItems) {
|
// Update filter data
|
||||||
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
Database.replaceGenreInFilterData(genre, newGenre)
|
||||||
|
|
||||||
if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge
|
const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre, newGenre])
|
||||||
|
for (const libraryItem of libraryItemsWithGenre) {
|
||||||
|
if (libraryItem.media.genres.includes(newGenre)) {
|
||||||
|
genreMerged = true // new genre is an existing genre so this is a merge
|
||||||
|
}
|
||||||
|
|
||||||
if (li.media.metadata.genres.includes(genre)) {
|
if (libraryItem.media.genres.includes(genre)) {
|
||||||
li.media.metadata.genres = li.media.metadata.genres.filter(g => g !== genre) // Remove old genre
|
libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre
|
||||||
if (!li.media.metadata.genres.includes(newGenre)) {
|
if (!libraryItem.media.genres.includes(newGenre)) {
|
||||||
li.media.metadata.genres.push(newGenre) // Add new genre
|
libraryItem.media.genres.push(newGenre)
|
||||||
}
|
}
|
||||||
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
|
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${libraryItem.media.title}"`)
|
||||||
await Database.updateLibraryItem(li)
|
await libraryItem.media.update({
|
||||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
genres: libraryItem.media.genres
|
||||||
|
})
|
||||||
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||||
|
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||||
numItemsUpdated++
|
numItemsUpdated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,7 +486,13 @@ class MiscController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: api/genres/:genre
|
/**
|
||||||
|
* DELETE: /api/genres/:genre
|
||||||
|
* Remove a genre
|
||||||
|
* :genre param is base64 encoded
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async deleteGenre(req, res) {
|
async deleteGenre(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`)
|
Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`)
|
||||||
@@ -295,17 +501,23 @@ class MiscController {
|
|||||||
|
|
||||||
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
|
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
|
||||||
|
|
||||||
let numItemsUpdated = 0
|
// Update filter data
|
||||||
for (const li of Database.libraryItems) {
|
Database.removeGenreFromFilterData(genre)
|
||||||
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
|
||||||
|
|
||||||
if (li.media.metadata.genres.includes(genre)) {
|
// Get all items with genre
|
||||||
li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
|
const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre])
|
||||||
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
|
|
||||||
await Database.updateLibraryItem(li)
|
let numItemsUpdated = 0
|
||||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
// Remove genre from items
|
||||||
numItemsUpdated++
|
for (const libraryItem of libraryItemsWithGenre) {
|
||||||
}
|
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${libraryItem.media.title}"`)
|
||||||
|
libraryItem.media.genres = libraryItem.media.genres.filter(g => g !== genre)
|
||||||
|
await libraryItem.media.update({
|
||||||
|
genres: libraryItem.media.genres
|
||||||
|
})
|
||||||
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||||
|
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||||
|
numItemsUpdated++
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -7,70 +7,187 @@ const Playlist = require('../objects/Playlist')
|
|||||||
class PlaylistController {
|
class PlaylistController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
// POST: api/playlists
|
/**
|
||||||
|
* POST: /api/playlists
|
||||||
|
* Create playlist
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async create(req, res) {
|
async create(req, res) {
|
||||||
const newPlaylist = new Playlist()
|
const oldPlaylist = new Playlist()
|
||||||
req.body.userId = req.user.id
|
req.body.userId = req.user.id
|
||||||
const success = newPlaylist.setData(req.body)
|
const success = oldPlaylist.setData(req.body)
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(400).send('Invalid playlist request data')
|
return res.status(400).send('Invalid playlist request data')
|
||||||
}
|
}
|
||||||
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
|
|
||||||
await Database.createPlaylist(newPlaylist)
|
// Create Playlist record
|
||||||
|
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
|
||||||
|
|
||||||
|
// Lookup all library items in playlist
|
||||||
|
const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId).filter(i => i)
|
||||||
|
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: libraryItemIds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create playlistMediaItem records
|
||||||
|
const mediaItemsToAdd = []
|
||||||
|
let order = 1
|
||||||
|
for (const mediaItemObj of oldPlaylist.items) {
|
||||||
|
const libraryItem = libraryItemsInPlaylist.find(li => li.id === mediaItemObj.libraryItemId)
|
||||||
|
if (!libraryItem) continue
|
||||||
|
|
||||||
|
mediaItemsToAdd.push({
|
||||||
|
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
|
||||||
|
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
|
||||||
|
playlistId: oldPlaylist.id,
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mediaItemsToAdd.length) {
|
||||||
|
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/playlists
|
/**
|
||||||
findAllForUser(req, res) {
|
* GET: /api/playlists
|
||||||
|
* Get all playlists for user
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
|
async findAllForUser(req, res) {
|
||||||
|
const playlistsForUser = await Database.playlistModel.findAll({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const playlists = []
|
||||||
|
for (const playlist of playlistsForUser) {
|
||||||
|
const jsonExpanded = await playlist.getOldJsonExpanded()
|
||||||
|
playlists.push(jsonExpanded)
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
playlists: Database.playlists.filter(p => p.userId === req.user.id).map(p => p.toJSONExpanded(Database.libraryItems))
|
playlists
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/playlists/:id
|
/**
|
||||||
findOne(req, res) {
|
* GET: /api/playlists/:id
|
||||||
res.json(req.playlist.toJSONExpanded(Database.libraryItems))
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
|
async findOne(req, res) {
|
||||||
|
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||||
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/playlists/:id
|
/**
|
||||||
|
* PATCH: /api/playlists/:id
|
||||||
|
* Update playlist
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const playlist = req.playlist
|
const updatedPlaylist = req.playlist.set(req.body)
|
||||||
let wasUpdated = playlist.update(req.body)
|
let wasUpdated = false
|
||||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
const changed = updatedPlaylist.changed()
|
||||||
|
if (changed?.length) {
|
||||||
|
await req.playlist.save()
|
||||||
|
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
|
||||||
|
wasUpdated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If array of items is passed in then update order of playlist media items
|
||||||
|
const libraryItemIds = req.body.items?.map(i => i.libraryItemId).filter(i => i) || []
|
||||||
|
if (libraryItemIds.length) {
|
||||||
|
const libraryItems = await Database.libraryItemModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: libraryItemIds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
|
||||||
|
order: [['order', 'ASC']]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set an array of mediaItemId
|
||||||
|
const newMediaItemIdOrder = []
|
||||||
|
for (const item of req.body.items) {
|
||||||
|
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
|
||||||
|
if (!libraryItem) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||||
|
newMediaItemIdOrder.push(mediaItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort existing playlist media items into new order
|
||||||
|
existingPlaylistMediaItems.sort((a, b) => {
|
||||||
|
const aIndex = newMediaItemIdOrder.findIndex(i => i === a.mediaItemId)
|
||||||
|
const bIndex = newMediaItemIdOrder.findIndex(i => i === b.mediaItemId)
|
||||||
|
return aIndex - bIndex
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update order on playlistMediaItem records
|
||||||
|
let order = 1
|
||||||
|
for (const playlistMediaItem of existingPlaylistMediaItems) {
|
||||||
|
if (playlistMediaItem.order !== order) {
|
||||||
|
await playlistMediaItem.update({
|
||||||
|
order
|
||||||
|
})
|
||||||
|
wasUpdated = true
|
||||||
|
}
|
||||||
|
order++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
await Database.updatePlaylist(playlist)
|
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
|
||||||
}
|
}
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: api/playlists/:id
|
/**
|
||||||
|
* DELETE: /api/playlists/:id
|
||||||
|
* Remove playlist
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
const playlist = req.playlist
|
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
await req.playlist.destroy()
|
||||||
await Database.removePlaylist(playlist.id)
|
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/playlists/:id/item
|
/**
|
||||||
|
* POST: /api/playlists/:id/item
|
||||||
|
* Add item to playlist
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async addItem(req, res) {
|
async addItem(req, res) {
|
||||||
const playlist = req.playlist
|
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
|
||||||
const itemToAdd = req.body
|
const itemToAdd = req.body
|
||||||
|
|
||||||
if (!itemToAdd.libraryItemId) {
|
if (!itemToAdd.libraryItemId) {
|
||||||
return res.status(400).send('Request body has no libraryItemId')
|
return res.status(400).send('Request body has no libraryItemId')
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === itemToAdd.libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(400).send('Library item not found')
|
return res.status(400).send('Library item not found')
|
||||||
}
|
}
|
||||||
if (libraryItem.libraryId !== playlist.libraryId) {
|
if (libraryItem.libraryId !== oldPlaylist.libraryId) {
|
||||||
return res.status(400).send('Library item in different library')
|
return res.status(400).send('Library item in different library')
|
||||||
}
|
}
|
||||||
if (playlist.containsItem(itemToAdd)) {
|
if (oldPlaylist.containsItem(itemToAdd)) {
|
||||||
return res.status(400).send('Item already in playlist')
|
return res.status(400).send('Item already in playlist')
|
||||||
}
|
}
|
||||||
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
|
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
|
||||||
@@ -80,160 +197,248 @@ class PlaylistController {
|
|||||||
return res.status(400).send('Episode not found in library item')
|
return res.status(400).send('Episode not found in library item')
|
||||||
}
|
}
|
||||||
|
|
||||||
playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
|
|
||||||
|
|
||||||
const playlistMediaItem = {
|
const playlistMediaItem = {
|
||||||
playlistId: playlist.id,
|
playlistId: oldPlaylist.id,
|
||||||
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||||
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||||
order: playlist.items.length
|
order: oldPlaylist.items.length + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
|
||||||
await Database.createPlaylistMediaItem(playlistMediaItem)
|
await Database.createPlaylistMediaItem(playlistMediaItem)
|
||||||
|
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: api/playlists/:id/item/:libraryItemId/:episodeId?
|
/**
|
||||||
|
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
|
||||||
|
* Remove item from playlist
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async removeItem(req, res) {
|
async removeItem(req, res) {
|
||||||
const playlist = req.playlist
|
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
|
||||||
const itemToRemove = {
|
if (!oldLibraryItem) {
|
||||||
libraryItemId: req.params.libraryItemId,
|
return res.status(404).send('Library item not found')
|
||||||
episodeId: req.params.episodeId || null
|
|
||||||
}
|
|
||||||
if (!playlist.containsItem(itemToRemove)) {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playlist.removeItem(itemToRemove.libraryItemId, itemToRemove.episodeId)
|
// Get playlist media items
|
||||||
|
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
|
||||||
|
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||||
|
order: [['order', 'ASC']]
|
||||||
|
})
|
||||||
|
|
||||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
// Check if media item to delete is in playlist
|
||||||
|
const mediaItemToRemove = playlistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
|
||||||
|
if (!mediaItemToRemove) {
|
||||||
|
return res.status(404).send('Media item not found in playlist')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove record
|
||||||
|
await mediaItemToRemove.destroy()
|
||||||
|
|
||||||
|
// Update playlist media items order
|
||||||
|
let order = 1
|
||||||
|
for (const mediaItem of playlistMediaItems) {
|
||||||
|
if (mediaItem.mediaItemId === mediaItemId) continue
|
||||||
|
if (mediaItem.order !== order) {
|
||||||
|
await mediaItem.update({
|
||||||
|
order
|
||||||
|
})
|
||||||
|
}
|
||||||
|
order++
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||||
|
|
||||||
// Playlist is removed when there are no items
|
// Playlist is removed when there are no items
|
||||||
if (!playlist.items.length) {
|
if (!jsonExpanded.items.length) {
|
||||||
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
|
||||||
await Database.removePlaylist(playlist.id)
|
await req.playlist.destroy()
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||||
} else {
|
} else {
|
||||||
await Database.updatePlaylist(playlist)
|
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/playlists/:id/batch/add
|
/**
|
||||||
|
* POST: /api/playlists/:id/batch/add
|
||||||
|
* Batch add playlist items
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async addBatch(req, res) {
|
async addBatch(req, res) {
|
||||||
const playlist = req.playlist
|
if (!req.body.items?.length) {
|
||||||
if (!req.body.items || !req.body.items.length) {
|
return res.status(400).send('Invalid request body')
|
||||||
return res.status(500).send('Invalid request body')
|
|
||||||
}
|
}
|
||||||
const itemsToAdd = req.body.items
|
const itemsToAdd = req.body.items
|
||||||
let hasUpdated = false
|
|
||||||
|
|
||||||
let order = playlist.items.length
|
const libraryItemIds = itemsToAdd.map(i => i.libraryItemId).filter(i => i)
|
||||||
const playlistMediaItems = []
|
if (!libraryItemIds.length) {
|
||||||
|
return res.status(400).send('Invalid request body')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all library items
|
||||||
|
const libraryItems = await Database.libraryItemModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: libraryItemIds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all existing playlist media items
|
||||||
|
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||||
|
order: [['order', 'ASC']]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mediaItemsToAdd = []
|
||||||
|
|
||||||
|
// Setup array of playlistMediaItem records to add
|
||||||
|
let order = existingPlaylistMediaItems.length + 1
|
||||||
for (const item of itemsToAdd) {
|
for (const item of itemsToAdd) {
|
||||||
if (!item.libraryItemId) {
|
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
|
||||||
return res.status(400).send('Item does not have libraryItemId')
|
|
||||||
}
|
|
||||||
|
|
||||||
const libraryItem = Database.getLibraryItem(item.libraryItemId)
|
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(400).send('Item not found with id ' + item.libraryItemId)
|
return res.status(404).send('Item not found with id ' + item.libraryItemId)
|
||||||
}
|
} else {
|
||||||
|
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||||
if (!playlist.containsItem(item)) {
|
if (existingPlaylistMediaItems.some(pmi => pmi.mediaItemId === mediaItemId)) {
|
||||||
playlistMediaItems.push({
|
// Already exists in playlist
|
||||||
playlistId: playlist.id,
|
continue
|
||||||
mediaItemId: item.episodeId || libraryItem.media.id, // podcastEpisodeId or bookId
|
} else {
|
||||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
mediaItemsToAdd.push({
|
||||||
order: order++
|
playlistId: req.playlist.id,
|
||||||
})
|
mediaItemId,
|
||||||
playlist.addItem(item.libraryItemId, item.episodeId)
|
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||||
hasUpdated = true
|
order: order++
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
let jsonExpanded = null
|
||||||
if (hasUpdated) {
|
if (mediaItemsToAdd.length) {
|
||||||
await Database.createBulkPlaylistMediaItems(playlistMediaItems)
|
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||||
|
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||||
|
} else {
|
||||||
|
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||||
}
|
}
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/playlists/:id/batch/remove
|
/**
|
||||||
|
* POST: /api/playlists/:id/batch/remove
|
||||||
|
* Batch remove playlist items
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async removeBatch(req, res) {
|
async removeBatch(req, res) {
|
||||||
const playlist = req.playlist
|
if (!req.body.items?.length) {
|
||||||
if (!req.body.items || !req.body.items.length) {
|
return res.status(400).send('Invalid request body')
|
||||||
return res.status(500).send('Invalid request body')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsToRemove = req.body.items
|
const itemsToRemove = req.body.items
|
||||||
|
const libraryItemIds = itemsToRemove.map(i => i.libraryItemId).filter(i => i)
|
||||||
|
if (!libraryItemIds.length) {
|
||||||
|
return res.status(400).send('Invalid request body')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all library items
|
||||||
|
const libraryItems = await Database.libraryItemModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: libraryItemIds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all existing playlist media items for playlist
|
||||||
|
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||||
|
order: [['order', 'ASC']]
|
||||||
|
})
|
||||||
|
let numMediaItems = existingPlaylistMediaItems.length
|
||||||
|
|
||||||
|
// Remove playlist media items
|
||||||
let hasUpdated = false
|
let hasUpdated = false
|
||||||
for (const item of itemsToRemove) {
|
for (const item of itemsToRemove) {
|
||||||
if (!item.libraryItemId) {
|
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
|
||||||
return res.status(400).send('Item does not have libraryItemId')
|
if (!libraryItem) continue
|
||||||
}
|
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||||
|
const existingMediaItem = existingPlaylistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
|
||||||
if (playlist.containsItem(item)) {
|
if (!existingMediaItem) continue
|
||||||
playlist.removeItem(item.libraryItemId, item.episodeId)
|
await existingMediaItem.destroy()
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
numMediaItems--
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
// Playlist is removed when there are no items
|
// Playlist is removed when there are no items
|
||||||
if (!playlist.items.length) {
|
if (!numMediaItems) {
|
||||||
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
|
||||||
await Database.removePlaylist(playlist.id)
|
await req.playlist.destroy()
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||||
} else {
|
} else {
|
||||||
await Database.updatePlaylist(playlist)
|
|
||||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/playlists/collection/:collectionId
|
/**
|
||||||
|
* POST: /api/playlists/collection/:collectionId
|
||||||
|
* Create a playlist from a collection
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
async createFromCollection(req, res) {
|
async createFromCollection(req, res) {
|
||||||
let collection = Database.collections.find(c => c.id === req.params.collectionId)
|
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
return res.status(404).send('Collection not found')
|
return res.status(404).send('Collection not found')
|
||||||
}
|
}
|
||||||
// Expand collection to get library items
|
// Expand collection to get library items
|
||||||
collection = collection.toJSONExpanded(Database.libraryItems)
|
const collectionExpanded = await collection.getOldJsonExpanded(req.user)
|
||||||
|
if (!collectionExpanded) {
|
||||||
// Filter out library items not accessible to user
|
// This can happen if the user has no access to all items in collection
|
||||||
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
|
return res.status(404).send('Collection not found')
|
||||||
|
|
||||||
if (!libraryItems.length) {
|
|
||||||
return res.status(400).send('Collection has no books accessible to user')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPlaylist = new Playlist()
|
// Playlists cannot be empty
|
||||||
|
if (!collectionExpanded.books.length) {
|
||||||
|
return res.status(400).send('Collection has no books')
|
||||||
|
}
|
||||||
|
|
||||||
const newPlaylistData = {
|
const oldPlaylist = new Playlist()
|
||||||
|
oldPlaylist.setData({
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
libraryId: collection.libraryId,
|
libraryId: collection.libraryId,
|
||||||
name: collection.name,
|
name: collection.name,
|
||||||
description: collection.description || null,
|
description: collection.description || null
|
||||||
items: libraryItems.map(li => ({ libraryItemId: li.id }))
|
})
|
||||||
}
|
|
||||||
newPlaylist.setData(newPlaylistData)
|
|
||||||
|
|
||||||
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
|
// Create Playlist record
|
||||||
await Database.createPlaylist(newPlaylist)
|
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
|
||||||
|
|
||||||
|
// Create PlaylistMediaItem records
|
||||||
|
const mediaItemsToAdd = []
|
||||||
|
let order = 1
|
||||||
|
for (const libraryItem of collectionExpanded.books) {
|
||||||
|
mediaItemsToAdd.push({
|
||||||
|
playlistId: newPlaylist.id,
|
||||||
|
mediaItemId: libraryItem.media.id,
|
||||||
|
mediaItemType: 'book',
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||||
|
|
||||||
|
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||||
res.json(jsonExpanded)
|
res.json(jsonExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
const playlist = Database.playlists.find(p => p.id === req.params.id)
|
const playlist = await Database.playlistModel.findByPk(req.params.id)
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
return res.status(404).send('Playlist not found')
|
return res.status(404).send('Playlist not found')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ const fs = require('../libs/fsExtra')
|
|||||||
|
|
||||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||||
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
|
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
|
||||||
const filePerms = require('../utils/filePerms')
|
|
||||||
|
const Scanner = require('../scanner/Scanner')
|
||||||
|
const CoverManager = require('../managers/CoverManager')
|
||||||
|
|
||||||
const LibraryItem = require('../objects/LibraryItem')
|
const LibraryItem = require('../objects/LibraryItem')
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
const payload = req.body
|
const payload = req.body
|
||||||
|
|
||||||
const library = Database.libraries.find(lib => lib.id === payload.libraryId)
|
const library = await Database.libraryModel.getOldById(payload.libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
||||||
return res.status(404).send('Library not found')
|
return res.status(404).send('Library not found')
|
||||||
@@ -34,9 +36,13 @@ class PodcastController {
|
|||||||
const podcastPath = filePathToPOSIX(payload.path)
|
const podcastPath = filePathToPOSIX(payload.path)
|
||||||
|
|
||||||
// Check if a library item with this podcast folder exists already
|
// Check if a library item with this podcast folder exists already
|
||||||
const existingLibraryItem = Database.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id)
|
const existingLibraryItem = (await Database.libraryItemModel.count({
|
||||||
|
where: {
|
||||||
|
path: podcastPath
|
||||||
|
}
|
||||||
|
})) > 0
|
||||||
if (existingLibraryItem) {
|
if (existingLibraryItem) {
|
||||||
Logger.error(`[PodcastController] Podcast already exists with name "${existingLibraryItem.media.metadata.title}" at path "${podcastPath}"`)
|
Logger.error(`[PodcastController] Podcast already exists at path "${podcastPath}"`)
|
||||||
return res.status(400).send('Podcast already exists')
|
return res.status(400).send('Podcast already exists')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +51,6 @@ class PodcastController {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
if (!success) return res.status(400).send('Invalid podcast path')
|
if (!success) return res.status(400).send('Invalid podcast path')
|
||||||
await filePerms.setDefault(podcastPath)
|
|
||||||
|
|
||||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ class PodcastController {
|
|||||||
if (payload.media.metadata.imageUrl) {
|
if (payload.media.metadata.imageUrl) {
|
||||||
// TODO: Scan cover image to library files
|
// TODO: Scan cover image to library files
|
||||||
// Podcast cover will always go into library item folder
|
// Podcast cover will always go into library item folder
|
||||||
const coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
|
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
|
||||||
if (coverResponse) {
|
if (coverResponse) {
|
||||||
if (coverResponse.error) {
|
if (coverResponse.error) {
|
||||||
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
|
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
|
||||||
@@ -198,7 +203,7 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const overrideDetails = req.query.override === '1'
|
const overrideDetails = req.query.override === '1'
|
||||||
const episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
||||||
if (episodesUpdated) {
|
if (episodesUpdated) {
|
||||||
await Database.updateLibraryItem(req.libraryItem)
|
await Database.updateLibraryItem(req.libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||||
@@ -241,18 +246,18 @@ class PodcastController {
|
|||||||
|
|
||||||
// DELETE: api/podcasts/:id/episode/:episodeId
|
// DELETE: api/podcasts/:id/episode/:episodeId
|
||||||
async removeEpisode(req, res) {
|
async removeEpisode(req, res) {
|
||||||
var episodeId = req.params.episodeId
|
const episodeId = req.params.episodeId
|
||||||
var libraryItem = req.libraryItem
|
const libraryItem = req.libraryItem
|
||||||
var hardDelete = req.query.hard === '1'
|
const hardDelete = req.query.hard === '1'
|
||||||
|
|
||||||
var episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
|
const episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
var audioFile = episode.audioFile
|
const audioFile = episode.audioFile
|
||||||
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
||||||
await fs.remove(audioFile.metadata.path).then(() => {
|
await fs.remove(audioFile.metadata.path).then(() => {
|
||||||
Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
|
Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
|
||||||
@@ -263,18 +268,53 @@ class PodcastController {
|
|||||||
|
|
||||||
// Remove episode from Podcast and library file
|
// Remove episode from Podcast and library file
|
||||||
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
|
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
|
||||||
if (episodeRemoved && episodeRemoved.audioFile) {
|
if (episodeRemoved?.audioFile) {
|
||||||
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update/remove playlists that had this podcast episode
|
||||||
|
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
|
||||||
|
where: {
|
||||||
|
mediaItemId: episodeId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
model: Database.playlistModel,
|
||||||
|
include: Database.playlistMediaItemModel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for (const pmi of playlistMediaItems) {
|
||||||
|
const numItems = pmi.playlist.playlistMediaItems.length - 1
|
||||||
|
|
||||||
|
if (!numItems) {
|
||||||
|
Logger.info(`[PodcastController] Playlist "${playlist.name}" has no more items - removing it`)
|
||||||
|
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
||||||
|
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
|
||||||
|
await pmi.playlist.destroy()
|
||||||
|
} else {
|
||||||
|
await pmi.destroy()
|
||||||
|
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
||||||
|
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove media progress for this episode
|
||||||
|
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||||
|
where: {
|
||||||
|
mediaItemId: episode.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (mediaProgressRemoved) {
|
||||||
|
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
await Database.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
res.json(libraryItem.toJSON())
|
res.json(libraryItem.toJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
const item = Database.libraryItems.find(li => li.id === req.params.id)
|
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item?.media) return res.sendStatus(404)
|
||||||
|
|
||||||
if (!item.isPodcast) {
|
if (!item.isPodcast) {
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||||
|
|
||||||
class RSSFeedController {
|
class RSSFeedController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
|
async getAll(req, res) {
|
||||||
|
const feeds = await this.rssFeedManager.getFeeds()
|
||||||
|
res.json({
|
||||||
|
feeds: feeds.map(f => f.toJSON()),
|
||||||
|
minified: feeds.map(f => f.toJSONMinified())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// POST: api/feeds/item/:itemId/open
|
// POST: api/feeds/item/:itemId/open
|
||||||
async openRSSFeedForItem(req, res) {
|
async openRSSFeedForItem(req, res) {
|
||||||
const options = req.body || {}
|
const options = req.body || {}
|
||||||
|
|
||||||
const item = Database.libraryItems.find(li => li.id === req.params.itemId)
|
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
|
||||||
if (!item) return res.sendStatus(404)
|
if (!item) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
@@ -30,7 +39,7 @@ class RSSFeedController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||||
if (this.rssFeedManager.findFeedBySlug(options.slug)) {
|
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||||
return res.status(400).send('Slug already in use')
|
return res.status(400).send('Slug already in use')
|
||||||
}
|
}
|
||||||
@@ -45,7 +54,7 @@ class RSSFeedController {
|
|||||||
async openRSSFeedForCollection(req, res) {
|
async openRSSFeedForCollection(req, res) {
|
||||||
const options = req.body || {}
|
const options = req.body || {}
|
||||||
|
|
||||||
const collection = Database.collections.find(li => li.id === req.params.collectionId)
|
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
|
||||||
if (!collection) return res.sendStatus(404)
|
if (!collection) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check request body options exist
|
// Check request body options exist
|
||||||
@@ -55,12 +64,12 @@ class RSSFeedController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||||
if (this.rssFeedManager.findFeedBySlug(options.slug)) {
|
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||||
return res.status(400).send('Slug already in use')
|
return res.status(400).send('Slug already in use')
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
|
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||||
const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
|
const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
|
||||||
|
|
||||||
// Check collection has audio tracks
|
// Check collection has audio tracks
|
||||||
@@ -79,7 +88,7 @@ class RSSFeedController {
|
|||||||
async openRSSFeedForSeries(req, res) {
|
async openRSSFeedForSeries(req, res) {
|
||||||
const options = req.body || {}
|
const options = req.body || {}
|
||||||
|
|
||||||
const series = Database.series.find(se => se.id === req.params.seriesId)
|
const series = await Database.seriesModel.getOldById(req.params.seriesId)
|
||||||
if (!series) return res.sendStatus(404)
|
if (!series) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check request body options exist
|
// Check request body options exist
|
||||||
@@ -89,14 +98,15 @@ class RSSFeedController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||||
if (this.rssFeedManager.findFeedBySlug(options.slug)) {
|
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||||
return res.status(400).send('Slug already in use')
|
return res.status(400).send('Slug already in use')
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesJson = series.toJSON()
|
const seriesJson = series.toJSON()
|
||||||
|
|
||||||
// Get books in series that have audio tracks
|
// Get books in series that have audio tracks
|
||||||
seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
|
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
|
||||||
|
|
||||||
// Check series has audio tracks
|
// Check series has audio tracks
|
||||||
if (!seriesJson.books.length) {
|
if (!seriesJson.books.length) {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
const Logger = require("../Logger")
|
const Logger = require("../Logger")
|
||||||
|
const BookFinder = require('../finders/BookFinder')
|
||||||
|
const PodcastFinder = require('../finders/PodcastFinder')
|
||||||
|
const AuthorFinder = require('../finders/AuthorFinder')
|
||||||
|
const MusicFinder = require('../finders/MusicFinder')
|
||||||
|
|
||||||
class SearchController {
|
class SearchController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -7,7 +11,7 @@ class SearchController {
|
|||||||
const provider = req.query.provider || 'google'
|
const provider = req.query.provider || 'google'
|
||||||
const title = req.query.title || ''
|
const title = req.query.title || ''
|
||||||
const author = req.query.author || ''
|
const author = req.query.author || ''
|
||||||
const results = await this.bookFinder.search(provider, title, author)
|
const results = await BookFinder.search(provider, title, author)
|
||||||
res.json(results)
|
res.json(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +25,8 @@ class SearchController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let results = null
|
let results = null
|
||||||
if (podcast) results = await this.podcastFinder.findCovers(query.title)
|
if (podcast) results = await PodcastFinder.findCovers(query.title)
|
||||||
else results = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
|
else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
|
||||||
res.json({
|
res.json({
|
||||||
results
|
results
|
||||||
})
|
})
|
||||||
@@ -30,20 +34,20 @@ class SearchController {
|
|||||||
|
|
||||||
async findPodcasts(req, res) {
|
async findPodcasts(req, res) {
|
||||||
const term = req.query.term
|
const term = req.query.term
|
||||||
const results = await this.podcastFinder.search(term)
|
const results = await PodcastFinder.search(term)
|
||||||
res.json(results)
|
res.json(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAuthor(req, res) {
|
async findAuthor(req, res) {
|
||||||
const query = req.query.q
|
const query = req.query.q
|
||||||
const author = await this.authorFinder.findAuthorByName(query)
|
const author = await AuthorFinder.findAuthorByName(query)
|
||||||
res.json(author)
|
res.json(author)
|
||||||
}
|
}
|
||||||
|
|
||||||
async findChapters(req, res) {
|
async findChapters(req, res) {
|
||||||
const asin = req.query.asin
|
const asin = req.query.asin
|
||||||
const region = (req.query.region || 'us').toLowerCase()
|
const region = (req.query.region || 'us').toLowerCase()
|
||||||
const chapterData = await this.bookFinder.findChapters(asin, region)
|
const chapterData = await BookFinder.findChapters(asin, region)
|
||||||
if (!chapterData) {
|
if (!chapterData) {
|
||||||
return res.json({ error: 'Chapters not found' })
|
return res.json({ error: 'Chapters not found' })
|
||||||
}
|
}
|
||||||
@@ -51,7 +55,7 @@ class SearchController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findMusicTrack(req, res) {
|
async findMusicTrack(req, res) {
|
||||||
const tracks = await this.musicFinder.searchTrack(req.query || {})
|
const tracks = await MusicFinder.searchTrack(req.query || {})
|
||||||
res.json({
|
res.json({
|
||||||
tracks
|
tracks
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||||
|
|
||||||
class SeriesController {
|
class SeriesController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -25,7 +26,7 @@ class SeriesController {
|
|||||||
const libraryItemsInSeries = req.libraryItemsInSeries
|
const libraryItemsInSeries = req.libraryItemsInSeries
|
||||||
const libraryItemsFinished = libraryItemsInSeries.filter(li => {
|
const libraryItemsFinished = libraryItemsInSeries.filter(li => {
|
||||||
const mediaProgress = req.user.getMediaProgress(li.id)
|
const mediaProgress = req.user.getMediaProgress(li.id)
|
||||||
return mediaProgress && mediaProgress.isFinished
|
return mediaProgress?.isFinished
|
||||||
})
|
})
|
||||||
seriesJson.progress = {
|
seriesJson.progress = {
|
||||||
libraryItemIds: libraryItemsInSeries.map(li => li.id),
|
libraryItemIds: libraryItemsInSeries.map(li => li.id),
|
||||||
@@ -35,24 +36,13 @@ class SeriesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (include.includes('rssfeed')) {
|
if (include.includes('rssfeed')) {
|
||||||
const feedObj = this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(seriesJson)
|
res.json(seriesJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(req, res) {
|
|
||||||
var q = (req.query.q || '').toLowerCase()
|
|
||||||
if (!q) return res.json([])
|
|
||||||
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
|
|
||||||
var series = Database.series.filter(se => se.name.toLowerCase().includes(q))
|
|
||||||
series = series.slice(0, limit)
|
|
||||||
res.json({
|
|
||||||
results: series
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const hasUpdated = req.series.update(req.body)
|
const hasUpdated = req.series.update(req.body)
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
@@ -62,18 +52,17 @@ class SeriesController {
|
|||||||
res.json(req.series.toJSON())
|
res.json(req.series.toJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
const series = Database.series.find(se => se.id === req.params.id)
|
const series = await Database.seriesModel.getOldById(req.params.id)
|
||||||
if (!series) return res.sendStatus(404)
|
if (!series) return res.sendStatus(404)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter out any library items not accessible to user
|
* Filter out any library items not accessible to user
|
||||||
*/
|
*/
|
||||||
const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
|
const libraryItems = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
|
||||||
const libraryItemsAccessible = libraryItems.filter(li => req.user.checkCanAccessLibraryItem(li))
|
if (!libraryItems.length) {
|
||||||
if (libraryItems.length && !libraryItemsAccessible.length) {
|
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" with no accessible books`, req.user)
|
||||||
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user)
|
return res.sendStatus(404)
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||||
@@ -85,7 +74,7 @@ class SeriesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.series = series
|
req.series = series
|
||||||
req.libraryItemsInSeries = libraryItemsAccessible
|
req.libraryItemsInSeries = libraryItems
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,17 +43,17 @@ class SessionController {
|
|||||||
res.json(payload)
|
res.json(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
getOpenSessions(req, res) {
|
async getOpenSessions(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[SessionController] getOpenSessions: Non-admin user requested open session data ${req.user.id}/"${req.user.username}"`)
|
Logger.error(`[SessionController] getOpenSessions: Non-admin user requested open session data ${req.user.id}/"${req.user.username}"`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
|
||||||
const openSessions = this.playbackSessionManager.sessions.map(se => {
|
const openSessions = this.playbackSessionManager.sessions.map(se => {
|
||||||
const user = Database.users.find(u => u.id === se.userId) || null
|
|
||||||
return {
|
return {
|
||||||
...se.toJSON(),
|
...se.toJSON(),
|
||||||
user: user ? { id: user.id, username: user.username } : null
|
user: minifiedUserObjects.find(u => u.id === se.userId) || null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,9 +62,9 @@ class SessionController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getOpenSession(req, res) {
|
async getOpenSession(req, res) {
|
||||||
var libraryItem = Database.getLibraryItem(req.session.libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId)
|
||||||
var sessionForClient = req.session.toJSONForClient(libraryItem)
|
const sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||||
res.json(sessionForClient)
|
res.json(sessionForClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class ToolsController {
|
|||||||
|
|
||||||
const libraryItems = []
|
const libraryItems = []
|
||||||
for (const libraryItemId of libraryItemIds) {
|
for (const libraryItemId of libraryItemIds) {
|
||||||
const libraryItem = Database.getLibraryItem(libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
|
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
@@ -99,15 +99,15 @@ class ToolsController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
|
Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
const item = Database.libraryItems.find(li => li.id === req.params.id)
|
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item?.media) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ class UserController {
|
|||||||
const includes = (req.query.include || '').split(',').map(i => i.trim())
|
const includes = (req.query.include || '').split(',').map(i => i.trim())
|
||||||
|
|
||||||
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
|
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
|
||||||
const users = Database.users.map(u => u.toJSONForBrowser(hideRootToken, true))
|
const allUsers = await Database.userModel.getOldUsers()
|
||||||
|
const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true))
|
||||||
|
|
||||||
if (includes.includes('latestSession')) {
|
if (includes.includes('latestSession')) {
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
@@ -31,25 +32,67 @@ class UserController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(req, res) {
|
/**
|
||||||
|
* GET: /api/users/:id
|
||||||
|
* Get a single user toJSONForBrowser
|
||||||
|
* Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt`
|
||||||
|
*
|
||||||
|
* @param {import("express").Request} req
|
||||||
|
* @param {import("express").Response} res
|
||||||
|
*/
|
||||||
|
async findOne(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error('User other than admin attempting to get user', req.user)
|
Logger.error('User other than admin attempting to get user', req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = Database.users.find(u => u.id === req.params.id)
|
// Get user media progress with associated mediaItem
|
||||||
if (!user) {
|
const mediaProgresses = await Database.mediaProgressModel.findAll({
|
||||||
return res.sendStatus(404)
|
where: {
|
||||||
}
|
userId: req.reqUser.id
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookModel,
|
||||||
|
attributes: ['id', 'title', 'coverPath', 'updatedAt']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.podcastEpisodeModel,
|
||||||
|
attributes: ['id', 'title'],
|
||||||
|
include: {
|
||||||
|
model: Database.podcastModel,
|
||||||
|
attributes: ['id', 'title', 'coverPath', 'updatedAt']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
|
const oldMediaProgresses = mediaProgresses.map(mp => {
|
||||||
|
const oldMediaProgress = mp.getOldMediaProgress()
|
||||||
|
oldMediaProgress.displayTitle = mp.mediaItem?.title
|
||||||
|
if (mp.mediaItem?.podcast) {
|
||||||
|
oldMediaProgress.displaySubtitle = mp.mediaItem.podcast?.title
|
||||||
|
oldMediaProgress.coverPath = mp.mediaItem.podcast?.coverPath
|
||||||
|
oldMediaProgress.mediaUpdatedAt = mp.mediaItem.podcast?.updatedAt
|
||||||
|
} else if (mp.mediaItem) {
|
||||||
|
oldMediaProgress.coverPath = mp.mediaItem.coverPath
|
||||||
|
oldMediaProgress.mediaUpdatedAt = mp.mediaItem.updatedAt
|
||||||
|
}
|
||||||
|
return oldMediaProgress
|
||||||
|
})
|
||||||
|
|
||||||
|
const userJson = req.reqUser.toJSONForBrowser(!req.user.isRoot)
|
||||||
|
|
||||||
|
userJson.mediaProgress = oldMediaProgresses
|
||||||
|
|
||||||
|
res.json(userJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(req, res) {
|
async create(req, res) {
|
||||||
var account = req.body
|
const account = req.body
|
||||||
|
const username = account.username
|
||||||
|
|
||||||
var username = account.username
|
const usernameExists = await Database.userModel.getUserByUsername(username)
|
||||||
var usernameExists = Database.users.find(u => u.username.toLowerCase() === username.toLowerCase())
|
|
||||||
if (usernameExists) {
|
if (usernameExists) {
|
||||||
return res.status(500).send('Username already taken')
|
return res.status(500).send('Username already taken')
|
||||||
}
|
}
|
||||||
@@ -73,7 +116,7 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
var user = req.reqUser
|
const user = req.reqUser
|
||||||
|
|
||||||
if (user.type === 'root' && !req.user.isRoot) {
|
if (user.type === 'root' && !req.user.isRoot) {
|
||||||
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
|
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
|
||||||
@@ -84,7 +127,7 @@ class UserController {
|
|||||||
var shouldUpdateToken = false
|
var shouldUpdateToken = false
|
||||||
|
|
||||||
if (account.username !== undefined && account.username !== user.username) {
|
if (account.username !== undefined && account.username !== user.username) {
|
||||||
var usernameExists = Database.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
|
const usernameExists = await Database.userModel.getUserByUsername(account.username)
|
||||||
if (usernameExists) {
|
if (usernameExists) {
|
||||||
return res.status(500).send('Username already taken')
|
return res.status(500).send('Username already taken')
|
||||||
}
|
}
|
||||||
@@ -126,9 +169,13 @@ class UserController {
|
|||||||
// Todo: check if user is logged in and cancel streams
|
// Todo: check if user is logged in and cancel streams
|
||||||
|
|
||||||
// Remove user playlists
|
// Remove user playlists
|
||||||
const userPlaylists = Database.playlists.filter(p => p.userId === user.id)
|
const userPlaylists = await Database.playlistModel.findAll({
|
||||||
|
where: {
|
||||||
|
userId: user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
for (const playlist of userPlaylists) {
|
for (const playlist of userPlaylists) {
|
||||||
await Database.removePlaylist(playlist.id)
|
await playlist.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
const userJson = user.toJSONForBrowser()
|
const userJson = user.toJSONForBrowser()
|
||||||
@@ -178,7 +225,7 @@ class UserController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
} else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) {
|
} else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) {
|
||||||
@@ -186,7 +233,7 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
req.reqUser = Database.users.find(u => u.id === req.params.id)
|
req.reqUser = await Database.userModel.getUserById(req.params.id)
|
||||||
if (!req.reqUser) {
|
if (!req.reqUser) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -5,23 +5,23 @@ const { Sequelize } = require('sequelize')
|
|||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
|
||||||
const getLibraryItemMinified = (libraryItemId) => {
|
const getLibraryItemMinified = (libraryItemId) => {
|
||||||
return Database.models.libraryItem.findByPk(libraryItemId, {
|
return Database.libraryItemModel.findByPk(libraryItemId, {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.models.book,
|
model: Database.bookModel,
|
||||||
attributes: [
|
attributes: [
|
||||||
'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags'
|
'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags'
|
||||||
],
|
],
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.models.author,
|
model: Database.authorModel,
|
||||||
attributes: ['id', 'name'],
|
attributes: ['id', 'name'],
|
||||||
through: {
|
through: {
|
||||||
attributes: []
|
attributes: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Database.models.series,
|
model: Database.seriesModel,
|
||||||
attributes: ['id', 'name'],
|
attributes: ['id', 'name'],
|
||||||
through: {
|
through: {
|
||||||
attributes: ['sequence']
|
attributes: ['sequence']
|
||||||
@@ -30,7 +30,7 @@ const getLibraryItemMinified = (libraryItemId) => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Database.models.podcast,
|
model: Database.podcastModel,
|
||||||
attributes: [
|
attributes: [
|
||||||
'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags',
|
'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags',
|
||||||
[Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes']
|
[Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes']
|
||||||
@@ -41,19 +41,19 @@ const getLibraryItemMinified = (libraryItemId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getLibraryItemExpanded = (libraryItemId) => {
|
const getLibraryItemExpanded = (libraryItemId) => {
|
||||||
return Database.models.libraryItem.findByPk(libraryItemId, {
|
return Database.libraryItemModel.findByPk(libraryItemId, {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.models.book,
|
model: Database.bookModel,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.models.author,
|
model: Database.authorModel,
|
||||||
through: {
|
through: {
|
||||||
attributes: []
|
attributes: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Database.models.series,
|
model: Database.seriesModel,
|
||||||
through: {
|
through: {
|
||||||
attributes: ['sequence']
|
attributes: ['sequence']
|
||||||
}
|
}
|
||||||
@@ -61,10 +61,10 @@ const getLibraryItemExpanded = (libraryItemId) => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Database.models.podcast,
|
model: Database.podcastModel,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.models.podcastEpisode
|
model: Database.podcastEpisodeModel
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ const Path = require('path')
|
|||||||
const Audnexus = require('../providers/Audnexus')
|
const Audnexus = require('../providers/Audnexus')
|
||||||
|
|
||||||
const { downloadFile } = require('../utils/fileUtils')
|
const { downloadFile } = require('../utils/fileUtils')
|
||||||
const filePerms = require('../utils/filePerms')
|
|
||||||
|
|
||||||
class AuthorFinder {
|
class AuthorFinder {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.AuthorPath = Path.join(global.MetadataPath, 'authors')
|
|
||||||
|
|
||||||
this.audnexus = new Audnexus()
|
this.audnexus = new Audnexus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +34,11 @@ class AuthorFinder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveAuthorImage(authorId, url) {
|
async saveAuthorImage(authorId, url) {
|
||||||
var authorDir = this.AuthorPath
|
var authorDir = Path.join(global.MetadataPath, 'authors')
|
||||||
var relAuthorDir = Path.posix.join('/metadata', 'authors')
|
var relAuthorDir = Path.posix.join('/metadata', 'authors')
|
||||||
|
|
||||||
if (!await fs.pathExists(authorDir)) {
|
if (!await fs.pathExists(authorDir)) {
|
||||||
await fs.ensureDir(authorDir)
|
await fs.ensureDir(authorDir)
|
||||||
await filePerms.setDefault(authorDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageExtension = url.toLowerCase().split('.').pop()
|
var imageExtension = url.toLowerCase().split('.').pop()
|
||||||
@@ -61,4 +57,4 @@ class AuthorFinder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = AuthorFinder
|
module.exports = new AuthorFinder()
|
||||||
@@ -253,4 +253,4 @@ class BookFinder {
|
|||||||
return this.audnexus.getChaptersByASIN(asin, region)
|
return this.audnexus.getChaptersByASIN(asin, region)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = BookFinder
|
module.exports = new BookFinder()
|
||||||
@@ -9,4 +9,4 @@ class MusicFinder {
|
|||||||
return this.musicBrainz.searchTrack(options)
|
return this.musicBrainz.searchTrack(options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = MusicFinder
|
module.exports = new MusicFinder()
|
||||||
@@ -22,4 +22,4 @@ class PodcastFinder {
|
|||||||
return results.map(r => r.cover).filter(r => r)
|
return results.map(r => r.cover).filter(r => r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = PodcastFinder
|
module.exports = new PodcastFinder()
|
||||||
@@ -5,7 +5,6 @@ const fs = require('../libs/fsExtra')
|
|||||||
const workerThreads = require('worker_threads')
|
const workerThreads = require('worker_threads')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Task = require('../objects/Task')
|
const Task = require('../objects/Task')
|
||||||
const filePerms = require('../utils/filePerms')
|
|
||||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||||
const toneHelpers = require('../utils/toneHelpers')
|
const toneHelpers = require('../utils/toneHelpers')
|
||||||
|
|
||||||
@@ -201,10 +200,6 @@ class AbMergeManager {
|
|||||||
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
||||||
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
|
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
|
||||||
|
|
||||||
// Set file permissions and ownership
|
|
||||||
await filePerms.setDefault(task.data.targetFilepath)
|
|
||||||
await filePerms.setDefault(task.data.itemCachePath)
|
|
||||||
|
|
||||||
task.setFinished()
|
task.setFinished()
|
||||||
await this.removeTask(task, false)
|
await this.removeTask(task, false)
|
||||||
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ const cron = require('../libs/nodeCron')
|
|||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const archiver = require('../libs/archiver')
|
const archiver = require('../libs/archiver')
|
||||||
const StreamZip = require('../libs/nodeStreamZip')
|
const StreamZip = require('../libs/nodeStreamZip')
|
||||||
|
const fileUtils = require('../utils/fileUtils')
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
const { getFileSize } = require('../utils/fileUtils')
|
const { getFileSize } = require('../utils/fileUtils')
|
||||||
const filePerms = require('../utils/filePerms')
|
|
||||||
|
|
||||||
const Backup = require('../objects/Backup')
|
const Backup = require('../objects/Backup')
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ class BackupManager {
|
|||||||
return res.status(500).send('Invalid backup file')
|
return res.status(500).send('Invalid backup file')
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = Path.join(this.BackupPath, backupFile.name)
|
const tempPath = Path.join(this.BackupPath, fileUtils.sanitizeFilename(backupFile.name))
|
||||||
const success = await backupFile.mv(tempPath).then(() => true).catch((error) => {
|
const success = await backupFile.mv(tempPath).then(() => true).catch((error) => {
|
||||||
Logger.error('[BackupManager] Failed to move backup file', path, error)
|
Logger.error('[BackupManager] Failed to move backup file', path, error)
|
||||||
return false
|
return false
|
||||||
@@ -93,8 +93,14 @@ class BackupManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const zip = new StreamZip.async({ file: tempPath })
|
const zip = new StreamZip.async({ file: tempPath })
|
||||||
|
let entries
|
||||||
const entries = await zip.entries()
|
try {
|
||||||
|
entries = await zip.entries()
|
||||||
|
} catch(error){
|
||||||
|
// Not a valid zip file
|
||||||
|
Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)
|
||||||
|
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
|
||||||
|
}
|
||||||
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
|
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
|
||||||
Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)
|
Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)
|
||||||
return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')
|
return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')
|
||||||
@@ -268,7 +274,7 @@ class BackupManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://github.com/TryGhost/node-sqlite3/pull/1116
|
* @see https://github.com/TryGhost/node-sqlite3/pull/1116
|
||||||
* @param {Backup} backup
|
* @param {Backup} backup
|
||||||
* @promise
|
* @promise
|
||||||
*/
|
*/
|
||||||
backupSqliteDb(backup) {
|
backupSqliteDb(backup) {
|
||||||
|
|||||||
@@ -1,42 +1,40 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const stream = require('stream')
|
const stream = require('stream')
|
||||||
const filePerms = require('../utils/filePerms')
|
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { resizeImage } = require('../utils/ffmpegHelpers')
|
const { resizeImage } = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
class CacheManager {
|
class CacheManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.CachePath = null
|
||||||
|
this.CoverCachePath = null
|
||||||
|
this.ImageCachePath = null
|
||||||
|
this.ItemCachePath = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create cache directory paths if they dont exist
|
||||||
|
*/
|
||||||
|
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
|
||||||
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
||||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||||
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
||||||
this.ItemCachePath = Path.join(this.CachePath, 'items')
|
this.ItemCachePath = Path.join(this.CachePath, 'items')
|
||||||
}
|
|
||||||
|
|
||||||
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
|
|
||||||
var pathsCreated = false
|
|
||||||
if (!(await fs.pathExists(this.CachePath))) {
|
if (!(await fs.pathExists(this.CachePath))) {
|
||||||
await fs.mkdir(this.CachePath)
|
await fs.mkdir(this.CachePath)
|
||||||
pathsCreated = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await fs.pathExists(this.CoverCachePath))) {
|
if (!(await fs.pathExists(this.CoverCachePath))) {
|
||||||
await fs.mkdir(this.CoverCachePath)
|
await fs.mkdir(this.CoverCachePath)
|
||||||
pathsCreated = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await fs.pathExists(this.ImageCachePath))) {
|
if (!(await fs.pathExists(this.ImageCachePath))) {
|
||||||
await fs.mkdir(this.ImageCachePath)
|
await fs.mkdir(this.ImageCachePath)
|
||||||
pathsCreated = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await fs.pathExists(this.ItemCachePath))) {
|
if (!(await fs.pathExists(this.ItemCachePath))) {
|
||||||
await fs.mkdir(this.ItemCachePath)
|
await fs.mkdir(this.ItemCachePath)
|
||||||
pathsCreated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathsCreated) {
|
|
||||||
await filePerms.setDefault(this.CachePath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +72,6 @@ class CacheManager {
|
|||||||
const writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
|
const writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
|
||||||
if (!writtenFile) return res.sendStatus(500)
|
if (!writtenFile) return res.sendStatus(500)
|
||||||
|
|
||||||
// Set owner and permissions of cache image
|
|
||||||
await filePerms.setDefault(path)
|
|
||||||
|
|
||||||
if (global.XAccel) {
|
if (global.XAccel) {
|
||||||
Logger.debug(`Use X-Accel to serve static file ${writtenFile}`)
|
Logger.debug(`Use X-Accel to serve static file ${writtenFile}`)
|
||||||
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + writtenFile }).send()
|
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + writtenFile }).send()
|
||||||
@@ -160,11 +155,8 @@ class CacheManager {
|
|||||||
let writtenFile = await resizeImage(author.imagePath, path, width, height)
|
let writtenFile = await resizeImage(author.imagePath, path, width, height)
|
||||||
if (!writtenFile) return res.sendStatus(500)
|
if (!writtenFile) return res.sendStatus(500)
|
||||||
|
|
||||||
// Set owner and permissions of cache image
|
|
||||||
await filePerms.setDefault(path)
|
|
||||||
|
|
||||||
var readStream = fs.createReadStream(writtenFile)
|
var readStream = fs.createReadStream(writtenFile)
|
||||||
readStream.pipe(res)
|
readStream.pipe(res)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = CacheManager
|
module.exports = new CacheManager()
|
||||||
+107
-20
@@ -3,24 +3,20 @@ const Path = require('path')
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const readChunk = require('../libs/readChunk')
|
const readChunk = require('../libs/readChunk')
|
||||||
const imageType = require('../libs/imageType')
|
const imageType = require('../libs/imageType')
|
||||||
const filePerms = require('../utils/filePerms')
|
|
||||||
|
|
||||||
const globals = require('../utils/globals')
|
const globals = require('../utils/globals')
|
||||||
const { downloadFile, filePathToPOSIX } = require('../utils/fileUtils')
|
const { downloadFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
|
||||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||||
|
const CacheManager = require('../managers/CacheManager')
|
||||||
|
|
||||||
class CoverManager {
|
class CoverManager {
|
||||||
constructor(cacheManager) {
|
constructor() { }
|
||||||
this.cacheManager = cacheManager
|
|
||||||
|
|
||||||
this.ItemMetadataPath = Path.posix.join(global.MetadataPath, 'items')
|
|
||||||
}
|
|
||||||
|
|
||||||
getCoverDirectory(libraryItem) {
|
getCoverDirectory(libraryItem) {
|
||||||
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) {
|
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
|
||||||
return libraryItem.path
|
return libraryItem.path
|
||||||
} else {
|
} else {
|
||||||
return Path.posix.join(this.ItemMetadataPath, libraryItem.id)
|
return Path.posix.join(Path.posix.join(global.MetadataPath, 'items'), libraryItem.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +103,10 @@ class CoverManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.removeOldCovers(coverDirPath, extname)
|
await this.removeOldCovers(coverDirPath, extname)
|
||||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||||
|
|
||||||
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
|
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
|
||||||
|
|
||||||
await filePerms.setDefault(coverFullPath)
|
|
||||||
libraryItem.updateMediaCover(coverFullPath)
|
libraryItem.updateMediaCover(coverFullPath)
|
||||||
return {
|
return {
|
||||||
cover: coverFullPath
|
cover: coverFullPath
|
||||||
@@ -146,11 +141,9 @@ class CoverManager {
|
|||||||
await fs.rename(temppath, coverFullPath)
|
await fs.rename(temppath, coverFullPath)
|
||||||
|
|
||||||
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
||||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||||
|
|
||||||
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
|
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
|
||||||
|
|
||||||
await filePerms.setDefault(coverFullPath)
|
|
||||||
libraryItem.updateMediaCover(coverFullPath)
|
libraryItem.updateMediaCover(coverFullPath)
|
||||||
return {
|
return {
|
||||||
cover: coverFullPath
|
cover: coverFullPath
|
||||||
@@ -180,6 +173,7 @@ class CoverManager {
|
|||||||
updated: false
|
updated: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cover path does not exist
|
// Cover path does not exist
|
||||||
if (!await fs.pathExists(coverPath)) {
|
if (!await fs.pathExists(coverPath)) {
|
||||||
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
|
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
|
||||||
@@ -187,8 +181,17 @@ class CoverManager {
|
|||||||
error: 'Cover path does not exist'
|
error: 'Cover path does not exist'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cover path is not a file
|
||||||
|
if (!await checkPathIsFile(coverPath)) {
|
||||||
|
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
|
||||||
|
return {
|
||||||
|
error: 'Cover path is not a file'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check valid image at path
|
// Check valid image at path
|
||||||
var imgtype = await this.checkFileIsValidImage(coverPath, true)
|
var imgtype = await this.checkFileIsValidImage(coverPath, false)
|
||||||
if (imgtype.error) {
|
if (imgtype.error) {
|
||||||
return imgtype
|
return imgtype
|
||||||
}
|
}
|
||||||
@@ -212,13 +215,12 @@ class CoverManager {
|
|||||||
error: 'Failed to copy cover to dir'
|
error: 'Failed to copy cover to dir'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await filePerms.setDefault(newCoverPath)
|
|
||||||
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
||||||
Logger.debug(`[CoverManager] cover copy success`)
|
Logger.debug(`[CoverManager] cover copy success`)
|
||||||
coverPath = newCoverPath
|
coverPath = newCoverPath
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||||
|
|
||||||
libraryItem.updateMediaCover(coverPath)
|
libraryItem.updateMediaCover(coverPath)
|
||||||
return {
|
return {
|
||||||
@@ -253,12 +255,97 @@ class CoverManager {
|
|||||||
|
|
||||||
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
|
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
|
||||||
if (success) {
|
if (success) {
|
||||||
await filePerms.setDefault(coverFilePath)
|
|
||||||
|
|
||||||
libraryItem.updateMediaCover(coverFilePath)
|
libraryItem.updateMediaCover(coverFilePath)
|
||||||
return coverFilePath
|
return coverFilePath
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract cover art from audio file and save for library item
|
||||||
|
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||||
|
* @param {string} libraryItemId
|
||||||
|
* @param {string} [libraryItemPath] null for isFile library items
|
||||||
|
* @returns {Promise<string>} returns cover path
|
||||||
|
*/
|
||||||
|
async saveEmbeddedCoverArtNew(audioFiles, libraryItemId, libraryItemPath) {
|
||||||
|
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
|
||||||
|
if (!audioFileWithCover) return null
|
||||||
|
|
||||||
|
let coverDirPath = null
|
||||||
|
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
|
||||||
|
coverDirPath = libraryItemPath
|
||||||
|
} else {
|
||||||
|
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
|
||||||
|
}
|
||||||
|
await fs.ensureDir(coverDirPath)
|
||||||
|
|
||||||
|
const coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
||||||
|
const coverFilePath = Path.join(coverDirPath, coverFilename)
|
||||||
|
|
||||||
|
const coverAlreadyExists = await fs.pathExists(coverFilePath)
|
||||||
|
if (coverAlreadyExists) {
|
||||||
|
Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${libraryItemPath}" - bail`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
|
||||||
|
if (success) {
|
||||||
|
return coverFilePath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string} libraryItemId
|
||||||
|
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
|
||||||
|
* @returns {Promise<{error:string}|{cover:string}>}
|
||||||
|
*/
|
||||||
|
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) {
|
||||||
|
try {
|
||||||
|
let coverDirPath = null
|
||||||
|
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
|
||||||
|
coverDirPath = libraryItemPath
|
||||||
|
} else {
|
||||||
|
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.ensureDir(coverDirPath)
|
||||||
|
|
||||||
|
const temppath = Path.posix.join(coverDirPath, 'cover')
|
||||||
|
const success = await downloadFile(url, temppath).then(() => true).catch((err) => {
|
||||||
|
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!success) {
|
||||||
|
return {
|
||||||
|
error: 'Failed to download image from url'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgtype = await this.checkFileIsValidImage(temppath, true)
|
||||||
|
if (imgtype.error) {
|
||||||
|
return imgtype
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverFullPath = Path.posix.join(coverDirPath, `cover.${imgtype.ext}`)
|
||||||
|
await fs.rename(temppath, coverFullPath)
|
||||||
|
|
||||||
|
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
||||||
|
await CacheManager.purgeCoverCache(libraryItemId)
|
||||||
|
|
||||||
|
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}"`)
|
||||||
|
return {
|
||||||
|
cover: coverFullPath
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
|
||||||
|
return {
|
||||||
|
error: 'Failed to fetch image from url'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = CoverManager
|
module.exports = new CoverManager()
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
const Sequelize = require('sequelize')
|
||||||
const cron = require('../libs/nodeCron')
|
const cron = require('../libs/nodeCron')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
const LibraryScanner = require('../scanner/LibraryScanner')
|
||||||
|
|
||||||
class CronManager {
|
class CronManager {
|
||||||
constructor(scanner, podcastManager) {
|
constructor(podcastManager) {
|
||||||
this.scanner = scanner
|
|
||||||
this.podcastManager = podcastManager
|
this.podcastManager = podcastManager
|
||||||
|
|
||||||
this.libraryScanCrons = []
|
this.libraryScanCrons = []
|
||||||
@@ -13,13 +14,21 @@ class CronManager {
|
|||||||
this.podcastCronExpressionsExecuting = []
|
this.podcastCronExpressionsExecuting = []
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
/**
|
||||||
this.initLibraryScanCrons()
|
* Initialize library scan crons & podcast download crons
|
||||||
this.initPodcastCrons()
|
* @param {oldLibrary[]} libraries
|
||||||
|
*/
|
||||||
|
async init(libraries) {
|
||||||
|
this.initLibraryScanCrons(libraries)
|
||||||
|
await this.initPodcastCrons()
|
||||||
}
|
}
|
||||||
|
|
||||||
initLibraryScanCrons() {
|
/**
|
||||||
for (const library of Database.libraries) {
|
* Initialize library scan crons
|
||||||
|
* @param {oldLibrary[]} libraries
|
||||||
|
*/
|
||||||
|
initLibraryScanCrons(libraries) {
|
||||||
|
for (const library of libraries) {
|
||||||
if (library.settings.autoScanCronExpression) {
|
if (library.settings.autoScanCronExpression) {
|
||||||
this.startCronForLibrary(library)
|
this.startCronForLibrary(library)
|
||||||
}
|
}
|
||||||
@@ -30,7 +39,7 @@ class CronManager {
|
|||||||
Logger.debug(`[CronManager] Init library scan cron for ${library.name} on schedule ${library.settings.autoScanCronExpression}`)
|
Logger.debug(`[CronManager] Init library scan cron for ${library.name} on schedule ${library.settings.autoScanCronExpression}`)
|
||||||
const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => {
|
const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => {
|
||||||
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
|
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
|
||||||
this.scanner.scan(library)
|
LibraryScanner.scan(library)
|
||||||
})
|
})
|
||||||
this.libraryScanCrons.push({
|
this.libraryScanCrons.push({
|
||||||
libraryId: library.id,
|
libraryId: library.id,
|
||||||
@@ -62,23 +71,34 @@ class CronManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initPodcastCrons() {
|
/**
|
||||||
|
* Init cron jobs for auto-download podcasts
|
||||||
|
*/
|
||||||
|
async initPodcastCrons() {
|
||||||
const cronExpressionMap = {}
|
const cronExpressionMap = {}
|
||||||
Database.libraryItems.forEach((li) => {
|
|
||||||
if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) {
|
const podcastsWithAutoDownload = await Database.podcastModel.findAll({
|
||||||
if (!li.media.autoDownloadSchedule) {
|
where: {
|
||||||
Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`)
|
autoDownloadEpisodes: true,
|
||||||
} else {
|
autoDownloadSchedule: {
|
||||||
if (!cronExpressionMap[li.media.autoDownloadSchedule]) {
|
[Sequelize.Op.not]: null
|
||||||
cronExpressionMap[li.media.autoDownloadSchedule] = {
|
|
||||||
expression: li.media.autoDownloadSchedule,
|
|
||||||
libraryItemIds: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cronExpressionMap[li.media.autoDownloadSchedule].libraryItemIds.push(li.id)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
model: Database.libraryItemModel
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const podcast of podcastsWithAutoDownload) {
|
||||||
|
if (!cronExpressionMap[podcast.autoDownloadSchedule]) {
|
||||||
|
cronExpressionMap[podcast.autoDownloadSchedule] = {
|
||||||
|
expression: podcast.autoDownloadSchedule,
|
||||||
|
libraryItemIds: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cronExpressionMap[podcast.autoDownloadSchedule].libraryItemIds.push(podcast.libraryItem.id)
|
||||||
|
}
|
||||||
|
|
||||||
if (!Object.keys(cronExpressionMap).length) return
|
if (!Object.keys(cronExpressionMap).length) return
|
||||||
|
|
||||||
Logger.debug(`[CronManager] Found ${Object.keys(cronExpressionMap).length} podcast episode schedules to start`)
|
Logger.debug(`[CronManager] Found ${Object.keys(cronExpressionMap).length} podcast episode schedules to start`)
|
||||||
@@ -119,7 +139,7 @@ class CronManager {
|
|||||||
// Get podcast library items to check
|
// Get podcast library items to check
|
||||||
const libraryItems = []
|
const libraryItems = []
|
||||||
for (const libraryItemId of libraryItemIds) {
|
for (const libraryItemId of libraryItemIds) {
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
|
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
|
||||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
|
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const filePerms = require('../utils/filePerms')
|
|
||||||
|
|
||||||
const DailyLog = require('../objects/DailyLog')
|
const DailyLog = require('../objects/DailyLog')
|
||||||
|
|
||||||
@@ -25,13 +24,11 @@ class LogManager {
|
|||||||
async ensureLogDirs() {
|
async ensureLogDirs() {
|
||||||
await fs.ensureDir(this.DailyLogPath)
|
await fs.ensureDir(this.DailyLogPath)
|
||||||
await fs.ensureDir(this.ScanLogPath)
|
await fs.ensureDir(this.ScanLogPath)
|
||||||
await filePerms.setDefault(Path.posix.join(global.MetadataPath, 'logs'), true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureScanLogDir() {
|
async ensureScanLogDir() {
|
||||||
if (!(await fs.pathExists(this.ScanLogPath))) {
|
if (!(await fs.pathExists(this.ScanLogPath))) {
|
||||||
await fs.mkdir(this.ScanLogPath)
|
await fs.mkdir(this.ScanLogPath)
|
||||||
await filePerms.setDefault(this.ScanLogPath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ class NotificationManager {
|
|||||||
return notificationData
|
return notificationData
|
||||||
}
|
}
|
||||||
|
|
||||||
onPodcastEpisodeDownloaded(libraryItem, episode) {
|
async onPodcastEpisodeDownloaded(libraryItem, episode) {
|
||||||
if (!Database.notificationSettings.isUseable) return
|
if (!Database.notificationSettings.isUseable) return
|
||||||
|
|
||||||
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
|
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
|
||||||
const library = Database.libraries.find(lib => lib.id === libraryItem.libraryId)
|
const library = await Database.libraryModel.getOldById(libraryItem.libraryId)
|
||||||
const eventData = {
|
const eventData = {
|
||||||
libraryItemId: libraryItem.id,
|
libraryItemId: libraryItem.id,
|
||||||
libraryId: libraryItem.libraryId,
|
libraryId: libraryItem.libraryId,
|
||||||
libraryName: library ? library.name : 'Unknown',
|
libraryName: library?.name || 'Unknown',
|
||||||
mediaTags: (libraryItem.media.tags || []).join(', '),
|
mediaTags: (libraryItem.media.tags || []).join(', '),
|
||||||
podcastTitle: libraryItem.media.metadata.title,
|
podcastTitle: libraryItem.media.metadata.title,
|
||||||
podcastAuthor: libraryItem.media.metadata.author || '',
|
podcastAuthor: libraryItem.media.metadata.author || '',
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class PlaybackSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncLocalSession(user, sessionJson, deviceInfo) {
|
async syncLocalSession(user, sessionJson, deviceInfo) {
|
||||||
const libraryItem = Database.getLibraryItem(sessionJson.libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
|
||||||
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
|
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
|
||||||
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
|
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
|
||||||
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
|
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
|
||||||
@@ -104,6 +104,9 @@ class PlaybackSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionJson.userId = user.id
|
||||||
|
sessionJson.serverVersion = serverVersion
|
||||||
|
|
||||||
// TODO: Temp update local playback session id to uuidv4 & library item/book/episode ids
|
// TODO: Temp update local playback session id to uuidv4 & library item/book/episode ids
|
||||||
if (sessionJson.id?.startsWith('play_local_')) {
|
if (sessionJson.id?.startsWith('play_local_')) {
|
||||||
if (!this.oldPlaybackSessionMap[sessionJson.id]) {
|
if (!this.oldPlaybackSessionMap[sessionJson.id]) {
|
||||||
@@ -256,13 +259,13 @@ class PlaybackSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.sessions.push(newPlaybackSession)
|
this.sessions.push(newPlaybackSession)
|
||||||
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems))
|
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
|
||||||
|
|
||||||
return newPlaybackSession
|
return newPlaybackSession
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncSession(user, session, syncData) {
|
async syncSession(user, session, syncData) {
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === session.libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
|
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
|
||||||
return null
|
return null
|
||||||
@@ -301,7 +304,7 @@ class PlaybackSessionManager {
|
|||||||
await this.saveSession(session)
|
await this.saveSession(session)
|
||||||
}
|
}
|
||||||
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
|
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
|
||||||
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems))
|
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
|
||||||
SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
|
SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
|
||||||
return this.removeSession(session.id)
|
return this.removeSession(session.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const fs = require('../libs/fsExtra')
|
|||||||
|
|
||||||
const { getPodcastFeed } = require('../utils/podcastUtils')
|
const { getPodcastFeed } = require('../utils/podcastUtils')
|
||||||
const { removeFile, downloadFile } = require('../utils/fileUtils')
|
const { removeFile, downloadFile } = require('../utils/fileUtils')
|
||||||
const filePerms = require('../utils/filePerms')
|
|
||||||
const { levenshteinDistance } = require('../utils/index')
|
const { levenshteinDistance } = require('../utils/index')
|
||||||
const opmlParser = require('../utils/parsers/parseOPML')
|
const opmlParser = require('../utils/parsers/parseOPML')
|
||||||
const opmlGenerator = require('../utils/generators/opmlGenerator')
|
const opmlGenerator = require('../utils/generators/opmlGenerator')
|
||||||
@@ -50,7 +49,7 @@ class PodcastManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
|
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
|
||||||
let index = libraryItem.media.episodes.length + 1
|
let index = Math.max(...libraryItem.media.episodes.filter(ep => ep.index == null || isNaN(ep.index)).map(ep => Number(ep.index))) + 1
|
||||||
for (const ep of episodesToDownload) {
|
for (const ep of episodesToDownload) {
|
||||||
const newPe = new PodcastEpisode()
|
const newPe = new PodcastEpisode()
|
||||||
newPe.setData(ep, index++)
|
newPe.setData(ep, index++)
|
||||||
@@ -96,7 +95,6 @@ class PodcastManager {
|
|||||||
if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) {
|
if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) {
|
||||||
Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at "${this.currentDownload.libraryItem.path}" - Creating it`)
|
Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at "${this.currentDownload.libraryItem.path}" - Creating it`)
|
||||||
await fs.mkdir(this.currentDownload.libraryItem.path)
|
await fs.mkdir(this.currentDownload.libraryItem.path)
|
||||||
await filePerms.setDefault(this.currentDownload.libraryItem.path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let success = false
|
let success = false
|
||||||
@@ -150,7 +148,7 @@ class PodcastManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItem = Database.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
|
const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
||||||
return false
|
return false
|
||||||
@@ -372,8 +370,13 @@ class PodcastManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
generateOPMLFileText(libraryItems) {
|
/**
|
||||||
return opmlGenerator.generate(libraryItems)
|
* OPML file string for podcasts in a library
|
||||||
|
* @param {import('../models/Podcast')[]} podcasts
|
||||||
|
* @returns {string} XML string
|
||||||
|
*/
|
||||||
|
generateOPMLFileText(podcasts) {
|
||||||
|
return opmlGenerator.generate(podcasts)
|
||||||
}
|
}
|
||||||
|
|
||||||
getDownloadQueueDetails(libraryId = null) {
|
getDownloadQueueDetails(libraryId = null) {
|
||||||
|
|||||||
@@ -6,26 +6,28 @@ const Database = require('../Database')
|
|||||||
|
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Feed = require('../objects/Feed')
|
const Feed = require('../objects/Feed')
|
||||||
|
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||||
|
|
||||||
class RssFeedManager {
|
class RssFeedManager {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
validateFeedEntity(feedObj) {
|
async validateFeedEntity(feedObj) {
|
||||||
if (feedObj.entityType === 'collection') {
|
if (feedObj.entityType === 'collection') {
|
||||||
if (!Database.collections.some(li => li.id === feedObj.entityId)) {
|
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
|
||||||
|
if (!collection) {
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
|
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else if (feedObj.entityType === 'libraryItem') {
|
} else if (feedObj.entityType === 'libraryItem') {
|
||||||
if (!Database.libraryItems.some(li => li.id === feedObj.entityId)) {
|
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
|
||||||
|
if (!libraryItemExists) {
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
|
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else if (feedObj.entityType === 'series') {
|
} else if (feedObj.entityType === 'series') {
|
||||||
const series = Database.series.find(s => s.id === feedObj.entityId)
|
const series = await Database.seriesModel.getOldById(feedObj.entityId)
|
||||||
const hasSeriesBook = series ? Database.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) : false
|
if (!series) {
|
||||||
if (!hasSeriesBook) {
|
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
|
||||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found or has no audio tracks`)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -35,29 +37,48 @@ class RssFeedManager {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate all feeds and remove invalid
|
||||||
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
for (const feed of Database.feeds) {
|
const feeds = await Database.feedModel.getOldFeeds()
|
||||||
|
for (const feed of feeds) {
|
||||||
// Remove invalid feeds
|
// Remove invalid feeds
|
||||||
if (!this.validateFeedEntity(feed)) {
|
if (!await this.validateFeedEntity(feed)) {
|
||||||
await Database.removeFeed(feed.id)
|
await Database.removeFeed(feed.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
|
||||||
|
* @param {string} entityId
|
||||||
|
* @returns {Promise<objects.Feed>} oldFeed
|
||||||
|
*/
|
||||||
findFeedForEntityId(entityId) {
|
findFeedForEntityId(entityId) {
|
||||||
return Database.feeds.find(feed => feed.entityId === entityId)
|
return Database.feedModel.findOneOld({ entityId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find open feed for a slug
|
||||||
|
* @param {string} slug
|
||||||
|
* @returns {Promise<objects.Feed>} oldFeed
|
||||||
|
*/
|
||||||
findFeedBySlug(slug) {
|
findFeedBySlug(slug) {
|
||||||
return Database.feeds.find(feed => feed.slug === slug)
|
return Database.feedModel.findOneOld({ slug })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find open feed for a slug
|
||||||
|
* @param {string} slug
|
||||||
|
* @returns {Promise<objects.Feed>} oldFeed
|
||||||
|
*/
|
||||||
findFeed(id) {
|
findFeed(id) {
|
||||||
return Database.feeds.find(feed => feed.id === id)
|
return Database.feedModel.findByPkOld(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFeed(req, res) {
|
async getFeed(req, res) {
|
||||||
const feed = this.findFeedBySlug(req.params.slug)
|
const feed = await this.findFeedBySlug(req.params.slug)
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||||
res.sendStatus(404)
|
res.sendStatus(404)
|
||||||
@@ -66,7 +87,7 @@ class RssFeedManager {
|
|||||||
|
|
||||||
// Check if feed needs to be updated
|
// Check if feed needs to be updated
|
||||||
if (feed.entityType === 'libraryItem') {
|
if (feed.entityType === 'libraryItem') {
|
||||||
const libraryItem = Database.getLibraryItem(feed.entityId)
|
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
|
||||||
|
|
||||||
let mostRecentlyUpdatedAt = libraryItem.updatedAt
|
let mostRecentlyUpdatedAt = libraryItem.updatedAt
|
||||||
if (libraryItem.isPodcast) {
|
if (libraryItem.isPodcast) {
|
||||||
@@ -77,13 +98,14 @@ class RssFeedManager {
|
|||||||
|
|
||||||
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
|
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
|
||||||
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
|
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
|
||||||
|
|
||||||
feed.updateFromItem(libraryItem)
|
feed.updateFromItem(libraryItem)
|
||||||
await Database.updateFeed(feed)
|
await Database.updateFeed(feed)
|
||||||
}
|
}
|
||||||
} else if (feed.entityType === 'collection') {
|
} else if (feed.entityType === 'collection') {
|
||||||
const collection = Database.collections.find(c => c.id === feed.entityId)
|
const collection = await Database.collectionModel.findByPk(feed.entityId)
|
||||||
if (collection) {
|
if (collection) {
|
||||||
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
|
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||||
|
|
||||||
// Find most recently updated item in collection
|
// Find most recently updated item in collection
|
||||||
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
|
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
|
||||||
@@ -101,11 +123,12 @@ class RssFeedManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (feed.entityType === 'series') {
|
} else if (feed.entityType === 'series') {
|
||||||
const series = Database.series.find(s => s.id === feed.entityId)
|
const series = await Database.seriesModel.getOldById(feed.entityId)
|
||||||
if (series) {
|
if (series) {
|
||||||
const seriesJson = series.toJSON()
|
const seriesJson = series.toJSON()
|
||||||
|
|
||||||
// Get books in series that have audio tracks
|
// Get books in series that have audio tracks
|
||||||
seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
|
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
|
||||||
|
|
||||||
// Find most recently updated item in series
|
// Find most recently updated item in series
|
||||||
let mostRecentlyUpdatedAt = seriesJson.updatedAt
|
let mostRecentlyUpdatedAt = seriesJson.updatedAt
|
||||||
@@ -134,8 +157,8 @@ class RssFeedManager {
|
|||||||
res.send(xml)
|
res.send(xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
getFeedItem(req, res) {
|
async getFeedItem(req, res) {
|
||||||
const feed = this.findFeedBySlug(req.params.slug)
|
const feed = await this.findFeedBySlug(req.params.slug)
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||||
res.sendStatus(404)
|
res.sendStatus(404)
|
||||||
@@ -150,8 +173,8 @@ class RssFeedManager {
|
|||||||
res.sendFile(episodePath)
|
res.sendFile(episodePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
getFeedCover(req, res) {
|
async getFeedCover(req, res) {
|
||||||
const feed = this.findFeedBySlug(req.params.slug)
|
const feed = await this.findFeedBySlug(req.params.slug)
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
||||||
res.sendStatus(404)
|
res.sendStatus(404)
|
||||||
@@ -225,7 +248,7 @@ class RssFeedManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async closeRssFeed(req, res) {
|
async closeRssFeed(req, res) {
|
||||||
const feed = this.findFeed(req.params.id)
|
const feed = await this.findFeed(req.params.id)
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
|
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
@@ -234,10 +257,16 @@ class RssFeedManager {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
closeFeedForEntityId(entityId) {
|
async closeFeedForEntityId(entityId) {
|
||||||
const feed = this.findFeedForEntityId(entityId)
|
const feed = await this.findFeedForEntityId(entityId)
|
||||||
if (!feed) return
|
if (!feed) return
|
||||||
return this.handleCloseFeed(feed)
|
return this.handleCloseFeed(feed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFeeds() {
|
||||||
|
const feeds = await Database.models.feed.getOldFeeds()
|
||||||
|
Logger.info(`[RssFeedManager] Fetched all feeds`)
|
||||||
|
return feeds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = RssFeedManager
|
module.exports = RssFeedManager
|
||||||
|
|||||||
+160
-72
@@ -1,86 +1,174 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model, literal } = require('sequelize')
|
||||||
|
|
||||||
const oldAuthor = require('../objects/entities/Author')
|
const oldAuthor = require('../objects/entities/Author')
|
||||||
|
|
||||||
module.exports = (sequelize) => {
|
class Author extends Model {
|
||||||
class Author extends Model {
|
constructor(values, options) {
|
||||||
static async getOldAuthors() {
|
super(values, options)
|
||||||
const authors = await this.findAll()
|
|
||||||
return authors.map(au => au.getOldAuthor())
|
|
||||||
}
|
|
||||||
|
|
||||||
getOldAuthor() {
|
/** @type {UUIDV4} */
|
||||||
return new oldAuthor({
|
this.id
|
||||||
id: this.id,
|
/** @type {string} */
|
||||||
asin: this.asin,
|
this.name
|
||||||
name: this.name,
|
/** @type {string} */
|
||||||
description: this.description,
|
this.lastFirst
|
||||||
imagePath: this.imagePath,
|
/** @type {string} */
|
||||||
libraryId: this.libraryId,
|
this.asin
|
||||||
addedAt: this.createdAt.valueOf(),
|
/** @type {string} */
|
||||||
updatedAt: this.updatedAt.valueOf()
|
this.description
|
||||||
})
|
/** @type {string} */
|
||||||
}
|
this.imagePath
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.libraryId
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
static updateFromOld(oldAuthor) {
|
static async getOldAuthors() {
|
||||||
const author = this.getFromOld(oldAuthor)
|
const authors = await this.findAll()
|
||||||
return this.update(author, {
|
return authors.map(au => au.getOldAuthor())
|
||||||
where: {
|
}
|
||||||
id: author.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
static createFromOld(oldAuthor) {
|
getOldAuthor() {
|
||||||
const author = this.getFromOld(oldAuthor)
|
return new oldAuthor({
|
||||||
return this.create(author)
|
id: this.id,
|
||||||
}
|
asin: this.asin,
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
imagePath: this.imagePath,
|
||||||
|
libraryId: this.libraryId,
|
||||||
|
addedAt: this.createdAt.valueOf(),
|
||||||
|
updatedAt: this.updatedAt.valueOf()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
static createBulkFromOld(oldAuthors) {
|
static updateFromOld(oldAuthor) {
|
||||||
const authors = oldAuthors.map(this.getFromOld)
|
const author = this.getFromOld(oldAuthor)
|
||||||
return this.bulkCreate(authors)
|
return this.update(author, {
|
||||||
}
|
where: {
|
||||||
|
id: author.id
|
||||||
static getFromOld(oldAuthor) {
|
|
||||||
return {
|
|
||||||
id: oldAuthor.id,
|
|
||||||
name: oldAuthor.name,
|
|
||||||
asin: oldAuthor.asin,
|
|
||||||
description: oldAuthor.description,
|
|
||||||
imagePath: oldAuthor.imagePath,
|
|
||||||
libraryId: oldAuthor.libraryId
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
static removeById(authorId) {
|
static createFromOld(oldAuthor) {
|
||||||
return this.destroy({
|
const author = this.getFromOld(oldAuthor)
|
||||||
where: {
|
return this.create(author)
|
||||||
id: authorId
|
}
|
||||||
}
|
|
||||||
})
|
static createBulkFromOld(oldAuthors) {
|
||||||
|
const authors = oldAuthors.map(this.getFromOld)
|
||||||
|
return this.bulkCreate(authors)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFromOld(oldAuthor) {
|
||||||
|
return {
|
||||||
|
id: oldAuthor.id,
|
||||||
|
name: oldAuthor.name,
|
||||||
|
lastFirst: oldAuthor.lastFirst,
|
||||||
|
asin: oldAuthor.asin,
|
||||||
|
description: oldAuthor.description,
|
||||||
|
imagePath: oldAuthor.imagePath,
|
||||||
|
libraryId: oldAuthor.libraryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Author.init({
|
static removeById(authorId) {
|
||||||
id: {
|
return this.destroy({
|
||||||
type: DataTypes.UUID,
|
where: {
|
||||||
defaultValue: DataTypes.UUIDV4,
|
id: authorId
|
||||||
primaryKey: true
|
}
|
||||||
},
|
})
|
||||||
name: DataTypes.STRING,
|
}
|
||||||
asin: DataTypes.STRING,
|
|
||||||
description: DataTypes.TEXT,
|
|
||||||
imagePath: DataTypes.STRING
|
|
||||||
}, {
|
|
||||||
sequelize,
|
|
||||||
modelName: 'author'
|
|
||||||
})
|
|
||||||
|
|
||||||
const { library } = sequelize.models
|
/**
|
||||||
library.hasMany(Author, {
|
* Get oldAuthor by id
|
||||||
onDelete: 'CASCADE'
|
* @param {string} authorId
|
||||||
})
|
* @returns {Promise<oldAuthor>}
|
||||||
Author.belongsTo(library)
|
*/
|
||||||
|
static async getOldById(authorId) {
|
||||||
|
const author = await this.findByPk(authorId)
|
||||||
|
if (!author) return null
|
||||||
|
return author.getOldAuthor()
|
||||||
|
}
|
||||||
|
|
||||||
return Author
|
/**
|
||||||
}
|
* Check if author exists
|
||||||
|
* @param {string} authorId
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
static async checkExistsById(authorId) {
|
||||||
|
return (await this.count({ where: { id: authorId } })) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get old author by name and libraryId. name case insensitive
|
||||||
|
* TODO: Look for authors ignoring punctuation
|
||||||
|
*
|
||||||
|
* @param {string} authorName
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @returns {Promise<oldAuthor>}
|
||||||
|
*/
|
||||||
|
static async getOldByNameAndLibrary(authorName, libraryId) {
|
||||||
|
const author = (await this.findOne({
|
||||||
|
where: [
|
||||||
|
literal(`name = ':authorName' COLLATE NOCASE`),
|
||||||
|
{
|
||||||
|
libraryId
|
||||||
|
}
|
||||||
|
],
|
||||||
|
replacements: {
|
||||||
|
authorName
|
||||||
|
}
|
||||||
|
}))?.getOldAuthor()
|
||||||
|
return author
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize model
|
||||||
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
*/
|
||||||
|
static init(sequelize) {
|
||||||
|
super.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: DataTypes.STRING,
|
||||||
|
lastFirst: DataTypes.STRING,
|
||||||
|
asin: DataTypes.STRING,
|
||||||
|
description: DataTypes.TEXT,
|
||||||
|
imagePath: DataTypes.STRING
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'author',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: [{
|
||||||
|
name: 'name',
|
||||||
|
collate: 'NOCASE'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// fields: [{
|
||||||
|
// name: 'lastFirst',
|
||||||
|
// collate: 'NOCASE'
|
||||||
|
// }]
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
fields: ['libraryId']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const { library } = sequelize.models
|
||||||
|
library.hasMany(Author, {
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
Author.belongsTo(library)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Author
|
||||||
|
|||||||
+250
-98
@@ -1,121 +1,273 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
module.exports = (sequelize) => {
|
/**
|
||||||
class Book extends Model {
|
* @typedef EBookFileObject
|
||||||
static getOldBook(libraryItemExpanded) {
|
* @property {string} ino
|
||||||
const bookExpanded = libraryItemExpanded.media
|
* @property {string} ebookFormat
|
||||||
const authors = bookExpanded.authors.map(au => {
|
* @property {number} addedAt
|
||||||
|
* @property {number} updatedAt
|
||||||
|
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef ChapterObject
|
||||||
|
* @property {number} id
|
||||||
|
* @property {number} start
|
||||||
|
* @property {number} end
|
||||||
|
* @property {string} title
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef AudioFileObject
|
||||||
|
* @property {number} index
|
||||||
|
* @property {string} ino
|
||||||
|
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
|
||||||
|
* @property {number} addedAt
|
||||||
|
* @property {number} updatedAt
|
||||||
|
* @property {number} trackNumFromMeta
|
||||||
|
* @property {number} discNumFromMeta
|
||||||
|
* @property {number} trackNumFromFilename
|
||||||
|
* @property {number} discNumFromFilename
|
||||||
|
* @property {boolean} manuallyVerified
|
||||||
|
* @property {string} format
|
||||||
|
* @property {number} duration
|
||||||
|
* @property {number} bitRate
|
||||||
|
* @property {string} language
|
||||||
|
* @property {string} codec
|
||||||
|
* @property {string} timeBase
|
||||||
|
* @property {number} channels
|
||||||
|
* @property {string} channelLayout
|
||||||
|
* @property {ChapterObject[]} chapters
|
||||||
|
* @property {Object} metaTags
|
||||||
|
* @property {string} mimeType
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Book extends Model {
|
||||||
|
constructor(values, options) {
|
||||||
|
super(values, options)
|
||||||
|
|
||||||
|
/** @type {string} */
|
||||||
|
this.id
|
||||||
|
/** @type {string} */
|
||||||
|
this.title
|
||||||
|
/** @type {string} */
|
||||||
|
this.titleIgnorePrefix
|
||||||
|
/** @type {string} */
|
||||||
|
this.publishedYear
|
||||||
|
/** @type {string} */
|
||||||
|
this.publishedDate
|
||||||
|
/** @type {string} */
|
||||||
|
this.publisher
|
||||||
|
/** @type {string} */
|
||||||
|
this.description
|
||||||
|
/** @type {string} */
|
||||||
|
this.isbn
|
||||||
|
/** @type {string} */
|
||||||
|
this.asin
|
||||||
|
/** @type {string} */
|
||||||
|
this.language
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.explicit
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.abridged
|
||||||
|
/** @type {string} */
|
||||||
|
this.coverPath
|
||||||
|
/** @type {number} */
|
||||||
|
this.duration
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.narrators
|
||||||
|
/** @type {AudioFileObject[]} */
|
||||||
|
this.audioFiles
|
||||||
|
/** @type {EBookFileObject} */
|
||||||
|
this.ebookFile
|
||||||
|
/** @type {ChapterObject[]} */
|
||||||
|
this.chapters
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.tags
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.genres
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
static getOldBook(libraryItemExpanded) {
|
||||||
|
const bookExpanded = libraryItemExpanded.media
|
||||||
|
let authors = []
|
||||||
|
if (bookExpanded.authors?.length) {
|
||||||
|
authors = bookExpanded.authors.map(au => {
|
||||||
return {
|
return {
|
||||||
id: au.id,
|
id: au.id,
|
||||||
name: au.name
|
name: au.name
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const series = bookExpanded.series.map(se => {
|
} else if (bookExpanded.bookAuthors?.length) {
|
||||||
|
authors = bookExpanded.bookAuthors.map(ba => {
|
||||||
|
if (ba.author) {
|
||||||
|
return {
|
||||||
|
id: ba.author.id,
|
||||||
|
name: ba.author.name
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}).filter(a => a)
|
||||||
|
}
|
||||||
|
|
||||||
|
let series = []
|
||||||
|
if (bookExpanded.series?.length) {
|
||||||
|
series = bookExpanded.series.map(se => {
|
||||||
return {
|
return {
|
||||||
id: se.id,
|
id: se.id,
|
||||||
name: se.name,
|
name: se.name,
|
||||||
sequence: se.bookSeries.sequence
|
sequence: se.bookSeries.sequence
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return {
|
} else if (bookExpanded.bookSeries?.length) {
|
||||||
id: bookExpanded.id,
|
series = bookExpanded.bookSeries.map(bs => {
|
||||||
libraryItemId: libraryItemExpanded.id,
|
if (bs.series) {
|
||||||
coverPath: bookExpanded.coverPath,
|
return {
|
||||||
tags: bookExpanded.tags,
|
id: bs.series.id,
|
||||||
audioFiles: bookExpanded.audioFiles,
|
name: bs.series.name,
|
||||||
chapters: bookExpanded.chapters,
|
sequence: bs.sequence
|
||||||
ebookFile: bookExpanded.ebookFile,
|
}
|
||||||
metadata: {
|
} else {
|
||||||
title: bookExpanded.title,
|
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
|
||||||
subtitle: bookExpanded.subtitle,
|
return null
|
||||||
authors: authors,
|
|
||||||
narrators: bookExpanded.narrators,
|
|
||||||
series: series,
|
|
||||||
genres: bookExpanded.genres,
|
|
||||||
publishedYear: bookExpanded.publishedYear,
|
|
||||||
publishedDate: bookExpanded.publishedDate,
|
|
||||||
publisher: bookExpanded.publisher,
|
|
||||||
description: bookExpanded.description,
|
|
||||||
isbn: bookExpanded.isbn,
|
|
||||||
asin: bookExpanded.asin,
|
|
||||||
language: bookExpanded.language,
|
|
||||||
explicit: bookExpanded.explicit,
|
|
||||||
abridged: bookExpanded.abridged
|
|
||||||
}
|
}
|
||||||
}
|
}).filter(s => s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return {
|
||||||
* @param {object} oldBook
|
id: bookExpanded.id,
|
||||||
* @returns {boolean} true if updated
|
libraryItemId: libraryItemExpanded.id,
|
||||||
*/
|
coverPath: bookExpanded.coverPath,
|
||||||
static saveFromOld(oldBook) {
|
tags: bookExpanded.tags,
|
||||||
const book = this.getFromOld(oldBook)
|
audioFiles: bookExpanded.audioFiles,
|
||||||
return this.update(book, {
|
chapters: bookExpanded.chapters,
|
||||||
where: {
|
ebookFile: bookExpanded.ebookFile,
|
||||||
id: book.id
|
metadata: {
|
||||||
}
|
title: bookExpanded.title,
|
||||||
}).then(result => result[0] > 0).catch((error) => {
|
subtitle: bookExpanded.subtitle,
|
||||||
Logger.error(`[Book] Failed to save book ${book.id}`, error)
|
authors: authors,
|
||||||
return false
|
narrators: bookExpanded.narrators,
|
||||||
})
|
series: series,
|
||||||
}
|
genres: bookExpanded.genres,
|
||||||
|
publishedYear: bookExpanded.publishedYear,
|
||||||
static getFromOld(oldBook) {
|
publishedDate: bookExpanded.publishedDate,
|
||||||
return {
|
publisher: bookExpanded.publisher,
|
||||||
id: oldBook.id,
|
description: bookExpanded.description,
|
||||||
title: oldBook.metadata.title,
|
isbn: bookExpanded.isbn,
|
||||||
subtitle: oldBook.metadata.subtitle,
|
asin: bookExpanded.asin,
|
||||||
publishedYear: oldBook.metadata.publishedYear,
|
language: bookExpanded.language,
|
||||||
publishedDate: oldBook.metadata.publishedDate,
|
explicit: bookExpanded.explicit,
|
||||||
publisher: oldBook.metadata.publisher,
|
abridged: bookExpanded.abridged
|
||||||
description: oldBook.metadata.description,
|
|
||||||
isbn: oldBook.metadata.isbn,
|
|
||||||
asin: oldBook.metadata.asin,
|
|
||||||
language: oldBook.metadata.language,
|
|
||||||
explicit: !!oldBook.metadata.explicit,
|
|
||||||
abridged: !!oldBook.metadata.abridged,
|
|
||||||
narrators: oldBook.metadata.narrators,
|
|
||||||
ebookFile: oldBook.ebookFile?.toJSON() || null,
|
|
||||||
coverPath: oldBook.coverPath,
|
|
||||||
audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [],
|
|
||||||
chapters: oldBook.chapters,
|
|
||||||
tags: oldBook.tags,
|
|
||||||
genres: oldBook.metadata.genres
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Book.init({
|
/**
|
||||||
id: {
|
* @param {object} oldBook
|
||||||
type: DataTypes.UUID,
|
* @returns {boolean} true if updated
|
||||||
defaultValue: DataTypes.UUIDV4,
|
*/
|
||||||
primaryKey: true
|
static saveFromOld(oldBook) {
|
||||||
},
|
const book = this.getFromOld(oldBook)
|
||||||
title: DataTypes.STRING,
|
return this.update(book, {
|
||||||
subtitle: DataTypes.STRING,
|
where: {
|
||||||
publishedYear: DataTypes.STRING,
|
id: book.id
|
||||||
publishedDate: DataTypes.STRING,
|
}
|
||||||
publisher: DataTypes.STRING,
|
}).then(result => result[0] > 0).catch((error) => {
|
||||||
description: DataTypes.TEXT,
|
Logger.error(`[Book] Failed to save book ${book.id}`, error)
|
||||||
isbn: DataTypes.STRING,
|
return false
|
||||||
asin: DataTypes.STRING,
|
})
|
||||||
language: DataTypes.STRING,
|
}
|
||||||
explicit: DataTypes.BOOLEAN,
|
|
||||||
abridged: DataTypes.BOOLEAN,
|
|
||||||
coverPath: DataTypes.STRING,
|
|
||||||
|
|
||||||
narrators: DataTypes.JSON,
|
static getFromOld(oldBook) {
|
||||||
audioFiles: DataTypes.JSON,
|
return {
|
||||||
ebookFile: DataTypes.JSON,
|
id: oldBook.id,
|
||||||
chapters: DataTypes.JSON,
|
title: oldBook.metadata.title,
|
||||||
tags: DataTypes.JSON,
|
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
|
||||||
genres: DataTypes.JSON
|
subtitle: oldBook.metadata.subtitle,
|
||||||
}, {
|
publishedYear: oldBook.metadata.publishedYear,
|
||||||
sequelize,
|
publishedDate: oldBook.metadata.publishedDate,
|
||||||
modelName: 'book'
|
publisher: oldBook.metadata.publisher,
|
||||||
})
|
description: oldBook.metadata.description,
|
||||||
|
isbn: oldBook.metadata.isbn,
|
||||||
|
asin: oldBook.metadata.asin,
|
||||||
|
language: oldBook.metadata.language,
|
||||||
|
explicit: !!oldBook.metadata.explicit,
|
||||||
|
abridged: !!oldBook.metadata.abridged,
|
||||||
|
narrators: oldBook.metadata.narrators,
|
||||||
|
ebookFile: oldBook.ebookFile?.toJSON() || null,
|
||||||
|
coverPath: oldBook.coverPath,
|
||||||
|
duration: oldBook.duration,
|
||||||
|
audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [],
|
||||||
|
chapters: oldBook.chapters,
|
||||||
|
tags: oldBook.tags,
|
||||||
|
genres: oldBook.metadata.genres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Book
|
/**
|
||||||
}
|
* Initialize model
|
||||||
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
*/
|
||||||
|
static init(sequelize) {
|
||||||
|
super.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
title: DataTypes.STRING,
|
||||||
|
titleIgnorePrefix: DataTypes.STRING,
|
||||||
|
subtitle: DataTypes.STRING,
|
||||||
|
publishedYear: DataTypes.STRING,
|
||||||
|
publishedDate: DataTypes.STRING,
|
||||||
|
publisher: DataTypes.STRING,
|
||||||
|
description: DataTypes.TEXT,
|
||||||
|
isbn: DataTypes.STRING,
|
||||||
|
asin: DataTypes.STRING,
|
||||||
|
language: DataTypes.STRING,
|
||||||
|
explicit: DataTypes.BOOLEAN,
|
||||||
|
abridged: DataTypes.BOOLEAN,
|
||||||
|
coverPath: DataTypes.STRING,
|
||||||
|
duration: DataTypes.FLOAT,
|
||||||
|
|
||||||
|
narrators: DataTypes.JSON,
|
||||||
|
audioFiles: DataTypes.JSON,
|
||||||
|
ebookFile: DataTypes.JSON,
|
||||||
|
chapters: DataTypes.JSON,
|
||||||
|
tags: DataTypes.JSON,
|
||||||
|
genres: DataTypes.JSON
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'book',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: [{
|
||||||
|
name: 'title',
|
||||||
|
collate: 'NOCASE'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// fields: [{
|
||||||
|
// name: 'titleIgnorePrefix',
|
||||||
|
// collate: 'NOCASE'
|
||||||
|
// }]
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
fields: ['publishedYear']
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// fields: ['duration']
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Book
|
||||||
+49
-32
@@ -1,40 +1,57 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
|
|
||||||
module.exports = (sequelize) => {
|
class BookAuthor extends Model {
|
||||||
class BookAuthor extends Model {
|
constructor(values, options) {
|
||||||
static removeByIds(authorId = null, bookId = null) {
|
super(values, options)
|
||||||
const where = {}
|
|
||||||
if (authorId) where.authorId = authorId
|
/** @type {UUIDV4} */
|
||||||
if (bookId) where.bookId = bookId
|
this.id
|
||||||
return this.destroy({
|
/** @type {UUIDV4} */
|
||||||
where
|
this.bookId
|
||||||
})
|
/** @type {UUIDV4} */
|
||||||
}
|
this.authorId
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
BookAuthor.init({
|
static removeByIds(authorId = null, bookId = null) {
|
||||||
id: {
|
const where = {}
|
||||||
type: DataTypes.UUID,
|
if (authorId) where.authorId = authorId
|
||||||
defaultValue: DataTypes.UUIDV4,
|
if (bookId) where.bookId = bookId
|
||||||
primaryKey: true
|
return this.destroy({
|
||||||
}
|
where
|
||||||
}, {
|
})
|
||||||
sequelize,
|
}
|
||||||
modelName: 'bookAuthor',
|
|
||||||
timestamps: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Super Many-to-Many
|
/**
|
||||||
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
* Initialize model
|
||||||
const { book, author } = sequelize.models
|
* @param {import('../Database').sequelize} sequelize
|
||||||
book.belongsToMany(author, { through: BookAuthor })
|
*/
|
||||||
author.belongsToMany(book, { through: BookAuthor })
|
static init(sequelize) {
|
||||||
|
super.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'bookAuthor',
|
||||||
|
timestamps: true,
|
||||||
|
updatedAt: false
|
||||||
|
})
|
||||||
|
|
||||||
book.hasMany(BookAuthor)
|
// Super Many-to-Many
|
||||||
BookAuthor.belongsTo(book)
|
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
||||||
|
const { book, author } = sequelize.models
|
||||||
|
book.belongsToMany(author, { through: BookAuthor })
|
||||||
|
author.belongsToMany(book, { through: BookAuthor })
|
||||||
|
|
||||||
author.hasMany(BookAuthor)
|
book.hasMany(BookAuthor)
|
||||||
BookAuthor.belongsTo(author)
|
BookAuthor.belongsTo(book)
|
||||||
|
|
||||||
return BookAuthor
|
author.hasMany(BookAuthor)
|
||||||
}
|
BookAuthor.belongsTo(author)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = BookAuthor
|
||||||
+57
-33
@@ -1,41 +1,65 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
|
|
||||||
module.exports = (sequelize) => {
|
class BookSeries extends Model {
|
||||||
class BookSeries extends Model {
|
constructor(values, options) {
|
||||||
static removeByIds(seriesId = null, bookId = null) {
|
super(values, options)
|
||||||
const where = {}
|
|
||||||
if (seriesId) where.seriesId = seriesId
|
/** @type {UUIDV4} */
|
||||||
if (bookId) where.bookId = bookId
|
this.id
|
||||||
return this.destroy({
|
/** @type {string} */
|
||||||
where
|
this.sequence
|
||||||
})
|
/** @type {UUIDV4} */
|
||||||
}
|
this.bookId
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.seriesId
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
BookSeries.init({
|
static removeByIds(seriesId = null, bookId = null) {
|
||||||
id: {
|
const where = {}
|
||||||
type: DataTypes.UUID,
|
if (seriesId) where.seriesId = seriesId
|
||||||
defaultValue: DataTypes.UUIDV4,
|
if (bookId) where.bookId = bookId
|
||||||
primaryKey: true
|
return this.destroy({
|
||||||
},
|
where
|
||||||
sequence: DataTypes.STRING
|
})
|
||||||
}, {
|
}
|
||||||
sequelize,
|
|
||||||
modelName: 'bookSeries',
|
|
||||||
timestamps: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Super Many-to-Many
|
/**
|
||||||
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
* Initialize model
|
||||||
const { book, series } = sequelize.models
|
* @param {import('../Database').sequelize} sequelize
|
||||||
book.belongsToMany(series, { through: BookSeries })
|
*/
|
||||||
series.belongsToMany(book, { through: BookSeries })
|
static init(sequelize) {
|
||||||
|
super.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
sequence: DataTypes.STRING
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'bookSeries',
|
||||||
|
timestamps: true,
|
||||||
|
updatedAt: false
|
||||||
|
})
|
||||||
|
|
||||||
book.hasMany(BookSeries)
|
// Super Many-to-Many
|
||||||
BookSeries.belongsTo(book)
|
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
||||||
|
const { book, series } = sequelize.models
|
||||||
|
book.belongsToMany(series, { through: BookSeries })
|
||||||
|
series.belongsToMany(book, { through: BookSeries })
|
||||||
|
|
||||||
series.hasMany(BookSeries)
|
book.hasMany(BookSeries, {
|
||||||
BookSeries.belongsTo(series)
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
BookSeries.belongsTo(book)
|
||||||
|
|
||||||
return BookSeries
|
series.hasMany(BookSeries, {
|
||||||
}
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
BookSeries.belongsTo(series)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BookSeries
|
||||||
+318
-92
@@ -1,116 +1,342 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model, Sequelize } = require('sequelize')
|
||||||
|
|
||||||
const oldCollection = require('../objects/Collection')
|
const oldCollection = require('../objects/Collection')
|
||||||
const { areEquivalent } = require('../utils/index')
|
|
||||||
|
|
||||||
module.exports = (sequelize) => {
|
|
||||||
class Collection extends Model {
|
class Collection extends Model {
|
||||||
static async getOldCollections() {
|
constructor(values, options) {
|
||||||
const collections = await this.findAll({
|
super(values, options)
|
||||||
include: {
|
|
||||||
model: sequelize.models.book,
|
/** @type {UUIDV4} */
|
||||||
include: sequelize.models.libraryItem
|
this.id
|
||||||
|
/** @type {string} */
|
||||||
|
this.name
|
||||||
|
/** @type {string} */
|
||||||
|
this.description
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.libraryId
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get all old collections
|
||||||
|
* @returns {Promise<oldCollection[]>}
|
||||||
|
*/
|
||||||
|
static async getOldCollections() {
|
||||||
|
const collections = await this.findAll({
|
||||||
|
include: {
|
||||||
|
model: this.sequelize.models.book,
|
||||||
|
include: this.sequelize.models.libraryItem
|
||||||
|
},
|
||||||
|
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||||
|
})
|
||||||
|
return collections.map(c => this.getOldCollection(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all old collections toJSONExpanded, items filtered for user permissions
|
||||||
|
* @param {[oldUser]} user
|
||||||
|
* @param {[string]} libraryId
|
||||||
|
* @param {[string[]]} include
|
||||||
|
* @returns {Promise<object[]>} oldCollection.toJSONExpanded
|
||||||
|
*/
|
||||||
|
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
|
||||||
|
let collectionWhere = null
|
||||||
|
if (libraryId) {
|
||||||
|
collectionWhere = {
|
||||||
|
libraryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally include rssfeed for collection
|
||||||
|
const collectionIncludes = []
|
||||||
|
if (include.includes('rssfeed')) {
|
||||||
|
collectionIncludes.push({
|
||||||
|
model: this.sequelize.models.feed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const collections = await this.findAll({
|
||||||
|
where: collectionWhere,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.book,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.libraryItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.author,
|
||||||
|
through: {
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.series,
|
||||||
|
through: {
|
||||||
|
attributes: ['sequence']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
]
|
||||||
},
|
},
|
||||||
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
|
...collectionIncludes
|
||||||
|
],
|
||||||
|
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||||
|
})
|
||||||
|
// TODO: Handle user permission restrictions on initial query
|
||||||
|
return collections.map(c => {
|
||||||
|
const oldCollection = this.getOldCollection(c)
|
||||||
|
|
||||||
|
// Filter books using user permissions
|
||||||
|
const books = c.books?.filter(b => {
|
||||||
|
if (user) {
|
||||||
|
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (b.explicit === true && !user.canAccessExplicitContent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
// Map to library items
|
||||||
|
const libraryItems = books.map(b => {
|
||||||
|
const libraryItem = b.libraryItem
|
||||||
|
delete b.libraryItem
|
||||||
|
libraryItem.media = b
|
||||||
|
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
|
||||||
})
|
})
|
||||||
return collections.map(c => this.getOldCollection(c))
|
|
||||||
}
|
|
||||||
|
|
||||||
static getOldCollection(collectionExpanded) {
|
// Users with restricted permissions will not see this collection
|
||||||
const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || []
|
if (!books.length && oldCollection.books.length) {
|
||||||
return new oldCollection({
|
return null
|
||||||
id: collectionExpanded.id,
|
}
|
||||||
libraryId: collectionExpanded.libraryId,
|
|
||||||
name: collectionExpanded.name,
|
|
||||||
description: collectionExpanded.description,
|
|
||||||
books: libraryItemIds,
|
|
||||||
lastUpdate: collectionExpanded.updatedAt.valueOf(),
|
|
||||||
createdAt: collectionExpanded.createdAt.valueOf()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
static createFromOld(oldCollection) {
|
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
|
||||||
const collection = this.getFromOld(oldCollection)
|
|
||||||
return this.create(collection)
|
|
||||||
}
|
|
||||||
|
|
||||||
static async fullUpdateFromOld(oldCollection, collectionBooks) {
|
// Map feed if found
|
||||||
const existingCollection = await this.findByPk(oldCollection.id, {
|
if (c.feeds?.length) {
|
||||||
include: sequelize.models.collectionBook
|
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
|
||||||
})
|
}
|
||||||
if (!existingCollection) return false
|
|
||||||
|
|
||||||
let hasUpdates = false
|
return collectionExpanded
|
||||||
const collection = this.getFromOld(oldCollection)
|
}).filter(c => c)
|
||||||
|
}
|
||||||
|
|
||||||
for (const cb of collectionBooks) {
|
/**
|
||||||
const existingCb = existingCollection.collectionBooks.find(i => i.bookId === cb.bookId)
|
* Get old collection toJSONExpanded, items filtered for user permissions
|
||||||
if (!existingCb) {
|
* @param {[oldUser]} user
|
||||||
await sequelize.models.collectionBook.create(cb)
|
* @param {[string[]]} include
|
||||||
hasUpdates = true
|
* @returns {Promise<object>} oldCollection.toJSONExpanded
|
||||||
} else if (existingCb.order != cb.order) {
|
*/
|
||||||
await existingCb.update({ order: cb.order })
|
async getOldJsonExpanded(user, include) {
|
||||||
hasUpdates = true
|
this.books = await this.getBooks({
|
||||||
}
|
include: [
|
||||||
}
|
{
|
||||||
for (const cb of existingCollection.collectionBooks) {
|
model: this.sequelize.models.libraryItem
|
||||||
// collectionBook was removed
|
},
|
||||||
if (!collectionBooks.some(i => i.bookId === cb.bookId)) {
|
{
|
||||||
await cb.destroy()
|
model: this.sequelize.models.author,
|
||||||
hasUpdates = true
|
through: {
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.series,
|
||||||
|
through: {
|
||||||
|
attributes: ['sequence']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
order: [Sequelize.literal('`collectionBook.order` ASC')]
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
const oldCollection = this.sequelize.models.collection.getOldCollection(this)
|
||||||
|
|
||||||
|
// Filter books using user permissions
|
||||||
|
// TODO: Handle user permission restrictions on initial query
|
||||||
|
const books = this.books?.filter(b => {
|
||||||
|
if (user) {
|
||||||
|
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (b.explicit === true && !user.canAccessExplicitContent) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}) || []
|
||||||
|
|
||||||
let hasCollectionUpdates = false
|
// Map to library items
|
||||||
for (const key in collection) {
|
const libraryItems = books.map(b => {
|
||||||
let existingValue = existingCollection[key]
|
const libraryItem = b.libraryItem
|
||||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
delete b.libraryItem
|
||||||
if (!areEquivalent(collection[key], existingValue)) {
|
libraryItem.media = b
|
||||||
hasCollectionUpdates = true
|
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
|
||||||
}
|
})
|
||||||
}
|
|
||||||
if (hasCollectionUpdates) {
|
// Users with restricted permissions will not see this collection
|
||||||
existingCollection.update(collection)
|
if (!books.length && oldCollection.books.length) {
|
||||||
hasUpdates = true
|
return null
|
||||||
}
|
|
||||||
return hasUpdates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFromOld(oldCollection) {
|
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
|
||||||
return {
|
|
||||||
id: oldCollection.id,
|
if (include?.includes('rssfeed')) {
|
||||||
name: oldCollection.name,
|
const feeds = await this.getFeeds()
|
||||||
description: oldCollection.description,
|
if (feeds?.length) {
|
||||||
libraryId: oldCollection.libraryId
|
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeById(collectionId) {
|
return collectionExpanded
|
||||||
return this.destroy({
|
}
|
||||||
where: {
|
|
||||||
id: collectionId
|
/**
|
||||||
}
|
* Get old collection from Collection
|
||||||
})
|
* @param {Collection} collectionExpanded
|
||||||
|
* @returns {oldCollection}
|
||||||
|
*/
|
||||||
|
static getOldCollection(collectionExpanded) {
|
||||||
|
const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || []
|
||||||
|
return new oldCollection({
|
||||||
|
id: collectionExpanded.id,
|
||||||
|
libraryId: collectionExpanded.libraryId,
|
||||||
|
name: collectionExpanded.name,
|
||||||
|
description: collectionExpanded.description,
|
||||||
|
books: libraryItemIds,
|
||||||
|
lastUpdate: collectionExpanded.updatedAt.valueOf(),
|
||||||
|
createdAt: collectionExpanded.createdAt.valueOf()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static createFromOld(oldCollection) {
|
||||||
|
const collection = this.getFromOld(oldCollection)
|
||||||
|
return this.create(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFromOld(oldCollection) {
|
||||||
|
return {
|
||||||
|
id: oldCollection.id,
|
||||||
|
name: oldCollection.name,
|
||||||
|
description: oldCollection.description,
|
||||||
|
libraryId: oldCollection.libraryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Collection.init({
|
static removeById(collectionId) {
|
||||||
id: {
|
return this.destroy({
|
||||||
type: DataTypes.UUID,
|
where: {
|
||||||
defaultValue: DataTypes.UUIDV4,
|
id: collectionId
|
||||||
primaryKey: true
|
}
|
||||||
},
|
})
|
||||||
name: DataTypes.STRING,
|
}
|
||||||
description: DataTypes.TEXT
|
|
||||||
}, {
|
|
||||||
sequelize,
|
|
||||||
modelName: 'collection'
|
|
||||||
})
|
|
||||||
|
|
||||||
const { library } = sequelize.models
|
/**
|
||||||
|
* Get old collection by id
|
||||||
|
* @param {string} collectionId
|
||||||
|
* @returns {Promise<oldCollection|null>} returns null if not found
|
||||||
|
*/
|
||||||
|
static async getOldById(collectionId) {
|
||||||
|
if (!collectionId) return null
|
||||||
|
const collection = await this.findByPk(collectionId, {
|
||||||
|
include: {
|
||||||
|
model: this.sequelize.models.book,
|
||||||
|
include: this.sequelize.models.libraryItem
|
||||||
|
},
|
||||||
|
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||||
|
})
|
||||||
|
if (!collection) return null
|
||||||
|
return this.getOldCollection(collection)
|
||||||
|
}
|
||||||
|
|
||||||
library.hasMany(Collection)
|
/**
|
||||||
Collection.belongsTo(library)
|
* Get old collection from current
|
||||||
|
* @returns {Promise<oldCollection>}
|
||||||
|
*/
|
||||||
|
async getOld() {
|
||||||
|
this.books = await this.getBooks({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.libraryItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.author,
|
||||||
|
through: {
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.series,
|
||||||
|
through: {
|
||||||
|
attributes: ['sequence']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
return Collection
|
],
|
||||||
}
|
order: [Sequelize.literal('`collectionBook.order` ASC')]
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
return this.sequelize.models.collection.getOldCollection(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all collections belonging to library
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @returns {Promise<number>} number of collections destroyed
|
||||||
|
*/
|
||||||
|
static async removeAllForLibrary(libraryId) {
|
||||||
|
if (!libraryId) return 0
|
||||||
|
return this.destroy({
|
||||||
|
where: {
|
||||||
|
libraryId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getAllForBook(bookId) {
|
||||||
|
const collections = await this.findAll({
|
||||||
|
include: {
|
||||||
|
model: this.sequelize.models.book,
|
||||||
|
where: {
|
||||||
|
id: bookId
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
include: this.sequelize.models.libraryItem
|
||||||
|
},
|
||||||
|
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||||
|
})
|
||||||
|
return collections.map(c => this.getOldCollection(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize model
|
||||||
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
*/
|
||||||
|
static init(sequelize) {
|
||||||
|
super.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: DataTypes.STRING,
|
||||||
|
description: DataTypes.TEXT
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'collection'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { library } = sequelize.models
|
||||||
|
|
||||||
|
library.hasMany(Collection)
|
||||||
|
Collection.belongsTo(library)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Collection
|
||||||
@@ -1,46 +1,61 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
|
|
||||||
module.exports = (sequelize) => {
|
class CollectionBook extends Model {
|
||||||
class CollectionBook extends Model {
|
constructor(values, options) {
|
||||||
static removeByIds(collectionId, bookId) {
|
super(values, options)
|
||||||
return this.destroy({
|
|
||||||
where: {
|
/** @type {UUIDV4} */
|
||||||
bookId,
|
this.id
|
||||||
collectionId
|
/** @type {number} */
|
||||||
}
|
this.order
|
||||||
})
|
/** @type {UUIDV4} */
|
||||||
}
|
this.bookId
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.collectionId
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionBook.init({
|
static removeByIds(collectionId, bookId) {
|
||||||
id: {
|
return this.destroy({
|
||||||
type: DataTypes.UUID,
|
where: {
|
||||||
defaultValue: DataTypes.UUIDV4,
|
bookId,
|
||||||
primaryKey: true
|
collectionId
|
||||||
},
|
}
|
||||||
order: DataTypes.INTEGER
|
})
|
||||||
}, {
|
}
|
||||||
sequelize,
|
|
||||||
timestamps: true,
|
|
||||||
updatedAt: false,
|
|
||||||
modelName: 'collectionBook'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Super Many-to-Many
|
static init(sequelize) {
|
||||||
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
super.init({
|
||||||
const { book, collection } = sequelize.models
|
id: {
|
||||||
book.belongsToMany(collection, { through: CollectionBook })
|
type: DataTypes.UUID,
|
||||||
collection.belongsToMany(book, { through: CollectionBook })
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
order: DataTypes.INTEGER
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
timestamps: true,
|
||||||
|
updatedAt: false,
|
||||||
|
modelName: 'collectionBook'
|
||||||
|
})
|
||||||
|
|
||||||
book.hasMany(CollectionBook, {
|
// Super Many-to-Many
|
||||||
onDelete: 'CASCADE'
|
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
||||||
})
|
const { book, collection } = sequelize.models
|
||||||
CollectionBook.belongsTo(book)
|
book.belongsToMany(collection, { through: CollectionBook })
|
||||||
|
collection.belongsToMany(book, { through: CollectionBook })
|
||||||
|
|
||||||
collection.hasMany(CollectionBook, {
|
book.hasMany(CollectionBook, {
|
||||||
onDelete: 'CASCADE'
|
onDelete: 'CASCADE'
|
||||||
})
|
})
|
||||||
CollectionBook.belongsTo(collection)
|
CollectionBook.belongsTo(book)
|
||||||
|
|
||||||
return CollectionBook
|
collection.hasMany(CollectionBook, {
|
||||||
}
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
CollectionBook.belongsTo(collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CollectionBook
|
||||||
+133
-102
@@ -1,116 +1,147 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
const oldDevice = require('../objects/DeviceInfo')
|
const oldDevice = require('../objects/DeviceInfo')
|
||||||
|
|
||||||
module.exports = (sequelize) => {
|
class Device extends Model {
|
||||||
class Device extends Model {
|
constructor(values, options) {
|
||||||
getOldDevice() {
|
super(values, options)
|
||||||
let browserVersion = null
|
|
||||||
let sdkVersion = null
|
|
||||||
if (this.clientName === 'Abs Android') {
|
|
||||||
sdkVersion = this.deviceVersion || null
|
|
||||||
} else {
|
|
||||||
browserVersion = this.deviceVersion || null
|
|
||||||
}
|
|
||||||
|
|
||||||
return new oldDevice({
|
/** @type {UUIDV4} */
|
||||||
id: this.id,
|
this.id
|
||||||
deviceId: this.deviceId,
|
/** @type {string} */
|
||||||
userId: this.userId,
|
this.deviceId
|
||||||
ipAddress: this.ipAddress,
|
/** @type {string} */
|
||||||
browserName: this.extraData.browserName || null,
|
this.clientName
|
||||||
browserVersion,
|
/** @type {string} */
|
||||||
osName: this.extraData.osName || null,
|
this.clientVersion
|
||||||
osVersion: this.extraData.osVersion || null,
|
/** @type {string} */
|
||||||
clientVersion: this.clientVersion || null,
|
this.ipAddress
|
||||||
manufacturer: this.extraData.manufacturer || null,
|
/** @type {string} */
|
||||||
model: this.extraData.model || null,
|
this.deviceName
|
||||||
sdkVersion,
|
/** @type {string} */
|
||||||
deviceName: this.deviceName,
|
this.deviceVersion
|
||||||
clientName: this.clientName
|
/** @type {object} */
|
||||||
})
|
this.extraData
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.userId
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
getOldDevice() {
|
||||||
|
let browserVersion = null
|
||||||
|
let sdkVersion = null
|
||||||
|
if (this.clientName === 'Abs Android') {
|
||||||
|
sdkVersion = this.deviceVersion || null
|
||||||
|
} else {
|
||||||
|
browserVersion = this.deviceVersion || null
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getOldDeviceByDeviceId(deviceId) {
|
return new oldDevice({
|
||||||
const device = await this.findOne({
|
id: this.id,
|
||||||
where: {
|
deviceId: this.deviceId,
|
||||||
deviceId
|
userId: this.userId,
|
||||||
}
|
ipAddress: this.ipAddress,
|
||||||
})
|
browserName: this.extraData.browserName || null,
|
||||||
if (!device) return null
|
browserVersion,
|
||||||
return device.getOldDevice()
|
osName: this.extraData.osName || null,
|
||||||
|
osVersion: this.extraData.osVersion || null,
|
||||||
|
clientVersion: this.clientVersion || null,
|
||||||
|
manufacturer: this.extraData.manufacturer || null,
|
||||||
|
model: this.extraData.model || null,
|
||||||
|
sdkVersion,
|
||||||
|
deviceName: this.deviceName,
|
||||||
|
clientName: this.clientName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getOldDeviceByDeviceId(deviceId) {
|
||||||
|
const device = await this.findOne({
|
||||||
|
where: {
|
||||||
|
deviceId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!device) return null
|
||||||
|
return device.getOldDevice()
|
||||||
|
}
|
||||||
|
|
||||||
|
static createFromOld(oldDevice) {
|
||||||
|
const device = this.getFromOld(oldDevice)
|
||||||
|
return this.create(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateFromOld(oldDevice) {
|
||||||
|
const device = this.getFromOld(oldDevice)
|
||||||
|
return this.update(device, {
|
||||||
|
where: {
|
||||||
|
id: device.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFromOld(oldDeviceInfo) {
|
||||||
|
let extraData = {}
|
||||||
|
|
||||||
|
if (oldDeviceInfo.manufacturer) {
|
||||||
|
extraData.manufacturer = oldDeviceInfo.manufacturer
|
||||||
|
}
|
||||||
|
if (oldDeviceInfo.model) {
|
||||||
|
extraData.model = oldDeviceInfo.model
|
||||||
|
}
|
||||||
|
if (oldDeviceInfo.osName) {
|
||||||
|
extraData.osName = oldDeviceInfo.osName
|
||||||
|
}
|
||||||
|
if (oldDeviceInfo.osVersion) {
|
||||||
|
extraData.osVersion = oldDeviceInfo.osVersion
|
||||||
|
}
|
||||||
|
if (oldDeviceInfo.browserName) {
|
||||||
|
extraData.browserName = oldDeviceInfo.browserName
|
||||||
}
|
}
|
||||||
|
|
||||||
static createFromOld(oldDevice) {
|
return {
|
||||||
const device = this.getFromOld(oldDevice)
|
id: oldDeviceInfo.id,
|
||||||
return this.create(device)
|
deviceId: oldDeviceInfo.deviceId,
|
||||||
}
|
clientName: oldDeviceInfo.clientName || null,
|
||||||
|
clientVersion: oldDeviceInfo.clientVersion || null,
|
||||||
static updateFromOld(oldDevice) {
|
ipAddress: oldDeviceInfo.ipAddress,
|
||||||
const device = this.getFromOld(oldDevice)
|
deviceName: oldDeviceInfo.deviceName || null,
|
||||||
return this.update(device, {
|
deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null,
|
||||||
where: {
|
userId: oldDeviceInfo.userId,
|
||||||
id: device.id
|
extraData
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
static getFromOld(oldDeviceInfo) {
|
|
||||||
let extraData = {}
|
|
||||||
|
|
||||||
if (oldDeviceInfo.manufacturer) {
|
|
||||||
extraData.manufacturer = oldDeviceInfo.manufacturer
|
|
||||||
}
|
|
||||||
if (oldDeviceInfo.model) {
|
|
||||||
extraData.model = oldDeviceInfo.model
|
|
||||||
}
|
|
||||||
if (oldDeviceInfo.osName) {
|
|
||||||
extraData.osName = oldDeviceInfo.osName
|
|
||||||
}
|
|
||||||
if (oldDeviceInfo.osVersion) {
|
|
||||||
extraData.osVersion = oldDeviceInfo.osVersion
|
|
||||||
}
|
|
||||||
if (oldDeviceInfo.browserName) {
|
|
||||||
extraData.browserName = oldDeviceInfo.browserName
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: oldDeviceInfo.id,
|
|
||||||
deviceId: oldDeviceInfo.deviceId,
|
|
||||||
clientName: oldDeviceInfo.clientName || null,
|
|
||||||
clientVersion: oldDeviceInfo.clientVersion || null,
|
|
||||||
ipAddress: oldDeviceInfo.ipAddress,
|
|
||||||
deviceName: oldDeviceInfo.deviceName || null,
|
|
||||||
deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null,
|
|
||||||
userId: oldDeviceInfo.userId,
|
|
||||||
extraData
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Device.init({
|
/**
|
||||||
id: {
|
* Initialize model
|
||||||
type: DataTypes.UUID,
|
* @param {import('../Database').sequelize} sequelize
|
||||||
defaultValue: DataTypes.UUIDV4,
|
*/
|
||||||
primaryKey: true
|
static init(sequelize) {
|
||||||
},
|
super.init({
|
||||||
deviceId: DataTypes.STRING,
|
id: {
|
||||||
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
|
type: DataTypes.UUID,
|
||||||
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
|
defaultValue: DataTypes.UUIDV4,
|
||||||
ipAddress: DataTypes.STRING,
|
primaryKey: true
|
||||||
deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3
|
},
|
||||||
deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK
|
deviceId: DataTypes.STRING,
|
||||||
extraData: DataTypes.JSON
|
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
|
||||||
}, {
|
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
|
||||||
sequelize,
|
ipAddress: DataTypes.STRING,
|
||||||
modelName: 'device'
|
deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3
|
||||||
})
|
deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK
|
||||||
|
extraData: DataTypes.JSON
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'device'
|
||||||
|
})
|
||||||
|
|
||||||
const { user } = sequelize.models
|
const { user } = sequelize.models
|
||||||
|
|
||||||
user.hasMany(Device, {
|
user.hasMany(Device, {
|
||||||
onDelete: 'CASCADE'
|
onDelete: 'CASCADE'
|
||||||
})
|
})
|
||||||
Device.belongsTo(user)
|
Device.belongsTo(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Device
|
module.exports = Device
|
||||||
}
|
|
||||||
+336
-228
@@ -1,253 +1,361 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
const oldFeed = require('../objects/Feed')
|
const oldFeed = require('../objects/Feed')
|
||||||
const areEquivalent = require('../utils/areEquivalent')
|
const areEquivalent = require('../utils/areEquivalent')
|
||||||
/*
|
|
||||||
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
|
|
||||||
* Feeds can be created from LibraryItem, Collection, Playlist or Series
|
|
||||||
*/
|
|
||||||
module.exports = (sequelize) => {
|
|
||||||
class Feed extends Model {
|
|
||||||
static async getOldFeeds() {
|
|
||||||
const feeds = await this.findAll({
|
|
||||||
include: {
|
|
||||||
model: sequelize.models.feedEpisode
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return feeds.map(f => this.getOldFeed(f))
|
|
||||||
}
|
|
||||||
|
|
||||||
static getOldFeed(feedExpanded) {
|
class Feed extends Model {
|
||||||
const episodes = feedExpanded.feedEpisodes.map((feedEpisode) => feedEpisode.getOldEpisode())
|
constructor(values, options) {
|
||||||
|
super(values, options)
|
||||||
|
|
||||||
return new oldFeed({
|
/** @type {UUIDV4} */
|
||||||
id: feedExpanded.id,
|
this.id
|
||||||
slug: feedExpanded.slug,
|
/** @type {string} */
|
||||||
userId: feedExpanded.userId,
|
this.slug
|
||||||
entityType: feedExpanded.entityType,
|
/** @type {string} */
|
||||||
entityId: feedExpanded.entityId,
|
this.entityType
|
||||||
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
|
/** @type {UUIDV4} */
|
||||||
meta: {
|
this.entityId
|
||||||
title: feedExpanded.title,
|
/** @type {Date} */
|
||||||
description: feedExpanded.description,
|
this.entityUpdatedAt
|
||||||
author: feedExpanded.author,
|
/** @type {string} */
|
||||||
imageUrl: feedExpanded.imageURL,
|
this.serverAddress
|
||||||
feedUrl: feedExpanded.feedURL,
|
/** @type {string} */
|
||||||
link: feedExpanded.siteURL,
|
this.feedURL
|
||||||
explicit: feedExpanded.explicit,
|
/** @type {string} */
|
||||||
type: feedExpanded.podcastType,
|
this.imageURL
|
||||||
language: feedExpanded.language,
|
/** @type {string} */
|
||||||
preventIndexing: feedExpanded.preventIndexing,
|
this.siteURL
|
||||||
ownerName: feedExpanded.ownerName,
|
/** @type {string} */
|
||||||
ownerEmail: feedExpanded.ownerEmail
|
this.title
|
||||||
},
|
/** @type {string} */
|
||||||
serverAddress: feedExpanded.serverAddress,
|
this.description
|
||||||
|
/** @type {string} */
|
||||||
|
this.author
|
||||||
|
/** @type {string} */
|
||||||
|
this.podcastType
|
||||||
|
/** @type {string} */
|
||||||
|
this.language
|
||||||
|
/** @type {string} */
|
||||||
|
this.ownerName
|
||||||
|
/** @type {string} */
|
||||||
|
this.ownerEmail
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.explicit
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.preventIndexing
|
||||||
|
/** @type {string} */
|
||||||
|
this.coverPath
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.userId
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getOldFeeds() {
|
||||||
|
const feeds = await this.findAll({
|
||||||
|
include: {
|
||||||
|
model: this.sequelize.models.feedEpisode
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return feeds.map(f => this.getOldFeed(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get old feed from Feed and optionally Feed with FeedEpisodes
|
||||||
|
* @param {Feed} feedExpanded
|
||||||
|
* @returns {oldFeed}
|
||||||
|
*/
|
||||||
|
static getOldFeed(feedExpanded) {
|
||||||
|
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||||
|
return new oldFeed({
|
||||||
|
id: feedExpanded.id,
|
||||||
|
slug: feedExpanded.slug,
|
||||||
|
userId: feedExpanded.userId,
|
||||||
|
entityType: feedExpanded.entityType,
|
||||||
|
entityId: feedExpanded.entityId,
|
||||||
|
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
|
||||||
|
coverPath: feedExpanded.coverPath || null,
|
||||||
|
meta: {
|
||||||
|
title: feedExpanded.title,
|
||||||
|
description: feedExpanded.description,
|
||||||
|
author: feedExpanded.author,
|
||||||
|
imageUrl: feedExpanded.imageURL,
|
||||||
feedUrl: feedExpanded.feedURL,
|
feedUrl: feedExpanded.feedURL,
|
||||||
episodes,
|
link: feedExpanded.siteURL,
|
||||||
createdAt: feedExpanded.createdAt.valueOf(),
|
explicit: feedExpanded.explicit,
|
||||||
updatedAt: feedExpanded.updatedAt.valueOf()
|
type: feedExpanded.podcastType,
|
||||||
})
|
language: feedExpanded.language,
|
||||||
}
|
preventIndexing: feedExpanded.preventIndexing,
|
||||||
|
ownerName: feedExpanded.ownerName,
|
||||||
|
ownerEmail: feedExpanded.ownerEmail
|
||||||
|
},
|
||||||
|
serverAddress: feedExpanded.serverAddress,
|
||||||
|
feedUrl: feedExpanded.feedURL,
|
||||||
|
episodes: episodes || [],
|
||||||
|
createdAt: feedExpanded.createdAt.valueOf(),
|
||||||
|
updatedAt: feedExpanded.updatedAt.valueOf()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
static removeById(feedId) {
|
static removeById(feedId) {
|
||||||
return this.destroy({
|
return this.destroy({
|
||||||
where: {
|
where: {
|
||||||
id: feedId
|
id: feedId
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
static async fullCreateFromOld(oldFeed) {
|
|
||||||
const feedObj = this.getFromOld(oldFeed)
|
|
||||||
const newFeed = await this.create(feedObj)
|
|
||||||
|
|
||||||
if (oldFeed.episodes?.length) {
|
|
||||||
for (const oldFeedEpisode of oldFeed.episodes) {
|
|
||||||
const feedEpisode = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
|
||||||
feedEpisode.feedId = newFeed.id
|
|
||||||
await sequelize.models.feedEpisode.create(feedEpisode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
static async fullUpdateFromOld(oldFeed) {
|
/**
|
||||||
const oldFeedEpisodes = oldFeed.episodes || []
|
* Find all library item ids that have an open feed (used in library filter)
|
||||||
const feedObj = this.getFromOld(oldFeed)
|
* @returns {Promise<Array<String>>} array of library item ids
|
||||||
|
*/
|
||||||
const existingFeed = await this.findByPk(feedObj.id, {
|
static async findAllLibraryItemIds() {
|
||||||
include: sequelize.models.feedEpisode
|
const feeds = await this.findAll({
|
||||||
})
|
attributes: ['entityId'],
|
||||||
if (!existingFeed) return false
|
where: {
|
||||||
|
entityType: 'libraryItem'
|
||||||
let hasUpdates = false
|
|
||||||
for (const feedEpisode of existingFeed.feedEpisodes) {
|
|
||||||
const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id)
|
|
||||||
// Episode removed
|
|
||||||
if (!oldFeedEpisode) {
|
|
||||||
feedEpisode.destroy()
|
|
||||||
} else {
|
|
||||||
let episodeHasUpdates = false
|
|
||||||
const oldFeedEpisodeCleaned = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
|
||||||
for (const key in oldFeedEpisodeCleaned) {
|
|
||||||
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
|
|
||||||
episodeHasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (episodeHasUpdates) {
|
|
||||||
await feedEpisode.update(oldFeedEpisodeCleaned)
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
return feeds.map(f => f.entityId).filter(f => f) || []
|
||||||
|
}
|
||||||
|
|
||||||
let feedHasUpdates = false
|
/**
|
||||||
for (const key in feedObj) {
|
* Find feed where and return oldFeed
|
||||||
let existingValue = existingFeed[key]
|
* @param {object} where sequelize where object
|
||||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
* @returns {Promise<objects.Feed>} oldFeed
|
||||||
|
*/
|
||||||
if (!areEquivalent(existingValue, feedObj[key])) {
|
static async findOneOld(where) {
|
||||||
feedHasUpdates = true
|
if (!where) return null
|
||||||
}
|
const feedExpanded = await this.findOne({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
model: this.sequelize.models.feedEpisode
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
if (!feedExpanded) return null
|
||||||
|
return this.getOldFeed(feedExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
if (feedHasUpdates) {
|
/**
|
||||||
await existingFeed.update(feedObj)
|
* Find feed and return oldFeed
|
||||||
hasUpdates = true
|
* @param {string} id
|
||||||
|
* @returns {Promise<objects.Feed>} oldFeed
|
||||||
|
*/
|
||||||
|
static async findByPkOld(id) {
|
||||||
|
if (!id) return null
|
||||||
|
const feedExpanded = await this.findByPk(id, {
|
||||||
|
include: {
|
||||||
|
model: this.sequelize.models.feedEpisode
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
if (!feedExpanded) return null
|
||||||
|
return this.getOldFeed(feedExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
return hasUpdates
|
static async fullCreateFromOld(oldFeed) {
|
||||||
}
|
const feedObj = this.getFromOld(oldFeed)
|
||||||
|
const newFeed = await this.create(feedObj)
|
||||||
|
|
||||||
static getFromOld(oldFeed) {
|
if (oldFeed.episodes?.length) {
|
||||||
const oldFeedMeta = oldFeed.meta || {}
|
for (const oldFeedEpisode of oldFeed.episodes) {
|
||||||
return {
|
const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
||||||
id: oldFeed.id,
|
feedEpisode.feedId = newFeed.id
|
||||||
slug: oldFeed.slug,
|
await this.sequelize.models.feedEpisode.create(feedEpisode)
|
||||||
entityType: oldFeed.entityType,
|
|
||||||
entityId: oldFeed.entityId,
|
|
||||||
entityUpdatedAt: oldFeed.entityUpdatedAt,
|
|
||||||
serverAddress: oldFeed.serverAddress,
|
|
||||||
feedURL: oldFeed.feedUrl,
|
|
||||||
imageURL: oldFeedMeta.imageUrl,
|
|
||||||
siteURL: oldFeedMeta.link,
|
|
||||||
title: oldFeedMeta.title,
|
|
||||||
description: oldFeedMeta.description,
|
|
||||||
author: oldFeedMeta.author,
|
|
||||||
podcastType: oldFeedMeta.type || null,
|
|
||||||
language: oldFeedMeta.language || null,
|
|
||||||
ownerName: oldFeedMeta.ownerName || null,
|
|
||||||
ownerEmail: oldFeedMeta.ownerEmail || null,
|
|
||||||
explicit: !!oldFeedMeta.explicit,
|
|
||||||
preventIndexing: !!oldFeedMeta.preventIndexing,
|
|
||||||
userId: oldFeed.userId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getEntity(options) {
|
|
||||||
if (!this.entityType) return Promise.resolve(null)
|
|
||||||
const mixinMethodName = `get${sequelize.uppercaseFirst(this.entityType)}`
|
|
||||||
return this[mixinMethodName](options)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Feed.init({
|
static async fullUpdateFromOld(oldFeed) {
|
||||||
id: {
|
const oldFeedEpisodes = oldFeed.episodes || []
|
||||||
type: DataTypes.UUID,
|
const feedObj = this.getFromOld(oldFeed)
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true
|
|
||||||
},
|
|
||||||
slug: DataTypes.STRING,
|
|
||||||
entityType: DataTypes.STRING,
|
|
||||||
entityId: DataTypes.UUIDV4,
|
|
||||||
entityUpdatedAt: DataTypes.DATE,
|
|
||||||
serverAddress: DataTypes.STRING,
|
|
||||||
feedURL: DataTypes.STRING,
|
|
||||||
imageURL: DataTypes.STRING,
|
|
||||||
siteURL: DataTypes.STRING,
|
|
||||||
title: DataTypes.STRING,
|
|
||||||
description: DataTypes.TEXT,
|
|
||||||
author: DataTypes.STRING,
|
|
||||||
podcastType: DataTypes.STRING,
|
|
||||||
language: DataTypes.STRING,
|
|
||||||
ownerName: DataTypes.STRING,
|
|
||||||
ownerEmail: DataTypes.STRING,
|
|
||||||
explicit: DataTypes.BOOLEAN,
|
|
||||||
preventIndexing: DataTypes.BOOLEAN
|
|
||||||
}, {
|
|
||||||
sequelize,
|
|
||||||
modelName: 'feed'
|
|
||||||
})
|
|
||||||
|
|
||||||
const { user, libraryItem, collection, series, playlist } = sequelize.models
|
const existingFeed = await this.findByPk(feedObj.id, {
|
||||||
|
include: this.sequelize.models.feedEpisode
|
||||||
|
})
|
||||||
|
if (!existingFeed) return false
|
||||||
|
|
||||||
user.hasMany(Feed)
|
let hasUpdates = false
|
||||||
Feed.belongsTo(user)
|
for (const feedEpisode of existingFeed.feedEpisodes) {
|
||||||
|
const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id)
|
||||||
libraryItem.hasMany(Feed, {
|
// Episode removed
|
||||||
foreignKey: 'entityId',
|
if (!oldFeedEpisode) {
|
||||||
constraints: false,
|
feedEpisode.destroy()
|
||||||
scope: {
|
} else {
|
||||||
entityType: 'libraryItem'
|
let episodeHasUpdates = false
|
||||||
}
|
const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
||||||
})
|
for (const key in oldFeedEpisodeCleaned) {
|
||||||
Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false })
|
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
|
||||||
|
episodeHasUpdates = true
|
||||||
collection.hasMany(Feed, {
|
}
|
||||||
foreignKey: 'entityId',
|
}
|
||||||
constraints: false,
|
if (episodeHasUpdates) {
|
||||||
scope: {
|
await feedEpisode.update(oldFeedEpisodeCleaned)
|
||||||
entityType: 'collection'
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
})
|
|
||||||
Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false })
|
|
||||||
|
|
||||||
series.hasMany(Feed, {
|
|
||||||
foreignKey: 'entityId',
|
|
||||||
constraints: false,
|
|
||||||
scope: {
|
|
||||||
entityType: 'series'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false })
|
|
||||||
|
|
||||||
playlist.hasMany(Feed, {
|
|
||||||
foreignKey: 'entityId',
|
|
||||||
constraints: false,
|
|
||||||
scope: {
|
|
||||||
entityType: 'playlist'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false })
|
|
||||||
|
|
||||||
Feed.addHook('afterFind', findResult => {
|
|
||||||
if (!findResult) return
|
|
||||||
|
|
||||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
|
||||||
for (const instance of findResult) {
|
|
||||||
if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) {
|
|
||||||
instance.entity = instance.libraryItem
|
|
||||||
instance.dataValues.entity = instance.dataValues.libraryItem
|
|
||||||
} else if (instance.entityType === 'collection' && instance.collection !== undefined) {
|
|
||||||
instance.entity = instance.collection
|
|
||||||
instance.dataValues.entity = instance.dataValues.collection
|
|
||||||
} else if (instance.entityType === 'series' && instance.series !== undefined) {
|
|
||||||
instance.entity = instance.series
|
|
||||||
instance.dataValues.entity = instance.dataValues.series
|
|
||||||
} else if (instance.entityType === 'playlist' && instance.playlist !== undefined) {
|
|
||||||
instance.entity = instance.playlist
|
|
||||||
instance.dataValues.entity = instance.dataValues.playlist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// To prevent mistakes:
|
|
||||||
delete instance.libraryItem
|
|
||||||
delete instance.dataValues.libraryItem
|
|
||||||
delete instance.collection
|
|
||||||
delete instance.dataValues.collection
|
|
||||||
delete instance.series
|
|
||||||
delete instance.dataValues.series
|
|
||||||
delete instance.playlist
|
|
||||||
delete instance.dataValues.playlist
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return Feed
|
let feedHasUpdates = false
|
||||||
}
|
for (const key in feedObj) {
|
||||||
|
let existingValue = existingFeed[key]
|
||||||
|
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
||||||
|
|
||||||
|
if (!areEquivalent(existingValue, feedObj[key])) {
|
||||||
|
feedHasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feedHasUpdates) {
|
||||||
|
await existingFeed.update(feedObj)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasUpdates
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFromOld(oldFeed) {
|
||||||
|
const oldFeedMeta = oldFeed.meta || {}
|
||||||
|
return {
|
||||||
|
id: oldFeed.id,
|
||||||
|
slug: oldFeed.slug,
|
||||||
|
entityType: oldFeed.entityType,
|
||||||
|
entityId: oldFeed.entityId,
|
||||||
|
entityUpdatedAt: oldFeed.entityUpdatedAt,
|
||||||
|
serverAddress: oldFeed.serverAddress,
|
||||||
|
feedURL: oldFeed.feedUrl,
|
||||||
|
coverPath: oldFeed.coverPath || null,
|
||||||
|
imageURL: oldFeedMeta.imageUrl,
|
||||||
|
siteURL: oldFeedMeta.link,
|
||||||
|
title: oldFeedMeta.title,
|
||||||
|
description: oldFeedMeta.description,
|
||||||
|
author: oldFeedMeta.author,
|
||||||
|
podcastType: oldFeedMeta.type || null,
|
||||||
|
language: oldFeedMeta.language || null,
|
||||||
|
ownerName: oldFeedMeta.ownerName || null,
|
||||||
|
ownerEmail: oldFeedMeta.ownerEmail || null,
|
||||||
|
explicit: !!oldFeedMeta.explicit,
|
||||||
|
preventIndexing: !!oldFeedMeta.preventIndexing,
|
||||||
|
userId: oldFeed.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntity(options) {
|
||||||
|
if (!this.entityType) return Promise.resolve(null)
|
||||||
|
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
|
||||||
|
return this[mixinMethodName](options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize model
|
||||||
|
*
|
||||||
|
* Polymorphic association: Feeds can be created from LibraryItem, Collection, Playlist or Series
|
||||||
|
* @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
|
||||||
|
*
|
||||||
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
*/
|
||||||
|
static init(sequelize) {
|
||||||
|
super.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
slug: DataTypes.STRING,
|
||||||
|
entityType: DataTypes.STRING,
|
||||||
|
entityId: DataTypes.UUIDV4,
|
||||||
|
entityUpdatedAt: DataTypes.DATE,
|
||||||
|
serverAddress: DataTypes.STRING,
|
||||||
|
feedURL: DataTypes.STRING,
|
||||||
|
imageURL: DataTypes.STRING,
|
||||||
|
siteURL: DataTypes.STRING,
|
||||||
|
title: DataTypes.STRING,
|
||||||
|
description: DataTypes.TEXT,
|
||||||
|
author: DataTypes.STRING,
|
||||||
|
podcastType: DataTypes.STRING,
|
||||||
|
language: DataTypes.STRING,
|
||||||
|
ownerName: DataTypes.STRING,
|
||||||
|
ownerEmail: DataTypes.STRING,
|
||||||
|
explicit: DataTypes.BOOLEAN,
|
||||||
|
preventIndexing: DataTypes.BOOLEAN,
|
||||||
|
coverPath: DataTypes.STRING
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'feed'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { user, libraryItem, collection, series, playlist } = sequelize.models
|
||||||
|
|
||||||
|
user.hasMany(Feed)
|
||||||
|
Feed.belongsTo(user)
|
||||||
|
|
||||||
|
libraryItem.hasMany(Feed, {
|
||||||
|
foreignKey: 'entityId',
|
||||||
|
constraints: false,
|
||||||
|
scope: {
|
||||||
|
entityType: 'libraryItem'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false })
|
||||||
|
|
||||||
|
collection.hasMany(Feed, {
|
||||||
|
foreignKey: 'entityId',
|
||||||
|
constraints: false,
|
||||||
|
scope: {
|
||||||
|
entityType: 'collection'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false })
|
||||||
|
|
||||||
|
series.hasMany(Feed, {
|
||||||
|
foreignKey: 'entityId',
|
||||||
|
constraints: false,
|
||||||
|
scope: {
|
||||||
|
entityType: 'series'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false })
|
||||||
|
|
||||||
|
playlist.hasMany(Feed, {
|
||||||
|
foreignKey: 'entityId',
|
||||||
|
constraints: false,
|
||||||
|
scope: {
|
||||||
|
entityType: 'playlist'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false })
|
||||||
|
|
||||||
|
Feed.addHook('afterFind', findResult => {
|
||||||
|
if (!findResult) return
|
||||||
|
|
||||||
|
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||||
|
for (const instance of findResult) {
|
||||||
|
if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) {
|
||||||
|
instance.entity = instance.libraryItem
|
||||||
|
instance.dataValues.entity = instance.dataValues.libraryItem
|
||||||
|
} else if (instance.entityType === 'collection' && instance.collection !== undefined) {
|
||||||
|
instance.entity = instance.collection
|
||||||
|
instance.dataValues.entity = instance.dataValues.collection
|
||||||
|
} else if (instance.entityType === 'series' && instance.series !== undefined) {
|
||||||
|
instance.entity = instance.series
|
||||||
|
instance.dataValues.entity = instance.dataValues.series
|
||||||
|
} else if (instance.entityType === 'playlist' && instance.playlist !== undefined) {
|
||||||
|
instance.entity = instance.playlist
|
||||||
|
instance.dataValues.entity = instance.dataValues.playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
// To prevent mistakes:
|
||||||
|
delete instance.libraryItem
|
||||||
|
delete instance.dataValues.libraryItem
|
||||||
|
delete instance.collection
|
||||||
|
delete instance.dataValues.collection
|
||||||
|
delete instance.series
|
||||||
|
delete instance.dataValues.series
|
||||||
|
delete instance.playlist
|
||||||
|
delete instance.dataValues.playlist
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Feed
|
||||||
+116
-73
@@ -1,82 +1,125 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
|
|
||||||
module.exports = (sequelize) => {
|
class FeedEpisode extends Model {
|
||||||
class FeedEpisode extends Model {
|
constructor(values, options) {
|
||||||
getOldEpisode() {
|
super(values, options)
|
||||||
const enclosure = {
|
|
||||||
url: this.enclosureURL,
|
|
||||||
size: this.enclosureSize,
|
|
||||||
type: this.enclosureType
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
title: this.title,
|
|
||||||
description: this.description,
|
|
||||||
enclosure,
|
|
||||||
pubDate: this.pubDate,
|
|
||||||
link: this.siteURL,
|
|
||||||
author: this.author,
|
|
||||||
explicit: this.explicit,
|
|
||||||
duration: this.duration,
|
|
||||||
season: this.season,
|
|
||||||
episode: this.episode,
|
|
||||||
episodeType: this.episodeType,
|
|
||||||
fullPath: this.filePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static getFromOld(oldFeedEpisode) {
|
/** @type {UUIDV4} */
|
||||||
return {
|
this.id
|
||||||
id: oldFeedEpisode.id,
|
/** @type {string} */
|
||||||
title: oldFeedEpisode.title,
|
this.title
|
||||||
author: oldFeedEpisode.author,
|
/** @type {string} */
|
||||||
description: oldFeedEpisode.description,
|
this.description
|
||||||
siteURL: oldFeedEpisode.link,
|
/** @type {string} */
|
||||||
enclosureURL: oldFeedEpisode.enclosure?.url || null,
|
this.siteURL
|
||||||
enclosureType: oldFeedEpisode.enclosure?.type || null,
|
/** @type {string} */
|
||||||
enclosureSize: oldFeedEpisode.enclosure?.size || null,
|
this.enclosureURL
|
||||||
pubDate: oldFeedEpisode.pubDate,
|
/** @type {string} */
|
||||||
season: oldFeedEpisode.season || null,
|
this.enclosureType
|
||||||
episode: oldFeedEpisode.episode || null,
|
/** @type {BigInt} */
|
||||||
episodeType: oldFeedEpisode.episodeType || null,
|
this.enclosureSize
|
||||||
duration: oldFeedEpisode.duration,
|
/** @type {string} */
|
||||||
filePath: oldFeedEpisode.fullPath,
|
this.pubDate
|
||||||
explicit: !!oldFeedEpisode.explicit
|
/** @type {string} */
|
||||||
}
|
this.season
|
||||||
|
/** @type {string} */
|
||||||
|
this.episode
|
||||||
|
/** @type {string} */
|
||||||
|
this.episodeType
|
||||||
|
/** @type {number} */
|
||||||
|
this.duration
|
||||||
|
/** @type {string} */
|
||||||
|
this.filePath
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.explicit
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.feedId
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
getOldEpisode() {
|
||||||
|
const enclosure = {
|
||||||
|
url: this.enclosureURL,
|
||||||
|
size: this.enclosureSize,
|
||||||
|
type: this.enclosureType
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
title: this.title,
|
||||||
|
description: this.description,
|
||||||
|
enclosure,
|
||||||
|
pubDate: this.pubDate,
|
||||||
|
link: this.siteURL,
|
||||||
|
author: this.author,
|
||||||
|
explicit: this.explicit,
|
||||||
|
duration: this.duration,
|
||||||
|
season: this.season,
|
||||||
|
episode: this.episode,
|
||||||
|
episodeType: this.episodeType,
|
||||||
|
fullPath: this.filePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FeedEpisode.init({
|
static getFromOld(oldFeedEpisode) {
|
||||||
id: {
|
return {
|
||||||
type: DataTypes.UUID,
|
id: oldFeedEpisode.id,
|
||||||
defaultValue: DataTypes.UUIDV4,
|
title: oldFeedEpisode.title,
|
||||||
primaryKey: true
|
author: oldFeedEpisode.author,
|
||||||
},
|
description: oldFeedEpisode.description,
|
||||||
title: DataTypes.STRING,
|
siteURL: oldFeedEpisode.link,
|
||||||
author: DataTypes.STRING,
|
enclosureURL: oldFeedEpisode.enclosure?.url || null,
|
||||||
description: DataTypes.TEXT,
|
enclosureType: oldFeedEpisode.enclosure?.type || null,
|
||||||
siteURL: DataTypes.STRING,
|
enclosureSize: oldFeedEpisode.enclosure?.size || null,
|
||||||
enclosureURL: DataTypes.STRING,
|
pubDate: oldFeedEpisode.pubDate,
|
||||||
enclosureType: DataTypes.STRING,
|
season: oldFeedEpisode.season || null,
|
||||||
enclosureSize: DataTypes.BIGINT,
|
episode: oldFeedEpisode.episode || null,
|
||||||
pubDate: DataTypes.STRING,
|
episodeType: oldFeedEpisode.episodeType || null,
|
||||||
season: DataTypes.STRING,
|
duration: oldFeedEpisode.duration,
|
||||||
episode: DataTypes.STRING,
|
filePath: oldFeedEpisode.fullPath,
|
||||||
episodeType: DataTypes.STRING,
|
explicit: !!oldFeedEpisode.explicit
|
||||||
duration: DataTypes.FLOAT,
|
}
|
||||||
filePath: DataTypes.STRING,
|
}
|
||||||
explicit: DataTypes.BOOLEAN
|
|
||||||
}, {
|
|
||||||
sequelize,
|
|
||||||
modelName: 'feedEpisode'
|
|
||||||
})
|
|
||||||
|
|
||||||
const { feed } = sequelize.models
|
/**
|
||||||
|
* Initialize model
|
||||||
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
*/
|
||||||
|
static init(sequelize) {
|
||||||
|
super.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
title: DataTypes.STRING,
|
||||||
|
author: DataTypes.STRING,
|
||||||
|
description: DataTypes.TEXT,
|
||||||
|
siteURL: DataTypes.STRING,
|
||||||
|
enclosureURL: DataTypes.STRING,
|
||||||
|
enclosureType: DataTypes.STRING,
|
||||||
|
enclosureSize: DataTypes.BIGINT,
|
||||||
|
pubDate: DataTypes.STRING,
|
||||||
|
season: DataTypes.STRING,
|
||||||
|
episode: DataTypes.STRING,
|
||||||
|
episodeType: DataTypes.STRING,
|
||||||
|
duration: DataTypes.FLOAT,
|
||||||
|
filePath: DataTypes.STRING,
|
||||||
|
explicit: DataTypes.BOOLEAN
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'feedEpisode'
|
||||||
|
})
|
||||||
|
|
||||||
feed.hasMany(FeedEpisode, {
|
const { feed } = sequelize.models
|
||||||
onDelete: 'CASCADE'
|
|
||||||
})
|
|
||||||
FeedEpisode.belongsTo(feed)
|
|
||||||
|
|
||||||
return FeedEpisode
|
feed.hasMany(FeedEpisode, {
|
||||||
}
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
FeedEpisode.belongsTo(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FeedEpisode
|
||||||
+246
-129
@@ -2,144 +2,261 @@ const { DataTypes, Model } = require('sequelize')
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const oldLibrary = require('../objects/Library')
|
const oldLibrary = require('../objects/Library')
|
||||||
|
|
||||||
module.exports = (sequelize) => {
|
/**
|
||||||
class Library extends Model {
|
* @typedef LibrarySettingsObject
|
||||||
static async getAllOldLibraries() {
|
* @property {number} coverAspectRatio BookCoverAspectRatio
|
||||||
const libraries = await this.findAll({
|
* @property {boolean} disableWatcher
|
||||||
include: sequelize.models.libraryFolder,
|
* @property {boolean} skipMatchingMediaWithAsin
|
||||||
order: [['displayOrder', 'ASC']]
|
* @property {boolean} skipMatchingMediaWithIsbn
|
||||||
})
|
* @property {string} autoScanCronExpression
|
||||||
return libraries.map(lib => this.getOldLibrary(lib))
|
* @property {boolean} audiobooksOnly
|
||||||
}
|
* @property {boolean} hideSingleBookSeries Do not show series that only have 1 book
|
||||||
|
*/
|
||||||
|
|
||||||
static getOldLibrary(libraryExpanded) {
|
class Library extends Model {
|
||||||
const folders = libraryExpanded.libraryFolders.map(folder => {
|
constructor(values, options) {
|
||||||
return {
|
super(values, options)
|
||||||
id: folder.id,
|
|
||||||
fullPath: folder.path,
|
|
||||||
libraryId: folder.libraryId,
|
|
||||||
addedAt: folder.createdAt.valueOf()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return new oldLibrary({
|
|
||||||
id: libraryExpanded.id,
|
|
||||||
oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null,
|
|
||||||
name: libraryExpanded.name,
|
|
||||||
folders,
|
|
||||||
displayOrder: libraryExpanded.displayOrder,
|
|
||||||
icon: libraryExpanded.icon,
|
|
||||||
mediaType: libraryExpanded.mediaType,
|
|
||||||
provider: libraryExpanded.provider,
|
|
||||||
settings: libraryExpanded.settings,
|
|
||||||
createdAt: libraryExpanded.createdAt.valueOf(),
|
|
||||||
lastUpdate: libraryExpanded.updatedAt.valueOf()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** @type {UUIDV4} */
|
||||||
* @param {object} oldLibrary
|
this.id
|
||||||
* @returns {Library|null}
|
/** @type {string} */
|
||||||
*/
|
this.name
|
||||||
static async createFromOld(oldLibrary) {
|
/** @type {number} */
|
||||||
const library = this.getFromOld(oldLibrary)
|
this.displayOrder
|
||||||
|
/** @type {string} */
|
||||||
|
this.icon
|
||||||
|
/** @type {string} */
|
||||||
|
this.mediaType
|
||||||
|
/** @type {string} */
|
||||||
|
this.provider
|
||||||
|
/** @type {Date} */
|
||||||
|
this.lastScan
|
||||||
|
/** @type {string} */
|
||||||
|
this.lastScanVersion
|
||||||
|
/** @type {LibrarySettingsObject} */
|
||||||
|
this.settings
|
||||||
|
/** @type {Object} */
|
||||||
|
this.extraData
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
library.libraryFolders = oldLibrary.folders.map(folder => {
|
/**
|
||||||
return {
|
* Get all old libraries
|
||||||
id: folder.id,
|
* @returns {Promise<oldLibrary[]>}
|
||||||
path: folder.fullPath
|
*/
|
||||||
}
|
static async getAllOldLibraries() {
|
||||||
})
|
const libraries = await this.findAll({
|
||||||
|
include: this.sequelize.models.libraryFolder,
|
||||||
|
order: [['displayOrder', 'ASC']]
|
||||||
|
})
|
||||||
|
return libraries.map(lib => this.getOldLibrary(lib))
|
||||||
|
}
|
||||||
|
|
||||||
return this.create(library, {
|
/**
|
||||||
include: sequelize.models.libraryFolder
|
* Convert expanded Library to oldLibrary
|
||||||
}).catch((error) => {
|
* @param {Library} libraryExpanded
|
||||||
Logger.error(`[Library] Failed to create library ${library.id}`, error)
|
* @returns {Promise<oldLibrary>}
|
||||||
return null
|
*/
|
||||||
})
|
static getOldLibrary(libraryExpanded) {
|
||||||
}
|
const folders = libraryExpanded.libraryFolders.map(folder => {
|
||||||
|
|
||||||
static async updateFromOld(oldLibrary) {
|
|
||||||
const existingLibrary = await this.findByPk(oldLibrary.id, {
|
|
||||||
include: sequelize.models.libraryFolder
|
|
||||||
})
|
|
||||||
if (!existingLibrary) {
|
|
||||||
Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const library = this.getFromOld(oldLibrary)
|
|
||||||
|
|
||||||
const libraryFolders = oldLibrary.folders.map(folder => {
|
|
||||||
return {
|
|
||||||
id: folder.id,
|
|
||||||
path: folder.fullPath,
|
|
||||||
libraryId: library.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
for (const libraryFolder of libraryFolders) {
|
|
||||||
const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id)
|
|
||||||
if (!existingLibraryFolder) {
|
|
||||||
await sequelize.models.libraryFolder.create(libraryFolder)
|
|
||||||
} else if (existingLibraryFolder.path !== libraryFolder.path) {
|
|
||||||
await existingLibraryFolder.update({ path: libraryFolder.path })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id))
|
|
||||||
for (const existingLibraryFolder of libraryFoldersRemoved) {
|
|
||||||
await existingLibraryFolder.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingLibrary.update(library)
|
|
||||||
}
|
|
||||||
|
|
||||||
static getFromOld(oldLibrary) {
|
|
||||||
const extraData = {}
|
|
||||||
if (oldLibrary.oldLibraryId) {
|
|
||||||
extraData.oldLibraryId = oldLibrary.oldLibraryId
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
id: oldLibrary.id,
|
id: folder.id,
|
||||||
name: oldLibrary.name,
|
fullPath: folder.path,
|
||||||
displayOrder: oldLibrary.displayOrder,
|
libraryId: folder.libraryId,
|
||||||
icon: oldLibrary.icon || null,
|
addedAt: folder.createdAt.valueOf()
|
||||||
mediaType: oldLibrary.mediaType || null,
|
}
|
||||||
provider: oldLibrary.provider,
|
})
|
||||||
settings: oldLibrary.settings?.toJSON() || {},
|
return new oldLibrary({
|
||||||
createdAt: oldLibrary.createdAt,
|
id: libraryExpanded.id,
|
||||||
updatedAt: oldLibrary.lastUpdate,
|
oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null,
|
||||||
extraData
|
name: libraryExpanded.name,
|
||||||
|
folders,
|
||||||
|
displayOrder: libraryExpanded.displayOrder,
|
||||||
|
icon: libraryExpanded.icon,
|
||||||
|
mediaType: libraryExpanded.mediaType,
|
||||||
|
provider: libraryExpanded.provider,
|
||||||
|
settings: libraryExpanded.settings,
|
||||||
|
createdAt: libraryExpanded.createdAt.valueOf(),
|
||||||
|
lastUpdate: libraryExpanded.updatedAt.valueOf()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} oldLibrary
|
||||||
|
* @returns {Library|null}
|
||||||
|
*/
|
||||||
|
static async createFromOld(oldLibrary) {
|
||||||
|
const library = this.getFromOld(oldLibrary)
|
||||||
|
|
||||||
|
library.libraryFolders = oldLibrary.folders.map(folder => {
|
||||||
|
return {
|
||||||
|
id: folder.id,
|
||||||
|
path: folder.fullPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.create(library, {
|
||||||
|
include: this.sequelize.models.libraryFolder
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[Library] Failed to create library ${library.id}`, error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update library and library folders
|
||||||
|
* @param {object} oldLibrary
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
static async updateFromOld(oldLibrary) {
|
||||||
|
const existingLibrary = await this.findByPk(oldLibrary.id, {
|
||||||
|
include: this.sequelize.models.libraryFolder
|
||||||
|
})
|
||||||
|
if (!existingLibrary) {
|
||||||
|
Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = this.getFromOld(oldLibrary)
|
||||||
|
|
||||||
|
const libraryFolders = oldLibrary.folders.map(folder => {
|
||||||
|
return {
|
||||||
|
id: folder.id,
|
||||||
|
path: folder.fullPath,
|
||||||
|
libraryId: library.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for (const libraryFolder of libraryFolders) {
|
||||||
|
const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id)
|
||||||
|
if (!existingLibraryFolder) {
|
||||||
|
await this.sequelize.models.libraryFolder.create(libraryFolder)
|
||||||
|
} else if (existingLibraryFolder.path !== libraryFolder.path) {
|
||||||
|
await existingLibraryFolder.update({ path: libraryFolder.path })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeById(libraryId) {
|
const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id))
|
||||||
return this.destroy({
|
for (const existingLibraryFolder of libraryFoldersRemoved) {
|
||||||
where: {
|
await existingLibraryFolder.destroy()
|
||||||
id: libraryId
|
}
|
||||||
}
|
|
||||||
})
|
return existingLibrary.update(library)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFromOld(oldLibrary) {
|
||||||
|
const extraData = {}
|
||||||
|
if (oldLibrary.oldLibraryId) {
|
||||||
|
extraData.oldLibraryId = oldLibrary.oldLibraryId
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: oldLibrary.id,
|
||||||
|
name: oldLibrary.name,
|
||||||
|
displayOrder: oldLibrary.displayOrder,
|
||||||
|
icon: oldLibrary.icon || null,
|
||||||
|
mediaType: oldLibrary.mediaType || null,
|
||||||
|
provider: oldLibrary.provider,
|
||||||
|
settings: oldLibrary.settings?.toJSON() || {},
|
||||||
|
createdAt: oldLibrary.createdAt,
|
||||||
|
updatedAt: oldLibrary.lastUpdate,
|
||||||
|
extraData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Library.init({
|
/**
|
||||||
id: {
|
* Destroy library by id
|
||||||
type: DataTypes.UUID,
|
* @param {string} libraryId
|
||||||
defaultValue: DataTypes.UUIDV4,
|
* @returns
|
||||||
primaryKey: true
|
*/
|
||||||
},
|
static removeById(libraryId) {
|
||||||
name: DataTypes.STRING,
|
return this.destroy({
|
||||||
displayOrder: DataTypes.INTEGER,
|
where: {
|
||||||
icon: DataTypes.STRING,
|
id: libraryId
|
||||||
mediaType: DataTypes.STRING,
|
}
|
||||||
provider: DataTypes.STRING,
|
})
|
||||||
lastScan: DataTypes.DATE,
|
}
|
||||||
lastScanVersion: DataTypes.STRING,
|
|
||||||
settings: DataTypes.JSON,
|
|
||||||
extraData: DataTypes.JSON
|
|
||||||
}, {
|
|
||||||
sequelize,
|
|
||||||
modelName: 'library'
|
|
||||||
})
|
|
||||||
|
|
||||||
return Library
|
/**
|
||||||
}
|
* Get all library ids
|
||||||
|
* @returns {Promise<string[]>} array of library ids
|
||||||
|
*/
|
||||||
|
static async getAllLibraryIds() {
|
||||||
|
const libraries = await this.findAll({
|
||||||
|
attributes: ['id', 'displayOrder'],
|
||||||
|
order: [['displayOrder', 'ASC']]
|
||||||
|
})
|
||||||
|
return libraries.map(l => l.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find Library by primary key & return oldLibrary
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @returns {Promise<oldLibrary|null>} Returns null if not found
|
||||||
|
*/
|
||||||
|
static async getOldById(libraryId) {
|
||||||
|
if (!libraryId) return null
|
||||||
|
const library = await this.findByPk(libraryId, {
|
||||||
|
include: this.sequelize.models.libraryFolder
|
||||||
|
})
|
||||||
|
if (!library) return null
|
||||||
|
return this.getOldLibrary(library)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the largest value in the displayOrder column
|
||||||
|
* Used for setting a new libraries display order
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
static getMaxDisplayOrder() {
|
||||||
|
return this.max('displayOrder') || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates displayOrder to be sequential
|
||||||
|
* Used after removing a library
|
||||||
|
*/
|
||||||
|
static async resetDisplayOrder() {
|
||||||
|
const libraries = await this.findAll({
|
||||||
|
order: [['displayOrder', 'ASC']]
|
||||||
|
})
|
||||||
|
for (let i = 0; i < libraries.length; i++) {
|
||||||
|
const library = libraries[i]
|
||||||
|
if (library.displayOrder !== i + 1) {
|
||||||
|
Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`)
|
||||||
|
await library.update({ displayOrder: i + 1 }).catch((error) => {
|
||||||
|
Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize model
|
||||||
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
*/
|
||||||
|
static init(sequelize) {
|
||||||
|
super.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: DataTypes.STRING,
|
||||||
|
displayOrder: DataTypes.INTEGER,
|
||||||
|
icon: DataTypes.STRING,
|
||||||
|
mediaType: DataTypes.STRING,
|
||||||
|
provider: DataTypes.STRING,
|
||||||
|
lastScan: DataTypes.DATE,
|
||||||
|
lastScanVersion: DataTypes.STRING,
|
||||||
|
settings: DataTypes.JSON,
|
||||||
|
extraData: DataTypes.JSON
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'library'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Library
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user