mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ test/
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
.idea/*
|
||||
|
||||
@@ -29,12 +29,6 @@ RUN npm ci --only=production
|
||||
|
||||
RUN apk del make python3 g++
|
||||
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
|
||||
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"]
|
||||
|
||||
@@ -60,13 +60,13 @@ install_ffmpeg() {
|
||||
fi
|
||||
|
||||
$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
|
||||
|
||||
# Temp downloading tone library to the ffmpeg dir
|
||||
echo "Getting 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
|
||||
|
||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||
|
||||
@@ -68,6 +68,9 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
@@ -346,8 +349,6 @@ export default {
|
||||
})
|
||||
},
|
||||
episodeAdded(episodeWithLibraryItem) {
|
||||
console.log('Podcast episode added', episodeWithLibraryItem)
|
||||
|
||||
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
|
||||
@@ -99,6 +99,11 @@ export default {
|
||||
id: 'config-item-metadata-utils',
|
||||
title: this.$strings.HeaderItemMetadataUtils,
|
||||
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()
|
||||
}
|
||||
|
||||
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 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
|
||||
},
|
||||
async init(bookshelf) {
|
||||
if (this.entityName === 'series') {
|
||||
this.booksPerFetch = 50
|
||||
} else {
|
||||
this.booksPerFetch = 100
|
||||
}
|
||||
this.checkUpdateSearchParams()
|
||||
this.initSizeData(bookshelf)
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ export default {
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
return this.series ? this.series.sequence : null
|
||||
return this.series?.sequence || null
|
||||
},
|
||||
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 === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
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
|
||||
},
|
||||
episodeProgress() {
|
||||
|
||||
@@ -36,7 +36,7 @@ export default {
|
||||
return this.narrator?.name || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this.narrator?.books?.length || 0
|
||||
return this.narrator?.numBooks || this.narrator?.books?.length || 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
|
||||
@@ -103,7 +103,7 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
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: {
|
||||
|
||||
@@ -348,6 +348,10 @@ export default {
|
||||
},
|
||||
tracks() {
|
||||
return [
|
||||
{
|
||||
id: 'none',
|
||||
name: this.$strings.LabelTracksNone
|
||||
},
|
||||
{
|
||||
id: 'single',
|
||||
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 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' }" />
|
||||
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
<img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,9 @@ export default {
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
invalidCoverFontSize() {
|
||||
return Math.max(this.sizeMultiplier * 0.8, 0.5)
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<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="flex py-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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,7 +352,8 @@ export default {
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
},
|
||||
librariesAccessible: []
|
||||
librariesAccessible: [],
|
||||
itemTagsSelected: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,9 +283,8 @@ export default {
|
||||
}
|
||||
if (success) {
|
||||
this.$toast.success('Update Successful')
|
||||
// this.$emit('close')
|
||||
} else {
|
||||
this.imageUrl = this.media.coverPath || ''
|
||||
} else if (this.media.coverPath) {
|
||||
this.imageUrl = this.media.coverPath
|
||||
}
|
||||
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>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr>
|
||||
<th class="text-left">Sort #</th>
|
||||
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
|
||||
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
|
||||
<th class="text-center w-28">{{ $strings.EpisodeDuration }}</th>
|
||||
<th class="text-center w-28">{{ $strings.EpisodeSize }}</th>
|
||||
<th class="text-center w-20 min-w-20">{{ $strings.LabelEpisode }}</th>
|
||||
<th class="text-left">{{ $strings.LabelEpisodeTitle }}</th>
|
||||
<th class="text-center w-28">{{ $strings.LabelEpisodeDuration }}</th>
|
||||
<th class="text-center w-28">{{ $strings.LabelEpisodeSize }}</th>
|
||||
</tr>
|
||||
<tr v-for="episode in episodes" :key="episode.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ episode.index }}</p>
|
||||
</td>
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ episode.episode }}</p>
|
||||
<td class="text-center w-20 min-w-20">
|
||||
<p>{{ episode.episode }}</p>
|
||||
</td>
|
||||
<td>
|
||||
{{ episode.title }}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<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" />
|
||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsDisableWatcherForLibrary }}</p>
|
||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@ export default {
|
||||
return {
|
||||
provider: null,
|
||||
useSquareBookCovers: false,
|
||||
disableWatcher: false,
|
||||
enableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
audiobooksOnly: false,
|
||||
@@ -95,7 +95,7 @@ export default {
|
||||
return {
|
||||
settings: {
|
||||
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
||||
disableWatcher: !!this.disableWatcher,
|
||||
disableWatcher: !this.enableWatcher,
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
||||
audiobooksOnly: !!this.audiobooksOnly,
|
||||
@@ -108,7 +108,7 @@ export default {
|
||||
},
|
||||
init() {
|
||||
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.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
||||
|
||||
@@ -132,6 +132,8 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
serverAddress: window.origin,
|
||||
slug: this.newFeedSlug,
|
||||
@@ -151,6 +153,9 @@ export default {
|
||||
const errorMsg = error.response ? error.response.data : null
|
||||
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
copyToClipboard(str) {
|
||||
this.$copyToClipboard(str, this)
|
||||
|
||||
124
client/components/modals/rssfeed/ViewFeedModal.vue
Normal file
124
client/components/modals/rssfeed/ViewFeedModal.vue
Normal file
@@ -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) {
|
||||
var basename = Path.basename(filename, Path.extname(filename))
|
||||
var numbersinpath = basename.match(/\d{1,5}/g)
|
||||
if (!numbersinpath || !numbersinpath.length) {
|
||||
var numbersinpath = basename.match(/\d+/g)
|
||||
if (!numbersinpath?.length) {
|
||||
return {
|
||||
index: -1,
|
||||
filename
|
||||
|
||||
@@ -133,12 +133,15 @@ export default {
|
||||
this.rendition.spread(settings.spread || 'auto')
|
||||
},
|
||||
prev() {
|
||||
if (!this.rendition?.manager) return
|
||||
return this.rendition?.prev()
|
||||
},
|
||||
next() {
|
||||
if (!this.rendition?.manager) return
|
||||
return this.rendition?.next()
|
||||
},
|
||||
goToChapter(href) {
|
||||
if (!this.rendition?.manager) return
|
||||
return this.rendition?.display(href)
|
||||
},
|
||||
keyUp(e) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</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">
|
||||
<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>
|
||||
@@ -58,26 +58,32 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
totalItems() {
|
||||
return this.libraryStats ? this.libraryStats.totalItems : 0
|
||||
return this.libraryStats?.totalItems || 0
|
||||
},
|
||||
totalAuthors() {
|
||||
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
||||
return this.libraryStats?.totalAuthors || 0
|
||||
},
|
||||
numAudioTracks() {
|
||||
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
||||
return this.libraryStats?.numAudioTracks || 0
|
||||
},
|
||||
totalDuration() {
|
||||
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
||||
return this.libraryStats?.totalDuration || 0
|
||||
},
|
||||
totalHours() {
|
||||
return Math.round(this.totalDuration / (60 * 60))
|
||||
},
|
||||
totalSizePretty() {
|
||||
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
||||
var totalSize = this.libraryStats?.totalSize || 0
|
||||
return this.$bytesPretty(totalSize, 1)
|
||||
},
|
||||
totalSizeNum() {
|
||||
|
||||
@@ -11,10 +11,6 @@
|
||||
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
|
||||
</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">
|
||||
**<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}
|
||||
</p>
|
||||
|
||||
@@ -71,11 +71,6 @@ export default {
|
||||
text: this.$strings.ButtonScan,
|
||||
action: 'scan',
|
||||
value: 'scan'
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonForceReScan,
|
||||
action: 'force-scan',
|
||||
value: 'force-scan'
|
||||
}
|
||||
]
|
||||
if (this.isBookLibrary) {
|
||||
@@ -137,26 +132,6 @@ export default {
|
||||
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() {
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),
|
||||
|
||||
@@ -343,6 +343,10 @@ export default {
|
||||
}
|
||||
this.$store.commit('libraries/removeCollection', collection)
|
||||
},
|
||||
seriesRemoved({ id, libraryId }) {
|
||||
if (this.currentLibraryId !== libraryId) return
|
||||
this.$store.commit('libraries/removeSeriesFromFilterData', id)
|
||||
},
|
||||
playlistAdded(playlist) {
|
||||
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
|
||||
this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
|
||||
@@ -442,6 +446,9 @@ export default {
|
||||
this.socket.on('collection_updated', this.collectionUpdated)
|
||||
this.socket.on('collection_removed', this.collectionRemoved)
|
||||
|
||||
// Series Listeners
|
||||
this.socket.on('series_removed', this.seriesRemoved)
|
||||
|
||||
// User Playlist Listeners
|
||||
this.socket.on('playlist_added', this.playlistAdded)
|
||||
this.socket.on('playlist_updated', this.playlistUpdated)
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.3.2",
|
||||
"version": "2.4.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.3.2",
|
||||
"version": "2.4.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.3.2",
|
||||
"version": "2.4.1",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -55,6 +55,7 @@ export default {
|
||||
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
|
||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||
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
|
||||
}
|
||||
return this.$strings.HeaderSettings
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<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 class="flex items-center py-2 mb-2">
|
||||
@@ -157,10 +160,10 @@
|
||||
</div>
|
||||
|
||||
<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-tooltip :text="$strings.LabelSettingsDisableWatcherHelp">
|
||||
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||
<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>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
@@ -259,9 +262,12 @@ export default {
|
||||
updatingServerSettings: false,
|
||||
homepageUseBookshelfView: false,
|
||||
useBookshelfView: false,
|
||||
scannerEnableWatcher: false,
|
||||
isPurgingCache: false,
|
||||
hasPrefixesChanged: false,
|
||||
newServerSettings: {},
|
||||
showConfirmPurgeCache: false,
|
||||
savingPrefixes: false,
|
||||
metadataFileFormats: [
|
||||
{
|
||||
text: '.json',
|
||||
@@ -304,15 +310,36 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateSortingPrefixes(val) {
|
||||
if (!val || !val.length) {
|
||||
sortingPrefixesUpdated(val) {
|
||||
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')
|
||||
return
|
||||
}
|
||||
var prefixes = val.map((prefix) => prefix.trim().toLowerCase())
|
||||
this.updateServerSettings({
|
||||
sortingPrefixes: prefixes
|
||||
})
|
||||
|
||||
this.savingPrefixes = true
|
||||
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) {
|
||||
this.updateServerSettings({
|
||||
@@ -337,6 +364,9 @@ export default {
|
||||
this.updateSettingsKey('metadataFileFormat', val)
|
||||
},
|
||||
updateSettingsKey(key, val) {
|
||||
if (key === 'scannerDisableWatcher') {
|
||||
this.newServerSettings.scannerDisableWatcher = val
|
||||
}
|
||||
this.updateServerSettings({
|
||||
[key]: val
|
||||
})
|
||||
@@ -363,6 +393,7 @@ export default {
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
||||
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
|
||||
|
||||
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
||||
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
|
||||
<template v-for="(author, index) in top10Authors">
|
||||
@@ -114,43 +114,49 @@ export default {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
totalItems() {
|
||||
return this.libraryStats ? this.libraryStats.totalItems : 0
|
||||
return this.libraryStats?.totalItems || 0
|
||||
},
|
||||
genresWithCount() {
|
||||
return this.libraryStats ? this.libraryStats.genresWithCount : []
|
||||
return this.libraryStats?.genresWithCount || []
|
||||
},
|
||||
top5Genres() {
|
||||
return this.genresWithCount.slice(0, 5)
|
||||
return this.genresWithCount?.slice(0, 5) || []
|
||||
},
|
||||
top10LongestItems() {
|
||||
return this.libraryStats ? this.libraryStats.longestItems || [] : []
|
||||
return this.libraryStats?.longestItems || []
|
||||
},
|
||||
longestItemDuration() {
|
||||
if (!this.top10LongestItems.length) return 0
|
||||
return this.top10LongestItems[0].duration
|
||||
},
|
||||
top10LargestItems() {
|
||||
return this.libraryStats ? this.libraryStats.largestItems || [] : []
|
||||
return this.libraryStats?.largestItems || []
|
||||
},
|
||||
largestItemSize() {
|
||||
if (!this.top10LargestItems.length) return 0
|
||||
return this.top10LargestItems[0].size
|
||||
},
|
||||
authorsWithCount() {
|
||||
return this.libraryStats ? this.libraryStats.authorsWithCount : []
|
||||
return this.libraryStats?.authorsWithCount || []
|
||||
},
|
||||
mostUsedAuthorCount() {
|
||||
if (!this.authorsWithCount.length) return 0
|
||||
return this.authorsWithCount[0].count
|
||||
},
|
||||
top10Authors() {
|
||||
return this.authorsWithCount.slice(0, 10)
|
||||
return this.authorsWithCount?.slice(0, 10) || []
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
176
client/pages/config/rss-feeds.vue
Normal file
176
client/pages/config/rss-feeds.vue
Normal file
@@ -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">
|
||||
<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">
|
||||
<th class="w-16 text-left">{{ $strings.LabelItem }}</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.LabelLastUpdate }}</th>
|
||||
</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>
|
||||
<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>
|
||||
<template v-if="item.media && item.media.metadata && item.episode">
|
||||
<p>{{ item.episode.title || 'Unknown' }}</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>
|
||||
<p>{{ item.displayTitle || 'Unknown' }}</p>
|
||||
<p v-if="item.displaySubtitle" class="text-white text-opacity-50 text-sm font-sans">{{ item.displaySubtitle }}</p>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
||||
@@ -124,9 +119,6 @@ export default {
|
||||
mediaProgress() {
|
||||
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||
},
|
||||
mediaProgressWithMedia() {
|
||||
return this.mediaProgress.filter((mp) => mp.media)
|
||||
},
|
||||
totalListeningTime() {
|
||||
return this.listeningStats.totalTime || 0
|
||||
},
|
||||
|
||||
@@ -160,7 +160,7 @@ export default {
|
||||
}
|
||||
|
||||
// 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)
|
||||
return false
|
||||
})
|
||||
@@ -761,6 +761,7 @@ export default {
|
||||
if (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('rss_feed_open', this.rssFeedOpen)
|
||||
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||
@@ -769,6 +770,7 @@ export default {
|
||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off(`${this.libraryItem.id}_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_closed', this.rssFeedClosed)
|
||||
|
||||
@@ -11,6 +11,7 @@ const languageCodeMap = {
|
||||
'fr': { label: 'Français', dateFnsLocale: 'fr' },
|
||||
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
||||
'lt': { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
||||
'nl': { label: 'Nederlands', dateFnsLocale: 'nl' },
|
||||
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
||||
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
||||
|
||||
@@ -234,6 +234,10 @@ export const mutations = {
|
||||
setNumUserPlaylists(state, 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) {
|
||||
if (!libraryItem || !state.filterData) return
|
||||
if (state.currentLibraryId !== libraryItem.libraryId) return
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Lösche {0} Episoden",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
|
||||
"HeaderSchedule": "Zeitplan",
|
||||
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Player schließen",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Serien zusammenfassen",
|
||||
"LabelCollection": "Sammlung",
|
||||
"LabelCollections": "Sammlungen",
|
||||
"LabelComplete": "Vollständig",
|
||||
"LabelConfirmPassword": "Passwort bestätigen",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Verzeichnis",
|
||||
"LabelDiscFromFilename": "CD aus dem Dateinamen",
|
||||
"LabelDiscFromMetadata": "CD aus den Metadaten",
|
||||
"LabelDiscover": "Finden",
|
||||
"LabelDownload": "Herunterladen",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Laufzeit",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Überwachung 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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Alles anzeigen",
|
||||
"LabelSize": "Größe",
|
||||
"LabelSleepTimer": "Einschlaf-Timer",
|
||||
"LabelSlug": "URL Teil",
|
||||
"LabelStart": "Start",
|
||||
"LabelStarted": "Gestartet",
|
||||
"LabelStartedAt": "Gestartet am",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Titel aus Metadaten",
|
||||
"LabelTracks": "Dateien",
|
||||
"LabelTracksMultiTrack": "Mehrfachdatei",
|
||||
"LabelTracksNone": "Keine Dateien",
|
||||
"LabelTracksSingleTrack": "Einzeldatei",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnabridged": "Ungekürzt",
|
||||
@@ -494,7 +502,7 @@
|
||||
"LabelViewBookmarks": "Lesezeichen anzeigen",
|
||||
"LabelViewChapters": "Kapitel anzeigen",
|
||||
"LabelViewQueue": "Spieler-Warteschlange anzeigen",
|
||||
"LabelVolume": "Volume",
|
||||
"LabelVolume": "Volumen",
|
||||
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
||||
"LabelYourAudiobookDuration": "Laufzeit Ihres Mediums",
|
||||
"LabelYourBookmarks": "Lesezeichen",
|
||||
@@ -514,8 +522,9 @@
|
||||
"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)",
|
||||
"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?",
|
||||
"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?",
|
||||
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
|
||||
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
|
||||
@@ -558,7 +567,7 @@
|
||||
"MessageMarkAllEpisodesNotFinished": "Alle Episoden als ungehört markieren",
|
||||
"MessageMarkAsFinished": "Als beendet 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",
|
||||
"MessageNoAuthors": "Keine Autoren",
|
||||
"MessageNoBackups": "Keine Sicherungen",
|
||||
@@ -594,7 +603,7 @@
|
||||
"MessagePauseChapter": "Kapitelwiedergabe pausieren",
|
||||
"MessagePlayChapter": "Kapitelanfang anhören",
|
||||
"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.",
|
||||
"MessageRemoveChapter": "Kapitel löschen",
|
||||
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
|
||||
@@ -702,4 +711,4 @@
|
||||
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
|
||||
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
|
||||
"ToastUserDeleteSuccess": "Benutzer gelöscht"
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Close player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Collapse Series",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Collections",
|
||||
"LabelComplete": "Complete",
|
||||
"LabelConfirmPassword": "Confirm Password",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Directory",
|
||||
"LabelDiscFromFilename": "Disc from Filename",
|
||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Duration",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Disable Watcher",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
||||
"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",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||
"LabelSettingsFindCovers": "Find covers",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Show All",
|
||||
"LabelSize": "Size",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Start",
|
||||
"LabelStarted": "Started",
|
||||
"LabelStartedAt": "Started At",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Track from Metadata",
|
||||
"LabelTracks": "Tracks",
|
||||
"LabelTracksMultiTrack": "Multi-track",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Single-track",
|
||||
"LabelType": "Type",
|
||||
"LabelUnabridged": "Unabridged",
|
||||
@@ -514,6 +522,7 @@
|
||||
"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",
|
||||
"MessageCheckingCron": "Checking cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Remover {0} Episodios",
|
||||
"HeaderRSSFeedGeneral": "Detalles RSS",
|
||||
"HeaderRSSFeedIsOpen": "Fuente RSS esta abierta",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Guardar Progreso de multimedia",
|
||||
"HeaderSchedule": "Horario",
|
||||
"HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Close player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Colapsar Series",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Colecciones",
|
||||
"LabelComplete": "Completo",
|
||||
"LabelConfirmPassword": "Confirmar Contraseña",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Directorio",
|
||||
"LabelDiscFromFilename": "Disco a partir del Nombre del Archivo",
|
||||
"LabelDiscFromMetadata": "Disco a partir de Metadata",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Descargar",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Duración",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Deshabilitar Watcher",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Mostrar Todos",
|
||||
"LabelSize": "Tamaño",
|
||||
"LabelSleepTimer": "Temporizador para Dormir",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Iniciar",
|
||||
"LabelStarted": "Indiciado",
|
||||
"LabelStartedAt": "Iniciado En",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Pista desde Metadata",
|
||||
"LabelTracks": "Pistas",
|
||||
"LabelTracksMultiTrack": "Multi-track",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Single-track",
|
||||
"LabelType": "Tipo",
|
||||
"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",
|
||||
"MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro",
|
||||
"MessageCheckingCron": "Checking cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?",
|
||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||
"MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?",
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
|
||||
"HeaderRSSFeedGeneral": "Détails de flux RSS",
|
||||
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
|
||||
"HeaderSchedule": "Programmation",
|
||||
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
|
||||
@@ -155,7 +156,7 @@
|
||||
"HeaderStatsRecentSessions": "Sessions récentes",
|
||||
"HeaderStatsTop10Authors": "Top 10 Auteurs",
|
||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||
"HeaderTableOfContents": "Table of Contents",
|
||||
"HeaderTableOfContents": "Table des matières",
|
||||
"HeaderTools": "Outils",
|
||||
"HeaderUpdateAccount": "Mettre à jour le compte",
|
||||
"HeaderUpdateAuthor": "Mettre à jour l’auteur",
|
||||
@@ -201,11 +202,12 @@
|
||||
"LabelClosePlayer": "Fermer le lecteur",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Réduire les séries",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Collections",
|
||||
"LabelComplete": "Complet",
|
||||
"LabelConfirmPassword": "Confirmer le mot de passe",
|
||||
"LabelContinueListening": "Continuer la lecture",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueReading": "Continuer la lecture",
|
||||
"LabelContinueSeries": "Continuer la série",
|
||||
"LabelCover": "Couverture",
|
||||
"LabelCoverImageURL": "URL vers l’image de couverture",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Répertoire",
|
||||
"LabelDiscFromFilename": "Disque depuis le fichier",
|
||||
"LabelDiscFromMetadata": "Disque depuis les métadonnées",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Téléchargement",
|
||||
"LabelDownloadNEpisodes": "Télécharger {0} épisode(s)",
|
||||
"LabelDuration": "Durée",
|
||||
@@ -252,7 +255,7 @@
|
||||
"LabelFinished": "Fini(e)",
|
||||
"LabelFolder": "Dossier",
|
||||
"LabelFolders": "Dossiers",
|
||||
"LabelFontScale": "Font scale",
|
||||
"LabelFontScale": "Taille de la police de caractère",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genres",
|
||||
@@ -284,16 +287,16 @@
|
||||
"LabelLastSeen": "Vu dernièrement",
|
||||
"LabelLastTime": "Progression",
|
||||
"LabelLastUpdate": "Dernière mise à jour",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLayoutSplitPage": "Split page",
|
||||
"LabelLayout": "Disposition",
|
||||
"LabelLayoutSinglePage": "Vue unique",
|
||||
"LabelLayoutSplitPage": "Vue partagée",
|
||||
"LabelLess": "Moins",
|
||||
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l’utilisateur",
|
||||
"LabelLibrary": "Bibliothèque",
|
||||
"LabelLibraryItem": "Article de bibliothèque",
|
||||
"LabelLibraryName": "Nom de la bibliothèque",
|
||||
"LabelLimit": "Limite",
|
||||
"LabelLineSpacing": "Line spacing",
|
||||
"LabelLineSpacing": "Interligne",
|
||||
"LabelListenAgain": "Écouter à nouveau",
|
||||
"LabelLogLevelDebug": "Debug",
|
||||
"LabelLogLevelInfo": "Info",
|
||||
@@ -318,7 +321,7 @@
|
||||
"LabelNewPassword": "Nouveau mot de passe",
|
||||
"LabelNextBackupDate": "Date de la prochaine sauvegarde",
|
||||
"LabelNextScheduledRun": "Prochain lancement prévu",
|
||||
"LabelNoEpisodesSelected": "No episodes selected",
|
||||
"LabelNoEpisodesSelected": "Aucun épisode sélectionné",
|
||||
"LabelNotes": "Notes",
|
||||
"LabelNotFinished": "Non terminé(e)",
|
||||
"LabelNotificationAppriseURL": "URL(s) d’Apprise",
|
||||
@@ -378,7 +381,7 @@
|
||||
"LabelSearchTitle": "Titre de recherche",
|
||||
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
|
||||
"LabelSeason": "Saison",
|
||||
"LabelSelectAllEpisodes": "Select all episodes",
|
||||
"LabelSelectAllEpisodes": "Sélectionner tous les épisodes",
|
||||
"LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours",
|
||||
"LabelSendEbookToDevice": "Envoyer le livre numérique à...",
|
||||
"LabelSequence": "Séquence",
|
||||
@@ -388,13 +391,16 @@
|
||||
"LabelSetEbookAsPrimary": "Définir comme principale",
|
||||
"LabelSetEbookAsSupplementary": "Définir comme supplémentaire",
|
||||
"LabelSettingsAudiobooksOnly": "Livres audios seulement",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "L’activation de ce paramètre ignorera les fichiers “ 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.",
|
||||
"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",
|
||||
"LabelSettingsChromecastSupport": "Support du Chromecast",
|
||||
"LabelSettingsDateFormat": "Format de date",
|
||||
"LabelSettingsDisableWatcher": "Désactiver la surveillance",
|
||||
"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*",
|
||||
"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": "Fonctionnalités expérimentales",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.",
|
||||
"LabelSettingsFindCovers": "Chercher des couvertures de livre",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Afficher Tout",
|
||||
"LabelSize": "Taille",
|
||||
"LabelSleepTimer": "Minuterie",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Démarrer",
|
||||
"LabelStarted": "Démarré",
|
||||
"LabelStartedAt": "Démarré à",
|
||||
@@ -451,11 +458,11 @@
|
||||
"LabelTag": "Étiquette",
|
||||
"LabelTags": "Étiquettes",
|
||||
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l’utilisateur",
|
||||
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||
"LabelTagsNotAccessibleToUser": "Étiquettes non accessibles à l’utilisateur",
|
||||
"LabelTasks": "Tâches en cours",
|
||||
"LabelTheme": "Theme",
|
||||
"LabelThemeDark": "Dark",
|
||||
"LabelThemeLight": "Light",
|
||||
"LabelTheme": "Thème",
|
||||
"LabelThemeDark": "Sombre",
|
||||
"LabelThemeLight": "Clair",
|
||||
"LabelTimeBase": "Base de temps",
|
||||
"LabelTimeListened": "Temps d’écoute",
|
||||
"LabelTimeListenedToday": "Nombres d’écoutes Aujourd’hui",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Piste depuis les métadonnées",
|
||||
"LabelTracks": "Pistes",
|
||||
"LabelTracksMultiTrack": "Piste multiple",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Piste simple",
|
||||
"LabelType": "Type",
|
||||
"LabelUnabridged": "Version intégrale",
|
||||
@@ -514,6 +522,7 @@
|
||||
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
|
||||
"MessageChapterStartIsAfter": "Le premier chapitre est situé au début de votre livre audio",
|
||||
"MessageCheckingCron": "Vérification du cron…",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?",
|
||||
"MessageConfirmDeleteFile": "Cela Le fichier sera supprimer de votre système. Êtes-vous sûr ?",
|
||||
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",
|
||||
@@ -702,4 +711,4 @@
|
||||
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
|
||||
"ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur",
|
||||
"ToastUserDeleteSuccess": "Utilisateur supprimé"
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Close player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Collapse Series",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Collections",
|
||||
"LabelComplete": "Complete",
|
||||
"LabelConfirmPassword": "Confirm Password",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Directory",
|
||||
"LabelDiscFromFilename": "Disc from Filename",
|
||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Duration",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Disable Watcher",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
||||
"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",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||
"LabelSettingsFindCovers": "Find covers",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Show All",
|
||||
"LabelSize": "Size",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Start",
|
||||
"LabelStarted": "Started",
|
||||
"LabelStartedAt": "Started At",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Track from Metadata",
|
||||
"LabelTracks": "Tracks",
|
||||
"LabelTracksMultiTrack": "Multi-track",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Single-track",
|
||||
"LabelType": "Type",
|
||||
"LabelUnabridged": "Unabridged",
|
||||
@@ -514,6 +522,7 @@
|
||||
"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",
|
||||
"MessageCheckingCron": "Checking cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Close player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Collapse Series",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Collections",
|
||||
"LabelComplete": "Complete",
|
||||
"LabelConfirmPassword": "Confirm Password",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Directory",
|
||||
"LabelDiscFromFilename": "Disc from Filename",
|
||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Duration",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Disable Watcher",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
||||
"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",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||
"LabelSettingsFindCovers": "Find covers",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Show All",
|
||||
"LabelSize": "Size",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Start",
|
||||
"LabelStarted": "Started",
|
||||
"LabelStartedAt": "Started At",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Track from Metadata",
|
||||
"LabelTracks": "Tracks",
|
||||
"LabelTracksMultiTrack": "Multi-track",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Single-track",
|
||||
"LabelType": "Type",
|
||||
"LabelUnabridged": "Unabridged",
|
||||
@@ -514,6 +522,7 @@
|
||||
"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",
|
||||
"MessageCheckingCron": "Checking cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Spremljen Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Close player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Collapse Series",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Kolekcije",
|
||||
"LabelComplete": "Complete",
|
||||
"LabelConfirmPassword": "Potvrdi lozinku",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Direktorij",
|
||||
"LabelDiscFromFilename": "CD iz imena datoteke",
|
||||
"LabelDiscFromMetadata": "CD iz metapodataka",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Preuzmi",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Trajanje",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Isključi Watchera",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku",
|
||||
"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",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
|
||||
"LabelSettingsFindCovers": "Pronađi covers",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Prikaži sve",
|
||||
"LabelSize": "Veličina",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Pokreni",
|
||||
"LabelStarted": "Pokrenuto",
|
||||
"LabelStartedAt": "Pokrenuto",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Track iz metapodataka",
|
||||
"LabelTracks": "Tracks",
|
||||
"LabelTracksMultiTrack": "Multi-track",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Single-track",
|
||||
"LabelType": "Tip",
|
||||
"LabelUnabridged": "Unabridged",
|
||||
@@ -514,6 +522,7 @@
|
||||
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
|
||||
"MessageCheckingCron": "Provjeravam cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
|
||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Progressi salvati",
|
||||
"HeaderSchedule": "Schedula",
|
||||
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Chiudi player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Comprimi Serie",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Raccolte",
|
||||
"LabelComplete": "Completo",
|
||||
"LabelConfirmPassword": "Conferma Password",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Elenco",
|
||||
"LabelDiscFromFilename": "Disco dal nome file",
|
||||
"LabelDiscFromMetadata": "Disco dal Metadata",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Durata",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Disattiva Watcher",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
|
||||
"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",
|
||||
"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",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Mostra Tutto",
|
||||
"LabelSize": "Dimensione",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Inizo",
|
||||
"LabelStarted": "Iniziato",
|
||||
"LabelStartedAt": "Iniziato al",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Traccia da Metadata",
|
||||
"LabelTracks": "Traccia",
|
||||
"LabelTracksMultiTrack": "Multi-traccia",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Traccia-singola",
|
||||
"LabelType": "Tipo",
|
||||
"LabelUnabridged": "Integrale",
|
||||
@@ -514,6 +522,7 @@
|
||||
"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",
|
||||
"MessageCheckingCron": "Controllo cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
|
||||
"MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?",
|
||||
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
|
||||
@@ -702,4 +711,4 @@
|
||||
"ToastSocketFailedToConnect": "Socket non riesce a connettersi",
|
||||
"ToastUserDeleteFailed": "Errore eliminazione utente",
|
||||
"ToastUserDeleteSuccess": "Utente eliminato"
|
||||
}
|
||||
}
|
||||
714
client/strings/lt.json
Normal file
714
client/strings/lt.json
Normal file
@@ -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",
|
||||
"HeaderRSSFeedGeneral": "RSS-details",
|
||||
"HeaderRSSFeedIsOpen": "RSS-feed is open",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Opgeslagen mediavoortgang",
|
||||
"HeaderSchedule": "Schema",
|
||||
"HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Sluit speler",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Series inklappen",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Collecties",
|
||||
"LabelComplete": "Compleet",
|
||||
"LabelConfirmPassword": "Bevestig wachtwoord",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Map",
|
||||
"LabelDiscFromFilename": "Schijf uit bestandsnaam",
|
||||
"LabelDiscFromMetadata": "Schijf uit metadata",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Duur",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Watcher uitschakelen",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
|
||||
"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",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
|
||||
"LabelSettingsFindCovers": "Zoek covers",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Toon alle",
|
||||
"LabelSize": "Grootte",
|
||||
"LabelSleepTimer": "Slaaptimer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Start",
|
||||
"LabelStarted": "Gestart",
|
||||
"LabelStartedAt": "Gestart op",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Track vanuit metadata",
|
||||
"LabelTracks": "Tracks",
|
||||
"LabelTracksMultiTrack": "Multi-track",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Single-track",
|
||||
"LabelType": "Type",
|
||||
"LabelUnabridged": "Onverkort",
|
||||
@@ -514,6 +522,7 @@
|
||||
"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",
|
||||
"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?",
|
||||
"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?",
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Usuń {0} odcinków",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Zapisany postęp",
|
||||
"HeaderSchedule": "Harmonogram",
|
||||
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Zamknij odtwarzacz",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Podsumuj serię",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Kolekcje",
|
||||
"LabelComplete": "Ukończone",
|
||||
"LabelConfirmPassword": "Potwierdź hasło",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Katalog",
|
||||
"LabelDiscFromFilename": "Oznaczenie dysku z nazwy pliku",
|
||||
"LabelDiscFromMetadata": "Oznaczenie dysku z metadanych",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Pobierz",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Czas trwania",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
|
||||
"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",
|
||||
"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",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Pokaż wszystko",
|
||||
"LabelSize": "Rozmiar",
|
||||
"LabelSleepTimer": "Wyłącznik czasowy",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Rozpocznij",
|
||||
"LabelStarted": "Rozpoczęty",
|
||||
"LabelStartedAt": "Rozpoczęto",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Ścieżka z metadanych",
|
||||
"LabelTracks": "Tracks",
|
||||
"LabelTracksMultiTrack": "Multi-track",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Single-track",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnabridged": "Unabridged",
|
||||
@@ -514,6 +522,7 @@
|
||||
"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",
|
||||
"MessageCheckingCron": "Sprawdzanie cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
|
||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "Удалить {0} эпизодов",
|
||||
"HeaderRSSFeedGeneral": "Сведения о RSS",
|
||||
"HeaderRSSFeedIsOpen": "RSS-канал открыт",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "Прогресс медиа сохранен",
|
||||
"HeaderSchedule": "Планировщик",
|
||||
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "Закрыть проигрыватель",
|
||||
"LabelCodec": "Кодек",
|
||||
"LabelCollapseSeries": "Свернуть серии",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "Коллекции",
|
||||
"LabelComplete": "Завершить",
|
||||
"LabelConfirmPassword": "Подтвердить пароль",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "Каталог",
|
||||
"LabelDiscFromFilename": "Диск из Имени файла",
|
||||
"LabelDiscFromMetadata": "Диск из Метаданных",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "Скачать",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Длина",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "Отключить отслеживание",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
|
||||
"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": "Экспериментальные функции",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
|
||||
"LabelSettingsFindCovers": "Найти обложки",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "Показать все",
|
||||
"LabelSize": "Размер",
|
||||
"LabelSleepTimer": "Таймер сна",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Начало",
|
||||
"LabelStarted": "Начат",
|
||||
"LabelStartedAt": "Начато В",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "Трек из Метаданных",
|
||||
"LabelTracks": "Треков",
|
||||
"LabelTracksMultiTrack": "Мультитрек",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "Один трек",
|
||||
"LabelType": "Тип",
|
||||
"LabelUnabridged": "Полное издание",
|
||||
@@ -514,6 +522,7 @@
|
||||
"MessageChapterErrorStartLtPrev": "Неверное время начала, должно быть больше или равно времени начала предыдущей главы",
|
||||
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
|
||||
"MessageCheckingCron": "Проверка cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
|
||||
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
|
||||
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"HeaderRemoveEpisodes": "移除 {0} 剧集",
|
||||
"HeaderRSSFeedGeneral": "RSS 详细信息",
|
||||
"HeaderRSSFeedIsOpen": "RSS 源已打开",
|
||||
"HeaderRSSFeeds": "RSS Feeds",
|
||||
"HeaderSavedMediaProgress": "保存媒体进度",
|
||||
"HeaderSchedule": "计划任务",
|
||||
"HeaderScheduleLibraryScans": "自动扫描媒体库",
|
||||
@@ -201,6 +202,7 @@
|
||||
"LabelClosePlayer": "关闭播放器",
|
||||
"LabelCodec": "编解码",
|
||||
"LabelCollapseSeries": "折叠系列",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollections": "收藏",
|
||||
"LabelComplete": "已完成",
|
||||
"LabelConfirmPassword": "确认密码",
|
||||
@@ -222,6 +224,7 @@
|
||||
"LabelDirectory": "目录",
|
||||
"LabelDiscFromFilename": "从文件名获取光盘",
|
||||
"LabelDiscFromMetadata": "从元数据获取光盘",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDownload": "下载",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "持续时间",
|
||||
@@ -395,6 +398,9 @@
|
||||
"LabelSettingsDisableWatcher": "禁用监视程序",
|
||||
"LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序",
|
||||
"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": "实验功能",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
|
||||
"LabelSettingsFindCovers": "查找封面",
|
||||
@@ -427,6 +433,7 @@
|
||||
"LabelShowAll": "全部显示",
|
||||
"LabelSize": "文件大小",
|
||||
"LabelSleepTimer": "睡眠定时",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "开始",
|
||||
"LabelStarted": "开始于",
|
||||
"LabelStartedAt": "从这开始",
|
||||
@@ -474,6 +481,7 @@
|
||||
"LabelTrackFromMetadata": "从源数据获取音轨",
|
||||
"LabelTracks": "音轨",
|
||||
"LabelTracksMultiTrack": "多轨",
|
||||
"LabelTracksNone": "No tracks",
|
||||
"LabelTracksSingleTrack": "单轨",
|
||||
"LabelType": "类型",
|
||||
"LabelUnabridged": "未删节",
|
||||
@@ -514,6 +522,7 @@
|
||||
"MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间",
|
||||
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
|
||||
"MessageCheckingCron": "检查计划任务...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
|
||||
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
|
||||
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
- 13378:80
|
||||
volumes:
|
||||
- ./audiobooks:/audiobooks
|
||||
- ./podcasts:/podcasts
|
||||
- ./metadata:/metadata
|
||||
- ./config:/config
|
||||
restart: unless-stopped
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.3.2",
|
||||
"version": "2.4.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.3.2",
|
||||
"version": "2.4.1",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.3.2",
|
||||
"version": "2.4.1",
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -33,6 +33,9 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
|
||||
* Merge your audio files into a single m4b
|
||||
* Embed metadata and cover image into your audio files (using [Tone](https://github.com/sandreas/tone))
|
||||
* 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)
|
||||
|
||||
|
||||
@@ -32,12 +32,13 @@ class Auth {
|
||||
await Database.updateServerSettings()
|
||||
|
||||
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
||||
if (Database.users.length) {
|
||||
for (const user of Database.users) {
|
||||
const users = await Database.userModel.getOldUsers()
|
||||
if (users.length) {
|
||||
for (const user of users) {
|
||||
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`)
|
||||
}
|
||||
await Database.updateBulkUsers(Database.users)
|
||||
await Database.updateBulkUsers(users)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,21 +94,32 @@ class Auth {
|
||||
|
||||
verifyToken(token) {
|
||||
return new Promise((resolve) => {
|
||||
jwt.verify(token, Database.serverSettings.tokenSecret, (err, payload) => {
|
||||
jwt.verify(token, Database.serverSettings.tokenSecret, async (err, payload) => {
|
||||
if (!payload || err) {
|
||||
Logger.error('JWT Verify Token Failed', err)
|
||||
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 {
|
||||
user: user.toJSONForBrowser(),
|
||||
userDefaultLibraryId: user.getDefaultLibraryId(Database.libraries),
|
||||
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
|
||||
serverSettings: Database.serverSettings.toJSONForBrowser(),
|
||||
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
|
||||
Source: global.Source
|
||||
@@ -119,7 +131,7 @@ class Auth {
|
||||
const username = (req.body.username || '').toLowerCase()
|
||||
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) {
|
||||
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)')
|
||||
} else {
|
||||
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)
|
||||
if (compare) {
|
||||
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
|
||||
res.json(this.getUserLoginResponsePayload(user))
|
||||
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
|
||||
res.json(userLoginResponsePayload)
|
||||
} else {
|
||||
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
@@ -164,7 +178,7 @@ class Auth {
|
||||
async userChangePassword(req, res) {
|
||||
var { password, newPassword } = req.body
|
||||
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
|
||||
if (matchingUser.type !== 'root' && !newPassword) {
|
||||
|
||||
@@ -6,26 +6,25 @@ const fs = require('./libs/fsExtra')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
const dbMigration = require('./utils/migrations/dbMigration')
|
||||
const Auth = require('./Auth')
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
this.sequelize = null
|
||||
this.dbPath = null
|
||||
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.collections = []
|
||||
this.playlists = []
|
||||
this.authors = []
|
||||
this.series = []
|
||||
|
||||
// Cached library filter data
|
||||
this.libraryFilterData = {}
|
||||
|
||||
/** @type {import('./objects/settings/ServerSettings')} */
|
||||
this.serverSettings = null
|
||||
/** @type {import('./objects/settings/NotificationSettings')} */
|
||||
this.notificationSettings = null
|
||||
/** @type {import('./objects/settings/EmailSettings')} */
|
||||
this.emailSettings = null
|
||||
}
|
||||
|
||||
@@ -33,10 +32,105 @@ class Database {
|
||||
return this.sequelize?.models || {}
|
||||
}
|
||||
|
||||
get hasRootUser() {
|
||||
return this.users.some(u => u.type === 'root')
|
||||
/** @type {typeof import('./models/User')} */
|
||||
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() {
|
||||
if (!await fs.pathExists(this.dbPath)) {
|
||||
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
|
||||
@@ -45,6 +139,10 @@ class Database {
|
||||
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) {
|
||||
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
|
||||
|
||||
@@ -58,15 +156,21 @@ class Database {
|
||||
await this.buildModels(force)
|
||||
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
||||
|
||||
|
||||
await this.loadData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to db
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async connect() {
|
||||
Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
|
||||
this.sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: this.dbPath,
|
||||
logging: false
|
||||
logging: false,
|
||||
transactionType: 'IMMEDIATE'
|
||||
})
|
||||
|
||||
// Helper function
|
||||
@@ -82,51 +186,73 @@ class Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from db
|
||||
*/
|
||||
async disconnect() {
|
||||
Logger.info(`[Database] Disconnecting sqlite db`)
|
||||
await this.sequelize.close()
|
||||
this.sequelize = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to db and init
|
||||
*/
|
||||
async reconnect() {
|
||||
Logger.info(`[Database] Reconnecting sqlite db`)
|
||||
await this.init()
|
||||
}
|
||||
|
||||
buildModels(force = false) {
|
||||
require('./models/User')(this.sequelize)
|
||||
require('./models/Library')(this.sequelize)
|
||||
require('./models/LibraryFolder')(this.sequelize)
|
||||
require('./models/Book')(this.sequelize)
|
||||
require('./models/Podcast')(this.sequelize)
|
||||
require('./models/PodcastEpisode')(this.sequelize)
|
||||
require('./models/LibraryItem')(this.sequelize)
|
||||
require('./models/MediaProgress')(this.sequelize)
|
||||
require('./models/Series')(this.sequelize)
|
||||
require('./models/BookSeries')(this.sequelize)
|
||||
require('./models/Author')(this.sequelize)
|
||||
require('./models/BookAuthor')(this.sequelize)
|
||||
require('./models/Collection')(this.sequelize)
|
||||
require('./models/CollectionBook')(this.sequelize)
|
||||
require('./models/Playlist')(this.sequelize)
|
||||
require('./models/PlaylistMediaItem')(this.sequelize)
|
||||
require('./models/Device')(this.sequelize)
|
||||
require('./models/PlaybackSession')(this.sequelize)
|
||||
require('./models/Feed')(this.sequelize)
|
||||
require('./models/FeedEpisode')(this.sequelize)
|
||||
require('./models/Setting')(this.sequelize)
|
||||
require('./models/User').init(this.sequelize)
|
||||
require('./models/Library').init(this.sequelize)
|
||||
require('./models/LibraryFolder').init(this.sequelize)
|
||||
require('./models/Book').init(this.sequelize)
|
||||
require('./models/Podcast').init(this.sequelize)
|
||||
require('./models/PodcastEpisode').init(this.sequelize)
|
||||
require('./models/LibraryItem').init(this.sequelize)
|
||||
require('./models/MediaProgress').init(this.sequelize)
|
||||
require('./models/Series').init(this.sequelize)
|
||||
require('./models/BookSeries').init(this.sequelize)
|
||||
require('./models/Author').init(this.sequelize)
|
||||
require('./models/BookAuthor').init(this.sequelize)
|
||||
require('./models/Collection').init(this.sequelize)
|
||||
require('./models/CollectionBook').init(this.sequelize)
|
||||
require('./models/Playlist').init(this.sequelize)
|
||||
require('./models/PlaylistMediaItem').init(this.sequelize)
|
||||
require('./models/Device').init(this.sequelize)
|
||||
require('./models/PlaybackSession').init(this.sequelize)
|
||||
require('./models/Feed').init(this.sequelize)
|
||||
require('./models/FeedEpisode').init(this.sequelize)
|
||||
require('./models/Setting').init(this.sequelize)
|
||||
|
||||
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() {
|
||||
if (this.isNew && await dbMigration.checkShouldMigrate()) {
|
||||
Logger.info(`[Database] New database was created and old database was detected - migrating old to new`)
|
||||
await dbMigration.migrate(this.models)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
const settingsData = await this.models.setting.getOldSettings()
|
||||
this.settings = settingsData.settings
|
||||
this.emailSettings = settingsData.emailSettings
|
||||
@@ -135,19 +261,17 @@ class Database {
|
||||
global.ServerSettings = this.serverSettings.toJSON()
|
||||
|
||||
// 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)
|
||||
}
|
||||
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()
|
||||
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()
|
||||
await this.cleanDatabase()
|
||||
|
||||
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) {
|
||||
Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)
|
||||
@@ -156,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
|
||||
const newUser = await this.models.user.createRootUser(username, pash, token)
|
||||
if (newUser) {
|
||||
this.users.push(newUser)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
await this.models.user.createRootUser(username, pash, auth)
|
||||
this.hasRootUser = true
|
||||
return true
|
||||
}
|
||||
|
||||
updateServerSettings() {
|
||||
@@ -180,7 +308,6 @@ class Database {
|
||||
async createUser(oldUser) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.user.createFromOld(oldUser)
|
||||
this.users.push(oldUser)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -194,10 +321,9 @@ class Database {
|
||||
return Promise.all(oldUsers.map(u => this.updateUser(u)))
|
||||
}
|
||||
|
||||
async removeUser(userId) {
|
||||
removeUser(userId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.user.removeById(userId)
|
||||
this.users = this.users.filter(u => u.id !== userId)
|
||||
return this.models.user.removeById(userId)
|
||||
}
|
||||
|
||||
upsertMediaProgress(oldMediaProgress) {
|
||||
@@ -215,10 +341,9 @@ class Database {
|
||||
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
|
||||
}
|
||||
|
||||
async createLibrary(oldLibrary) {
|
||||
createLibrary(oldLibrary) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.library.createFromOld(oldLibrary)
|
||||
this.libraries.push(oldLibrary)
|
||||
return this.models.library.createFromOld(oldLibrary)
|
||||
}
|
||||
|
||||
updateLibrary(oldLibrary) {
|
||||
@@ -226,59 +351,9 @@ class Database {
|
||||
return this.models.library.updateFromOld(oldLibrary)
|
||||
}
|
||||
|
||||
async removeLibrary(libraryId) {
|
||||
removeLibrary(libraryId) {
|
||||
if (!this.sequelize) return false
|
||||
await 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)
|
||||
return this.models.library.removeById(libraryId)
|
||||
}
|
||||
|
||||
createBulkCollectionBooks(collectionBooks) {
|
||||
@@ -286,64 +361,6 @@ class Database {
|
||||
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) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playlistMediaItem.create(playlistMediaItem)
|
||||
@@ -354,53 +371,21 @@ class Database {
|
||||
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) {
|
||||
if (!this.sequelize) return false
|
||||
await oldLibraryItem.saveMetadata()
|
||||
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
||||
this.libraryItems.push(oldLibraryItem)
|
||||
}
|
||||
|
||||
updateLibraryItem(oldLibraryItem) {
|
||||
async updateLibraryItem(oldLibraryItem) {
|
||||
if (!this.sequelize) return false
|
||||
await oldLibraryItem.saveMetadata()
|
||||
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) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.libraryItem.removeById(libraryItemId)
|
||||
this.libraryItems = this.libraryItems.filter(li => li.id !== libraryItemId)
|
||||
}
|
||||
|
||||
async createFeed(oldFeed) {
|
||||
@@ -426,31 +411,26 @@ class Database {
|
||||
async createSeries(oldSeries) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.series.createFromOld(oldSeries)
|
||||
this.series.push(oldSeries)
|
||||
}
|
||||
|
||||
async createBulkSeries(oldSeriesObjs) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.series.createBulkFromOld(oldSeriesObjs)
|
||||
this.series.push(...oldSeriesObjs)
|
||||
}
|
||||
|
||||
async removeSeries(seriesId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.series.removeById(seriesId)
|
||||
this.series = this.series.filter(se => se.id !== seriesId)
|
||||
}
|
||||
|
||||
async createAuthor(oldAuthor) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.author.createFromOld(oldAuthor)
|
||||
this.authors.push(oldAuthor)
|
||||
}
|
||||
|
||||
async createBulkAuthors(oldAuthors) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.author.createBulkFromOld(oldAuthors)
|
||||
this.authors.push(...oldAuthors)
|
||||
}
|
||||
|
||||
updateAuthor(oldAuthor) {
|
||||
@@ -461,24 +441,17 @@ class Database {
|
||||
async removeAuthor(authorId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.author.removeById(authorId)
|
||||
this.authors = this.authors.filter(au => au.id !== authorId)
|
||||
}
|
||||
|
||||
async createBulkBookAuthors(bookAuthors) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||
this.authors.push(...bookAuthors)
|
||||
}
|
||||
|
||||
async removeBulkBookAuthors(authorId = null, bookId = null) {
|
||||
if (!this.sequelize) return false
|
||||
if (!authorId && !bookId) return
|
||||
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) {
|
||||
@@ -520,6 +493,204 @@ class Database {
|
||||
if (!this.sequelize) return false
|
||||
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()
|
||||
131
server/Server.js
131
server/Server.js
@@ -1,4 +1,5 @@
|
||||
const Path = require('path')
|
||||
const Sequelize = require('sequelize')
|
||||
const express = require('express')
|
||||
const http = require('http')
|
||||
const fs = require('./libs/fsExtra')
|
||||
@@ -8,24 +9,19 @@ const rateLimit = require('./libs/expressRateLimit')
|
||||
const { version } = require('../package.json')
|
||||
|
||||
// Utils
|
||||
const filePerms = require('./utils/filePerms')
|
||||
const fileUtils = require('./utils/fileUtils')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
const Watcher = require('./Watcher')
|
||||
const Scanner = require('./scanner/Scanner')
|
||||
const Database = require('./Database')
|
||||
const SocketAuthority = require('./SocketAuthority')
|
||||
|
||||
const routes = require('./routes/index')
|
||||
|
||||
const ApiRouter = require('./routers/ApiRouter')
|
||||
const HlsRouter = require('./routers/HlsRouter')
|
||||
|
||||
const NotificationManager = require('./managers/NotificationManager')
|
||||
const EmailManager = require('./managers/EmailManager')
|
||||
const CoverManager = require('./managers/CoverManager')
|
||||
const AbMergeManager = require('./managers/AbMergeManager')
|
||||
const CacheManager = require('./managers/CacheManager')
|
||||
const LogManager = require('./managers/LogManager')
|
||||
@@ -36,6 +32,7 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
const TaskManager = require('./managers/TaskManager')
|
||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||
@@ -52,11 +49,9 @@ class Server {
|
||||
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
filePerms.setDefaultDirSync(global.ConfigPath, false)
|
||||
}
|
||||
if (!fs.pathExistsSync(global.MetadataPath)) {
|
||||
fs.mkdirSync(global.MetadataPath)
|
||||
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
||||
}
|
||||
|
||||
this.watcher = new Watcher()
|
||||
@@ -68,16 +63,12 @@ class Server {
|
||||
this.emailManager = new EmailManager()
|
||||
this.backupManager = new BackupManager()
|
||||
this.logManager = new LogManager()
|
||||
this.cacheManager = new CacheManager()
|
||||
this.abMergeManager = new AbMergeManager(this.taskManager)
|
||||
this.playbackSessionManager = new PlaybackSessionManager()
|
||||
this.coverManager = new CoverManager(this.cacheManager)
|
||||
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager)
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager)
|
||||
this.rssFeedManager = new RssFeedManager()
|
||||
|
||||
this.scanner = new Scanner(this.coverManager, this.taskManager)
|
||||
this.cronManager = new CronManager(this.scanner, this.podcastManager)
|
||||
this.cronManager = new CronManager(this.podcastManager)
|
||||
|
||||
// Routers
|
||||
this.apiRouter = new ApiRouter(this)
|
||||
@@ -93,6 +84,18 @@ class Server {
|
||||
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() {
|
||||
Logger.info('[Server] Init v' + version)
|
||||
await this.playbackSessionManager.removeOrphanStreams()
|
||||
@@ -105,21 +108,20 @@ class Server {
|
||||
}
|
||||
|
||||
await this.cleanUserData() // Remove invalid user item progress
|
||||
await this.purgeMetadata() // Remove metadata folders without library item
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
await CacheManager.ensureCachePaths()
|
||||
|
||||
await this.backupManager.init()
|
||||
await this.logManager.init()
|
||||
await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series
|
||||
await this.rssFeedManager.init()
|
||||
this.cronManager.init()
|
||||
|
||||
const libraries = await Database.libraryModel.getAllOldLibraries()
|
||||
await this.cronManager.init(libraries)
|
||||
|
||||
if (Database.serverSettings.scannerDisableWatcher) {
|
||||
Logger.info(`[Server] Watcher is disabled`)
|
||||
this.watcher.disabled = true
|
||||
} else {
|
||||
this.watcher.initWatcher(Database.libraries)
|
||||
this.watcher.on('files', this.filesChanged.bind(this))
|
||||
this.watcher.initWatcher(libraries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,63 +240,56 @@ class Server {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async filesChanged(fileUpdates) {
|
||||
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
||||
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
|
||||
/**
|
||||
* Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist
|
||||
*/
|
||||
async cleanUserData() {
|
||||
for (const _user of Database.users) {
|
||||
if (_user.mediaProgress.length) {
|
||||
for (const mediaProgress of _user.mediaProgress) {
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === mediaProgress.libraryItemId)
|
||||
if (libraryItem && mediaProgress.episodeId) {
|
||||
const episode = libraryItem.media.checkHasEpisode?.(mediaProgress.episodeId)
|
||||
if (episode) continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.debug(`[Server] Removing media progress ${mediaProgress.id} data from user ${_user.username}`)
|
||||
await Database.removeMediaProgress(mediaProgress.id)
|
||||
// Get all media progress without an associated media item
|
||||
const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
|
||||
where: {
|
||||
'$podcastEpisode.id$': null,
|
||||
'$book.id$': null
|
||||
},
|
||||
attributes: ['id'],
|
||||
include: [
|
||||
{
|
||||
model: Database.bookModel,
|
||||
attributes: ['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
|
||||
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 => {
|
||||
if (!Database.series.some(se => se.id === seriesId)) { // Series removed
|
||||
if (!seriesHiding.includes(seriesId)) { // Series removed
|
||||
hasUpdated = true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ class SocketAuthority {
|
||||
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() {
|
||||
const onlineUsersMap = {}
|
||||
Object.values(this.clients).filter(c => c.user).forEach(client => {
|
||||
@@ -19,7 +22,7 @@ class SocketAuthority {
|
||||
onlineUsersMap[client.user.id].connections++
|
||||
} else {
|
||||
onlineUsersMap[client.user.id] = {
|
||||
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems),
|
||||
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions),
|
||||
connections: 1
|
||||
}
|
||||
}
|
||||
@@ -31,9 +34,12 @@ class SocketAuthority {
|
||||
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
|
||||
// TODO: validate that filter is actually a function
|
||||
/**
|
||||
* Emits event to all authorized clients
|
||||
* @param {string} evt
|
||||
* @param {any} data
|
||||
* @param {Function} [filter] optional filter function to only send event to specific users
|
||||
*/
|
||||
emitter(evt, data, filter = null) {
|
||||
for (const socketId in this.clients) {
|
||||
if (this.clients[socketId].user) {
|
||||
@@ -48,7 +54,7 @@ class SocketAuthority {
|
||||
clientEmitter(userId, evt, data) {
|
||||
const clients = this.getClientsForUser(userId)
|
||||
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) => {
|
||||
if (client.socket) {
|
||||
@@ -83,13 +89,13 @@ class SocketAuthority {
|
||||
}
|
||||
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
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
|
||||
// Scanning
|
||||
socket.on('cancel_scan', this.cancelScan.bind(this))
|
||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
@@ -102,16 +108,16 @@ class SocketAuthority {
|
||||
|
||||
const _client = this.clients[socket.id]
|
||||
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) {
|
||||
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]
|
||||
} else {
|
||||
Logger.debug('[Server] User Offline ' + _client.user.username)
|
||||
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
|
||||
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
|
||||
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||
|
||||
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]
|
||||
}
|
||||
})
|
||||
@@ -126,13 +132,13 @@ class SocketAuthority {
|
||||
if (client.user && client.user.isAdminOrUp) {
|
||||
this.emitter('admin_message', payload.message || '')
|
||||
} 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', () => {
|
||||
const client = this.clients[socket.id] || {}
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -147,9 +153,13 @@ class SocketAuthority {
|
||||
return socket.emit('invalid_token')
|
||||
}
|
||||
const client = this.clients[socket.id]
|
||||
if (!client) {
|
||||
Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
@@ -159,9 +169,9 @@ class SocketAuthority {
|
||||
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
|
||||
user.lastSeen = Date.now()
|
||||
@@ -170,7 +180,7 @@ class SocketAuthority {
|
||||
const initialPayload = {
|
||||
userId: client.user.id,
|
||||
username: client.user.username,
|
||||
librariesScanning: this.Server.scanner.librariesScanning
|
||||
librariesScanning: this.Server.getLibrariesScanning()
|
||||
}
|
||||
if (user.isAdminOrUp) {
|
||||
initialPayload.usersOnline = this.getUsersOnline()
|
||||
@@ -183,23 +193,23 @@ class SocketAuthority {
|
||||
if (socketId && this.clients[socketId]) {
|
||||
const client = this.clients[socketId]
|
||||
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) {
|
||||
Logger.debug('[Server] User Offline ' + client.user.username)
|
||||
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, Database.libraryItems))
|
||||
Logger.debug('[SocketAuthority] User Offline ' + client.user.username)
|
||||
this.adminEmitter('user_offline', client.user.toJSONForPublic())
|
||||
}
|
||||
|
||||
delete this.clients[socketId].user
|
||||
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
|
||||
} else if (socketId) {
|
||||
Logger.warn(`[Server] No client for socket ${socketId}`)
|
||||
Logger.warn(`[SocketAuthority] No client for socket ${socketId}`)
|
||||
}
|
||||
}
|
||||
|
||||
cancelScan(id) {
|
||||
Logger.debug('[Server] Cancel scan', id)
|
||||
this.Server.scanner.setCancelLibraryScan(id)
|
||||
Logger.debug('[SocketAuthority] Cancel scan', id)
|
||||
this.Server.cancelLibraryScan(id)
|
||||
}
|
||||
}
|
||||
module.exports = new SocketAuthority()
|
||||
@@ -1,21 +1,34 @@
|
||||
const Path = require('path')
|
||||
const EventEmitter = require('events')
|
||||
const Watcher = require('./libs/watcher/watcher')
|
||||
const Logger = require('./Logger')
|
||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||
|
||||
const { filePathToPOSIX } = require('./utils/fileUtils')
|
||||
|
||||
/**
|
||||
* @typedef PendingFileUpdate
|
||||
* @property {string} path
|
||||
* @property {string} relPath
|
||||
* @property {string} folderId
|
||||
* @property {string} type
|
||||
*/
|
||||
class FolderWatcher extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.paths = [] // Not used
|
||||
this.pendingFiles = [] // Not used
|
||||
|
||||
/** @type {{id:string, name:string, folders:import('./objects/Folder')[], paths:string[], watcher:Watcher[]}[]} */
|
||||
this.libraryWatchers = []
|
||||
/** @type {PendingFileUpdate[]} */
|
||||
this.pendingFileUpdates = []
|
||||
this.pendingDelay = 4000
|
||||
this.pendingTimeout = null
|
||||
|
||||
/** @type {string[]} */
|
||||
this.ignoreDirs = []
|
||||
/** @type {string[]} */
|
||||
this.pendingDirsToRemoveFromIgnore = []
|
||||
|
||||
this.disabled = false
|
||||
}
|
||||
|
||||
@@ -29,11 +42,12 @@ class FolderWatcher extends EventEmitter {
|
||||
return
|
||||
}
|
||||
Logger.info(`[Watcher] Initializing watcher for "${library.name}".`)
|
||||
var folderPaths = library.folderPaths
|
||||
|
||||
const folderPaths = library.folderPaths
|
||||
folderPaths.forEach((fp) => {
|
||||
Logger.debug(`[Watcher] Init watcher for library folder path "${fp}"`)
|
||||
})
|
||||
var watcher = new Watcher(folderPaths, {
|
||||
const watcher = new Watcher(folderPaths, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
renameDetection: true,
|
||||
renameTimeout: 2000,
|
||||
@@ -144,6 +158,12 @@ class FolderWatcher extends EventEmitter {
|
||||
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
||||
}
|
||||
|
||||
/**
|
||||
* File update detected from watcher
|
||||
* @param {string} libraryId
|
||||
* @param {string} path
|
||||
* @param {string} type
|
||||
*/
|
||||
addFileUpdate(libraryId, path, type) {
|
||||
path = filePathToPOSIX(path)
|
||||
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}"`)
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
|
||||
return
|
||||
@@ -184,7 +211,8 @@ class FolderWatcher extends EventEmitter {
|
||||
// Notify server of update after "pendingDelay"
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFileUpdates)
|
||||
// this.emit('files', this.pendingFileUpdates)
|
||||
LibraryScanner.scanFilesChanged(this.pendingFileUpdates)
|
||||
this.pendingFileUpdates = []
|
||||
}, this.pendingDelay)
|
||||
}
|
||||
@@ -195,24 +223,50 @@ class FolderWatcher extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to POSIX and remove trailing slash
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
cleanDirPath(path) {
|
||||
path = filePathToPOSIX(path)
|
||||
if (path.endsWith('/')) path = path.slice(0, -1)
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore this directory if files are picked up by watcher
|
||||
* @param {string} path
|
||||
*/
|
||||
addIgnoreDir(path) {
|
||||
path = this.cleanDirPath(path)
|
||||
if (this.ignoreDirs.includes(path)) return
|
||||
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
|
||||
Logger.debug(`[Watcher] Ignoring directory "${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) {
|
||||
path = this.cleanDirPath(path)
|
||||
if (!this.ignoreDirs.includes(path)) return
|
||||
Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
|
||||
this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
|
||||
if (!this.ignoreDirs.includes(path) || this.pendingDirsToRemoveFromIgnore.includes(path)) return
|
||||
|
||||
// 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
|
||||
@@ -1,10 +1,13 @@
|
||||
|
||||
const sequelize = require('sequelize')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const { createNewSortInstance } = require('../libs/fastSort')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
|
||||
@@ -15,18 +18,13 @@ class AuthorController {
|
||||
constructor() { }
|
||||
|
||||
async findOne(req, res) {
|
||||
const libraryId = req.query.library
|
||||
const include = (req.query.include || '').split(',')
|
||||
|
||||
const authorJson = req.author.toJSON()
|
||||
|
||||
// Used on author landing page to include library items and items grouped in series
|
||||
if (include.includes('items')) {
|
||||
authorJson.libraryItems = Database.libraryItems.filter(li => {
|
||||
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)
|
||||
})
|
||||
authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
|
||||
|
||||
if (include.includes('series')) {
|
||||
const seriesMap = {}
|
||||
@@ -72,13 +70,13 @@ class AuthorController {
|
||||
// Updating/removing cover image
|
||||
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
|
||||
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
|
||||
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
await this.coverManager.removeFile(req.author.imagePath)
|
||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
await CoverManager.removeFile(req.author.imagePath)
|
||||
} 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 (req.author.imagePath) {
|
||||
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
}
|
||||
payload.imagePath = imageData.path
|
||||
hasUpdated = true
|
||||
@@ -90,7 +88,7 @@ class AuthorController {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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) {
|
||||
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
|
||||
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
|
||||
bookAuthorsToCreate.push({
|
||||
@@ -118,11 +127,11 @@ class AuthorController {
|
||||
// Remove old author
|
||||
await Database.removeAuthor(req.author.id)
|
||||
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
|
||||
const numBooks = Database.libraryItems.filter(li => {
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
|
||||
}).length
|
||||
const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
|
||||
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
|
||||
|
||||
res.json({
|
||||
@@ -137,8 +146,8 @@ class AuthorController {
|
||||
if (hasUpdated) {
|
||||
req.author.updatedAt = Date.now()
|
||||
|
||||
const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
|
||||
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 => {
|
||||
libraryItem.media.metadata.updateAuthor(req.author)
|
||||
})
|
||||
@@ -148,10 +157,7 @@ class AuthorController {
|
||||
}
|
||||
|
||||
await Database.updateAuthor(req.author)
|
||||
const numBooks = Database.libraryItems.filter(li => {
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||
}).length
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(itemsWithAuthor.length))
|
||||
}
|
||||
|
||||
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) {
|
||||
let authorData = null
|
||||
const region = req.body.region || 'us'
|
||||
if (req.body.asin) {
|
||||
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region)
|
||||
authorData = await AuthorFinder.findAuthorByASIN(req.body.asin, region)
|
||||
} else {
|
||||
authorData = await this.authorFinder.findAuthorByName(req.body.q, region)
|
||||
authorData = await AuthorFinder.findAuthorByName(req.body.q, region)
|
||||
}
|
||||
if (!authorData) {
|
||||
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
|
||||
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) {
|
||||
req.author.imagePath = imageData.path
|
||||
hasUpdates = true
|
||||
@@ -211,9 +206,8 @@ class AuthorController {
|
||||
req.author.updatedAt = Date.now()
|
||||
|
||||
await Database.updateAuthor(req.author)
|
||||
const numBooks = Database.libraryItems.filter(li => {
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||
}).length
|
||||
|
||||
const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
}
|
||||
|
||||
@@ -240,11 +234,11 @@ class AuthorController {
|
||||
height: height ? parseInt(height) : null,
|
||||
width: width ? parseInt(width) : null
|
||||
}
|
||||
return this.cacheManager.handleAuthorCache(res, author, options)
|
||||
return CacheManager.handleAuthorCache(res, author, options)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const author = Database.authors.find(au => au.id === req.params.id)
|
||||
async middleware(req, res, next) {
|
||||
const author = await Database.authorModel.getOldById(req.params.id)
|
||||
if (!author) return res.sendStatus(404)
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const Logger = require('../Logger')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
|
||||
class CacheController {
|
||||
constructor() { }
|
||||
@@ -8,7 +8,7 @@ class CacheController {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
await this.cacheManager.purgeAll()
|
||||
await CacheManager.purgeAll()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class CacheController {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
await this.cacheManager.purgeItems()
|
||||
await CacheManager.purgeItems()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const Sequelize = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
@@ -7,162 +8,326 @@ const Collection = require('../objects/Collection')
|
||||
class CollectionController {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* POST: /api/collections
|
||||
* Create new collection
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
var newCollection = new Collection()
|
||||
const newCollection = new Collection()
|
||||
req.body.userId = req.user.id
|
||||
var success = newCollection.setData(req.body)
|
||||
if (!success) {
|
||||
return res.status(500).send('Invalid collection data')
|
||||
if (!newCollection.setData(req.body)) {
|
||||
return res.status(400).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)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
findAll(req, res) {
|
||||
async findAll(req, res) {
|
||||
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
|
||||
res.json({
|
||||
collections: Database.collections.map(c => c.toJSONExpanded(Database.libraryItems))
|
||||
collections: collectionsExpanded
|
||||
})
|
||||
}
|
||||
|
||||
async findOne(req, res) {
|
||||
const includeEntities = (req.query.include || '').split(',')
|
||||
|
||||
const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems)
|
||||
|
||||
if (includeEntities.includes('rssfeed')) {
|
||||
const feedData = await this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
|
||||
collectionExpanded.rssFeed = feedData?.toJSONMinified() || null
|
||||
const collectionExpanded = await req.collection.getOldJsonExpanded(req.user, includeEntities)
|
||||
if (!collectionExpanded) {
|
||||
// This may happen if the user is restricted from all books
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
res.json(collectionExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/collections/:id
|
||||
* Update collection
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const collection = req.collection
|
||||
const wasUpdated = collection.update(req.body)
|
||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
let wasUpdated = false
|
||||
|
||||
// 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) {
|
||||
await Database.updateCollection(collection)
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
const collection = req.collection
|
||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
|
||||
// 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)
|
||||
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) {
|
||||
const collection = req.collection
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === req.body.id)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
|
||||
if (!libraryItem) {
|
||||
return res.status(500).send('Book not found')
|
||||
return res.status(404).send('Book not found')
|
||||
}
|
||||
if (libraryItem.libraryId !== collection.libraryId) {
|
||||
return res.status(500).send('Book in different library')
|
||||
if (libraryItem.libraryId !== req.collection.libraryId) {
|
||||
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 = {
|
||||
collectionId: collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
order: collection.books.length
|
||||
// Check if book is already in collection
|
||||
const collectionBooks = await req.collection.getCollectionBooks()
|
||||
if (collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
|
||||
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)
|
||||
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) {
|
||||
const collection = req.collection
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
|
||||
if (!libraryItem) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (collection.books.includes(req.params.bookId)) {
|
||||
collection.removeBook(req.params.bookId)
|
||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
// Get books in collection ordered
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
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)
|
||||
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) {
|
||||
const collection = req.collection
|
||||
if (!req.body.books || !req.body.books.length) {
|
||||
// filter out invalid libraryItemIds
|
||||
const bookIdsToAdd = (req.body.books || []).filter(b => !!b && typeof b == 'string')
|
||||
if (!bookIdsToAdd.length) {
|
||||
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 = []
|
||||
let hasUpdated = false
|
||||
|
||||
let order = collection.books.length
|
||||
for (const libraryItemId of bookIdsToAdd) {
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
if (!collection.books.includes(libraryItemId)) {
|
||||
collection.addBook(libraryItemId)
|
||||
// Check and set new collection books to add
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (!collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
|
||||
collectionBooksToAdd.push({
|
||||
collectionId: collection.id,
|
||||
collectionId: req.collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
order: order++
|
||||
})
|
||||
hasUpdated = true
|
||||
} else {
|
||||
Logger.warn(`[CollectionController] addBatch: Library item ${libraryItem.id} already in collection`)
|
||||
}
|
||||
}
|
||||
|
||||
let jsonExpanded = null
|
||||
if (hasUpdated) {
|
||||
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) {
|
||||
const collection = req.collection
|
||||
if (!req.body.books || !req.body.books.length) {
|
||||
// filter out invalid libraryItemIds
|
||||
const bookIdsToRemove = (req.body.books || []).filter(b => !!b && typeof b == 'string')
|
||||
if (!bookIdsToRemove.length) {
|
||||
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)) {
|
||||
collection.removeBook(libraryItemId)
|
||||
// Get library items associated with ids
|
||||
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
|
||||
}
|
||||
order++
|
||||
}
|
||||
|
||||
let jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
if (hasUpdated) {
|
||||
await Database.updateCollection(collection)
|
||||
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
}
|
||||
res.json(collection.toJSONExpanded(Database.libraryItems))
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
async middleware(req, res, next) {
|
||||
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) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class EmailController {
|
||||
async sendEBookToDevice(req, res) {
|
||||
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) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
||||
@@ -17,12 +17,11 @@ class FileSystemController {
|
||||
})
|
||||
|
||||
// Do not include existing mapped library paths in response
|
||||
Database.libraries.forEach(lib => {
|
||||
lib.folders.forEach((folder) => {
|
||||
let dir = folder.fullPath
|
||||
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
||||
excludedDirs.push(dir)
|
||||
})
|
||||
const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths()
|
||||
libraryFoldersPaths.forEach((path) => {
|
||||
let dir = path || ''
|
||||
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
||||
excludedDirs.push(dir)
|
||||
})
|
||||
|
||||
res.json({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,24 @@ const zipHelpers = require('../utils/zipHelpers')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
const { ScanResult } = require('../utils/constants')
|
||||
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 {
|
||||
constructor() { }
|
||||
|
||||
// Example expand with authors: api/items/:id?expanded=1&include=authors
|
||||
/**
|
||||
* 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(',')
|
||||
if (req.query.expanded == 1) {
|
||||
@@ -29,17 +42,7 @@ class LibraryItemController {
|
||||
item.rssFeed = feedData?.toJSONMinified() || null
|
||||
}
|
||||
|
||||
if (item.mediaType == 'book') {
|
||||
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')) {
|
||||
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
|
||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
|
||||
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
||||
@@ -56,7 +59,7 @@ class LibraryItemController {
|
||||
var libraryItem = req.libraryItem
|
||||
// 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)) {
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
}
|
||||
|
||||
const hasUpdates = libraryItem.update(req.body)
|
||||
@@ -71,13 +74,14 @@ class LibraryItemController {
|
||||
async delete(req, res) {
|
||||
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||
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) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -103,7 +107,7 @@ class LibraryItemController {
|
||||
|
||||
// Item has cover and update is removing cover so purge it from cache
|
||||
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
}
|
||||
|
||||
// Book specific
|
||||
@@ -124,7 +128,7 @@ class LibraryItemController {
|
||||
// Book specific - Get all series being removed from this item
|
||||
let seriesRemoved = []
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -135,7 +139,7 @@ class LibraryItemController {
|
||||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
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) {
|
||||
@@ -164,10 +168,10 @@ class LibraryItemController {
|
||||
var result = null
|
||||
if (req.body && 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) {
|
||||
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
|
||||
result = await this.coverManager.uploadCover(libraryItem, req.files.cover)
|
||||
result = await CoverManager.uploadCover(libraryItem, req.files.cover)
|
||||
} else {
|
||||
return res.status(400).send('Invalid request no file or url')
|
||||
}
|
||||
@@ -193,7 +197,7 @@ class LibraryItemController {
|
||||
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) {
|
||||
return res.status(500).send(validationResult.error)
|
||||
}
|
||||
@@ -213,7 +217,7 @@ class LibraryItemController {
|
||||
|
||||
if (libraryItem.media.coverPath) {
|
||||
libraryItem.updateMediaCover('')
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
}
|
||||
@@ -242,7 +246,7 @@ class LibraryItemController {
|
||||
height: height ? parseInt(height) : null,
|
||||
width: width ? parseInt(width) : null
|
||||
}
|
||||
return this.cacheManager.handleCoverCache(res, libraryItem, options)
|
||||
return CacheManager.handleCoverCache(res, libraryItem, options)
|
||||
}
|
||||
|
||||
// GET: api/items/:id/stream
|
||||
@@ -296,7 +300,7 @@ class LibraryItemController {
|
||||
var libraryItem = req.libraryItem
|
||||
|
||||
var options = req.body || {}
|
||||
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
res.json(matchResult)
|
||||
}
|
||||
|
||||
@@ -309,18 +313,23 @@ class LibraryItemController {
|
||||
const hardDelete = req.query.hard == 1 // Delete files from filesystem
|
||||
|
||||
const { libraryItemIds } = req.body
|
||||
if (!libraryItemIds || !libraryItemIds.length) {
|
||||
return res.sendStatus(500)
|
||||
if (!libraryItemIds?.length) {
|
||||
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) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
for (let i = 0; i < itemsToDelete.length; i++) {
|
||||
const libraryItemPath = itemsToDelete[i].path
|
||||
Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`)
|
||||
await this.handleDeleteLibraryItem(itemsToDelete[i])
|
||||
|
||||
const libraryId = itemsToDelete[0].libraryId
|
||||
for (const libraryItem of itemsToDelete) {
|
||||
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) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
@@ -328,28 +337,42 @@ class LibraryItemController {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/items/batch/update
|
||||
async batchUpdate(req, res) {
|
||||
var updatePayloads = req.body
|
||||
if (!updatePayloads || !updatePayloads.length) {
|
||||
const updatePayloads = req.body
|
||||
if (!updatePayloads?.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var itemsUpdated = 0
|
||||
let itemsUpdated = 0
|
||||
|
||||
for (let i = 0; i < updatePayloads.length; i++) {
|
||||
var mediaPayload = updatePayloads[i].mediaPayload
|
||||
var libraryItem = Database.libraryItems.find(_li => _li.id === updatePayloads[i].id)
|
||||
for (const updatePayload of updatePayloads) {
|
||||
const mediaPayload = updatePayload.mediaPayload
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
|
||||
if (!libraryItem) return null
|
||||
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||
|
||||
var hasUpdates = libraryItem.media.update(mediaPayload)
|
||||
if (hasUpdates) {
|
||||
let seriesRemoved = []
|
||||
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}`)
|
||||
|
||||
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)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
itemsUpdated++
|
||||
@@ -368,13 +391,11 @@ class LibraryItemController {
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(403).send('Invalid payload')
|
||||
}
|
||||
const libraryItems = []
|
||||
libraryItemIds.forEach((lid) => {
|
||||
const li = Database.libraryItems.find(_li => _li.id === lid)
|
||||
if (li) libraryItems.push(li.toJSONExpanded())
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
id: libraryItemIds
|
||||
})
|
||||
res.json({
|
||||
libraryItems
|
||||
libraryItems: libraryItems.map(li => li.toJSONExpanded())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -393,7 +414,9 @@ class LibraryItemController {
|
||||
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) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
@@ -401,7 +424,7 @@ class LibraryItemController {
|
||||
res.sendStatus(200)
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
@@ -428,23 +451,31 @@ class LibraryItemController {
|
||||
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) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
|
||||
const libraryId = libraryItems[0].libraryId
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (libraryItem.isFile) {
|
||||
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
|
||||
} 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) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
|
||||
@@ -456,7 +487,8 @@ class LibraryItemController {
|
||||
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({
|
||||
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
|
||||
})
|
||||
@@ -529,7 +561,7 @@ class LibraryItemController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const ffprobeData = await this.scanner.probeAudioFile(audioFile)
|
||||
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile)
|
||||
res.json(ffprobeData)
|
||||
}
|
||||
|
||||
@@ -679,8 +711,8 @@ class LibraryItemController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
req.libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
async middleware(req, res, next) {
|
||||
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!req.libraryItem?.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
|
||||
@@ -59,7 +59,7 @@ class MeController {
|
||||
|
||||
// PATCH: api/me/progress/:id
|
||||
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) {
|
||||
return res.status(404).send('Item not found')
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class MeController {
|
||||
// PATCH: api/me/progress/:id/:episodeId
|
||||
async createUpdateEpisodeMediaProgress(req, res) {
|
||||
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) {
|
||||
return res.status(404).send('Item not found')
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class MeController {
|
||||
|
||||
let shouldUpdate = false
|
||||
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 (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
|
||||
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
|
||||
@@ -122,10 +122,10 @@ class MeController {
|
||||
|
||||
// POST: api/me/item/:id/bookmark
|
||||
async createBookmark(req, res) {
|
||||
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!libraryItem) return res.sendStatus(404)
|
||||
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||
|
||||
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)
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
res.json(bookmark)
|
||||
@@ -133,15 +133,17 @@ class MeController {
|
||||
|
||||
// PATCH: api/me/item/:id/bookmark
|
||||
async updateBookmark(req, res) {
|
||||
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!libraryItem) return res.sendStatus(404)
|
||||
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||
|
||||
const { time, title } = req.body
|
||||
if (!req.user.findBookmark(libraryItem.id, time)) {
|
||||
if (!req.user.findBookmark(req.params.id, time)) {
|
||||
Logger.error(`[MeController] updateBookmark not found`)
|
||||
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)
|
||||
|
||||
await Database.updateUser(req.user)
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
res.json(bookmark)
|
||||
@@ -149,16 +151,17 @@ class MeController {
|
||||
|
||||
// DELETE: api/me/item/:id/bookmark/:time
|
||||
async removeBookmark(req, res) {
|
||||
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!libraryItem) return res.sendStatus(404)
|
||||
var time = Number(req.params.time)
|
||||
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||
|
||||
const time = Number(req.params.time)
|
||||
if (isNaN(time)) return res.sendStatus(500)
|
||||
|
||||
if (!req.user.findBookmark(libraryItem.id, time)) {
|
||||
if (!req.user.findBookmark(req.params.id, time)) {
|
||||
Logger.error(`[MeController] removeBookmark not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
req.user.removeBookmark(libraryItem.id, time)
|
||||
|
||||
req.user.removeBookmark(req.params.id, time)
|
||||
await Database.updateUser(req.user)
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
res.sendStatus(200)
|
||||
@@ -188,12 +191,13 @@ class MeController {
|
||||
for (const localProgress of localMediaProgress) {
|
||||
if (!localProgress.libraryItemId) {
|
||||
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) {
|
||||
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
let mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
|
||||
@@ -242,13 +246,15 @@ class MeController {
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
let itemsInProgress = []
|
||||
// TODO: More efficient to do this in a single query
|
||||
for (const mediaProgress of req.user.mediaProgress) {
|
||||
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
|
||||
const libraryItem = Database.getLibraryItem(mediaProgress.libraryItemId)
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(mediaProgress.libraryItemId)
|
||||
if (libraryItem) {
|
||||
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
||||
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
|
||||
@@ -278,7 +284,7 @@ class MeController {
|
||||
|
||||
// GET: api/me/series/:id/remove-from-continue-listening
|
||||
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) {
|
||||
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||
return res.sendStatus(404)
|
||||
@@ -294,7 +300,7 @@ class MeController {
|
||||
|
||||
// GET: api/me/series/:id/readd-to-continue-listening
|
||||
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) {
|
||||
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||
return res.sendStatus(404)
|
||||
@@ -310,9 +316,19 @@ class MeController {
|
||||
|
||||
// GET: api/me/progress/:id/remove-from-continue-listening
|
||||
async removeItemFromContinueListening(req, res) {
|
||||
const mediaProgress = req.user.mediaProgress.find(mp => mp.id === req.params.id)
|
||||
if (!mediaProgress) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
|
||||
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())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
const Sequelize = require('sequelize')
|
||||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||
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 :(
|
||||
@@ -14,7 +15,12 @@ const { isObject } = require('../utils/index')
|
||||
class MiscController {
|
||||
constructor() { }
|
||||
|
||||
// POST: api/upload
|
||||
/**
|
||||
* POST: /api/upload
|
||||
* Update library item
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async handleUpload(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.warn('User attempted to upload without permission', req.user)
|
||||
@@ -24,18 +30,18 @@ class MiscController {
|
||||
Logger.error('Invalid request, no files')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
var files = Object.values(req.files)
|
||||
var title = req.body.title
|
||||
var author = req.body.author
|
||||
var series = req.body.series
|
||||
var libraryId = req.body.library
|
||||
var folderId = req.body.folder
|
||||
const files = Object.values(req.files)
|
||||
const title = req.body.title
|
||||
const author = req.body.author
|
||||
const series = req.body.series
|
||||
const libraryId = req.body.library
|
||||
const folderId = req.body.folder
|
||||
|
||||
var library = Database.libraries.find(lib => lib.id === libraryId)
|
||||
const library = await Database.libraryModel.getOldById(libraryId)
|
||||
if (!library) {
|
||||
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) {
|
||||
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
|
||||
var outputDirectory = ''
|
||||
var firstDirPath = ''
|
||||
let outputDirectory = ''
|
||||
let firstDirPath = ''
|
||||
|
||||
if (library.isPodcast) { // Podcasts only in 1 folder
|
||||
outputDirectory = Path.join(folder.fullPath, title)
|
||||
@@ -62,8 +68,7 @@ class MiscController {
|
||||
}
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(outputDirectory)
|
||||
if (exists) {
|
||||
if (await fs.pathExists(outputDirectory)) {
|
||||
Logger.error(`[Server] Upload 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)
|
||||
}
|
||||
|
||||
// GET: api/tasks
|
||||
/**
|
||||
* GET: /api/tasks
|
||||
* Get tasks for task manager
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
getTasks(req, res) {
|
||||
const includeArray = (req.query.include || '').split(',')
|
||||
|
||||
@@ -106,7 +114,12 @@ class MiscController {
|
||||
res.json(data)
|
||||
}
|
||||
|
||||
// PATCH: api/settings (admin)
|
||||
/**
|
||||
* PATCH: /api/settings
|
||||
* Update server settings
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async updateServerSettings(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to update server settings', req.user)
|
||||
@@ -114,7 +127,7 @@ class MiscController {
|
||||
}
|
||||
const settingsUpdate = req.body
|
||||
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)
|
||||
@@ -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) {
|
||||
Logger.error('Invalid user in authorize')
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
const userResponse = this.auth.getUserLoginResponsePayload(req.user)
|
||||
const userResponse = await this.auth.getUserLoginResponsePayload(req.user)
|
||||
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) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const tags = []
|
||||
Database.libraryItems.forEach((li) => {
|
||||
if (li.media.tags && li.media.tags.length) {
|
||||
li.media.tags.forEach((tag) => {
|
||||
if (!tags.includes(tag)) tags.push(tag)
|
||||
})
|
||||
}
|
||||
const books = await Database.bookModel.findAll({
|
||||
attributes: ['tags'],
|
||||
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
|
||||
[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({
|
||||
tags: tags
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/tags/rename
|
||||
/**
|
||||
* POST: /api/tags/rename
|
||||
* Rename tag
|
||||
* Req.body { tag, newTag }
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async renameTag(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
|
||||
@@ -177,19 +323,26 @@ class MiscController {
|
||||
let tagMerged = false
|
||||
let numItemsUpdated = 0
|
||||
|
||||
for (const li of Database.libraryItems) {
|
||||
if (!li.media.tags || !li.media.tags.length) continue
|
||||
// Update filter data
|
||||
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)) {
|
||||
li.media.tags = li.media.tags.filter(t => t !== tag) // Remove old tag
|
||||
if (!li.media.tags.includes(newTag)) {
|
||||
li.media.tags.push(newTag) // Add new tag
|
||||
if (libraryItem.media.tags.includes(tag)) {
|
||||
libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag
|
||||
if (!libraryItem.media.tags.includes(newTag)) {
|
||||
libraryItem.media.tags.push(newTag)
|
||||
}
|
||||
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
|
||||
await Database.updateLibraryItem(li)
|
||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${libraryItem.media.title}"`)
|
||||
await libraryItem.media.update({
|
||||
tags: libraryItem.media.tags
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
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) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
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()
|
||||
|
||||
let numItemsUpdated = 0
|
||||
for (const li of Database.libraryItems) {
|
||||
if (!li.media.tags || !li.media.tags.length) continue
|
||||
// Get all items with tag
|
||||
const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag])
|
||||
|
||||
if (li.media.tags.includes(tag)) {
|
||||
li.media.tags = li.media.tags.filter(t => t !== tag)
|
||||
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
|
||||
await Database.updateLibraryItem(li)
|
||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
// Update filterdata
|
||||
Database.removeTagFromFilterData(tag)
|
||||
|
||||
let numItemsUpdated = 0
|
||||
// Remove tag from items
|
||||
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({
|
||||
@@ -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) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
const genres = []
|
||||
Database.libraryItems.forEach((li) => {
|
||||
if (li.media.metadata.genres && li.media.metadata.genres.length) {
|
||||
li.media.metadata.genres.forEach((genre) => {
|
||||
if (!genres.includes(genre)) genres.push(genre)
|
||||
})
|
||||
}
|
||||
const books = await Database.bookModel.findAll({
|
||||
attributes: ['genres'],
|
||||
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
|
||||
[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({
|
||||
genres
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/genres/rename
|
||||
/**
|
||||
* POST: /api/genres/rename
|
||||
* Rename genres
|
||||
* Req.body { genre, newGenre }
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async renameGenre(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
|
||||
@@ -263,19 +456,26 @@ class MiscController {
|
||||
let genreMerged = false
|
||||
let numItemsUpdated = 0
|
||||
|
||||
for (const li of Database.libraryItems) {
|
||||
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
||||
// Update filter data
|
||||
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)) {
|
||||
li.media.metadata.genres = li.media.metadata.genres.filter(g => g !== genre) // Remove old genre
|
||||
if (!li.media.metadata.genres.includes(newGenre)) {
|
||||
li.media.metadata.genres.push(newGenre) // Add new genre
|
||||
if (libraryItem.media.genres.includes(genre)) {
|
||||
libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre
|
||||
if (!libraryItem.media.genres.includes(newGenre)) {
|
||||
libraryItem.media.genres.push(newGenre)
|
||||
}
|
||||
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
|
||||
await Database.updateLibraryItem(li)
|
||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${libraryItem.media.title}"`)
|
||||
await libraryItem.media.update({
|
||||
genres: libraryItem.media.genres
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
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) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
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()
|
||||
|
||||
let numItemsUpdated = 0
|
||||
for (const li of Database.libraryItems) {
|
||||
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
||||
// Update filter data
|
||||
Database.removeGenreFromFilterData(genre)
|
||||
|
||||
if (li.media.metadata.genres.includes(genre)) {
|
||||
li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
|
||||
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
|
||||
await Database.updateLibraryItem(li)
|
||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
// Get all items with genre
|
||||
const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre])
|
||||
|
||||
let numItemsUpdated = 0
|
||||
// Remove genre from items
|
||||
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({
|
||||
|
||||
@@ -7,70 +7,187 @@ const Playlist = require('../objects/Playlist')
|
||||
class PlaylistController {
|
||||
constructor() { }
|
||||
|
||||
// POST: api/playlists
|
||||
/**
|
||||
* POST: /api/playlists
|
||||
* Create playlist
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
const newPlaylist = new Playlist()
|
||||
const oldPlaylist = new Playlist()
|
||||
req.body.userId = req.user.id
|
||||
const success = newPlaylist.setData(req.body)
|
||||
const success = oldPlaylist.setData(req.body)
|
||||
if (!success) {
|
||||
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)
|
||||
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({
|
||||
playlists: Database.playlists.filter(p => p.userId === req.user.id).map(p => p.toJSONExpanded(Database.libraryItems))
|
||||
playlists
|
||||
})
|
||||
}
|
||||
|
||||
// GET: api/playlists/:id
|
||||
findOne(req, res) {
|
||||
res.json(req.playlist.toJSONExpanded(Database.libraryItems))
|
||||
/**
|
||||
* GET: /api/playlists/:id
|
||||
* @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) {
|
||||
const playlist = req.playlist
|
||||
let wasUpdated = playlist.update(req.body)
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
const updatedPlaylist = req.playlist.set(req.body)
|
||||
let wasUpdated = false
|
||||
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) {
|
||||
await Database.updatePlaylist(playlist)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// DELETE: api/playlists/:id
|
||||
/**
|
||||
* DELETE: /api/playlists/:id
|
||||
* Remove playlist
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const playlist = req.playlist
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
await Database.removePlaylist(playlist.id)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
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) {
|
||||
const playlist = req.playlist
|
||||
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
|
||||
const itemToAdd = req.body
|
||||
|
||||
if (!itemToAdd.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) {
|
||||
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')
|
||||
}
|
||||
if (playlist.containsItem(itemToAdd)) {
|
||||
if (oldPlaylist.containsItem(itemToAdd)) {
|
||||
return res.status(400).send('Item already in playlist')
|
||||
}
|
||||
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')
|
||||
}
|
||||
|
||||
playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
|
||||
|
||||
const playlistMediaItem = {
|
||||
playlistId: playlist.id,
|
||||
playlistId: oldPlaylist.id,
|
||||
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: playlist.items.length
|
||||
order: oldPlaylist.items.length + 1
|
||||
}
|
||||
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
await Database.createPlaylistMediaItem(playlistMediaItem)
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', 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) {
|
||||
const playlist = req.playlist
|
||||
const itemToRemove = {
|
||||
libraryItemId: req.params.libraryItemId,
|
||||
episodeId: req.params.episodeId || null
|
||||
}
|
||||
if (!playlist.containsItem(itemToRemove)) {
|
||||
return res.sendStatus(404)
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
|
||||
if (!oldLibraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
||||
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
|
||||
if (!playlist.items.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
await Database.removePlaylist(playlist.id)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
if (!jsonExpanded.items.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
} else {
|
||||
await Database.updatePlaylist(playlist)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', 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) {
|
||||
const playlist = req.playlist
|
||||
if (!req.body.items || !req.body.items.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
const itemsToAdd = req.body.items
|
||||
let hasUpdated = false
|
||||
|
||||
let order = playlist.items.length
|
||||
const playlistMediaItems = []
|
||||
const libraryItemIds = itemsToAdd.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
|
||||
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) {
|
||||
if (!item.libraryItemId) {
|
||||
return res.status(400).send('Item does not have libraryItemId')
|
||||
}
|
||||
|
||||
const libraryItem = Database.getLibraryItem(item.libraryItemId)
|
||||
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(400).send('Item not found with id ' + item.libraryItemId)
|
||||
}
|
||||
|
||||
if (!playlist.containsItem(item)) {
|
||||
playlistMediaItems.push({
|
||||
playlistId: playlist.id,
|
||||
mediaItemId: item.episodeId || libraryItem.media.id, // podcastEpisodeId or bookId
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
})
|
||||
playlist.addItem(item.libraryItemId, item.episodeId)
|
||||
hasUpdated = true
|
||||
return res.status(404).send('Item not found with id ' + item.libraryItemId)
|
||||
} else {
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
if (existingPlaylistMediaItems.some(pmi => pmi.mediaItemId === mediaItemId)) {
|
||||
// Already exists in playlist
|
||||
continue
|
||||
} else {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
if (hasUpdated) {
|
||||
await Database.createBulkPlaylistMediaItems(playlistMediaItems)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
let jsonExpanded = null
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
} else {
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
}
|
||||
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) {
|
||||
const playlist = req.playlist
|
||||
if (!req.body.items || !req.body.items.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
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
|
||||
for (const item of itemsToRemove) {
|
||||
if (!item.libraryItemId) {
|
||||
return res.status(400).send('Item does not have libraryItemId')
|
||||
}
|
||||
|
||||
if (playlist.containsItem(item)) {
|
||||
playlist.removeItem(item.libraryItemId, item.episodeId)
|
||||
hasUpdated = true
|
||||
}
|
||||
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
const existingMediaItem = existingPlaylistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
|
||||
if (!existingMediaItem) continue
|
||||
await existingMediaItem.destroy()
|
||||
hasUpdated = true
|
||||
numMediaItems--
|
||||
}
|
||||
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
if (hasUpdated) {
|
||||
// Playlist is removed when there are no items
|
||||
if (!playlist.items.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
await Database.removePlaylist(playlist.id)
|
||||
if (!numMediaItems) {
|
||||
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
} else {
|
||||
await Database.updatePlaylist(playlist)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', 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) {
|
||||
let collection = Database.collections.find(c => c.id === req.params.collectionId)
|
||||
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
// Expand collection to get library items
|
||||
collection = collection.toJSONExpanded(Database.libraryItems)
|
||||
|
||||
// Filter out library items not accessible to user
|
||||
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
|
||||
|
||||
if (!libraryItems.length) {
|
||||
return res.status(400).send('Collection has no books accessible to user')
|
||||
const collectionExpanded = await collection.getOldJsonExpanded(req.user)
|
||||
if (!collectionExpanded) {
|
||||
// This can happen if the user has no access to all items in collection
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
|
||||
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,
|
||||
libraryId: collection.libraryId,
|
||||
name: collection.name,
|
||||
description: collection.description || null,
|
||||
items: libraryItems.map(li => ({ libraryItemId: li.id }))
|
||||
}
|
||||
newPlaylist.setData(newPlaylistData)
|
||||
description: collection.description || null
|
||||
})
|
||||
|
||||
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
|
||||
await Database.createPlaylist(newPlaylist)
|
||||
// Create Playlist record
|
||||
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)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
async middleware(req, res, next) {
|
||||
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) {
|
||||
return res.status(404).send('Playlist not found')
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ const fs = require('../libs/fsExtra')
|
||||
|
||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||
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')
|
||||
|
||||
@@ -19,7 +21,7 @@ class PodcastController {
|
||||
}
|
||||
const payload = req.body
|
||||
|
||||
const library = Database.libraries.find(lib => lib.id === payload.libraryId)
|
||||
const library = await Database.libraryModel.getOldById(payload.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
||||
return res.status(404).send('Library not found')
|
||||
@@ -34,9 +36,13 @@ class PodcastController {
|
||||
const podcastPath = filePathToPOSIX(payload.path)
|
||||
|
||||
// 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) {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -45,7 +51,6 @@ class PodcastController {
|
||||
return false
|
||||
})
|
||||
if (!success) return res.status(400).send('Invalid podcast path')
|
||||
await filePerms.setDefault(podcastPath)
|
||||
|
||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||
|
||||
@@ -71,7 +76,7 @@ class PodcastController {
|
||||
if (payload.media.metadata.imageUrl) {
|
||||
// TODO: Scan cover image to library files
|
||||
// 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.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 episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
||||
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
||||
if (episodesUpdated) {
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
@@ -241,18 +246,18 @@ class PodcastController {
|
||||
|
||||
// DELETE: api/podcasts/:id/episode/:episodeId
|
||||
async removeEpisode(req, res) {
|
||||
var episodeId = req.params.episodeId
|
||||
var libraryItem = req.libraryItem
|
||||
var hardDelete = req.query.hard === '1'
|
||||
const episodeId = req.params.episodeId
|
||||
const libraryItem = req.libraryItem
|
||||
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) {
|
||||
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (hardDelete) {
|
||||
var audioFile = episode.audioFile
|
||||
const audioFile = episode.audioFile
|
||||
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
||||
await fs.remove(audioFile.metadata.path).then(() => {
|
||||
Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
|
||||
@@ -263,18 +268,53 @@ class PodcastController {
|
||||
|
||||
// Remove episode from Podcast and library file
|
||||
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
|
||||
if (episodeRemoved && episodeRemoved.audioFile) {
|
||||
if (episodeRemoved?.audioFile) {
|
||||
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)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
res.json(libraryItem.toJSON())
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const item = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
async middleware(req, res, next) {
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!item?.media) return res.sendStatus(404)
|
||||
|
||||
if (!item.isPodcast) {
|
||||
return res.sendStatus(500)
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
class RSSFeedController {
|
||||
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
|
||||
async openRSSFeedForItem(req, res) {
|
||||
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)
|
||||
|
||||
// Check user can access this library item
|
||||
@@ -45,7 +54,7 @@ class RSSFeedController {
|
||||
async openRSSFeedForCollection(req, res) {
|
||||
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)
|
||||
|
||||
// Check request body options exist
|
||||
@@ -60,7 +69,7 @@ class RSSFeedController {
|
||||
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)
|
||||
|
||||
// Check collection has audio tracks
|
||||
@@ -79,7 +88,7 @@ class RSSFeedController {
|
||||
async openRSSFeedForSeries(req, res) {
|
||||
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)
|
||||
|
||||
// Check request body options exist
|
||||
@@ -95,8 +104,9 @@ class RSSFeedController {
|
||||
}
|
||||
|
||||
const seriesJson = series.toJSON()
|
||||
|
||||
// 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
|
||||
if (!seriesJson.books.length) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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 {
|
||||
constructor() { }
|
||||
@@ -7,7 +11,7 @@ class SearchController {
|
||||
const provider = req.query.provider || 'google'
|
||||
const title = req.query.title || ''
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -21,8 +25,8 @@ class SearchController {
|
||||
}
|
||||
|
||||
let results = null
|
||||
if (podcast) results = await this.podcastFinder.findCovers(query.title)
|
||||
else results = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
|
||||
if (podcast) results = await PodcastFinder.findCovers(query.title)
|
||||
else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
|
||||
res.json({
|
||||
results
|
||||
})
|
||||
@@ -30,20 +34,20 @@ class SearchController {
|
||||
|
||||
async findPodcasts(req, res) {
|
||||
const term = req.query.term
|
||||
const results = await this.podcastFinder.search(term)
|
||||
const results = await PodcastFinder.search(term)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
async findAuthor(req, res) {
|
||||
const query = req.query.q
|
||||
const author = await this.authorFinder.findAuthorByName(query)
|
||||
const author = await AuthorFinder.findAuthorByName(query)
|
||||
res.json(author)
|
||||
}
|
||||
|
||||
async findChapters(req, res) {
|
||||
const asin = req.query.asin
|
||||
const region = (req.query.region || 'us').toLowerCase()
|
||||
const chapterData = await this.bookFinder.findChapters(asin, region)
|
||||
const chapterData = await BookFinder.findChapters(asin, region)
|
||||
if (!chapterData) {
|
||||
return res.json({ error: 'Chapters not found' })
|
||||
}
|
||||
@@ -51,7 +55,7 @@ class SearchController {
|
||||
}
|
||||
|
||||
async findMusicTrack(req, res) {
|
||||
const tracks = await this.musicFinder.searchTrack(req.query || {})
|
||||
const tracks = await MusicFinder.searchTrack(req.query || {})
|
||||
res.json({
|
||||
tracks
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
class SeriesController {
|
||||
constructor() { }
|
||||
@@ -25,7 +26,7 @@ class SeriesController {
|
||||
const libraryItemsInSeries = req.libraryItemsInSeries
|
||||
const libraryItemsFinished = libraryItemsInSeries.filter(li => {
|
||||
const mediaProgress = req.user.getMediaProgress(li.id)
|
||||
return mediaProgress && mediaProgress.isFinished
|
||||
return mediaProgress?.isFinished
|
||||
})
|
||||
seriesJson.progress = {
|
||||
libraryItemIds: libraryItemsInSeries.map(li => li.id),
|
||||
@@ -42,17 +43,6 @@ class SeriesController {
|
||||
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) {
|
||||
const hasUpdated = req.series.update(req.body)
|
||||
if (hasUpdated) {
|
||||
@@ -62,18 +52,17 @@ class SeriesController {
|
||||
res.json(req.series.toJSON())
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const series = Database.series.find(se => se.id === req.params.id)
|
||||
async middleware(req, res, next) {
|
||||
const series = await Database.seriesModel.getOldById(req.params.id)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
||||
/**
|
||||
* Filter out any library items not accessible to user
|
||||
*/
|
||||
const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
|
||||
const libraryItemsAccessible = libraryItems.filter(li => req.user.checkCanAccessLibraryItem(li))
|
||||
if (libraryItems.length && !libraryItemsAccessible.length) {
|
||||
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user)
|
||||
return res.sendStatus(403)
|
||||
const libraryItems = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
|
||||
if (!libraryItems.length) {
|
||||
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" with no accessible books`, req.user)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
@@ -85,7 +74,7 @@ class SeriesController {
|
||||
}
|
||||
|
||||
req.series = series
|
||||
req.libraryItemsInSeries = libraryItemsAccessible
|
||||
req.libraryItemsInSeries = libraryItems
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,17 +43,17 @@ class SessionController {
|
||||
res.json(payload)
|
||||
}
|
||||
|
||||
getOpenSessions(req, res) {
|
||||
async getOpenSessions(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[SessionController] getOpenSessions: Non-admin user requested open session data ${req.user.id}/"${req.user.username}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
|
||||
const openSessions = this.playbackSessionManager.sessions.map(se => {
|
||||
const user = Database.users.find(u => u.id === se.userId) || null
|
||||
return {
|
||||
...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) {
|
||||
var libraryItem = Database.getLibraryItem(req.session.libraryItemId)
|
||||
var sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||
async getOpenSession(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId)
|
||||
const sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||
res.json(sessionForClient)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class ToolsController {
|
||||
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
const libraryItem = Database.getLibraryItem(libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
|
||||
return res.sendStatus(404)
|
||||
@@ -99,15 +99,15 @@ class ToolsController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
async middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.params.id) {
|
||||
const item = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!item?.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
|
||||
@@ -17,7 +17,8 @@ class UserController {
|
||||
const includes = (req.query.include || '').split(',').map(i => i.trim())
|
||||
|
||||
// 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')) {
|
||||
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) {
|
||||
Logger.error('User other than admin attempting to get user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const user = Database.users.find(u => u.id === req.params.id)
|
||||
if (!user) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
// Get user media progress with associated mediaItem
|
||||
const mediaProgresses = await Database.mediaProgressModel.findAll({
|
||||
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) {
|
||||
var account = req.body
|
||||
const account = req.body
|
||||
const username = account.username
|
||||
|
||||
var username = account.username
|
||||
var usernameExists = Database.users.find(u => u.username.toLowerCase() === username.toLowerCase())
|
||||
const usernameExists = await Database.userModel.getUserByUsername(username)
|
||||
if (usernameExists) {
|
||||
return res.status(500).send('Username already taken')
|
||||
}
|
||||
@@ -73,7 +116,7 @@ class UserController {
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
var user = req.reqUser
|
||||
const user = req.reqUser
|
||||
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
|
||||
@@ -84,7 +127,7 @@ class UserController {
|
||||
var shouldUpdateToken = false
|
||||
|
||||
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) {
|
||||
return res.status(500).send('Username already taken')
|
||||
}
|
||||
@@ -126,9 +169,13 @@ class UserController {
|
||||
// Todo: check if user is logged in and cancel streams
|
||||
|
||||
// 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) {
|
||||
await Database.removePlaylist(playlist.id)
|
||||
await playlist.destroy()
|
||||
}
|
||||
|
||||
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) {
|
||||
return res.sendStatus(403)
|
||||
} else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) {
|
||||
@@ -186,7 +233,7 @@ class UserController {
|
||||
}
|
||||
|
||||
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) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
@@ -5,23 +5,23 @@ const { Sequelize } = require('sequelize')
|
||||
const Database = require('../Database')
|
||||
|
||||
const getLibraryItemMinified = (libraryItemId) => {
|
||||
return Database.models.libraryItem.findByPk(libraryItemId, {
|
||||
return Database.libraryItemModel.findByPk(libraryItemId, {
|
||||
include: [
|
||||
{
|
||||
model: Database.models.book,
|
||||
model: Database.bookModel,
|
||||
attributes: [
|
||||
'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags'
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: Database.models.author,
|
||||
model: Database.authorModel,
|
||||
attributes: ['id', 'name'],
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.models.series,
|
||||
model: Database.seriesModel,
|
||||
attributes: ['id', 'name'],
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
@@ -30,7 +30,7 @@ const getLibraryItemMinified = (libraryItemId) => {
|
||||
]
|
||||
},
|
||||
{
|
||||
model: Database.models.podcast,
|
||||
model: Database.podcastModel,
|
||||
attributes: [
|
||||
'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']
|
||||
@@ -41,19 +41,19 @@ const getLibraryItemMinified = (libraryItemId) => {
|
||||
}
|
||||
|
||||
const getLibraryItemExpanded = (libraryItemId) => {
|
||||
return Database.models.libraryItem.findByPk(libraryItemId, {
|
||||
return Database.libraryItemModel.findByPk(libraryItemId, {
|
||||
include: [
|
||||
{
|
||||
model: Database.models.book,
|
||||
model: Database.bookModel,
|
||||
include: [
|
||||
{
|
||||
model: Database.models.author,
|
||||
model: Database.authorModel,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.models.series,
|
||||
model: Database.seriesModel,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
@@ -61,10 +61,10 @@ const getLibraryItemExpanded = (libraryItemId) => {
|
||||
]
|
||||
},
|
||||
{
|
||||
model: Database.models.podcast,
|
||||
model: Database.podcastModel,
|
||||
include: [
|
||||
{
|
||||
model: Database.models.podcastEpisode
|
||||
model: Database.podcastEpisodeModel
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -4,12 +4,9 @@ const Path = require('path')
|
||||
const Audnexus = require('../providers/Audnexus')
|
||||
|
||||
const { downloadFile } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
class AuthorFinder {
|
||||
constructor() {
|
||||
this.AuthorPath = Path.join(global.MetadataPath, 'authors')
|
||||
|
||||
this.audnexus = new Audnexus()
|
||||
}
|
||||
|
||||
@@ -37,12 +34,11 @@ class AuthorFinder {
|
||||
}
|
||||
|
||||
async saveAuthorImage(authorId, url) {
|
||||
var authorDir = this.AuthorPath
|
||||
var authorDir = Path.join(global.MetadataPath, 'authors')
|
||||
var relAuthorDir = Path.posix.join('/metadata', 'authors')
|
||||
|
||||
if (!await fs.pathExists(authorDir)) {
|
||||
await fs.ensureDir(authorDir)
|
||||
await filePerms.setDefault(authorDir)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
module.exports = BookFinder
|
||||
module.exports = new BookFinder()
|
||||
@@ -9,4 +9,4 @@ class MusicFinder {
|
||||
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)
|
||||
}
|
||||
}
|
||||
module.exports = PodcastFinder
|
||||
module.exports = new PodcastFinder()
|
||||
@@ -5,7 +5,6 @@ const fs = require('../libs/fsExtra')
|
||||
const workerThreads = require('worker_threads')
|
||||
const Logger = require('../Logger')
|
||||
const Task = require('../objects/Task')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||
const toneHelpers = require('../utils/toneHelpers')
|
||||
|
||||
@@ -201,10 +200,6 @@ class AbMergeManager {
|
||||
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${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()
|
||||
await this.removeTask(task, false)
|
||||
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
||||
|
||||
@@ -8,10 +8,10 @@ const cron = require('../libs/nodeCron')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const archiver = require('../libs/archiver')
|
||||
const StreamZip = require('../libs/nodeStreamZip')
|
||||
const fileUtils = require('../utils/fileUtils')
|
||||
|
||||
// Utils
|
||||
const { getFileSize } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
const Backup = require('../objects/Backup')
|
||||
|
||||
@@ -83,7 +83,7 @@ class BackupManager {
|
||||
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) => {
|
||||
Logger.error('[BackupManager] Failed to move backup file', path, error)
|
||||
return false
|
||||
@@ -93,8 +93,14 @@ class BackupManager {
|
||||
}
|
||||
|
||||
const zip = new StreamZip.async({ file: tempPath })
|
||||
|
||||
const entries = await zip.entries()
|
||||
let 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')) {
|
||||
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.')
|
||||
@@ -268,7 +274,7 @@ class BackupManager {
|
||||
|
||||
/**
|
||||
* @see https://github.com/TryGhost/node-sqlite3/pull/1116
|
||||
* @param {Backup} backup
|
||||
* @param {Backup} backup
|
||||
* @promise
|
||||
*/
|
||||
backupSqliteDb(backup) {
|
||||
|
||||
@@ -1,42 +1,40 @@
|
||||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const stream = require('stream')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const Logger = require('../Logger')
|
||||
const { resizeImage } = require('../utils/ffmpegHelpers')
|
||||
|
||||
class CacheManager {
|
||||
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.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
||||
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))) {
|
||||
await fs.mkdir(this.CachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(this.CoverCachePath))) {
|
||||
await fs.mkdir(this.CoverCachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(this.ImageCachePath))) {
|
||||
await fs.mkdir(this.ImageCachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(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)
|
||||
if (!writtenFile) return res.sendStatus(500)
|
||||
|
||||
// Set owner and permissions of cache image
|
||||
await filePerms.setDefault(path)
|
||||
|
||||
if (global.XAccel) {
|
||||
Logger.debug(`Use X-Accel to serve static file ${writtenFile}`)
|
||||
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)
|
||||
if (!writtenFile) return res.sendStatus(500)
|
||||
|
||||
// Set owner and permissions of cache image
|
||||
await filePerms.setDefault(path)
|
||||
|
||||
var readStream = fs.createReadStream(writtenFile)
|
||||
readStream.pipe(res)
|
||||
}
|
||||
}
|
||||
module.exports = CacheManager
|
||||
module.exports = new CacheManager()
|
||||
@@ -3,24 +3,20 @@ const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const readChunk = require('../libs/readChunk')
|
||||
const imageType = require('../libs/imageType')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
const globals = require('../utils/globals')
|
||||
const { downloadFile, filePathToPOSIX } = require('../utils/fileUtils')
|
||||
const { downloadFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
|
||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
|
||||
class CoverManager {
|
||||
constructor(cacheManager) {
|
||||
this.cacheManager = cacheManager
|
||||
|
||||
this.ItemMetadataPath = Path.posix.join(global.MetadataPath, 'items')
|
||||
}
|
||||
constructor() { }
|
||||
|
||||
getCoverDirectory(libraryItem) {
|
||||
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) {
|
||||
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
|
||||
return libraryItem.path
|
||||
} 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.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
|
||||
|
||||
await filePerms.setDefault(coverFullPath)
|
||||
libraryItem.updateMediaCover(coverFullPath)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
@@ -146,11 +141,9 @@ class CoverManager {
|
||||
await fs.rename(temppath, coverFullPath)
|
||||
|
||||
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}"`)
|
||||
|
||||
await filePerms.setDefault(coverFullPath)
|
||||
libraryItem.updateMediaCover(coverFullPath)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
@@ -180,6 +173,7 @@ class CoverManager {
|
||||
updated: false
|
||||
}
|
||||
}
|
||||
|
||||
// Cover path does not exist
|
||||
if (!await fs.pathExists(coverPath)) {
|
||||
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
|
||||
@@ -187,8 +181,17 @@ class CoverManager {
|
||||
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
|
||||
var imgtype = await this.checkFileIsValidImage(coverPath, true)
|
||||
var imgtype = await this.checkFileIsValidImage(coverPath, false)
|
||||
if (imgtype.error) {
|
||||
return imgtype
|
||||
}
|
||||
@@ -212,13 +215,12 @@ class CoverManager {
|
||||
error: 'Failed to copy cover to dir'
|
||||
}
|
||||
}
|
||||
await filePerms.setDefault(newCoverPath)
|
||||
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
||||
Logger.debug(`[CoverManager] cover copy success`)
|
||||
coverPath = newCoverPath
|
||||
}
|
||||
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
libraryItem.updateMediaCover(coverPath)
|
||||
return {
|
||||
@@ -253,12 +255,97 @@ class CoverManager {
|
||||
|
||||
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
|
||||
if (success) {
|
||||
await filePerms.setDefault(coverFilePath)
|
||||
|
||||
libraryItem.updateMediaCover(coverFilePath)
|
||||
return coverFilePath
|
||||
}
|
||||
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 Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const LibraryScanner = require('../scanner/LibraryScanner')
|
||||
|
||||
class CronManager {
|
||||
constructor(scanner, podcastManager) {
|
||||
this.scanner = scanner
|
||||
constructor(podcastManager) {
|
||||
this.podcastManager = podcastManager
|
||||
|
||||
this.libraryScanCrons = []
|
||||
@@ -13,13 +14,21 @@ class CronManager {
|
||||
this.podcastCronExpressionsExecuting = []
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initLibraryScanCrons()
|
||||
this.initPodcastCrons()
|
||||
/**
|
||||
* Initialize library scan crons & podcast download crons
|
||||
* @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) {
|
||||
this.startCronForLibrary(library)
|
||||
}
|
||||
@@ -30,7 +39,7 @@ class CronManager {
|
||||
Logger.debug(`[CronManager] Init library scan cron for ${library.name} on schedule ${library.settings.autoScanCronExpression}`)
|
||||
const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => {
|
||||
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
|
||||
this.scanner.scan(library)
|
||||
LibraryScanner.scan(library)
|
||||
})
|
||||
this.libraryScanCrons.push({
|
||||
libraryId: library.id,
|
||||
@@ -62,23 +71,34 @@ class CronManager {
|
||||
}
|
||||
}
|
||||
|
||||
initPodcastCrons() {
|
||||
/**
|
||||
* Init cron jobs for auto-download podcasts
|
||||
*/
|
||||
async initPodcastCrons() {
|
||||
const cronExpressionMap = {}
|
||||
Database.libraryItems.forEach((li) => {
|
||||
if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) {
|
||||
if (!li.media.autoDownloadSchedule) {
|
||||
Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`)
|
||||
} else {
|
||||
if (!cronExpressionMap[li.media.autoDownloadSchedule]) {
|
||||
cronExpressionMap[li.media.autoDownloadSchedule] = {
|
||||
expression: li.media.autoDownloadSchedule,
|
||||
libraryItemIds: []
|
||||
}
|
||||
}
|
||||
cronExpressionMap[li.media.autoDownloadSchedule].libraryItemIds.push(li.id)
|
||||
|
||||
const podcastsWithAutoDownload = await Database.podcastModel.findAll({
|
||||
where: {
|
||||
autoDownloadEpisodes: true,
|
||||
autoDownloadSchedule: {
|
||||
[Sequelize.Op.not]: null
|
||||
}
|
||||
},
|
||||
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
|
||||
|
||||
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
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
|
||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
const DailyLog = require('../objects/DailyLog')
|
||||
|
||||
@@ -25,13 +24,11 @@ class LogManager {
|
||||
async ensureLogDirs() {
|
||||
await fs.ensureDir(this.DailyLogPath)
|
||||
await fs.ensureDir(this.ScanLogPath)
|
||||
await filePerms.setDefault(Path.posix.join(global.MetadataPath, 'logs'), true)
|
||||
}
|
||||
|
||||
async ensureScanLogDir() {
|
||||
if (!(await fs.pathExists(this.ScanLogPath))) {
|
||||
await fs.mkdir(this.ScanLogPath)
|
||||
await filePerms.setDefault(this.ScanLogPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,15 +14,15 @@ class NotificationManager {
|
||||
return notificationData
|
||||
}
|
||||
|
||||
onPodcastEpisodeDownloaded(libraryItem, episode) {
|
||||
async onPodcastEpisodeDownloaded(libraryItem, episode) {
|
||||
if (!Database.notificationSettings.isUseable) return
|
||||
|
||||
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 = {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryId: libraryItem.libraryId,
|
||||
libraryName: library ? library.name : 'Unknown',
|
||||
libraryName: library?.name || 'Unknown',
|
||||
mediaTags: (libraryItem.media.tags || []).join(', '),
|
||||
podcastTitle: libraryItem.media.metadata.title,
|
||||
podcastAuthor: libraryItem.media.metadata.author || '',
|
||||
|
||||
@@ -93,7 +93,7 @@ class PlaybackSessionManager {
|
||||
}
|
||||
|
||||
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
|
||||
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
|
||||
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
|
||||
@@ -259,13 +259,13 @@ class PlaybackSessionManager {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
|
||||
return null
|
||||
@@ -304,7 +304,7 @@ class PlaybackSessionManager {
|
||||
await this.saveSession(session)
|
||||
}
|
||||
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)
|
||||
return this.removeSession(session.id)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ const fs = require('../libs/fsExtra')
|
||||
|
||||
const { getPodcastFeed } = require('../utils/podcastUtils')
|
||||
const { removeFile, downloadFile } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
const opmlParser = require('../utils/parsers/parseOPML')
|
||||
const opmlGenerator = require('../utils/generators/opmlGenerator')
|
||||
@@ -50,7 +49,7 @@ class PodcastManager {
|
||||
}
|
||||
|
||||
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) {
|
||||
const newPe = new PodcastEpisode()
|
||||
newPe.setData(ep, index++)
|
||||
@@ -96,7 +95,6 @@ class PodcastManager {
|
||||
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`)
|
||||
await fs.mkdir(this.currentDownload.libraryItem.path)
|
||||
await filePerms.setDefault(this.currentDownload.libraryItem.path)
|
||||
}
|
||||
|
||||
let success = false
|
||||
@@ -150,7 +148,7 @@ class PodcastManager {
|
||||
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) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
||||
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) {
|
||||
|
||||
@@ -6,26 +6,28 @@ const Database = require('../Database')
|
||||
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Feed = require('../objects/Feed')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
class RssFeedManager {
|
||||
constructor() { }
|
||||
|
||||
validateFeedEntity(feedObj) {
|
||||
async validateFeedEntity(feedObj) {
|
||||
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`)
|
||||
return false
|
||||
}
|
||||
} 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`)
|
||||
return false
|
||||
}
|
||||
} else if (feedObj.entityType === 'series') {
|
||||
const series = Database.series.find(s => s.id === feedObj.entityId)
|
||||
const hasSeriesBook = series ? Database.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) : false
|
||||
if (!hasSeriesBook) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found or has no audio tracks`)
|
||||
const series = await Database.seriesModel.getOldById(feedObj.entityId)
|
||||
if (!series) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
@@ -39,10 +41,10 @@ class RssFeedManager {
|
||||
* Validate all feeds and remove invalid
|
||||
*/
|
||||
async init() {
|
||||
const feeds = await Database.models.feed.getOldFeeds()
|
||||
const feeds = await Database.feedModel.getOldFeeds()
|
||||
for (const feed of feeds) {
|
||||
// Remove invalid feeds
|
||||
if (!this.validateFeedEntity(feed)) {
|
||||
if (!await this.validateFeedEntity(feed)) {
|
||||
await Database.removeFeed(feed.id)
|
||||
}
|
||||
}
|
||||
@@ -50,29 +52,29 @@ class RssFeedManager {
|
||||
|
||||
/**
|
||||
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
|
||||
* @param {string} entityId
|
||||
* @param {string} entityId
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
findFeedForEntityId(entityId) {
|
||||
return Database.models.feed.findOneOld({ entityId })
|
||||
return Database.feedModel.findOneOld({ entityId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open feed for a slug
|
||||
* @param {string} slug
|
||||
* @param {string} slug
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
findFeedBySlug(slug) {
|
||||
return Database.models.feed.findOneOld({ slug })
|
||||
return Database.feedModel.findOneOld({ slug })
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open feed for a slug
|
||||
* @param {string} slug
|
||||
* @param {string} slug
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
findFeed(id) {
|
||||
return Database.models.feed.findByPkOld(id)
|
||||
return Database.feedModel.findByPkOld(id)
|
||||
}
|
||||
|
||||
async getFeed(req, res) {
|
||||
@@ -85,7 +87,7 @@ class RssFeedManager {
|
||||
|
||||
// Check if feed needs to be updated
|
||||
if (feed.entityType === 'libraryItem') {
|
||||
const libraryItem = Database.getLibraryItem(feed.entityId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
|
||||
|
||||
let mostRecentlyUpdatedAt = libraryItem.updatedAt
|
||||
if (libraryItem.isPodcast) {
|
||||
@@ -96,13 +98,14 @@ class RssFeedManager {
|
||||
|
||||
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
|
||||
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
|
||||
|
||||
feed.updateFromItem(libraryItem)
|
||||
await Database.updateFeed(feed)
|
||||
}
|
||||
} 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) {
|
||||
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||
|
||||
// Find most recently updated item in collection
|
||||
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
|
||||
@@ -120,11 +123,12 @@ class RssFeedManager {
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
const seriesJson = series.toJSON()
|
||||
|
||||
// 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
|
||||
let mostRecentlyUpdatedAt = seriesJson.updatedAt
|
||||
@@ -258,5 +262,11 @@ class RssFeedManager {
|
||||
if (!feed) return
|
||||
return this.handleCloseFeed(feed)
|
||||
}
|
||||
|
||||
async getFeeds() {
|
||||
const feeds = await Database.models.feed.getOldFeeds()
|
||||
Logger.info(`[RssFeedManager] Fetched all feeds`)
|
||||
return feeds
|
||||
}
|
||||
}
|
||||
module.exports = RssFeedManager
|
||||
|
||||
@@ -1,86 +1,171 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const { DataTypes, Model, literal } = require('sequelize')
|
||||
|
||||
const oldAuthor = require('../objects/entities/Author')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Author extends Model {
|
||||
static async getOldAuthors() {
|
||||
const authors = await this.findAll()
|
||||
return authors.map(au => au.getOldAuthor())
|
||||
}
|
||||
class Author extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
getOldAuthor() {
|
||||
return new oldAuthor({
|
||||
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()
|
||||
})
|
||||
}
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {string} */
|
||||
this.lastFirst
|
||||
/** @type {string} */
|
||||
this.asin
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {string} */
|
||||
this.imagePath
|
||||
/** @type {UUIDV4} */
|
||||
this.libraryId
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
static updateFromOld(oldAuthor) {
|
||||
const author = this.getFromOld(oldAuthor)
|
||||
return this.update(author, {
|
||||
where: {
|
||||
id: author.id
|
||||
}
|
||||
})
|
||||
}
|
||||
static async getOldAuthors() {
|
||||
const authors = await this.findAll()
|
||||
return authors.map(au => au.getOldAuthor())
|
||||
}
|
||||
|
||||
static createFromOld(oldAuthor) {
|
||||
const author = this.getFromOld(oldAuthor)
|
||||
return this.create(author)
|
||||
}
|
||||
getOldAuthor() {
|
||||
return new oldAuthor({
|
||||
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) {
|
||||
const authors = oldAuthors.map(this.getFromOld)
|
||||
return this.bulkCreate(authors)
|
||||
}
|
||||
|
||||
static getFromOld(oldAuthor) {
|
||||
return {
|
||||
id: oldAuthor.id,
|
||||
name: oldAuthor.name,
|
||||
asin: oldAuthor.asin,
|
||||
description: oldAuthor.description,
|
||||
imagePath: oldAuthor.imagePath,
|
||||
libraryId: oldAuthor.libraryId
|
||||
static updateFromOld(oldAuthor) {
|
||||
const author = this.getFromOld(oldAuthor)
|
||||
return this.update(author, {
|
||||
where: {
|
||||
id: author.id
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static removeById(authorId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: authorId
|
||||
}
|
||||
})
|
||||
static createFromOld(oldAuthor) {
|
||||
const author = this.getFromOld(oldAuthor)
|
||||
return this.create(author)
|
||||
}
|
||||
|
||||
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({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
asin: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
imagePath: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'author'
|
||||
})
|
||||
static removeById(authorId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: authorId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const { library } = sequelize.models
|
||||
library.hasMany(Author, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Author.belongsTo(library)
|
||||
/**
|
||||
* Get oldAuthor by id
|
||||
* @param {string} authorId
|
||||
* @returns {Promise<oldAuthor>}
|
||||
*/
|
||||
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
|
||||
}
|
||||
]
|
||||
}))?.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
|
||||
|
||||
@@ -1,121 +1,273 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Book extends Model {
|
||||
static getOldBook(libraryItemExpanded) {
|
||||
const bookExpanded = libraryItemExpanded.media
|
||||
const authors = bookExpanded.authors.map(au => {
|
||||
/**
|
||||
* @typedef EBookFileObject
|
||||
* @property {string} ino
|
||||
* @property {string} ebookFormat
|
||||
* @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 {
|
||||
id: au.id,
|
||||
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 {
|
||||
id: se.id,
|
||||
name: se.name,
|
||||
sequence: se.bookSeries.sequence
|
||||
}
|
||||
})
|
||||
return {
|
||||
id: bookExpanded.id,
|
||||
libraryItemId: libraryItemExpanded.id,
|
||||
coverPath: bookExpanded.coverPath,
|
||||
tags: bookExpanded.tags,
|
||||
audioFiles: bookExpanded.audioFiles,
|
||||
chapters: bookExpanded.chapters,
|
||||
ebookFile: bookExpanded.ebookFile,
|
||||
metadata: {
|
||||
title: bookExpanded.title,
|
||||
subtitle: bookExpanded.subtitle,
|
||||
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
|
||||
} else if (bookExpanded.bookSeries?.length) {
|
||||
series = bookExpanded.bookSeries.map(bs => {
|
||||
if (bs.series) {
|
||||
return {
|
||||
id: bs.series.id,
|
||||
name: bs.series.name,
|
||||
sequence: bs.sequence
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}).filter(s => s)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} oldBook
|
||||
* @returns {boolean} true if updated
|
||||
*/
|
||||
static saveFromOld(oldBook) {
|
||||
const book = this.getFromOld(oldBook)
|
||||
return this.update(book, {
|
||||
where: {
|
||||
id: book.id
|
||||
}
|
||||
}).then(result => result[0] > 0).catch((error) => {
|
||||
Logger.error(`[Book] Failed to save book ${book.id}`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
static getFromOld(oldBook) {
|
||||
return {
|
||||
id: oldBook.id,
|
||||
title: oldBook.metadata.title,
|
||||
subtitle: oldBook.metadata.subtitle,
|
||||
publishedYear: oldBook.metadata.publishedYear,
|
||||
publishedDate: oldBook.metadata.publishedDate,
|
||||
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,
|
||||
audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [],
|
||||
chapters: oldBook.chapters,
|
||||
tags: oldBook.tags,
|
||||
genres: oldBook.metadata.genres
|
||||
return {
|
||||
id: bookExpanded.id,
|
||||
libraryItemId: libraryItemExpanded.id,
|
||||
coverPath: bookExpanded.coverPath,
|
||||
tags: bookExpanded.tags,
|
||||
audioFiles: bookExpanded.audioFiles,
|
||||
chapters: bookExpanded.chapters,
|
||||
ebookFile: bookExpanded.ebookFile,
|
||||
metadata: {
|
||||
title: bookExpanded.title,
|
||||
subtitle: bookExpanded.subtitle,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Book.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
title: 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,
|
||||
/**
|
||||
* @param {object} oldBook
|
||||
* @returns {boolean} true if updated
|
||||
*/
|
||||
static saveFromOld(oldBook) {
|
||||
const book = this.getFromOld(oldBook)
|
||||
return this.update(book, {
|
||||
where: {
|
||||
id: book.id
|
||||
}
|
||||
}).then(result => result[0] > 0).catch((error) => {
|
||||
Logger.error(`[Book] Failed to save book ${book.id}`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
narrators: DataTypes.JSON,
|
||||
audioFiles: DataTypes.JSON,
|
||||
ebookFile: DataTypes.JSON,
|
||||
chapters: DataTypes.JSON,
|
||||
tags: DataTypes.JSON,
|
||||
genres: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'book'
|
||||
})
|
||||
static getFromOld(oldBook) {
|
||||
return {
|
||||
id: oldBook.id,
|
||||
title: oldBook.metadata.title,
|
||||
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
|
||||
subtitle: oldBook.metadata.subtitle,
|
||||
publishedYear: oldBook.metadata.publishedYear,
|
||||
publishedDate: oldBook.metadata.publishedDate,
|
||||
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
|
||||
@@ -1,40 +1,57 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class BookAuthor extends Model {
|
||||
static removeByIds(authorId = null, bookId = null) {
|
||||
const where = {}
|
||||
if (authorId) where.authorId = authorId
|
||||
if (bookId) where.bookId = bookId
|
||||
return this.destroy({
|
||||
where
|
||||
})
|
||||
}
|
||||
class BookAuthor extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {UUIDV4} */
|
||||
this.bookId
|
||||
/** @type {UUIDV4} */
|
||||
this.authorId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
BookAuthor.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'bookAuthor',
|
||||
timestamps: false
|
||||
})
|
||||
static removeByIds(authorId = null, bookId = null) {
|
||||
const where = {}
|
||||
if (authorId) where.authorId = authorId
|
||||
if (bookId) where.bookId = bookId
|
||||
return this.destroy({
|
||||
where
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
const { book, author } = sequelize.models
|
||||
book.belongsToMany(author, { through: BookAuthor })
|
||||
author.belongsToMany(book, { through: BookAuthor })
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'bookAuthor',
|
||||
timestamps: true,
|
||||
updatedAt: false
|
||||
})
|
||||
|
||||
book.hasMany(BookAuthor)
|
||||
BookAuthor.belongsTo(book)
|
||||
// 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
|
||||
const { book, author } = sequelize.models
|
||||
book.belongsToMany(author, { through: BookAuthor })
|
||||
author.belongsToMany(book, { through: BookAuthor })
|
||||
|
||||
author.hasMany(BookAuthor)
|
||||
BookAuthor.belongsTo(author)
|
||||
book.hasMany(BookAuthor)
|
||||
BookAuthor.belongsTo(book)
|
||||
|
||||
return BookAuthor
|
||||
}
|
||||
author.hasMany(BookAuthor)
|
||||
BookAuthor.belongsTo(author)
|
||||
}
|
||||
}
|
||||
module.exports = BookAuthor
|
||||
@@ -1,41 +1,65 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class BookSeries extends Model {
|
||||
static removeByIds(seriesId = null, bookId = null) {
|
||||
const where = {}
|
||||
if (seriesId) where.seriesId = seriesId
|
||||
if (bookId) where.bookId = bookId
|
||||
return this.destroy({
|
||||
where
|
||||
})
|
||||
}
|
||||
class BookSeries extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.sequence
|
||||
/** @type {UUIDV4} */
|
||||
this.bookId
|
||||
/** @type {UUIDV4} */
|
||||
this.seriesId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
BookSeries.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
sequence: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'bookSeries',
|
||||
timestamps: false
|
||||
})
|
||||
static removeByIds(seriesId = null, bookId = null) {
|
||||
const where = {}
|
||||
if (seriesId) where.seriesId = seriesId
|
||||
if (bookId) where.bookId = bookId
|
||||
return this.destroy({
|
||||
where
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
const { book, series } = sequelize.models
|
||||
book.belongsToMany(series, { through: BookSeries })
|
||||
series.belongsToMany(book, { through: BookSeries })
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
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)
|
||||
BookSeries.belongsTo(book)
|
||||
// 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
|
||||
const { book, series } = sequelize.models
|
||||
book.belongsToMany(series, { through: BookSeries })
|
||||
series.belongsToMany(book, { through: BookSeries })
|
||||
|
||||
series.hasMany(BookSeries)
|
||||
BookSeries.belongsTo(series)
|
||||
book.hasMany(BookSeries, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
BookSeries.belongsTo(book)
|
||||
|
||||
return BookSeries
|
||||
}
|
||||
series.hasMany(BookSeries, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
BookSeries.belongsTo(series)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BookSeries
|
||||
@@ -1,116 +1,342 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const { DataTypes, Model, Sequelize } = require('sequelize')
|
||||
|
||||
const oldCollection = require('../objects/Collection')
|
||||
const { areEquivalent } = require('../utils/index')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Collection extends Model {
|
||||
static async getOldCollections() {
|
||||
const collections = await this.findAll({
|
||||
include: {
|
||||
model: sequelize.models.book,
|
||||
include: sequelize.models.libraryItem
|
||||
|
||||
class Collection extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
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) {
|
||||
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()
|
||||
})
|
||||
}
|
||||
// Users with restricted permissions will not see this collection
|
||||
if (!books.length && oldCollection.books.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
static createFromOld(oldCollection) {
|
||||
const collection = this.getFromOld(oldCollection)
|
||||
return this.create(collection)
|
||||
}
|
||||
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
|
||||
|
||||
static async fullUpdateFromOld(oldCollection, collectionBooks) {
|
||||
const existingCollection = await this.findByPk(oldCollection.id, {
|
||||
include: sequelize.models.collectionBook
|
||||
})
|
||||
if (!existingCollection) return false
|
||||
// Map feed if found
|
||||
if (c.feeds?.length) {
|
||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
|
||||
}
|
||||
|
||||
let hasUpdates = false
|
||||
const collection = this.getFromOld(oldCollection)
|
||||
return collectionExpanded
|
||||
}).filter(c => c)
|
||||
}
|
||||
|
||||
for (const cb of collectionBooks) {
|
||||
const existingCb = existingCollection.collectionBooks.find(i => i.bookId === cb.bookId)
|
||||
if (!existingCb) {
|
||||
await sequelize.models.collectionBook.create(cb)
|
||||
hasUpdates = true
|
||||
} else if (existingCb.order != cb.order) {
|
||||
await existingCb.update({ order: cb.order })
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
for (const cb of existingCollection.collectionBooks) {
|
||||
// collectionBook was removed
|
||||
if (!collectionBooks.some(i => i.bookId === cb.bookId)) {
|
||||
await cb.destroy()
|
||||
hasUpdates = true
|
||||
/**
|
||||
* Get old collection toJSONExpanded, items filtered for user permissions
|
||||
* @param {[oldUser]} user
|
||||
* @param {[string[]]} include
|
||||
* @returns {Promise<object>} oldCollection.toJSONExpanded
|
||||
*/
|
||||
async getOldJsonExpanded(user, include) {
|
||||
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']
|
||||
}
|
||||
},
|
||||
|
||||
],
|
||||
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
|
||||
for (const key in collection) {
|
||||
let existingValue = existingCollection[key]
|
||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
||||
if (!areEquivalent(collection[key], existingValue)) {
|
||||
hasCollectionUpdates = true
|
||||
}
|
||||
}
|
||||
if (hasCollectionUpdates) {
|
||||
existingCollection.update(collection)
|
||||
hasUpdates = true
|
||||
}
|
||||
return hasUpdates
|
||||
// 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)
|
||||
})
|
||||
|
||||
// Users with restricted permissions will not see this collection
|
||||
if (!books.length && oldCollection.books.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
static getFromOld(oldCollection) {
|
||||
return {
|
||||
id: oldCollection.id,
|
||||
name: oldCollection.name,
|
||||
description: oldCollection.description,
|
||||
libraryId: oldCollection.libraryId
|
||||
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
|
||||
|
||||
if (include?.includes('rssfeed')) {
|
||||
const feeds = await this.getFeeds()
|
||||
if (feeds?.length) {
|
||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
|
||||
}
|
||||
}
|
||||
|
||||
static removeById(collectionId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: collectionId
|
||||
}
|
||||
})
|
||||
return collectionExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
description: DataTypes.TEXT
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'collection'
|
||||
})
|
||||
static removeById(collectionId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class CollectionBook extends Model {
|
||||
static removeByIds(collectionId, bookId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
bookId,
|
||||
collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
class CollectionBook extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {number} */
|
||||
this.order
|
||||
/** @type {UUIDV4} */
|
||||
this.bookId
|
||||
/** @type {UUIDV4} */
|
||||
this.collectionId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
CollectionBook.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
order: DataTypes.INTEGER
|
||||
}, {
|
||||
sequelize,
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
modelName: 'collectionBook'
|
||||
})
|
||||
static removeByIds(collectionId, bookId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
bookId,
|
||||
collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
const { book, collection } = sequelize.models
|
||||
book.belongsToMany(collection, { through: CollectionBook })
|
||||
collection.belongsToMany(book, { through: CollectionBook })
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
order: DataTypes.INTEGER
|
||||
}, {
|
||||
sequelize,
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
modelName: 'collectionBook'
|
||||
})
|
||||
|
||||
book.hasMany(CollectionBook, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
CollectionBook.belongsTo(book)
|
||||
// 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
|
||||
const { book, collection } = sequelize.models
|
||||
book.belongsToMany(collection, { through: CollectionBook })
|
||||
collection.belongsToMany(book, { through: CollectionBook })
|
||||
|
||||
collection.hasMany(CollectionBook, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
CollectionBook.belongsTo(collection)
|
||||
book.hasMany(CollectionBook, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
CollectionBook.belongsTo(book)
|
||||
|
||||
return CollectionBook
|
||||
}
|
||||
collection.hasMany(CollectionBook, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
CollectionBook.belongsTo(collection)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CollectionBook
|
||||
@@ -1,116 +1,147 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const oldDevice = require('../objects/DeviceInfo')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Device extends Model {
|
||||
getOldDevice() {
|
||||
let browserVersion = null
|
||||
let sdkVersion = null
|
||||
if (this.clientName === 'Abs Android') {
|
||||
sdkVersion = this.deviceVersion || null
|
||||
} else {
|
||||
browserVersion = this.deviceVersion || null
|
||||
}
|
||||
class Device extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
return new oldDevice({
|
||||
id: this.id,
|
||||
deviceId: this.deviceId,
|
||||
userId: this.userId,
|
||||
ipAddress: this.ipAddress,
|
||||
browserName: this.extraData.browserName || null,
|
||||
browserVersion,
|
||||
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
|
||||
})
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.deviceId
|
||||
/** @type {string} */
|
||||
this.clientName
|
||||
/** @type {string} */
|
||||
this.clientVersion
|
||||
/** @type {string} */
|
||||
this.ipAddress
|
||||
/** @type {string} */
|
||||
this.deviceName
|
||||
/** @type {string} */
|
||||
this.deviceVersion
|
||||
/** @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) {
|
||||
const device = await this.findOne({
|
||||
where: {
|
||||
deviceId
|
||||
}
|
||||
})
|
||||
if (!device) return null
|
||||
return device.getOldDevice()
|
||||
return new oldDevice({
|
||||
id: this.id,
|
||||
deviceId: this.deviceId,
|
||||
userId: this.userId,
|
||||
ipAddress: this.ipAddress,
|
||||
browserName: this.extraData.browserName || null,
|
||||
browserVersion,
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
deviceId: DataTypes.STRING,
|
||||
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
|
||||
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
|
||||
ipAddress: DataTypes.STRING,
|
||||
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'
|
||||
})
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
deviceId: DataTypes.STRING,
|
||||
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
|
||||
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
|
||||
ipAddress: DataTypes.STRING,
|
||||
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, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Device.belongsTo(user)
|
||||
user.hasMany(Device, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Device.belongsTo(user)
|
||||
}
|
||||
}
|
||||
|
||||
return Device
|
||||
}
|
||||
module.exports = Device
|
||||
@@ -1,300 +1,361 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const oldFeed = require('../objects/Feed')
|
||||
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) {
|
||||
const episodes = feedExpanded.feedEpisodes.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||
class Feed extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
return new oldFeed({
|
||||
id: feedExpanded.id,
|
||||
slug: feedExpanded.slug,
|
||||
userId: feedExpanded.userId,
|
||||
entityType: feedExpanded.entityType,
|
||||
entityId: feedExpanded.entityId,
|
||||
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
|
||||
meta: {
|
||||
title: feedExpanded.title,
|
||||
description: feedExpanded.description,
|
||||
author: feedExpanded.author,
|
||||
imageUrl: feedExpanded.imageURL,
|
||||
feedUrl: feedExpanded.feedURL,
|
||||
link: feedExpanded.siteURL,
|
||||
explicit: feedExpanded.explicit,
|
||||
type: feedExpanded.podcastType,
|
||||
language: feedExpanded.language,
|
||||
preventIndexing: feedExpanded.preventIndexing,
|
||||
ownerName: feedExpanded.ownerName,
|
||||
ownerEmail: feedExpanded.ownerEmail
|
||||
},
|
||||
serverAddress: feedExpanded.serverAddress,
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.slug
|
||||
/** @type {string} */
|
||||
this.entityType
|
||||
/** @type {UUIDV4} */
|
||||
this.entityId
|
||||
/** @type {Date} */
|
||||
this.entityUpdatedAt
|
||||
/** @type {string} */
|
||||
this.serverAddress
|
||||
/** @type {string} */
|
||||
this.feedURL
|
||||
/** @type {string} */
|
||||
this.imageURL
|
||||
/** @type {string} */
|
||||
this.siteURL
|
||||
/** @type {string} */
|
||||
this.title
|
||||
/** @type {string} */
|
||||
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,
|
||||
episodes,
|
||||
createdAt: feedExpanded.createdAt.valueOf(),
|
||||
updatedAt: feedExpanded.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
link: feedExpanded.siteURL,
|
||||
explicit: feedExpanded.explicit,
|
||||
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) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: feedId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all library item ids that have an open feed (used in library filter)
|
||||
* @returns {Promise<Array<String>>} array of library item ids
|
||||
*/
|
||||
static async findAllLibraryItemIds() {
|
||||
const feeds = await this.findAll({
|
||||
attributes: ['entityId'],
|
||||
where: {
|
||||
entityType: 'libraryItem'
|
||||
}
|
||||
})
|
||||
return feeds.map(f => f.entityId).filter(f => f) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Find feed where and return oldFeed
|
||||
* @param {object} where sequelize where object
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
static async findOneOld(where) {
|
||||
if (!where) return null
|
||||
const feedExpanded = await this.findOne({
|
||||
where,
|
||||
include: {
|
||||
model: sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
if (!feedExpanded) return null
|
||||
return this.getOldFeed(feedExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find feed and return oldFeed
|
||||
* @param {string} id
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
static async findByPkOld(id) {
|
||||
if (!id) return null
|
||||
const feedExpanded = await this.findByPk(id, {
|
||||
include: {
|
||||
model: sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
if (!feedExpanded) return null
|
||||
return this.getOldFeed(feedExpanded)
|
||||
}
|
||||
|
||||
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 removeById(feedId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: feedId
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async fullUpdateFromOld(oldFeed) {
|
||||
const oldFeedEpisodes = oldFeed.episodes || []
|
||||
const feedObj = this.getFromOld(oldFeed)
|
||||
|
||||
const existingFeed = await this.findByPk(feedObj.id, {
|
||||
include: sequelize.models.feedEpisode
|
||||
})
|
||||
if (!existingFeed) return false
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Find all library item ids that have an open feed (used in library filter)
|
||||
* @returns {Promise<Array<String>>} array of library item ids
|
||||
*/
|
||||
static async findAllLibraryItemIds() {
|
||||
const feeds = await this.findAll({
|
||||
attributes: ['entityId'],
|
||||
where: {
|
||||
entityType: 'libraryItem'
|
||||
}
|
||||
})
|
||||
return feeds.map(f => f.entityId).filter(f => f) || []
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
/**
|
||||
* Find feed where and return oldFeed
|
||||
* @param {object} where sequelize where object
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
static async findOneOld(where) {
|
||||
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)
|
||||
hasUpdates = true
|
||||
/**
|
||||
* Find feed and return oldFeed
|
||||
* @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) {
|
||||
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,
|
||||
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
|
||||
if (oldFeed.episodes?.length) {
|
||||
for (const oldFeedEpisode of oldFeed.episodes) {
|
||||
const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
||||
feedEpisode.feedId = newFeed.id
|
||||
await this.sequelize.models.feedEpisode.create(feedEpisode)
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(options) {
|
||||
if (!this.entityType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${sequelize.uppercaseFirst(this.entityType)}`
|
||||
return this[mixinMethodName](options)
|
||||
}
|
||||
}
|
||||
|
||||
Feed.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
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'feed'
|
||||
})
|
||||
static async fullUpdateFromOld(oldFeed) {
|
||||
const oldFeedEpisodes = oldFeed.episodes || []
|
||||
const feedObj = this.getFromOld(oldFeed)
|
||||
|
||||
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)
|
||||
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
|
||||
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 = this.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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1,82 +1,125 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class FeedEpisode extends Model {
|
||||
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
|
||||
}
|
||||
}
|
||||
class FeedEpisode extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
static getFromOld(oldFeedEpisode) {
|
||||
return {
|
||||
id: oldFeedEpisode.id,
|
||||
title: oldFeedEpisode.title,
|
||||
author: oldFeedEpisode.author,
|
||||
description: oldFeedEpisode.description,
|
||||
siteURL: oldFeedEpisode.link,
|
||||
enclosureURL: oldFeedEpisode.enclosure?.url || null,
|
||||
enclosureType: oldFeedEpisode.enclosure?.type || null,
|
||||
enclosureSize: oldFeedEpisode.enclosure?.size || null,
|
||||
pubDate: oldFeedEpisode.pubDate,
|
||||
season: oldFeedEpisode.season || null,
|
||||
episode: oldFeedEpisode.episode || null,
|
||||
episodeType: oldFeedEpisode.episodeType || null,
|
||||
duration: oldFeedEpisode.duration,
|
||||
filePath: oldFeedEpisode.fullPath,
|
||||
explicit: !!oldFeedEpisode.explicit
|
||||
}
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.title
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {string} */
|
||||
this.siteURL
|
||||
/** @type {string} */
|
||||
this.enclosureURL
|
||||
/** @type {string} */
|
||||
this.enclosureType
|
||||
/** @type {BigInt} */
|
||||
this.enclosureSize
|
||||
/** @type {string} */
|
||||
this.pubDate
|
||||
/** @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({
|
||||
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'
|
||||
})
|
||||
static getFromOld(oldFeedEpisode) {
|
||||
return {
|
||||
id: oldFeedEpisode.id,
|
||||
title: oldFeedEpisode.title,
|
||||
author: oldFeedEpisode.author,
|
||||
description: oldFeedEpisode.description,
|
||||
siteURL: oldFeedEpisode.link,
|
||||
enclosureURL: oldFeedEpisode.enclosure?.url || null,
|
||||
enclosureType: oldFeedEpisode.enclosure?.type || null,
|
||||
enclosureSize: oldFeedEpisode.enclosure?.size || null,
|
||||
pubDate: oldFeedEpisode.pubDate,
|
||||
season: oldFeedEpisode.season || null,
|
||||
episode: oldFeedEpisode.episode || null,
|
||||
episodeType: oldFeedEpisode.episodeType || null,
|
||||
duration: oldFeedEpisode.duration,
|
||||
filePath: oldFeedEpisode.fullPath,
|
||||
explicit: !!oldFeedEpisode.explicit
|
||||
}
|
||||
}
|
||||
|
||||
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, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
FeedEpisode.belongsTo(feed)
|
||||
const { feed } = sequelize.models
|
||||
|
||||
return FeedEpisode
|
||||
}
|
||||
feed.hasMany(FeedEpisode, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
FeedEpisode.belongsTo(feed)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FeedEpisode
|
||||
@@ -2,144 +2,261 @@ const { DataTypes, Model } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const oldLibrary = require('../objects/Library')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Library extends Model {
|
||||
static async getAllOldLibraries() {
|
||||
const libraries = await this.findAll({
|
||||
include: sequelize.models.libraryFolder,
|
||||
order: [['displayOrder', 'ASC']]
|
||||
})
|
||||
return libraries.map(lib => this.getOldLibrary(lib))
|
||||
}
|
||||
/**
|
||||
* @typedef LibrarySettingsObject
|
||||
* @property {number} coverAspectRatio BookCoverAspectRatio
|
||||
* @property {boolean} disableWatcher
|
||||
* @property {boolean} skipMatchingMediaWithAsin
|
||||
* @property {boolean} skipMatchingMediaWithIsbn
|
||||
* @property {string} autoScanCronExpression
|
||||
* @property {boolean} audiobooksOnly
|
||||
* @property {boolean} hideSingleBookSeries Do not show series that only have 1 book
|
||||
*/
|
||||
|
||||
static getOldLibrary(libraryExpanded) {
|
||||
const folders = libraryExpanded.libraryFolders.map(folder => {
|
||||
return {
|
||||
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()
|
||||
})
|
||||
}
|
||||
class Library extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/**
|
||||
* @param {object} oldLibrary
|
||||
* @returns {Library|null}
|
||||
*/
|
||||
static async createFromOld(oldLibrary) {
|
||||
const library = this.getFromOld(oldLibrary)
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {number} */
|
||||
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 {
|
||||
id: folder.id,
|
||||
path: folder.fullPath
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Get all old libraries
|
||||
* @returns {Promise<oldLibrary[]>}
|
||||
*/
|
||||
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
|
||||
}).catch((error) => {
|
||||
Logger.error(`[Library] Failed to create library ${library.id}`, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
/**
|
||||
* Convert expanded Library to oldLibrary
|
||||
* @param {Library} libraryExpanded
|
||||
* @returns {Promise<oldLibrary>}
|
||||
*/
|
||||
static getOldLibrary(libraryExpanded) {
|
||||
const folders = libraryExpanded.libraryFolders.map(folder => {
|
||||
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
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: libraryId
|
||||
}
|
||||
})
|
||||
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 {
|
||||
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: {
|
||||
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'
|
||||
})
|
||||
/**
|
||||
* Destroy library by id
|
||||
* @param {string} libraryId
|
||||
* @returns
|
||||
*/
|
||||
static removeById(libraryId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: libraryId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,25 +1,55 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class LibraryFolder extends Model { }
|
||||
class LibraryFolder extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
LibraryFolder.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
path: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'libraryFolder'
|
||||
})
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.path
|
||||
/** @type {UUIDV4} */
|
||||
this.libraryId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
const { library } = sequelize.models
|
||||
library.hasMany(LibraryFolder, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
LibraryFolder.belongsTo(library)
|
||||
/**
|
||||
* Gets all library folder path strings
|
||||
* @returns {Promise<string[]>} array of library folder paths
|
||||
*/
|
||||
static async getAllLibraryFolderPaths() {
|
||||
const libraryFolders = await this.findAll({
|
||||
attributes: ['path']
|
||||
})
|
||||
return libraryFolders.map(l => l.path)
|
||||
}
|
||||
|
||||
return LibraryFolder
|
||||
}
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
path: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'libraryFolder'
|
||||
})
|
||||
|
||||
const { library } = sequelize.models
|
||||
library.hasMany(LibraryFolder, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
LibraryFolder.belongsTo(library)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LibraryFolder
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +1,184 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
/*
|
||||
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
|
||||
* Book has many MediaProgress. PodcastEpisode has many MediaProgress.
|
||||
*/
|
||||
module.exports = (sequelize) => {
|
||||
class MediaProgress extends Model {
|
||||
getOldMediaProgress() {
|
||||
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
|
||||
class MediaProgress extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
userId: this.userId,
|
||||
libraryItemId: this.extraData?.libraryItemId || null,
|
||||
episodeId: isPodcastEpisode ? this.mediaItemId : null,
|
||||
mediaItemId: this.mediaItemId,
|
||||
mediaItemType: this.mediaItemType,
|
||||
duration: this.duration,
|
||||
progress: this.extraData?.progress || null,
|
||||
currentTime: this.currentTime,
|
||||
isFinished: !!this.isFinished,
|
||||
hideFromContinueListening: !!this.hideFromContinueListening,
|
||||
ebookLocation: this.ebookLocation,
|
||||
ebookProgress: this.ebookProgress,
|
||||
lastUpdate: this.updatedAt.valueOf(),
|
||||
startedAt: this.createdAt.valueOf(),
|
||||
finishedAt: this.finishedAt?.valueOf() || null
|
||||
}
|
||||
}
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {UUIDV4} */
|
||||
this.mediaItemId
|
||||
/** @type {string} */
|
||||
this.mediaItemType
|
||||
/** @type {number} */
|
||||
this.duration
|
||||
/** @type {number} */
|
||||
this.currentTime
|
||||
/** @type {boolean} */
|
||||
this.isFinished
|
||||
/** @type {boolean} */
|
||||
this.hideFromContinueListening
|
||||
/** @type {string} */
|
||||
this.ebookLocation
|
||||
/** @type {number} */
|
||||
this.ebookProgress
|
||||
/** @type {Date} */
|
||||
this.finishedAt
|
||||
/** @type {Object} */
|
||||
this.extraData
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
static upsertFromOld(oldMediaProgress) {
|
||||
const mediaProgress = this.getFromOld(oldMediaProgress)
|
||||
return this.upsert(mediaProgress)
|
||||
}
|
||||
getOldMediaProgress() {
|
||||
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
|
||||
|
||||
static getFromOld(oldMediaProgress) {
|
||||
return {
|
||||
id: oldMediaProgress.id,
|
||||
userId: oldMediaProgress.userId,
|
||||
mediaItemId: oldMediaProgress.mediaItemId,
|
||||
mediaItemType: oldMediaProgress.mediaItemType,
|
||||
duration: oldMediaProgress.duration,
|
||||
currentTime: oldMediaProgress.currentTime,
|
||||
ebookLocation: oldMediaProgress.ebookLocation || null,
|
||||
ebookProgress: oldMediaProgress.ebookProgress || null,
|
||||
isFinished: !!oldMediaProgress.isFinished,
|
||||
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
|
||||
finishedAt: oldMediaProgress.finishedAt,
|
||||
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
|
||||
updatedAt: oldMediaProgress.lastUpdate,
|
||||
extraData: {
|
||||
libraryItemId: oldMediaProgress.libraryItemId,
|
||||
progress: oldMediaProgress.progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static removeById(mediaProgressId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: mediaProgressId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getMediaItem(options) {
|
||||
if (!this.mediaItemType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
|
||||
return this[mixinMethodName](options)
|
||||
return {
|
||||
id: this.id,
|
||||
userId: this.userId,
|
||||
libraryItemId: this.extraData?.libraryItemId || null,
|
||||
episodeId: isPodcastEpisode ? this.mediaItemId : null,
|
||||
mediaItemId: this.mediaItemId,
|
||||
mediaItemType: this.mediaItemType,
|
||||
duration: this.duration,
|
||||
progress: this.extraData?.progress || 0,
|
||||
currentTime: this.currentTime,
|
||||
isFinished: !!this.isFinished,
|
||||
hideFromContinueListening: !!this.hideFromContinueListening,
|
||||
ebookLocation: this.ebookLocation,
|
||||
ebookProgress: this.ebookProgress,
|
||||
lastUpdate: this.updatedAt.valueOf(),
|
||||
startedAt: this.createdAt.valueOf(),
|
||||
finishedAt: this.finishedAt?.valueOf() || null
|
||||
}
|
||||
}
|
||||
|
||||
static upsertFromOld(oldMediaProgress) {
|
||||
const mediaProgress = this.getFromOld(oldMediaProgress)
|
||||
return this.upsert(mediaProgress)
|
||||
}
|
||||
|
||||
MediaProgress.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
mediaItemId: DataTypes.UUIDV4,
|
||||
mediaItemType: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
currentTime: DataTypes.FLOAT,
|
||||
isFinished: DataTypes.BOOLEAN,
|
||||
hideFromContinueListening: DataTypes.BOOLEAN,
|
||||
ebookLocation: DataTypes.STRING,
|
||||
ebookProgress: DataTypes.FLOAT,
|
||||
finishedAt: DataTypes.DATE,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'mediaProgress'
|
||||
})
|
||||
|
||||
const { book, podcastEpisode, user } = sequelize.models
|
||||
|
||||
book.hasMany(MediaProgress, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'book'
|
||||
}
|
||||
})
|
||||
MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
podcastEpisode.hasMany(MediaProgress, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'podcastEpisode'
|
||||
}
|
||||
})
|
||||
MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
MediaProgress.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
|
||||
instance.mediaItem = instance.book
|
||||
instance.dataValues.mediaItem = instance.dataValues.book
|
||||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||
instance.mediaItem = instance.podcastEpisode
|
||||
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
|
||||
static getFromOld(oldMediaProgress) {
|
||||
return {
|
||||
id: oldMediaProgress.id,
|
||||
userId: oldMediaProgress.userId,
|
||||
mediaItemId: oldMediaProgress.mediaItemId,
|
||||
mediaItemType: oldMediaProgress.mediaItemType,
|
||||
duration: oldMediaProgress.duration,
|
||||
currentTime: oldMediaProgress.currentTime,
|
||||
ebookLocation: oldMediaProgress.ebookLocation || null,
|
||||
ebookProgress: oldMediaProgress.ebookProgress || null,
|
||||
isFinished: !!oldMediaProgress.isFinished,
|
||||
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
|
||||
finishedAt: oldMediaProgress.finishedAt,
|
||||
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
|
||||
updatedAt: oldMediaProgress.lastUpdate,
|
||||
extraData: {
|
||||
libraryItemId: oldMediaProgress.libraryItemId,
|
||||
progress: oldMediaProgress.progress
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.book
|
||||
delete instance.dataValues.book
|
||||
delete instance.podcastEpisode
|
||||
delete instance.dataValues.podcastEpisode
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
user.hasMany(MediaProgress, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
MediaProgress.belongsTo(user)
|
||||
static removeById(mediaProgressId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: mediaProgressId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return MediaProgress
|
||||
}
|
||||
getMediaItem(options) {
|
||||
if (!this.mediaItemType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
|
||||
return this[mixinMethodName](options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
*
|
||||
* Polymorphic association: Book has many MediaProgress. PodcastEpisode has many MediaProgress.
|
||||
* @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
|
||||
},
|
||||
mediaItemId: DataTypes.UUIDV4,
|
||||
mediaItemType: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
currentTime: DataTypes.FLOAT,
|
||||
isFinished: DataTypes.BOOLEAN,
|
||||
hideFromContinueListening: DataTypes.BOOLEAN,
|
||||
ebookLocation: DataTypes.STRING,
|
||||
ebookProgress: DataTypes.FLOAT,
|
||||
finishedAt: DataTypes.DATE,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'mediaProgress',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['updatedAt']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { book, podcastEpisode, user } = sequelize.models
|
||||
|
||||
book.hasMany(MediaProgress, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'book'
|
||||
}
|
||||
})
|
||||
MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
podcastEpisode.hasMany(MediaProgress, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'podcastEpisode'
|
||||
}
|
||||
})
|
||||
MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
MediaProgress.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
|
||||
instance.mediaItem = instance.book
|
||||
instance.dataValues.mediaItem = instance.dataValues.book
|
||||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||
instance.mediaItem = instance.podcastEpisode
|
||||
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.book
|
||||
delete instance.dataValues.book
|
||||
delete instance.podcastEpisode
|
||||
delete instance.dataValues.podcastEpisode
|
||||
}
|
||||
})
|
||||
|
||||
user.hasMany(MediaProgress, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
MediaProgress.belongsTo(user)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MediaProgress
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user