mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-24 02:48:32 -05:00
Compare commits
48 Commits
v2.13.3
...
migrations
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f37d4a7d5 | ||
|
|
2f83e86d69 | ||
|
|
5c49a8ce6a | ||
|
|
854f308eae | ||
|
|
16ba6b53ba | ||
|
|
0af29a378a | ||
|
|
def34a860b | ||
|
|
f8034e1b78 | ||
|
|
01fbea02f1 | ||
|
|
3d9af89e24 | ||
|
|
d430d9f3ed | ||
|
|
0c24a1e626 | ||
|
|
1099dbe642 | ||
|
|
2df3277dcd | ||
|
|
6ae14213f5 | ||
|
|
61bd029303 | ||
|
|
5b09bd8242 | ||
|
|
703477b157 | ||
|
|
03ff5d8ae1 | ||
|
|
220f7ef7cd | ||
|
|
682a99dd43 | ||
|
|
fac5de582d | ||
|
|
7cbf9de8ca | ||
|
|
ce213c3d89 | ||
|
|
32cd0360e6 | ||
|
|
1ec23a5699 | ||
|
|
48330f6432 | ||
|
|
28358debbc | ||
|
|
54b7ed6117 | ||
|
|
0cfd2ee63b | ||
|
|
37a0990741 | ||
|
|
7a0cd1eb34 | ||
|
|
ac3277da09 | ||
|
|
65d1e7be56 | ||
|
|
80685afa7e | ||
|
|
f892453892 | ||
|
|
422bb8c31c | ||
|
|
6fb1202c1c | ||
|
|
4ddd2788f0 | ||
|
|
8a28029809 | ||
|
|
423a2129d1 | ||
|
|
a338097514 | ||
|
|
84b67abb03 | ||
|
|
5ec8406653 | ||
|
|
b3ce300d32 | ||
|
|
3f93b93d9e | ||
|
|
e32c83db63 | ||
|
|
0344a63b48 |
@@ -264,7 +264,6 @@ export default {
|
||||
libraryItems.forEach((item) => {
|
||||
let subtitle = ''
|
||||
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
|
||||
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
|
||||
queueItems.push({
|
||||
libraryItemId: item.id,
|
||||
libraryId: item.libraryId,
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
{{ seriesName }}
|
||||
</p>
|
||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||
<span class="font-mono">{{ numShowing }}</span>
|
||||
<span class="font-mono">{{ $formatNumber(numShowing) }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</template>
|
||||
<!-- library & collections page -->
|
||||
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
|
||||
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
|
||||
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
|
||||
|
||||
<!-- issues page remove all button -->
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ $formatNumber(numShowing) }} {{ entityName }}</ui-btn>
|
||||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
@@ -246,9 +246,6 @@ export default {
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isLibraryPage() {
|
||||
return this.page === ''
|
||||
},
|
||||
@@ -281,7 +278,6 @@ export default {
|
||||
},
|
||||
entityName() {
|
||||
if (this.isAlbumsPage) return 'Albums'
|
||||
if (this.isMusicLibrary) return 'Tracks'
|
||||
|
||||
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
|
||||
if (!this.page) return this.$strings.LabelBooks
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
|
||||
<div id="videoDock" />
|
||||
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
|
||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="flex items-start mb-6 lg:mb-0" :class="isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="min-w-0 w-full">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||
@@ -12,10 +11,9 @@
|
||||
</nuxt-link>
|
||||
<widgets-explicit-indicator v-if="isExplicit" />
|
||||
</div>
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
||||
<div class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
||||
<span class="material-symbols text-sm">person</span>
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
@@ -140,9 +138,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.streamLibraryItem?.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
@@ -174,10 +169,6 @@ export default {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || 'Unknown'
|
||||
},
|
||||
musicArtists() {
|
||||
if (!this.isMusic) return null
|
||||
return this.mediaMetadata.artists.join(', ')
|
||||
},
|
||||
hasNextItemInQueue() {
|
||||
return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1
|
||||
},
|
||||
|
||||
@@ -95,14 +95,6 @@
|
||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-symbols text-xl">album</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
|
||||
|
||||
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-symbols text-2xl"></span>
|
||||
|
||||
@@ -172,9 +164,6 @@ export default {
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isPodcastDownloadQueuePage() {
|
||||
return this.$route.name === 'library-library-podcast-download-queue'
|
||||
},
|
||||
@@ -184,9 +173,6 @@ export default {
|
||||
isPodcastLatestPage() {
|
||||
return this.$route.name === 'library-library-podcast-latest'
|
||||
},
|
||||
isMusicAlbumsPage() {
|
||||
return this.paramId === 'albums'
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
|
||||
@@ -226,9 +226,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
@@ -336,7 +333,6 @@ export default {
|
||||
displayLineTwo() {
|
||||
if (this.recentEpisode) return this.title
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.isMusic) return this.artist
|
||||
if (this.collapsedSeries) return ''
|
||||
if (this.isAuthorBookshelfView) {
|
||||
return this.mediaMetadata.publishedYear || ''
|
||||
@@ -364,7 +360,6 @@ export default {
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||
},
|
||||
userProgress() {
|
||||
if (this.isMusic) return null
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
@@ -420,7 +415,7 @@ export default {
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.ebookFormat
|
||||
@@ -464,8 +459,6 @@ export default {
|
||||
return this.store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
moreMenuItems() {
|
||||
if (this.isMusic) return []
|
||||
|
||||
if (this.recentEpisode) {
|
||||
const items = [
|
||||
{
|
||||
|
||||
@@ -27,38 +27,6 @@
|
||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbum" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicAlbum }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicAlbumArtist }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicTrackPretty" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicTrackPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicDiscPretty" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicDiscPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="podcastType" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
||||
@@ -97,7 +65,7 @@
|
||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div v-if="tracks.length || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||
</div>
|
||||
@@ -134,10 +102,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.libraryItem.mediaType === 'podcast'
|
||||
},
|
||||
audioFile() {
|
||||
// Music track
|
||||
return this.media.audioFile
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
@@ -168,25 +132,6 @@ export default {
|
||||
publisher() {
|
||||
return this.mediaMetadata.publisher || ''
|
||||
},
|
||||
musicArtists() {
|
||||
return this.mediaMetadata.artists || []
|
||||
},
|
||||
musicAlbum() {
|
||||
return this.mediaMetadata.album || ''
|
||||
},
|
||||
musicAlbumArtist() {
|
||||
return this.mediaMetadata.albumArtist || ''
|
||||
},
|
||||
musicTrackPretty() {
|
||||
if (!this.mediaMetadata.trackNumber) return null
|
||||
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
|
||||
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
|
||||
},
|
||||
musicDiscPretty() {
|
||||
if (!this.mediaMetadata.discNumber) return null
|
||||
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
|
||||
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
|
||||
},
|
||||
narrators() {
|
||||
return this.mediaMetadata.narrators || []
|
||||
},
|
||||
@@ -220,4 +165,4 @@ export default {
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -98,9 +98,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.libraryMediaType === 'music'
|
||||
},
|
||||
seriesItems() {
|
||||
return [
|
||||
{
|
||||
@@ -274,35 +271,9 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
musicItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
textPlural: this.$strings.LabelGenres,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
textPlural: this.$strings.LabelTags,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
},
|
||||
selectItems() {
|
||||
if (this.isSeries) return this.seriesItems
|
||||
if (this.isPodcast) return this.podcastItems
|
||||
if (this.isMusic) return this.musicItems
|
||||
return this.bookItems
|
||||
},
|
||||
selectedItemSublist() {
|
||||
|
||||
@@ -56,9 +56,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.libraryMediaType === 'music'
|
||||
},
|
||||
podcastItems() {
|
||||
return [
|
||||
{
|
||||
@@ -148,40 +145,10 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
musicItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelDuration,
|
||||
value: 'media.duration'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
]
|
||||
},
|
||||
selectItems() {
|
||||
let items = null
|
||||
if (this.isPodcast) {
|
||||
items = this.podcastItems
|
||||
} else if (this.isMusic) {
|
||||
items = this.musicItems
|
||||
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
|
||||
items = this.seriesItems
|
||||
} else {
|
||||
|
||||
@@ -351,7 +351,7 @@ export default {
|
||||
update: type === 'admin',
|
||||
delete: type === 'admin',
|
||||
upload: type === 'admin',
|
||||
accessExplicitContent: true,
|
||||
accessExplicitContent: type === 'admin',
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
@@ -386,7 +386,7 @@ export default {
|
||||
upload: false,
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
accessExplicitContent: true,
|
||||
accessExplicitContent: false,
|
||||
selectedTagsNotAccessible: false
|
||||
},
|
||||
librariesAccessible: [],
|
||||
|
||||
@@ -178,22 +178,6 @@ export default {
|
||||
methods: {
|
||||
toggleFullscreen(isFullscreen) {
|
||||
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
|
||||
|
||||
var videoPlayerEl = document.getElementById('video-player')
|
||||
if (videoPlayerEl) {
|
||||
if (isFullscreen) {
|
||||
videoPlayerEl.style.width = '100vw'
|
||||
videoPlayerEl.style.height = '100vh'
|
||||
videoPlayerEl.style.top = '0px'
|
||||
videoPlayerEl.style.left = '0px'
|
||||
} else {
|
||||
videoPlayerEl.style.width = '384px'
|
||||
videoPlayerEl.style.height = '216px'
|
||||
videoPlayerEl.style.top = 'unset'
|
||||
videoPlayerEl.style.bottom = '80px'
|
||||
videoPlayerEl.style.left = '16px'
|
||||
}
|
||||
}
|
||||
},
|
||||
setDuration(duration) {
|
||||
this.duration = duration
|
||||
|
||||
5126
client/package-lock.json
generated
5126
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.13.3",
|
||||
"version": "2.13.4",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
@@ -27,7 +27,7 @@
|
||||
"fast-average-color": "^9.4.0",
|
||||
"hls.js": "^1.5.7",
|
||||
"libarchive.js": "^1.3.0",
|
||||
"nuxt": "^2.17.3",
|
||||
"nuxt": "^2.18.1",
|
||||
"nuxt-socket-io": "^1.1.18",
|
||||
"trix": "^1.3.1",
|
||||
"v-click-outside": "^3.1.2",
|
||||
|
||||
@@ -39,16 +39,11 @@
|
||||
><span :key="index" v-if="index < seriesList.length - 1">, </span>
|
||||
</template>
|
||||
|
||||
<template v-if="!isVideo">
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
|
||||
<p v-else-if="musicArtists.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
|
||||
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
</template>
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
|
||||
<content-library-item-details :library-item="libraryItem" />
|
||||
</div>
|
||||
@@ -109,7 +104,7 @@
|
||||
<ui-icon-btn icon="" outlined class="mx-0.5" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="!isPodcast && !isMusic" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
|
||||
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -220,12 +215,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.libraryItem.mediaType === 'podcast'
|
||||
},
|
||||
isVideo() {
|
||||
return this.libraryItem.mediaType === 'video'
|
||||
},
|
||||
isMusic() {
|
||||
return this.libraryItem.mediaType === 'music'
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
@@ -240,8 +229,6 @@ export default {
|
||||
},
|
||||
showPlayButton() {
|
||||
if (this.isMissing || this.isInvalid) return false
|
||||
if (this.isMusic) return !!this.audioFile
|
||||
if (this.isVideo) return !!this.videoFile
|
||||
if (this.isPodcast) return this.podcastEpisodes.length
|
||||
return this.tracks.length
|
||||
},
|
||||
@@ -292,9 +279,6 @@ export default {
|
||||
authors() {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
musicArtists() {
|
||||
return this.mediaMetadata.artists || []
|
||||
},
|
||||
series() {
|
||||
return this.mediaMetadata.series || []
|
||||
},
|
||||
@@ -309,7 +293,7 @@ export default {
|
||||
})
|
||||
},
|
||||
duration() {
|
||||
if (!this.tracks.length && !this.audioFile) return 0
|
||||
if (!this.tracks.length) return 0
|
||||
return this.media.duration
|
||||
},
|
||||
libraryFiles() {
|
||||
@@ -321,18 +305,10 @@ export default {
|
||||
ebookFile() {
|
||||
return this.media.ebookFile
|
||||
},
|
||||
videoFile() {
|
||||
return this.media.videoFile
|
||||
},
|
||||
audioFile() {
|
||||
// Music track
|
||||
return this.media.audioFile
|
||||
},
|
||||
description() {
|
||||
return this.mediaMetadata.description || ''
|
||||
},
|
||||
userMediaProgress() {
|
||||
if (this.isMusic) return null
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userIsFinished() {
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import Hls from 'hls.js'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
export default class LocalVideoPlayer extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
super()
|
||||
|
||||
this.ctx = ctx
|
||||
this.player = null
|
||||
|
||||
this.libraryItem = null
|
||||
this.videoTrack = null
|
||||
this.isHlsTranscode = null
|
||||
this.hlsInstance = null
|
||||
this.usingNativeplayer = false
|
||||
this.startTime = 0
|
||||
this.playWhenReady = false
|
||||
this.defaultPlaybackRate = 1
|
||||
|
||||
this.playableMimeTypes = []
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (document.getElementById('video-player')) {
|
||||
document.getElementById('video-player').remove()
|
||||
}
|
||||
var videoEl = document.createElement('video')
|
||||
videoEl.id = 'video-player'
|
||||
// videoEl.style.display = 'none'
|
||||
videoEl.className = 'absolute bg-black z-50'
|
||||
videoEl.style.height = '216px'
|
||||
videoEl.style.width = '384px'
|
||||
videoEl.style.bottom = '80px'
|
||||
videoEl.style.left = '16px'
|
||||
document.body.appendChild(videoEl)
|
||||
this.player = videoEl
|
||||
|
||||
this.player.addEventListener('play', this.evtPlay.bind(this))
|
||||
this.player.addEventListener('pause', this.evtPause.bind(this))
|
||||
this.player.addEventListener('progress', this.evtProgress.bind(this))
|
||||
this.player.addEventListener('ended', this.evtEnded.bind(this))
|
||||
this.player.addEventListener('error', this.evtError.bind(this))
|
||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||
|
||||
var mimeTypes = ['video/mp4']
|
||||
var mimeTypeCanPlayMap = {}
|
||||
mimeTypes.forEach((mt) => {
|
||||
var canPlay = this.player.canPlayType(mt)
|
||||
mimeTypeCanPlayMap[mt] = canPlay
|
||||
if (canPlay) this.playableMimeTypes.push(mt)
|
||||
})
|
||||
console.log(`[LocalVideoPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
|
||||
}
|
||||
|
||||
evtPlay() {
|
||||
this.emit('stateChange', 'PLAYING')
|
||||
}
|
||||
evtPause() {
|
||||
this.emit('stateChange', 'PAUSED')
|
||||
}
|
||||
evtProgress() {
|
||||
var lastBufferTime = this.getLastBufferedTime()
|
||||
this.emit('buffertimeUpdate', lastBufferTime)
|
||||
}
|
||||
evtEnded() {
|
||||
console.log(`[LocalVideoPlayer] Ended`)
|
||||
this.emit('finished')
|
||||
}
|
||||
evtError(error) {
|
||||
console.error('Player error', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
evtLoadedMetadata(data) {
|
||||
if (!this.isHlsTranscode) {
|
||||
this.player.currentTime = this.startTime
|
||||
}
|
||||
|
||||
this.emit('stateChange', 'LOADED')
|
||||
if (this.playWhenReady) {
|
||||
this.playWhenReady = false
|
||||
this.play()
|
||||
}
|
||||
}
|
||||
evtTimeupdate() {
|
||||
if (this.player.paused) {
|
||||
this.emit('timeupdate', this.getCurrentTime())
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyHlsInstance()
|
||||
if (this.player) {
|
||||
this.player.remove()
|
||||
}
|
||||
}
|
||||
|
||||
set(libraryItem, videoTrack, isHlsTranscode, startTime, playWhenReady = false) {
|
||||
this.libraryItem = libraryItem
|
||||
this.videoTrack = videoTrack
|
||||
this.isHlsTranscode = isHlsTranscode
|
||||
this.playWhenReady = playWhenReady
|
||||
this.startTime = startTime
|
||||
|
||||
if (this.hlsInstance) {
|
||||
this.destroyHlsInstance()
|
||||
}
|
||||
|
||||
if (this.isHlsTranscode) {
|
||||
this.setHlsStream()
|
||||
} else {
|
||||
this.setDirectPlay()
|
||||
}
|
||||
}
|
||||
|
||||
setHlsStream() {
|
||||
// iOS does not support Media Elements but allows for HLS in the native video player
|
||||
if (!Hls.isSupported()) {
|
||||
console.warn('HLS is not supported - fallback to using video element')
|
||||
this.usingNativeplayer = true
|
||||
this.player.src = this.videoTrack.relativeContentUrl
|
||||
this.player.currentTime = this.startTime
|
||||
return
|
||||
}
|
||||
|
||||
var hlsOptions = {
|
||||
startPosition: this.startTime || -1
|
||||
// No longer needed because token is put in a query string
|
||||
// xhrSetup: (xhr) => {
|
||||
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||
// }
|
||||
}
|
||||
this.hlsInstance = new Hls(hlsOptions)
|
||||
|
||||
this.hlsInstance.attachMedia(this.player)
|
||||
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
this.hlsInstance.loadSource(this.videoTrack.relativeContentUrl)
|
||||
|
||||
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] Manifest Parsed')
|
||||
})
|
||||
|
||||
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||
console.error('[HLS] Error', data.type, data.details, data)
|
||||
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||
console.error('[HLS] BUFFER STALLED ERROR')
|
||||
}
|
||||
})
|
||||
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||
console.log('[HLS] Destroying HLS Instance')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
setDirectPlay() {
|
||||
this.player.src = this.videoTrack.relativeContentUrl
|
||||
console.log(`[LocalVideoPlayer] Loading track src ${this.videoTrack.relativeContentUrl}`)
|
||||
this.player.load()
|
||||
}
|
||||
|
||||
destroyHlsInstance() {
|
||||
if (!this.hlsInstance) return
|
||||
if (this.hlsInstance.destroy) {
|
||||
var temp = this.hlsInstance
|
||||
temp.destroy()
|
||||
}
|
||||
this.hlsInstance = null
|
||||
}
|
||||
|
||||
async resetStream(startTime) {
|
||||
this.destroyHlsInstance()
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
this.set(this.libraryItem, this.videoTrack, this.isHlsTranscode, startTime, true)
|
||||
}
|
||||
|
||||
playPause() {
|
||||
if (!this.player) return
|
||||
if (this.player.paused) this.play()
|
||||
else this.pause()
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.player) this.player.play()
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.player) this.player.pause()
|
||||
}
|
||||
|
||||
getCurrentTime() {
|
||||
return this.player ? this.player.currentTime : 0
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
return this.videoTrack.duration
|
||||
}
|
||||
|
||||
setPlaybackRate(playbackRate) {
|
||||
if (!this.player) return
|
||||
this.defaultPlaybackRate = playbackRate
|
||||
this.player.playbackRate = playbackRate
|
||||
}
|
||||
|
||||
seek(time) {
|
||||
if (!this.player) return
|
||||
this.player.currentTime = Math.max(0, time)
|
||||
}
|
||||
|
||||
setVolume(volume) {
|
||||
if (!this.player) return
|
||||
this.player.volume = volume
|
||||
}
|
||||
|
||||
// Utils
|
||||
isValidDuration(duration) {
|
||||
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
getBufferedRanges() {
|
||||
if (!this.player) return []
|
||||
const ranges = []
|
||||
const seekable = this.player.buffered || []
|
||||
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0, length = seekable.length; i < length; i++) {
|
||||
let start = seekable.start(i)
|
||||
let end = seekable.end(i)
|
||||
if (!this.isValidDuration(start)) {
|
||||
start = 0
|
||||
}
|
||||
if (!this.isValidDuration(end)) {
|
||||
end = 0
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
start: start + offset,
|
||||
end: end + offset
|
||||
})
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
getLastBufferedTime() {
|
||||
var bufferedRanges = this.getBufferedRanges()
|
||||
if (!bufferedRanges.length) return 0
|
||||
|
||||
var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)
|
||||
if (buff) return buff.end
|
||||
|
||||
var last = bufferedRanges[bufferedRanges.length - 1]
|
||||
return last.end
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import LocalAudioPlayer from './LocalAudioPlayer'
|
||||
import LocalVideoPlayer from './LocalVideoPlayer'
|
||||
import CastPlayer from './CastPlayer'
|
||||
import AudioTrack from './AudioTrack'
|
||||
import VideoTrack from './VideoTrack'
|
||||
|
||||
export default class PlayerHandler {
|
||||
constructor(ctx) {
|
||||
@@ -16,8 +14,6 @@ export default class PlayerHandler {
|
||||
this.player = null
|
||||
this.playerState = 'IDLE'
|
||||
this.isHlsTranscode = false
|
||||
this.isVideo = false
|
||||
this.isMusic = false
|
||||
this.currentSessionId = null
|
||||
this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page)
|
||||
this.startTime = 0
|
||||
@@ -65,12 +61,10 @@ export default class PlayerHandler {
|
||||
|
||||
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
|
||||
this.libraryItem = libraryItem
|
||||
this.isVideo = libraryItem.mediaType === 'video'
|
||||
this.isMusic = libraryItem.mediaType === 'music'
|
||||
|
||||
this.episodeId = episodeId
|
||||
this.playWhenReady = playWhenReady
|
||||
this.initialPlaybackRate = this.isMusic ? 1 : playbackRate
|
||||
this.initialPlaybackRate = playbackRate
|
||||
|
||||
this.startTimeOverride = startTimeOverride == null || isNaN(startTimeOverride) ? undefined : Number(startTimeOverride)
|
||||
|
||||
@@ -97,7 +91,7 @@ export default class PlayerHandler {
|
||||
this.playWhenReady = playWhenReady
|
||||
this.prepare()
|
||||
}
|
||||
} else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) {
|
||||
} else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer)) {
|
||||
console.log('[PlayerHandler] Switching to local player')
|
||||
|
||||
this.stopPlayInterval()
|
||||
@@ -107,11 +101,7 @@ export default class PlayerHandler {
|
||||
this.player.destroy()
|
||||
}
|
||||
|
||||
if (this.isVideo) {
|
||||
this.player = new LocalVideoPlayer(this.ctx)
|
||||
} else {
|
||||
this.player = new LocalAudioPlayer(this.ctx)
|
||||
}
|
||||
this.player = new LocalAudioPlayer(this.ctx)
|
||||
|
||||
this.setPlayerListeners()
|
||||
|
||||
@@ -203,7 +193,7 @@ export default class PlayerHandler {
|
||||
supportedMimeTypes: this.player.playableMimeTypes,
|
||||
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
||||
forceTranscode,
|
||||
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
|
||||
forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
|
||||
}
|
||||
|
||||
const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
||||
@@ -218,7 +208,6 @@ export default class PlayerHandler {
|
||||
if (!this.player) this.switchPlayer() // Must set player first for open sessions
|
||||
|
||||
this.libraryItem = session.libraryItem
|
||||
this.isVideo = session.libraryItem.mediaType === 'video'
|
||||
this.playWhenReady = false
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.startTimeOverride = undefined
|
||||
@@ -237,28 +226,16 @@ export default class PlayerHandler {
|
||||
|
||||
console.log('[PlayerHandler] Preparing Session', session)
|
||||
|
||||
if (session.videoTrack) {
|
||||
var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
|
||||
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||
this.isHlsTranscode = false
|
||||
}
|
||||
|
||||
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
} else {
|
||||
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||
this.isHlsTranscode = false
|
||||
}
|
||||
|
||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||
this.isHlsTranscode = false
|
||||
}
|
||||
|
||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
|
||||
// browser media session api
|
||||
this.ctx.setMediaSession()
|
||||
}
|
||||
@@ -333,8 +310,6 @@ export default class PlayerHandler {
|
||||
}
|
||||
|
||||
sendProgressSync(currentTime) {
|
||||
if (this.isMusic) return
|
||||
|
||||
const diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime)
|
||||
if (diffSinceLastSync < 1) return
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
export default class VideoTrack {
|
||||
constructor(track, userToken) {
|
||||
this.index = track.index || 0
|
||||
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||
this.duration = track.duration || 0
|
||||
this.title = track.title || ''
|
||||
this.contentUrl = track.contentUrl || null
|
||||
this.mimeType = track.mimeType
|
||||
this.metadata = track.metadata || {}
|
||||
|
||||
this.userToken = userToken
|
||||
}
|
||||
|
||||
get fullContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
|
||||
get relativeContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
|
||||
return this.contentUrl + `?token=${this.userToken}`
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (isNaN(bytes) || bytes == 0) {
|
||||
return '0 Bytes'
|
||||
}
|
||||
const k = 1024
|
||||
const k = 1000
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"ButtonStats": "পরিসংখ্যান",
|
||||
"ButtonSubmit": "জমা দিন",
|
||||
"ButtonTest": "পরীক্ষা",
|
||||
"ButtonUnlinkOpenId": "ওপেন আইডি লিঙ্কমুক্ত করুন",
|
||||
"ButtonUpload": "আপলোড",
|
||||
"ButtonUploadBackup": "আপলোড ব্যাকআপ",
|
||||
"ButtonUploadCover": "কভার আপলোড করুন",
|
||||
@@ -238,7 +239,7 @@
|
||||
"LabelBackupLocation": "ব্যাকআপ অবস্থান",
|
||||
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
|
||||
"LabelBackupsMaxBackupSize": "সর্বোচ্চ ব্যাকআপ আকার (GB-তে)",
|
||||
"LabelBackupsMaxBackupSize": "সর্বোচ্চ ব্যাকআপ আকার (GB-তে) (অসীমের জন্য 0)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "ভুল কনফিগারেশনের বিরুদ্ধে সুরক্ষা হিসেবে ব্যাকআপগুলি ব্যর্থ হবে যদি তারা কনফিগার করা আকার অতিক্রম করে।",
|
||||
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
|
||||
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
|
||||
@@ -295,7 +296,7 @@
|
||||
"LabelEmail": "ইমেইল",
|
||||
"LabelEmailSettingsFromAddress": "ঠিকানা থেকে",
|
||||
"LabelEmailSettingsRejectUnauthorized": "অননুমোদিত সার্টিফিকেট প্রত্যাখ্যান করুন",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to।",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "SSL প্রমাণপত্রের বৈধতা নিষ্ক্রিয় করা আপনার সংযোগকে নিরাপত্তা ঝুঁকিতে ফেলতে পারে, যেমন ম্যান-ইন-দ্য-মিডল আক্রমণ। শুধুমাত্র এই বিকল্পটি নিষ্ক্রিয় করুন যদি আপনি এর প্রভাবগুলি বুঝতে পারেন এবং আপনি যে মেইল সার্ভারের সাথে সংযোগ করছেন তাকে বিশ্বাস করেন।",
|
||||
"LabelEmailSettingsSecure": "নিরাপদ",
|
||||
"LabelEmailSettingsSecureHelp": "যদি সত্য হয় সার্ভারের সাথে সংযোগ করার সময় সংযোগটি TLS ব্যবহার করবে। মিথ্যা হলে TLS ব্যবহার করা হবে যদি সার্ভার STARTTLS এক্সটেনশন সমর্থন করে। বেশিরভাগ ক্ষেত্রে এই মানটিকে সত্য হিসাবে সেট করুন যদি আপনি পোর্ট 465-এর সাথে সংযোগ করছেন। পোর্ট 587 বা পোর্টের জন্য 25 এটি মিথ্যা রাখুন। (nodemailer.com/smtp/#authentication থেকে)",
|
||||
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
|
||||
@@ -309,12 +310,18 @@
|
||||
"LabelEpisodes": "পর্বগুলো",
|
||||
"LabelExample": "উদাহরণ",
|
||||
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
|
||||
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
|
||||
"LabelExplicit": "বিশদ",
|
||||
"LabelExplicitChecked": "সুস্পষ্ট (পরীক্ষিত)",
|
||||
"LabelExplicitUnchecked": "অস্পষ্ট (অপরিক্ষীত)",
|
||||
"LabelExportOPML": "OPML এক্সপোর্ট করুন",
|
||||
"LabelFeedURL": "ফিড ইউআরএল",
|
||||
"LabelFetchingMetadata": "মেটাডেটা আনা হচ্ছে",
|
||||
"LabelFile": "ফাইল",
|
||||
"LabelFileBirthtime": "ফাইল জন্মের সময়",
|
||||
"LabelFileBornDate": "জন্ম {0}",
|
||||
"LabelFileModified": "ফাইল পরিবর্তিত",
|
||||
"LabelFileModifiedDate": "পরিবর্তিত {0}",
|
||||
"LabelFilename": "ফাইলের নাম",
|
||||
"LabelFilterByUser": "ব্যবহারকারী দ্বারা ফিল্টারকৃত",
|
||||
"LabelFindEpisodes": "পর্বগুলো খুঁজুন",
|
||||
@@ -322,7 +329,8 @@
|
||||
"LabelFolder": "ফোল্ডার",
|
||||
"LabelFolders": "ফোল্ডারগুলো",
|
||||
"LabelFontBold": "বোল্ড",
|
||||
"LabelFontFamily": "ফন্ট পরিবার",
|
||||
"LabelFontBoldness": "হরফ বোল্ডনেস",
|
||||
"LabelFontFamily": "হরফ পরিবার",
|
||||
"LabelFontItalic": "ইটালিক",
|
||||
"LabelFontScale": "ফন্ট স্কেল",
|
||||
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
|
||||
@@ -332,9 +340,11 @@
|
||||
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
|
||||
"LabelHasEbook": "ই-বই আছে",
|
||||
"LabelHasSupplementaryEbook": "পরিপূরক ই-বই আছে",
|
||||
"LabelHideSubtitles": "সাবটাইটেল লুকান",
|
||||
"LabelHighestPriority": "সর্বোচ্চ অগ্রাধিকার",
|
||||
"LabelHost": "নিমন্ত্রণকর্তা",
|
||||
"LabelHour": "ঘন্টা",
|
||||
"LabelHours": "ঘন্টা",
|
||||
"LabelIcon": "আইকন",
|
||||
"LabelImageURLFromTheWeb": "ওয়েব থেকে ছবির ইউআরএল",
|
||||
"LabelInProgress": "প্রগতিতে আছে",
|
||||
@@ -351,8 +361,11 @@
|
||||
"LabelIntervalEveryHour": "প্রতি ঘন্টা",
|
||||
"LabelInvert": "উল্টানো",
|
||||
"LabelItem": "আইটেম",
|
||||
"LabelJumpBackwardAmount": "পিছন দিকে ঝাঁপের পরিমাণ",
|
||||
"LabelJumpForwardAmount": "সামনের দিকে ঝাঁপের পরিমাণ",
|
||||
"LabelLanguage": "ভাষা",
|
||||
"LabelLanguageDefaultServer": "সার্ভারের ডিফল্ট ভাষা",
|
||||
"LabelLanguages": "ভাষাসমূহ",
|
||||
"LabelLastBookAdded": "শেষ বই যোগ করা হয়েছে",
|
||||
"LabelLastBookUpdated": "শেষ বই আপডেট করা হয়েছে",
|
||||
"LabelLastSeen": "শেষ দেখা",
|
||||
@@ -364,6 +377,7 @@
|
||||
"LabelLess": "কম",
|
||||
"LabelLibrariesAccessibleToUser": "ব্যবহারকারীর কাছে অ্যাক্সেসযোগ্য লাইব্রেরি",
|
||||
"LabelLibrary": "লাইব্রেরি",
|
||||
"LabelLibraryFilterSublistEmpty": "না {0}",
|
||||
"LabelLibraryItem": "লাইব্রেরি আইটেম",
|
||||
"LabelLibraryName": "লাইব্রেরির নাম",
|
||||
"LabelLimit": "সীমা",
|
||||
@@ -383,6 +397,7 @@
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "উচ্চ অগ্রাধিকারের মেটাডেটার উৎসগুলো নিম্ন অগ্রাধিকারের মেটাডেটা উৎসগুলোকে ওভাররাইড করবে",
|
||||
"LabelMetadataProvider": "মেটাডেটা প্রদানকারী",
|
||||
"LabelMinute": "মিনিট",
|
||||
"LabelMinutes": "মিনিটস",
|
||||
"LabelMissing": "নিখোঁজ",
|
||||
"LabelMissingEbook": "কোনও ই-বই নেই",
|
||||
"LabelMissingSupplementaryEbook": "কোনও সম্পূরক ই-বই নেই",
|
||||
@@ -399,6 +414,7 @@
|
||||
"LabelNewestEpisodes": "নতুনতম পর্ব",
|
||||
"LabelNextBackupDate": "পরবর্তী ব্যাকআপ তারিখ",
|
||||
"LabelNextScheduledRun": "পরবর্তী নির্ধারিত দৌড়",
|
||||
"LabelNoCustomMetadataProviders": "কোনো কাস্টম মেটাডেটা প্রদানকারী নেই",
|
||||
"LabelNoEpisodesSelected": "কোন পর্ব নির্বাচন করা হয়নি",
|
||||
"LabelNotFinished": "সমাপ্ত হয়নি",
|
||||
"LabelNotStarted": "শুরু হয়নি",
|
||||
@@ -421,6 +437,7 @@
|
||||
"LabelOverwrite": "পুনঃলিখিত",
|
||||
"LabelPassword": "পাসওয়ার্ড",
|
||||
"LabelPath": "পথ",
|
||||
"LabelPermanent": "স্থায়ী",
|
||||
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
|
||||
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
|
||||
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
|
||||
@@ -431,6 +448,7 @@
|
||||
"LabelPersonalYearReview": "আপনার বছরের পর্যালোচনা ({0})",
|
||||
"LabelPhotoPathURL": "ছবি পথ/ইউআরএল",
|
||||
"LabelPlayMethod": "প্লে পদ্ধতি",
|
||||
"LabelPlayerChapterNumberMarker": "{1} এর মধ্যে {0}",
|
||||
"LabelPlaylists": "প্লেলিস্ট",
|
||||
"LabelPodcast": "পডকাস্ট",
|
||||
"LabelPodcastSearchRegion": "পডকাস্ট অনুসন্ধান অঞ্চল",
|
||||
@@ -442,15 +460,20 @@
|
||||
"LabelPrimaryEbook": "প্রাথমিক ই-বই",
|
||||
"LabelProgress": "প্রগতি",
|
||||
"LabelProvider": "প্রদানকারী",
|
||||
"LabelProviderAuthorizationValue": "অনুমোদন শিরোনামের মান",
|
||||
"LabelPubDate": "প্রকাশের তারিখ",
|
||||
"LabelPublishYear": "প্রকাশের বছর",
|
||||
"LabelPublishedDate": "প্রকাশিত {0}",
|
||||
"LabelPublisher": "প্রকাশক",
|
||||
"LabelPublishers": "প্রকাশকরা",
|
||||
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
|
||||
"LabelRSSFeedCustomOwnerName": "কাস্টম মালিকের নাম",
|
||||
"LabelRSSFeedOpen": "আরএসএস ফিড খুলুন",
|
||||
"LabelRSSFeedPreventIndexing": "সূচীকরণ প্রতিরোধ করুন",
|
||||
"LabelRSSFeedSlug": "আরএসএস ফিড স্লাগ",
|
||||
"LabelRSSFeedURL": "আরএসএস ফিড ইউআরএল",
|
||||
"LabelRandomly": "এলোমেলোভাবে",
|
||||
"LabelReAddSeriesToContinueListening": "শোনা চালিয়ে যেতে সিরিজ পুনরায় যোগ করুন",
|
||||
"LabelRead": "পড়ুন",
|
||||
"LabelReadAgain": "আবার পড়ুন",
|
||||
"LabelReadEbookWithoutProgress": "প্রগতি না রেখে ই-বই পড়ুন",
|
||||
@@ -466,6 +489,7 @@
|
||||
"LabelSearchTitle": "অনুসন্ধান শিরোনাম",
|
||||
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
|
||||
"LabelSeason": "সেশন",
|
||||
"LabelSelectAll": "সব নির্বাচন করুন",
|
||||
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
|
||||
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
|
||||
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
|
||||
@@ -488,7 +512,8 @@
|
||||
"LabelSettingsEnableWatcher": "প্রহরী সক্ষম করুন",
|
||||
"LabelSettingsEnableWatcherForLibrary": "লাইব্রেরির জন্য ফোল্ডার প্রহরী সক্ষম করুন",
|
||||
"LabelSettingsEnableWatcherHelp": "ফাইলের পরিবর্তন শনাক্ত হলে আইটেমগুলির স্বয়ংক্রিয় যোগ/আপডেট সক্ষম করবে। *সার্ভার পুনরায় চালু করতে হবে",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files।",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "ইপাবে স্ক্রিপ্ট করা বিষয়বস্তুর অনুমতি দিন",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "ইপাব ফাইলগুলিকে স্ক্রিপ্ট চালানোর অনুমতি দিন। আপনি ইপাব ফাইলগুলির উৎসকে বিশ্বাস না করলে এই সেটিংটি নিষ্ক্রিয় রাখার সুপারিশ করা হলো।",
|
||||
"LabelSettingsExperimentalFeatures": "পরীক্ষামূলক বৈশিষ্ট্য",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "ফিচারের বৈশিষ্ট্য যা আপনার প্রতিক্রিয়া ব্যবহার করতে পারে এবং পরীক্ষায় সহায়তা করতে পারে। গিটহাব আলোচনা খুলতে ক্লিক করুন।",
|
||||
"LabelSettingsFindCovers": "কভার খুঁজুন",
|
||||
@@ -498,7 +523,7 @@
|
||||
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
|
||||
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের হোম পেজ শেল্ফ প্রথম বইটি দেখায় যেটি সিরিজে শুরু হয়নি যেটিতে অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করা হলে তা শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চালিয়ে যাবে।",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
|
||||
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
|
||||
"LabelSettingsParseSubtitlesHelp": "অডিওবুক ফোল্ডারের নাম থেকে সাবটাইটেল বের করুন৷<br>সাবটাইটেল অবশ্যই \" - \"<br>অর্থাৎ \"বুকের শিরোনাম - এখানে একটি সাবটাইটেল\" এর সাবটাইটেল আছে \"এখানে একটি সাবটাইটেল\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "মিলিত মেটাডেটা পছন্দ করুন",
|
||||
@@ -514,7 +539,12 @@
|
||||
"LabelSettingsStoreMetadataWithItem": "আইটেমের সাথে মেটাডেটা সংরক্ষণ করুন",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে",
|
||||
"LabelSettingsTimeFormat": "সময় বিন্যাস",
|
||||
"LabelShare": "শেয়ার করুন",
|
||||
"LabelShareOpen": "শেয়ার খোলা",
|
||||
"LabelShareURL": "শেয়ার ইউআরএল",
|
||||
"LabelShowAll": "সব দেখান",
|
||||
"LabelShowSeconds": "সেকেন্ড দেখান",
|
||||
"LabelShowSubtitles": "সহ-শিরোনাম দেখান",
|
||||
"LabelSize": "আকার",
|
||||
"LabelSleepTimer": "স্লিপ টাইমার",
|
||||
"LabelSlug": "স্লাগ",
|
||||
@@ -552,6 +582,10 @@
|
||||
"LabelThemeDark": "অন্ধকার",
|
||||
"LabelThemeLight": "আলো",
|
||||
"LabelTimeBase": "সময় বেস",
|
||||
"LabelTimeDurationXHours": "{0} ঘণ্টা",
|
||||
"LabelTimeDurationXMinutes": "{0} মিনিট",
|
||||
"LabelTimeDurationXSeconds": "{0} সেকেন্ড",
|
||||
"LabelTimeInMinutes": "মিনিটে সময়",
|
||||
"LabelTimeListened": "সময় শোনা হয়েছে",
|
||||
"LabelTimeListenedToday": "আজ শোনার সময়",
|
||||
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
|
||||
@@ -575,6 +609,7 @@
|
||||
"LabelUnabridged": "অসংলগ্ন",
|
||||
"LabelUndo": "পূর্বাবস্থা",
|
||||
"LabelUnknown": "অজানা",
|
||||
"LabelUnknownPublishDate": "প্রকাশের তারিখ অজানা",
|
||||
"LabelUpdateCover": "কভার আপডেট করুন",
|
||||
"LabelUpdateCoverHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান কভারগুলি ওভাররাইট করার অনুমতি দিন",
|
||||
"LabelUpdateDetails": "বিশদ আপডেট করুন",
|
||||
@@ -591,9 +626,12 @@
|
||||
"LabelVersion": "সংস্করণ",
|
||||
"LabelViewBookmarks": "বুকমার্ক দেখুন",
|
||||
"LabelViewChapters": "অধ্যায় দেখুন",
|
||||
"LabelViewPlayerSettings": "প্লেয়ার সেটিংস দেখুন",
|
||||
"LabelViewQueue": "প্লেয়ার সারি দেখুন",
|
||||
"LabelVolume": "ভলিউম",
|
||||
"LabelWeekdaysToRun": "চলতে হবে সপ্তাহের দিন",
|
||||
"LabelXBooks": "{0}টি বই",
|
||||
"LabelXItems": "{0}টি আইটেম",
|
||||
"LabelYearReviewHide": "পর্যালোচনার বছর লুকান",
|
||||
"LabelYearReviewShow": "পর্যালোচনার বছর দেখুন",
|
||||
"LabelYourAudiobookDuration": "আপনার অডিওবুকের সময়কাল",
|
||||
@@ -601,12 +639,16 @@
|
||||
"LabelYourPlaylists": "আপনার প্লেলিস্ট",
|
||||
"LabelYourProgress": "আপনার অগ্রগতি",
|
||||
"MessageAddToPlayerQueue": "প্লেয়ার সারিতে যোগ করুন",
|
||||
"MessageAppriseDescription": "এই বৈশিষ্ট্যটি ব্যবহার করার জন্য আপনাকে <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">এর একটি উদাহরণ থাকতে হবে </a> চলমান বা একটি এপিআই যা সেই একই অনুরোধগুলি পরিচালনা করবে৷ <br /> বিজ্ঞপ্তি পাঠানোর জন্য Apprise API Url সম্পূর্ণ URL পাথ হওয়া উচিত, যেমন, যদি আপনার API উদাহরণ <code>http://192.168 এ পরিবেশিত হয়৷ 1.1:8337</code> তারপর আপনি <code>http://192.168.1.1:8337/notify</code> লিখবেন।",
|
||||
"MessageAppriseDescription": "এই বৈশিষ্ট্যটি ব্যবহার করার জন্য আপনাকে <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> চালানোর একটি উদাহরণ বা একটি এপিআই পরিচালনা করতে হবে যে একই অনুরোধ পরিচালনা করবে। <br />অ্যাপ্রাইজ এপিআই ইউআরএলটি বিজ্ঞপ্তি পাঠানোর জন্য সম্পূর্ণ ইউআরএল পথ হওয়া উচিত, যেমন, যদি আপনার API ইনস্ট্যান্স <code>http://192.168.1.1:8337</code> এ পরিবেশিত হয় তাহলে আপনি <code> রাখবেন >http://192.168.1.1:8337/notify</code>।",
|
||||
"MessageBackupsDescription": "ব্যাকআপের মধ্যে রয়েছে ব্যবহারকারী, ব্যবহারকারীর অগ্রগতি, লাইব্রেরি আইটেমের বিবরণ, সার্ভার সেটিংস এবং <code>/metadata/items</code> & <code>/metadata/authors</code>-এ সংরক্ষিত ছবি। ব্যাকআপগুলি <strong> আপনার লাইব্রেরি ফোল্ডারে সঞ্চিত কোনো ফাইল >অন্তর্ভুক্ত করবেন না</strong>।",
|
||||
"MessageBackupsLocationEditNote": "দ্রষ্টব্য: ব্যাকআপ অবস্থান আপডেট করলে বিদ্যমান ব্যাকআপগুলি সরানো বা সংশোধন করা হবে না",
|
||||
"MessageBackupsLocationNoEditNote": "দ্রষ্টব্য: ব্যাকআপ অবস্থান একটি পরিবেশ পরিবর্তনশীল মাধ্যমে স্থির করা হয়েছে এবং এখানে পরিবর্তন করা যাবে না।",
|
||||
"MessageBackupsLocationPathEmpty": "ব্যাকআপ অবস্থানের পথ খালি থাকতে পারবে না",
|
||||
"MessageBatchQuickMatchDescription": "কুইক ম্যাচ নির্বাচিত আইটেমগুলির জন্য অনুপস্থিত কভার এবং মেটাডেটা যোগ করার চেষ্টা করবে। বিদ্যমান কভার এবং/অথবা মেটাডেটা ওভাররাইট করার জন্য দ্রুত ম্যাচকে অনুমতি দিতে নীচের বিকল্পগুলি সক্ষম করুন।",
|
||||
"MessageBookshelfNoCollections": "আপনি এখনও কোনো সংগ্রহ করেননি",
|
||||
"MessageBookshelfNoRSSFeeds": "কোনও RSS ফিড খোলা নেই",
|
||||
"MessageBookshelfNoResultsForFilter": "ফিল্টার \"{0}: {1}\" এর জন্য কোন ফলাফল নেই",
|
||||
"MessageBookshelfNoResultsForQuery": "প্রশ্নের জন্য কোন ফলাফল নেই",
|
||||
"MessageBookshelfNoSeries": "আপনার কোনো সিরিজ নেই",
|
||||
"MessageChapterEndIsAfter": "অধ্যায়ের সমাপ্তি আপনার অডিওবুকের শেষে",
|
||||
"MessageChapterErrorFirstNotZero": "প্রথম অধ্যায় 0 এ শুরু হতে হবে",
|
||||
@@ -616,16 +658,24 @@
|
||||
"MessageCheckingCron": "ক্রন পরীক্ষা করা হচ্ছে...",
|
||||
"MessageConfirmCloseFeed": "আপনি কি নিশ্চিত যে আপনি এই ফিডটি বন্ধ করতে চান?",
|
||||
"MessageConfirmDeleteBackup": "আপনি কি নিশ্চিত যে আপনি {0} এর ব্যাকআপ মুছে ফেলতে চান?",
|
||||
"MessageConfirmDeleteDevice": "আপনি কি নিশ্চিতভাবে ই-রিডার ডিভাইস \"{0}\" মুছতে চান?",
|
||||
"MessageConfirmDeleteFile": "এটি আপনার ফাইল সিস্টেম থেকে ফাইলটি মুছে দেবে। আপনি কি নিশ্চিত?",
|
||||
"MessageConfirmDeleteLibrary": "আপনি কি নিশ্চিত যে আপনি স্থায়ীভাবে লাইব্রেরি \"{0}\" মুছে ফেলতে চান?",
|
||||
"MessageConfirmDeleteLibraryItem": "এটি ডাটাবেস এবং আপনার ফাইল সিস্টেম থেকে লাইব্রেরি আইটেমটি মুছে ফেলবে। আপনি কি নিশ্চিত?",
|
||||
"MessageConfirmDeleteLibraryItems": "এটি ডাটাবেস এবং আপনার ফাইল সিস্টেম থেকে {0}টি লাইব্রেরি আইটেম মুছে ফেলবে। আপনি কি নিশ্চিত?",
|
||||
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
|
||||
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
|
||||
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
|
||||
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
|
||||
"MessageConfirmMarkItemFinished": "আপনি কি \"{0}\" কে সমাপ্ত হিসাবে চিহ্নিত করার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmMarkItemNotFinished": "আপনি কি \"{0}\" শেষ হয়নি বলে চিহ্নিত করার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmMarkSeriesFinished": "আপনি কি নিশ্চিত যে আপনি এই সিরিজের সমস্ত বইকে সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "আপনি কি নিশ্চিত যে আপনি এই সিরিজের সমস্ত বইকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
|
||||
"MessageConfirmNotificationTestTrigger": "পরীক্ষার তথ্য দিয়ে এই বিজ্ঞপ্তিটি ট্রিগার করবেন?",
|
||||
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
|
||||
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
|
||||
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
|
||||
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
|
||||
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
|
||||
@@ -642,12 +692,15 @@
|
||||
"MessageConfirmRenameTag": "আপনি কি সব আইটেমের জন্য \"{0}\" ট্যাগের নাম পরিবর্তন করে \"{1}\" করার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmRenameTagMergeNote": "দ্রষ্টব্য: এই ট্যাগটি আগে থেকেই বিদ্যমান তাই সেগুলিকে একত্র করা হবে।",
|
||||
"MessageConfirmRenameTagWarning": "সতর্কতা! একটি ভিন্ন কেসিং সহ একটি অনুরূপ ট্যাগ ইতিমধ্যেই বিদ্যমান \"{0}\"।",
|
||||
"MessageConfirmResetProgress": "আপনি কি আপনার অগ্রগতি রিসেট করার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmSendEbookToDevice": "আপনি কি নিশ্চিত যে আপনি \"{2}\" ডিভাইসে {0} ইবুক \"{1}\" পাঠাতে চান?",
|
||||
"MessageConfirmUnlinkOpenId": "আপনি কি এই ব্যবহারকারীকে ওপেনআইডি থেকে লিঙ্কমুক্ত করার বিষয়ে নিশ্চিত?",
|
||||
"MessageDownloadingEpisode": "ডাউনলোডিং পর্ব",
|
||||
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
|
||||
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
|
||||
"MessageEmbedFinished": "এম্বেড করা শেষ!",
|
||||
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
|
||||
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below।",
|
||||
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
|
||||
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
|
||||
"MessageFetching": "আনয় হচ্ছে...",
|
||||
"MessageForceReScanDescription": "সকল ফাইল আবার নতুন স্ক্যানের মত স্ক্যান করবে। অডিও ফাইল ID3 ট্যাগ, OPF ফাইল, এবং টেক্সট ফাইলগুলি নতুন হিসাবে স্ক্যান করা হবে।",
|
||||
@@ -659,7 +712,7 @@
|
||||
"MessageListeningSessionsInTheLastYear": "গত বছরে {0}টি শোনার সেশন",
|
||||
"MessageLoading": "লোড হচ্ছে...",
|
||||
"MessageLoadingFolders": "ফোল্ডার লোড হচ্ছে...",
|
||||
"MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>।",
|
||||
"MessageLogsDescription": "লগগুলি JSON ফাইল হিসাবে <code>/metadata/logs</code>-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি <code>/metadata/logs/crash_logs.txt</code>-এ সংরক্ষণ করা হয়।",
|
||||
"MessageM4BFailed": "M4B ব্যর্থ!",
|
||||
"MessageM4BFinished": "M4B সমাপ্ত!",
|
||||
"MessageMapChapterTitles": "টাইমস্ট্যাম্প সামঞ্জস্য না করে আপনার বিদ্যমান অডিওবুক অধ্যায়গুলিতে অধ্যায়ের শিরোনাম ম্যাপ করুন",
|
||||
@@ -676,6 +729,7 @@
|
||||
"MessageNoCollections": "কোন সংগ্রহ নেই",
|
||||
"MessageNoCoversFound": "কোন কভার পাওয়া যায়নি",
|
||||
"MessageNoDescription": "কোন বর্ণনা নেই",
|
||||
"MessageNoDevices": "কোনো ডিভাইস নেই",
|
||||
"MessageNoDownloadsInProgress": "বর্তমানে কোনো ডাউনলোড চলছে না",
|
||||
"MessageNoDownloadsQueued": "কোনও ডাউনলোড সারি নেই",
|
||||
"MessageNoEpisodeMatchesFound": "কোন পর্বের মিল পাওয়া যায়নি",
|
||||
@@ -698,10 +752,12 @@
|
||||
"MessageNoUpdatesWereNecessary": "কোন আপডেটের প্রয়োজন ছিল না",
|
||||
"MessageNoUserPlaylists": "আপনার কোনো প্লেলিস্ট নেই",
|
||||
"MessageNotYetImplemented": "এখনও বাস্তবায়িত হয়নি",
|
||||
"MessageOpmlPreviewNote": "দ্রষ্টব্য: এটি পার্স করা OPML ফাইলের একটি পূর্বরূপ। প্রকৃত পডকাস্ট শিরোনাম RSS ফিড থেকে নেওয়া হবে।",
|
||||
"MessageOr": "বা",
|
||||
"MessagePauseChapter": "পজ অধ্যায় প্লেব্যাক",
|
||||
"MessagePlayChapter": "অধ্যায়ের শুরুতে শুনুন",
|
||||
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
|
||||
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
|
||||
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
|
||||
"MessageRemoveChapter": "অধ্যায় সরান",
|
||||
@@ -716,6 +772,9 @@
|
||||
"MessageSelected": "{0}টি নির্বাচিত",
|
||||
"MessageServerCouldNotBeReached": "সার্ভারে পৌঁছানো যায়নি",
|
||||
"MessageSetChaptersFromTracksDescription": "প্রতিটি অডিও ফাইলকে অধ্যায় হিসেবে ব্যবহার করে অধ্যায় সেট করুন এবং অডিও ফাইলের নাম হিসেবে অধ্যায়ের শিরোনাম করুন",
|
||||
"MessageShareExpirationWillBe": "মেয়াদ শেষ হবে <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "মেয়াদ শেষ হবে {0}",
|
||||
"MessageShareURLWillBe": "শেয়ার করা ইউআরএল হবে <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "\"{0}\" এর জন্য {1} এ প্লেব্যাক শুরু করবেন?",
|
||||
"MessageThinking": "চিন্তা করছি...",
|
||||
"MessageUploaderItemFailed": "আপলোড করতে ব্যর্থ",
|
||||
@@ -739,20 +798,48 @@
|
||||
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
|
||||
"PlaceholderSearch": "অনুসন্ধান..",
|
||||
"PlaceholderSearchEpisode": "অনুসন্ধান পর্ব..",
|
||||
"StatsAuthorsAdded": "লেখক যোগ করা হয়েছে",
|
||||
"StatsBooksAdded": "বই যোগ করা হয়েছে",
|
||||
"StatsBooksAdditional": "কিছু সংযোজনের মধ্যে রয়েছে…",
|
||||
"StatsBooksFinished": "বই সমাপ্ত",
|
||||
"StatsBooksFinishedThisYear": "এ বছর শেষ হওয়া কিছু বই …",
|
||||
"StatsBooksListenedTo": "বই শোনা হয়েছে",
|
||||
"StatsCollectionGrewTo": "আপনার বইয়ের সংগ্রহ বেড়েছে…",
|
||||
"StatsSessions": "অধিবেশনসমূহ",
|
||||
"StatsSpentListening": "শুনে কাটিয়েছেন",
|
||||
"StatsTopAuthor": "শীর্ষস্থানীয় লেখক",
|
||||
"StatsTopAuthors": "শীর্ষস্থানীয় লেখকগণ",
|
||||
"StatsTopGenre": "শীর্ষ ঘরানা",
|
||||
"StatsTopGenres": "শীর্ষ ঘরানাগুলো",
|
||||
"StatsTopMonth": "সেরা মাস",
|
||||
"StatsTopNarrator": "শীর্ষ কথক",
|
||||
"StatsTopNarrators": "শীর্ষ কথকগণ",
|
||||
"StatsTotalDuration": "মোট সময়কাল…",
|
||||
"StatsYearInReview": "বাৎসরিক পর্যালোচনা",
|
||||
"ToastAccountUpdateFailed": "অ্যাকাউন্ট আপডেট করতে ব্যর্থ",
|
||||
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
|
||||
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
|
||||
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
|
||||
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
|
||||
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
|
||||
"ToastAuthorSearchNotFound": "লেখক পাওয়া যায়নি",
|
||||
"ToastAuthorUpdateFailed": "লেখক আপডেট করতে ব্যর্থ",
|
||||
"ToastAuthorUpdateMerged": "লেখক একত্রিত হয়েছে",
|
||||
"ToastAuthorUpdateSuccess": "লেখক আপডেট করেছেন",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "লেখক আপডেট করেছেন (কোন ছবি পাওয়া যায়নি)",
|
||||
"ToastBackupAppliedSuccess": "ব্যাকআপ প্রয়োগ করা হয়েছে",
|
||||
"ToastBackupCreateFailed": "ব্যাকআপ তৈরি করতে ব্যর্থ",
|
||||
"ToastBackupCreateSuccess": "ব্যাকআপ তৈরি করা হয়েছে",
|
||||
"ToastBackupDeleteFailed": "ব্যাকআপ মুছে ফেলতে ব্যর্থ",
|
||||
"ToastBackupDeleteSuccess": "ব্যাকআপ মুছে ফেলা হয়েছে",
|
||||
"ToastBackupInvalidMaxKeep": "রাখার জন্য অকার্যকর ব্যাকআপের সংখ্যা",
|
||||
"ToastBackupInvalidMaxSize": "অকার্যকর সর্বোচ্চ ব্যাকআপ আকার",
|
||||
"ToastBackupPathUpdateFailed": "ব্যাকআপ পথ আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"ToastBackupRestoreFailed": "ব্যাকআপ পুনরুদ্ধার করতে ব্যর্থ",
|
||||
"ToastBackupUploadFailed": "ব্যাকআপ আপলোড করতে ব্যর্থ",
|
||||
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
|
||||
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
|
||||
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
|
||||
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
|
||||
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
||||
@@ -760,20 +847,50 @@
|
||||
"ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে",
|
||||
"ToastBookmarkUpdateFailed": "বুকমার্ক আপডেট করতে ব্যর্থ",
|
||||
"ToastBookmarkUpdateSuccess": "বুকমার্ক আপডেট করা হয়েছে",
|
||||
"ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে",
|
||||
"ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে",
|
||||
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
||||
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
|
||||
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
||||
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
||||
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
|
||||
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
|
||||
"ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে",
|
||||
"ToastCollectionUpdateFailed": "সংগ্রহ আপডেট করতে ব্যর্থ",
|
||||
"ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে",
|
||||
"ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে",
|
||||
"ToastDeleteFileFailed": "ফাইল মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||
"ToastDeleteFileSuccess": "ফাইল মুছে ফেলা হয়েছে",
|
||||
"ToastDeviceAddFailed": "ডিভাইস যোগ করতে ব্যর্থ হয়েছে",
|
||||
"ToastDeviceNameAlreadyExists": "এই নামের ইরিডার ডিভাইস ইতিমধ্যেই বিদ্যমান",
|
||||
"ToastDeviceTestEmailFailed": "পরীক্ষামূলক ইমেল পাঠাতে ব্যর্থ হয়েছে",
|
||||
"ToastDeviceTestEmailSuccess": "পরীক্ষামূলক ইমেল পাঠানো হয়েছে",
|
||||
"ToastDeviceUpdateFailed": "ডিভাইস আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"ToastEmailSettingsUpdateFailed": "ইমেল সেটিংস আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"ToastEmailSettingsUpdateSuccess": "ইমেল সেটিংস আপডেট করা হয়েছে",
|
||||
"ToastEncodeCancelFailed": "এনকোড বাতিল করতে ব্যর্থ হয়েছে",
|
||||
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
|
||||
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
|
||||
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
|
||||
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
|
||||
"ToastFailedToUpdateAccount": "অ্যাকাউন্ট আপডেট করতে ব্যর্থ",
|
||||
"ToastFailedToUpdateUser": "ব্যবহারকারী আপডেট করতে ব্যর্থ",
|
||||
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
|
||||
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
|
||||
"ToastItemCoverUpdateFailed": "আইটেম কভার আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
|
||||
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
|
||||
"ToastItemDeletedSuccess": "মুছে ফেলা আইটেম",
|
||||
"ToastItemDetailsUpdateFailed": "আইটেমের বিবরণ আপডেট করতে ব্যর্থ",
|
||||
"ToastItemDetailsUpdateSuccess": "আইটেমের বিবরণ আপডেট করা হয়েছে",
|
||||
"ToastItemMarkedAsFinishedFailed": "সমাপ্ত হিসাবে চিহ্নিত করতে ব্যর্থ",
|
||||
"ToastItemMarkedAsFinishedSuccess": "আইটেম সমাপ্ত হিসাবে চিহ্নিত",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "সমাপ্ত হয়নি হিসাবে চিহ্নিত করতে ব্যর্থ",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "আইটেম সমাপ্ত হয়নি বলে চিহ্নিত",
|
||||
"ToastItemUpdateFailed": "আইটেম আপডেট করতে ব্যর্থ",
|
||||
"ToastItemUpdateSuccess": "আইটেম আপডেট করা হয়েছে",
|
||||
"ToastLibraryCreateFailed": "লাইব্রেরি তৈরি করতে ব্যর্থ",
|
||||
"ToastLibraryCreateSuccess": "লাইব্রেরি \"{0}\" তৈরি করা হয়েছে",
|
||||
"ToastLibraryDeleteFailed": "লাইব্রেরি মুছে ফেলতে ব্যর্থ",
|
||||
@@ -782,6 +899,25 @@
|
||||
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
|
||||
"ToastLibraryUpdateFailed": "লাইব্রেরি আপডেট করতে ব্যর্থ",
|
||||
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
|
||||
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
|
||||
"ToastNameRequired": "নাম আবশ্যক",
|
||||
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
|
||||
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
|
||||
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
|
||||
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
|
||||
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
|
||||
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
|
||||
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
|
||||
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
|
||||
"ToastNotificationFailedMaximum": "সর্বাধিক ব্যর্থ প্রচেষ্টা >= 0 হতে হবে",
|
||||
"ToastNotificationQueueMaximum": "সর্বাধিক বিজ্ঞপ্তি সারি >= 0 হতে হবে",
|
||||
"ToastNotificationSettingsUpdateFailed": "বিজ্ঞপ্তি সেটিংস আপডেট করতে ব্যর্থ",
|
||||
"ToastNotificationSettingsUpdateSuccess": "বিজ্ঞপ্তি সেটিংস আপডেট করা হয়েছে",
|
||||
"ToastNotificationTestTriggerFailed": "পরীক্ষামূলক বিজ্ঞপ্তি ট্রিগার করতে ব্যর্থ হয়েছে",
|
||||
"ToastNotificationTestTriggerSuccess": "পরীক্ষামুলক বিজ্ঞপ্তি ট্রিগার হয়েছে",
|
||||
"ToastNotificationUpdateFailed": "বিজ্ঞপ্তি আপডেট করতে ব্যর্থ",
|
||||
"ToastNotificationUpdateSuccess": "বিজ্ঞপ্তি আপডেট হয়েছে",
|
||||
"ToastPlaylistCreateFailed": "প্লেলিস্ট তৈরি করতে ব্যর্থ",
|
||||
"ToastPlaylistCreateSuccess": "প্লেলিস্ট তৈরি করা হয়েছে",
|
||||
"ToastPlaylistRemoveSuccess": "প্লেলিস্ট সরানো হয়েছে",
|
||||
@@ -789,19 +925,52 @@
|
||||
"ToastPlaylistUpdateSuccess": "প্লেলিস্ট আপডেট করা হয়েছে",
|
||||
"ToastPodcastCreateFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
|
||||
"ToastPodcastCreateSuccess": "পডকাস্ট সফলভাবে তৈরি করা হয়েছে",
|
||||
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
|
||||
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
|
||||
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
|
||||
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
|
||||
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
|
||||
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
|
||||
"ToastProviderRemoveSuccess": "প্রদানকারী সরানো হয়েছে",
|
||||
"ToastRSSFeedCloseFailed": "RSS ফিড বন্ধ করতে ব্যর্থ",
|
||||
"ToastRSSFeedCloseSuccess": "RSS ফিড বন্ধ",
|
||||
"ToastRemoveFailed": "মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||
"ToastRemoveItemFromCollectionFailed": "সংগ্রহ থেকে আইটেম সরাতে ব্যর্থ",
|
||||
"ToastRemoveItemFromCollectionSuccess": "সংগ্রহ থেকে আইটেম সরানো হয়েছে",
|
||||
"ToastRemoveItemsWithIssuesFailed": "সমস্যাযুক্ত লাইব্রেরি আইটেমগুলি সরাতে ব্যর্থ হয়েছে",
|
||||
"ToastRemoveItemsWithIssuesSuccess": "সমস্যাযুক্ত লাইব্রেরি আইটেম সরানো হয়েছে",
|
||||
"ToastRenameFailed": "পুনঃনামকরণ ব্যর্থ হয়েছে",
|
||||
"ToastRescanFailed": "{0} এর জন্য পুনরায় স্ক্যান করা ব্যর্থ হয়েছে",
|
||||
"ToastRescanRemoved": "পুনরায় স্ক্যান সম্পূর্ণ,আইটেম সরানো হয়েছে",
|
||||
"ToastRescanUpToDate": "পুনরায় স্ক্যান সম্পূর্ণ, আইটেম সাম্প্রতিক ছিল",
|
||||
"ToastRescanUpdated": "পুনরায় স্ক্যান সম্পূর্ণ, আইটেম আপডেট করা হয়েছে",
|
||||
"ToastScanFailed": "লাইব্রেরি আইটেম স্ক্যান করতে ব্যর্থ হয়েছে",
|
||||
"ToastSelectAtLeastOneUser": "অন্তত একজন ব্যবহারকারী নির্বাচন করুন",
|
||||
"ToastSendEbookToDeviceFailed": "ডিভাইসে ইবুক পাঠাতে ব্যর্থ",
|
||||
"ToastSendEbookToDeviceSuccess": "ইবুক \"{0}\" ডিভাইসে পাঠানো হয়েছে",
|
||||
"ToastSeriesUpdateFailed": "সিরিজ আপডেট ব্যর্থ হয়েছে",
|
||||
"ToastSeriesUpdateSuccess": "সিরিজ আপডেট সাফল্য",
|
||||
"ToastServerSettingsUpdateFailed": "সার্ভার সেটিংস আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"ToastServerSettingsUpdateSuccess": "সার্ভার সেটিংস আপডেট করা হয়েছে",
|
||||
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
|
||||
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
|
||||
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
|
||||
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
|
||||
"ToastSlugRequired": "স্লাগ আবশ্যক",
|
||||
"ToastSocketConnected": "সকেট সংযুক্ত",
|
||||
"ToastSocketDisconnected": "সকেট সংযোগ বিচ্ছিন্ন",
|
||||
"ToastSocketFailedToConnect": "সকেট সংযোগ করতে ব্যর্থ হয়েছে",
|
||||
"ToastSortingPrefixesEmptyError": "কমপক্ষে ১ টি সাজানোর উপসর্গ থাকতে হবে",
|
||||
"ToastSortingPrefixesUpdateFailed": "বাছাই উপসর্গ আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"ToastSortingPrefixesUpdateSuccess": "বাছাই করা উপসর্গ আপডেট করা হয়েছে ({0}টি আইটেম)",
|
||||
"ToastTitleRequired": "শিরোনাম আবশ্যক",
|
||||
"ToastUnknownError": "অজানা ত্রুটি",
|
||||
"ToastUnlinkOpenIdFailed": "OpenID থেকে ব্যবহারকারীকে আনলিঙ্ক করতে ব্যর্থ হয়েছে",
|
||||
"ToastUnlinkOpenIdSuccess": "OpenID থেকে ব্যবহারকারীকে লিঙ্কমুক্ত করা হয়েছে",
|
||||
"ToastUserDeleteFailed": "ব্যবহারকারী মুছতে ব্যর্থ",
|
||||
"ToastUserDeleteSuccess": "ব্যবহারকারী মুছে ফেলা হয়েছে"
|
||||
"ToastUserDeleteSuccess": "ব্যবহারকারী মুছে ফেলা হয়েছে",
|
||||
"ToastUserPasswordChangeSuccess": "পাসওয়ার্ড সফলভাবে পরিবর্তন করা হয়েছে",
|
||||
"ToastUserPasswordMismatch": "পাসওয়ার্ড মিলছে না",
|
||||
"ToastUserPasswordMustChange": "নতুন পাসওয়ার্ড পুরানো পাসওয়ার্ডের সাথে মিলতে পারবে না",
|
||||
"ToastUserRootRequireName": "একটি রুট ব্যবহারকারীর নাম লিখতে হবে"
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"ButtonStats": "Statistiken",
|
||||
"ButtonSubmit": "Ok",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUnlinkOpenId": "OpenID trennen",
|
||||
"ButtonUpload": "Hochladen",
|
||||
"ButtonUploadBackup": "Sicherung hochladen",
|
||||
"ButtonUploadCover": "Titelbild hochladen",
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"ButtonStats": "Estadísticas",
|
||||
"ButtonSubmit": "Enviar",
|
||||
"ButtonTest": "Prueba",
|
||||
"ButtonUnlinkOpenId": "Desvincular OpenID",
|
||||
"ButtonUpload": "Subir",
|
||||
"ButtonUploadBackup": "Subir Respaldo",
|
||||
"ButtonUploadCover": "Subir Portada",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"ButtonStats": "Statistika",
|
||||
"ButtonSubmit": "Posreduj",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUnlinkOpenId": "Prekini povezavo OpenID",
|
||||
"ButtonUpload": "Naloži",
|
||||
"ButtonUploadBackup": "Naloži varnostno kopijo",
|
||||
"ButtonUploadCover": "Naloži naslovnico",
|
||||
@@ -289,8 +290,8 @@
|
||||
"LabelDurationComparisonLonger": "({0} dlje)",
|
||||
"LabelDurationComparisonShorter": "({0} krajše)",
|
||||
"LabelDurationFound": "Najdeno trajanje:",
|
||||
"LabelEbook": "Eknjiga",
|
||||
"LabelEbooks": "Eknjige",
|
||||
"LabelEbook": "E-knjiga",
|
||||
"LabelEbooks": "E-knjige",
|
||||
"LabelEdit": "Uredi",
|
||||
"LabelEmail": "E-pošta",
|
||||
"LabelEmailSettingsFromAddress": "Iz naslova",
|
||||
@@ -337,8 +338,8 @@
|
||||
"LabelGenre": "Žanr",
|
||||
"LabelGenres": "Žanri",
|
||||
"LabelHardDeleteFile": "Trdo brisanje datoteke",
|
||||
"LabelHasEbook": "Ima eknjigo",
|
||||
"LabelHasSupplementaryEbook": "Ima dodatno eknjigo",
|
||||
"LabelHasEbook": "Ima e-knjigo",
|
||||
"LabelHasSupplementaryEbook": "Ima dodatno e-knjigo",
|
||||
"LabelHideSubtitles": "Skrij podnapise",
|
||||
"LabelHighestPriority": "Najvišja prioriteta",
|
||||
"LabelHost": "Gostitelj",
|
||||
@@ -456,7 +457,7 @@
|
||||
"LabelPort": "Vrata",
|
||||
"LabelPrefixesToIgnore": "Predpone, ki jih je treba prezreti (neobčutljivo na velike in male črke)",
|
||||
"LabelPreventIndexing": "Preprečite, da bi vaš vir indeksirali imeniki podcastov iTunes in Google",
|
||||
"LabelPrimaryEbook": "Primarna eknjiga",
|
||||
"LabelPrimaryEbook": "Primarna e-knjiga",
|
||||
"LabelProgress": "Napredek",
|
||||
"LabelProvider": "Ponudnik",
|
||||
"LabelProviderAuthorizationValue": "Vrednost glave avtorizacije",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ButtonChooseFiles": "选择文件",
|
||||
"ButtonClearFilter": "清除过滤器",
|
||||
"ButtonCloseFeed": "关闭源",
|
||||
"ButtonCloseSession": "关闭开放会话",
|
||||
"ButtonCollections": "收藏",
|
||||
"ButtonConfigureScanner": "配置扫描",
|
||||
"ButtonCreate": "创建",
|
||||
@@ -28,6 +29,9 @@
|
||||
"ButtonEdit": "编辑",
|
||||
"ButtonEditChapters": "编辑章节",
|
||||
"ButtonEditPodcast": "编辑播客",
|
||||
"ButtonEnable": "启用",
|
||||
"ButtonFireAndFail": "故障和失败",
|
||||
"ButtonFireOnTest": "测试事件触发",
|
||||
"ButtonForceReScan": "强制重新扫描",
|
||||
"ButtonFullPath": "完整路径",
|
||||
"ButtonHide": "隐藏",
|
||||
@@ -46,6 +50,7 @@
|
||||
"ButtonNevermind": "没有关系",
|
||||
"ButtonNext": "下一个",
|
||||
"ButtonNextChapter": "下一章节",
|
||||
"ButtonNextItemInQueue": "队列中的下一个项目",
|
||||
"ButtonOk": "确定",
|
||||
"ButtonOpenFeed": "打开源",
|
||||
"ButtonOpenManager": "打开管理器",
|
||||
@@ -55,6 +60,7 @@
|
||||
"ButtonPlaylists": "播放列表",
|
||||
"ButtonPrevious": "上一个",
|
||||
"ButtonPreviousChapter": "上一章节",
|
||||
"ButtonProbeAudioFile": "探测音频文件",
|
||||
"ButtonPurgeAllCache": "清理所有缓存",
|
||||
"ButtonPurgeItemsCache": "清理项目缓存",
|
||||
"ButtonQueueAddItem": "添加到队列",
|
||||
@@ -92,6 +98,7 @@
|
||||
"ButtonStats": "统计数据",
|
||||
"ButtonSubmit": "提交",
|
||||
"ButtonTest": "测试",
|
||||
"ButtonUnlinkOpenId": "取消 OpenID 链接",
|
||||
"ButtonUpload": "上传",
|
||||
"ButtonUploadBackup": "上传备份",
|
||||
"ButtonUploadCover": "上传封面",
|
||||
@@ -104,6 +111,7 @@
|
||||
"ErrorUploadFetchMetadataNoResults": "无法获取元数据 - 尝试更新标题和/或作者",
|
||||
"ErrorUploadLacksTitle": "必须有标题",
|
||||
"HeaderAccount": "帐户",
|
||||
"HeaderAddCustomMetadataProvider": "添加自定义元数据提供商",
|
||||
"HeaderAdvanced": "高级",
|
||||
"HeaderAppriseNotificationSettings": "测试通知设置",
|
||||
"HeaderAudioTracks": "音轨",
|
||||
@@ -118,7 +126,7 @@
|
||||
"HeaderCover": "封面",
|
||||
"HeaderCurrentDownloads": "当前下载",
|
||||
"HeaderCustomMessageOnLogin": "登录时的自定义消息",
|
||||
"HeaderCustomMetadataProviders": "自定义元数据提供者",
|
||||
"HeaderCustomMetadataProviders": "自定义元数据提供商",
|
||||
"HeaderDetails": "详情",
|
||||
"HeaderDownloadQueue": "下载队列",
|
||||
"HeaderEbookFiles": "电子书文件",
|
||||
@@ -149,6 +157,8 @@
|
||||
"HeaderMetadataToEmbed": "嵌入元数据",
|
||||
"HeaderNewAccount": "新建帐户",
|
||||
"HeaderNewLibrary": "新建媒体库",
|
||||
"HeaderNotificationCreate": "创建通知",
|
||||
"HeaderNotificationUpdate": "更新通知",
|
||||
"HeaderNotifications": "通知",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证",
|
||||
"HeaderOpenRSSFeed": "打开 RSS 源",
|
||||
@@ -206,6 +216,7 @@
|
||||
"LabelAddToPlaylist": "添加到播放列表",
|
||||
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
|
||||
"LabelAddedAt": "添加于",
|
||||
"LabelAddedDate": "添加 {0}",
|
||||
"LabelAdminUsersOnly": "仅限管理员用户",
|
||||
"LabelAll": "全部",
|
||||
"LabelAllUsers": "所有用户",
|
||||
@@ -235,6 +246,7 @@
|
||||
"LabelBitrate": "比特率",
|
||||
"LabelBooks": "图书",
|
||||
"LabelButtonText": "按钮文本",
|
||||
"LabelByAuthor": "由 {0}",
|
||||
"LabelChangePassword": "修改密码",
|
||||
"LabelChannels": "声道",
|
||||
"LabelChapterTitle": "章节标题",
|
||||
@@ -244,6 +256,7 @@
|
||||
"LabelClosePlayer": "关闭播放器",
|
||||
"LabelCodec": "编解码",
|
||||
"LabelCollapseSeries": "折叠系列",
|
||||
"LabelCollapseSubSeries": "折叠子系列",
|
||||
"LabelCollection": "收藏",
|
||||
"LabelCollections": "收藏",
|
||||
"LabelComplete": "已完成",
|
||||
@@ -294,8 +307,10 @@
|
||||
"LabelEpisode": "剧集",
|
||||
"LabelEpisodeTitle": "剧集标题",
|
||||
"LabelEpisodeType": "剧集类型",
|
||||
"LabelEpisodes": "剧集",
|
||||
"LabelExample": "示例",
|
||||
"LabelExpandSeries": "展开系列",
|
||||
"LabelExpandSubSeries": "展开子系列",
|
||||
"LabelExplicit": "信息准确",
|
||||
"LabelExplicitChecked": "明确(已选中)",
|
||||
"LabelExplicitUnchecked": "不明确 (未选中)",
|
||||
@@ -304,7 +319,9 @@
|
||||
"LabelFetchingMetadata": "正在获取元数据",
|
||||
"LabelFile": "文件",
|
||||
"LabelFileBirthtime": "文件创建时间",
|
||||
"LabelFileBornDate": "生于 {0}",
|
||||
"LabelFileModified": "文件修改时间",
|
||||
"LabelFileModifiedDate": "已修改 {0}",
|
||||
"LabelFilename": "文件名",
|
||||
"LabelFilterByUser": "按用户筛选",
|
||||
"LabelFindEpisodes": "查找剧集",
|
||||
@@ -360,6 +377,7 @@
|
||||
"LabelLess": "较少",
|
||||
"LabelLibrariesAccessibleToUser": "用户可访问的媒体库",
|
||||
"LabelLibrary": "媒体库",
|
||||
"LabelLibraryFilterSublistEmpty": "没有 {0}",
|
||||
"LabelLibraryItem": "媒体库项目",
|
||||
"LabelLibraryName": "媒体库名称",
|
||||
"LabelLimit": "限制",
|
||||
@@ -371,13 +389,13 @@
|
||||
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
|
||||
"LabelLowestPriority": "最低优先级",
|
||||
"LabelMatchExistingUsersBy": "匹配现有用户",
|
||||
"LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过SSO提供商提供的唯一 id 进行匹配",
|
||||
"LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过 SSO 提供商提供的唯一 id 进行匹配",
|
||||
"LabelMediaPlayer": "媒体播放器",
|
||||
"LabelMediaType": "媒体类型",
|
||||
"LabelMetaTag": "元数据标签",
|
||||
"LabelMetaTags": "元标签",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "较高优先级的元数据源将覆盖较低优先级的元数据源",
|
||||
"LabelMetadataProvider": "元数据提供者",
|
||||
"LabelMetadataProvider": "元数据提供商",
|
||||
"LabelMinute": "分钟",
|
||||
"LabelMinutes": "分钟",
|
||||
"LabelMissing": "丢失",
|
||||
@@ -396,7 +414,7 @@
|
||||
"LabelNewestEpisodes": "最新剧集",
|
||||
"LabelNextBackupDate": "下次备份日期",
|
||||
"LabelNextScheduledRun": "下次任务运行",
|
||||
"LabelNoCustomMetadataProviders": "没有自定义元数据提供程序",
|
||||
"LabelNoCustomMetadataProviders": "没有自定义元数据提供商",
|
||||
"LabelNoEpisodesSelected": "未选择任何剧集",
|
||||
"LabelNotFinished": "未听完",
|
||||
"LabelNotStarted": "未开始",
|
||||
@@ -412,7 +430,7 @@
|
||||
"LabelNotificationsMaxQueueSizeHelp": "通知事件被限制为每秒触发 1 个. 如果队列处于最大大小, 则将忽略事件. 这可以防止通知垃圾邮件.",
|
||||
"LabelNumberOfBooks": "图书数量",
|
||||
"LabelNumberOfEpisodes": "# 集",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "OpenID 声明的名称, 该声明包含应用程序内用户操作的高级权限, 该权限将应用于非管理员角色(<b>如果已配置</b>). 如果响应中缺少声明, 获取 ABS 的权限将被拒绝. 如果缺少单个选项, 它将被视为 <code>禁用</code>. 确保身份提供者的声明与预期结构匹配:",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "OpenID 声明的名称, 该声明包含应用程序内用户操作的高级权限, 该权限将应用于非管理员角色(<b>如果已配置</b>). 如果响应中缺少声明, 获取 ABS 的权限将被拒绝. 如果缺少单个选项, 它将被视为 <code>禁用</code>. 确保身份提供商的声明与预期结构匹配:",
|
||||
"LabelOpenIDClaims": "将以下选项留空以禁用高级组和权限分配, 然后自动分配 'User' 组.",
|
||||
"LabelOpenIDGroupClaimDescription": "OpenID 声明的名称, 该声明包含用户组的列表. 通常称为<code>组</code><b>如果已配置</b>, 应用程序将根据用户的组成员身份自动分配角色, 前提是这些组在声明中以不区分大小写的方式命名为 'Admin', 'User' 或 'Guest'. 声明应包含一个列表, 如果用户属于多个组, 则应用程序将分配与最高访问级别相对应的角色. 如果没有组匹配, 访问将被拒绝.",
|
||||
"LabelOpenRSSFeed": "打开 RSS 源",
|
||||
@@ -430,6 +448,7 @@
|
||||
"LabelPersonalYearReview": "你的年度回顾 ({0})",
|
||||
"LabelPhotoPathURL": "图片路径或 URL",
|
||||
"LabelPlayMethod": "播放方法",
|
||||
"LabelPlayerChapterNumberMarker": "{0} 于 {1}",
|
||||
"LabelPlaylists": "播放列表",
|
||||
"LabelPodcast": "播客",
|
||||
"LabelPodcastSearchRegion": "播客搜索地区",
|
||||
@@ -440,9 +459,11 @@
|
||||
"LabelPreventIndexing": "防止 iTunes 和 Google 播客目录对你的源进行索引",
|
||||
"LabelPrimaryEbook": "主电子书",
|
||||
"LabelProgress": "进度",
|
||||
"LabelProvider": "供应商",
|
||||
"LabelProvider": "提供商",
|
||||
"LabelProviderAuthorizationValue": "授权标头值",
|
||||
"LabelPubDate": "出版日期",
|
||||
"LabelPublishYear": "发布年份",
|
||||
"LabelPublishedDate": "已发布 {0}",
|
||||
"LabelPublisher": "出版商",
|
||||
"LabelPublishers": "出版商",
|
||||
"LabelRSSFeedCustomOwnerEmail": "自定义所有者电子邮件",
|
||||
@@ -526,6 +547,7 @@
|
||||
"LabelShowSubtitles": "显示标题",
|
||||
"LabelSize": "文件大小",
|
||||
"LabelSleepTimer": "睡眠定时",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "开始",
|
||||
"LabelStartTime": "开始时间",
|
||||
"LabelStarted": "开始于",
|
||||
@@ -587,6 +609,7 @@
|
||||
"LabelUnabridged": "未删节",
|
||||
"LabelUndo": "撤消",
|
||||
"LabelUnknown": "未知",
|
||||
"LabelUnknownPublishDate": "未知发布日期",
|
||||
"LabelUpdateCover": "更新封面",
|
||||
"LabelUpdateCoverHelp": "找到匹配项时允许覆盖所选书籍存在的封面",
|
||||
"LabelUpdateDetails": "更新详细信息",
|
||||
@@ -635,16 +658,22 @@
|
||||
"MessageCheckingCron": "检查计划任务...",
|
||||
"MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?",
|
||||
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
|
||||
"MessageConfirmDeleteDevice": "您确定要删除电子阅读器设备 \"{0}\" 吗?",
|
||||
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
|
||||
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
|
||||
"MessageConfirmDeleteLibraryItem": "这将从数据库和文件系统中删除库项目. 你确定吗?",
|
||||
"MessageConfirmDeleteLibraryItems": "这将从数据库和文件系统中删除 {0} 个库项目. 你确定吗?",
|
||||
"MessageConfirmDeleteMetadataProvider": "是否确实要删除自定义元数据提供商 \"{0}\" ?",
|
||||
"MessageConfirmDeleteNotification": "您确定要删除此通知吗?",
|
||||
"MessageConfirmDeleteSession": "你确定要删除此会话吗?",
|
||||
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?",
|
||||
"MessageConfirmMarkItemFinished": "您确定要将 \"{0}\" 标记为已完成吗?",
|
||||
"MessageConfirmMarkItemNotFinished": "您确定要将 \"{0}\" 标记为未完成吗?",
|
||||
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
|
||||
"MessageConfirmNotificationTestTrigger": "使用测试数据触发此通知吗?",
|
||||
"MessageConfirmPurgeCache": "清除缓存将删除 <code>/metadata/cache</code> 整个目录. <br /><br />你确定要删除缓存目录吗?",
|
||||
"MessageConfirmPurgeItemsCache": "清除项目缓存将删除 <code>/metadata/cache/items</code> 整个目录.<br />你确定吗?",
|
||||
"MessageConfirmQuickEmbed": "警告! 快速嵌入不会备份你的音频文件. 确保你有音频文件的备份. <br><br>你是否想继续吗?",
|
||||
@@ -663,7 +692,9 @@
|
||||
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
|
||||
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
|
||||
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
|
||||
"MessageConfirmResetProgress": "你确定要重置进度吗?",
|
||||
"MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "您确定要取消该用户与 OpenID 的链接吗?",
|
||||
"MessageDownloadingEpisode": "正在下载剧集",
|
||||
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
|
||||
"MessageEmbedFailed": "嵌入失败!",
|
||||
@@ -698,6 +729,7 @@
|
||||
"MessageNoCollections": "没有收藏",
|
||||
"MessageNoCoversFound": "没有找到封面",
|
||||
"MessageNoDescription": "没有描述",
|
||||
"MessageNoDevices": "没有设备",
|
||||
"MessageNoDownloadsInProgress": "当前没有正在进行的下载",
|
||||
"MessageNoDownloadsQueued": "下载队列无任务",
|
||||
"MessageNoEpisodeMatchesFound": "没有找到任何剧集匹配项",
|
||||
@@ -725,6 +757,7 @@
|
||||
"MessagePauseChapter": "暂停章节播放",
|
||||
"MessagePlayChapter": "开始章节播放",
|
||||
"MessagePlaylistCreateFromCollection": "从收藏中创建播放列表",
|
||||
"MessagePleaseWait": "请稍等...",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url",
|
||||
"MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.",
|
||||
"MessageRemoveChapter": "移除章节",
|
||||
@@ -785,18 +818,28 @@
|
||||
"StatsYearInReview": "年度回顾",
|
||||
"ToastAccountUpdateFailed": "账户更新失败",
|
||||
"ToastAccountUpdateSuccess": "帐户已更新",
|
||||
"ToastAppriseUrlRequired": "必须输入 Apprise URL",
|
||||
"ToastAuthorImageRemoveSuccess": "作者图像已删除",
|
||||
"ToastAuthorNotFound": "未找到作者 \"{0}\"",
|
||||
"ToastAuthorRemoveSuccess": "作者已删除",
|
||||
"ToastAuthorSearchNotFound": "未找到作者",
|
||||
"ToastAuthorUpdateFailed": "作者更新失败",
|
||||
"ToastAuthorUpdateMerged": "作者已合并",
|
||||
"ToastAuthorUpdateSuccess": "作者已更新",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "作者已更新 (未找到图像)",
|
||||
"ToastBackupAppliedSuccess": "已应用备份",
|
||||
"ToastBackupCreateFailed": "备份创建失败",
|
||||
"ToastBackupCreateSuccess": "备份已创建",
|
||||
"ToastBackupDeleteFailed": "备份删除失败",
|
||||
"ToastBackupDeleteSuccess": "备份已删除",
|
||||
"ToastBackupInvalidMaxKeep": "要保留的备份数无效",
|
||||
"ToastBackupInvalidMaxSize": "最大备份大小无效",
|
||||
"ToastBackupPathUpdateFailed": "无法更新备份路径",
|
||||
"ToastBackupRestoreFailed": "备份还原失败",
|
||||
"ToastBackupUploadFailed": "上传备份失败",
|
||||
"ToastBackupUploadSuccess": "备份已上传",
|
||||
"ToastBatchDeleteFailed": "批量删除失败",
|
||||
"ToastBatchDeleteSuccess": "批量删除成功",
|
||||
"ToastBatchUpdateFailed": "批量更新失败",
|
||||
"ToastBatchUpdateSuccess": "批量更新成功",
|
||||
"ToastBookmarkCreateFailed": "创建书签失败",
|
||||
@@ -808,22 +851,46 @@
|
||||
"ToastCachePurgeSuccess": "缓存清除成功",
|
||||
"ToastChaptersHaveErrors": "章节有错误",
|
||||
"ToastChaptersMustHaveTitles": "章节必须有标题",
|
||||
"ToastChaptersRemoved": "已删除章节",
|
||||
"ToastCollectionItemsAddFailed": "项目添加到收藏夹失败",
|
||||
"ToastCollectionItemsAddSuccess": "项目添加到收藏夹成功",
|
||||
"ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除",
|
||||
"ToastCollectionRemoveSuccess": "收藏夹已删除",
|
||||
"ToastCollectionUpdateFailed": "更新收藏夹失败",
|
||||
"ToastCollectionUpdateSuccess": "收藏夹已更新",
|
||||
"ToastCoverUpdateFailed": "封面更新失败",
|
||||
"ToastDeleteFileFailed": "删除文件失败",
|
||||
"ToastDeleteFileSuccess": "文件已删除",
|
||||
"ToastDeviceAddFailed": "添加设备失败",
|
||||
"ToastDeviceNameAlreadyExists": "同名的电子阅读器设备已存在",
|
||||
"ToastDeviceTestEmailFailed": "无法发送测试电子邮件",
|
||||
"ToastDeviceTestEmailSuccess": "测试邮件已发送",
|
||||
"ToastDeviceUpdateFailed": "无法更新设备",
|
||||
"ToastEmailSettingsUpdateFailed": "无法更新电子邮件设置",
|
||||
"ToastEmailSettingsUpdateSuccess": "电子邮件设置已更新",
|
||||
"ToastEncodeCancelFailed": "取消编码失败",
|
||||
"ToastEncodeCancelSucces": "编码已取消",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "无法清除队列",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "剧集下载队列已清空",
|
||||
"ToastErrorCannotShare": "无法在此设备上本地共享",
|
||||
"ToastFailedToLoadData": "加载数据失败",
|
||||
"ToastFailedToShare": "分享失败",
|
||||
"ToastFailedToUpdateAccount": "无法更新账户",
|
||||
"ToastFailedToUpdateUser": "无法更新用户",
|
||||
"ToastInvalidImageUrl": "图片网址无效",
|
||||
"ToastInvalidUrl": "网址无效",
|
||||
"ToastItemCoverUpdateFailed": "更新项目封面失败",
|
||||
"ToastItemCoverUpdateSuccess": "项目封面已更新",
|
||||
"ToastItemDeletedFailed": "删除项目失败",
|
||||
"ToastItemDeletedSuccess": "已删除项目",
|
||||
"ToastItemDetailsUpdateFailed": "更新项目详细信息失败",
|
||||
"ToastItemDetailsUpdateSuccess": "项目详细信息已更新",
|
||||
"ToastItemMarkedAsFinishedFailed": "无法标记为已听完",
|
||||
"ToastItemMarkedAsFinishedSuccess": "标记为已听完的项目",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "无法标记为未听完",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "标记为未听完的项目",
|
||||
"ToastItemUpdateFailed": "更新项目失败",
|
||||
"ToastItemUpdateSuccess": "项目已更新",
|
||||
"ToastLibraryCreateFailed": "创建媒体库失败",
|
||||
"ToastLibraryCreateSuccess": "媒体库 \"{0}\" 创建成功",
|
||||
"ToastLibraryDeleteFailed": "删除媒体库失败",
|
||||
@@ -832,6 +899,25 @@
|
||||
"ToastLibraryScanStarted": "媒体库扫描已启动",
|
||||
"ToastLibraryUpdateFailed": "更新图书库失败",
|
||||
"ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新",
|
||||
"ToastNameEmailRequired": "姓名和电子邮件为必填项",
|
||||
"ToastNameRequired": "姓名为必填项",
|
||||
"ToastNewUserCreatedFailed": "无法创建帐户: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "已创建新帐户",
|
||||
"ToastNewUserLibraryError": "必须至少选择一个图书馆",
|
||||
"ToastNewUserPasswordError": "必须有密码, 只有root用户可以有空密码",
|
||||
"ToastNewUserTagError": "必须至少选择一个标签",
|
||||
"ToastNewUserUsernameError": "输入用户名",
|
||||
"ToastNoUpdatesNecessary": "无需更新",
|
||||
"ToastNotificationCreateFailed": "无法创建通知",
|
||||
"ToastNotificationDeleteFailed": "删除通知失败",
|
||||
"ToastNotificationFailedMaximum": "最大失败尝试次数必须 >= 0",
|
||||
"ToastNotificationQueueMaximum": "最大通知队列必须 >= 0",
|
||||
"ToastNotificationSettingsUpdateFailed": "无法更新通知设置",
|
||||
"ToastNotificationSettingsUpdateSuccess": "通知设置已更新",
|
||||
"ToastNotificationTestTriggerFailed": "无法触发测试通知",
|
||||
"ToastNotificationTestTriggerSuccess": "触发测试通知",
|
||||
"ToastNotificationUpdateFailed": "更新通知失败",
|
||||
"ToastNotificationUpdateSuccess": "通知已更新",
|
||||
"ToastPlaylistCreateFailed": "创建播放列表失败",
|
||||
"ToastPlaylistCreateSuccess": "已成功创建播放列表",
|
||||
"ToastPlaylistRemoveSuccess": "播放列表已删除",
|
||||
@@ -839,24 +925,52 @@
|
||||
"ToastPlaylistUpdateSuccess": "播放列表已更新",
|
||||
"ToastPodcastCreateFailed": "创建播客失败",
|
||||
"ToastPodcastCreateSuccess": "已成功创建播客",
|
||||
"ToastPodcastGetFeedFailed": "无法获取播客信息",
|
||||
"ToastPodcastNoEpisodesInFeed": "RSS 订阅中未找到任何剧集",
|
||||
"ToastPodcastNoRssFeed": "播客没有 RSS 源",
|
||||
"ToastProviderCreatedFailed": "无法添加提供商",
|
||||
"ToastProviderCreatedSuccess": "已添加新提供商",
|
||||
"ToastProviderNameAndUrlRequired": "名称和网址必需填写",
|
||||
"ToastProviderRemoveSuccess": "提供商已移除",
|
||||
"ToastRSSFeedCloseFailed": "关闭 RSS 源失败",
|
||||
"ToastRSSFeedCloseSuccess": "RSS 源已关闭",
|
||||
"ToastRemoveFailed": "删除失败",
|
||||
"ToastRemoveItemFromCollectionFailed": "从收藏中删除项目失败",
|
||||
"ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除",
|
||||
"ToastRemoveItemsWithIssuesFailed": "无法删除有问题的库项目",
|
||||
"ToastRemoveItemsWithIssuesSuccess": "已删除有问题的库项目",
|
||||
"ToastRenameFailed": "重命名失败",
|
||||
"ToastRescanFailed": "{0} 重新扫描失败",
|
||||
"ToastRescanRemoved": "重新扫描完成项目已删除",
|
||||
"ToastRescanUpToDate": "重新扫描完成项目已更新",
|
||||
"ToastRescanUpdated": "重新扫描完成项目已更新",
|
||||
"ToastScanFailed": "扫描库项目失败",
|
||||
"ToastSelectAtLeastOneUser": "至少选择一位用户",
|
||||
"ToastSendEbookToDeviceFailed": "发送电子书到设备失败",
|
||||
"ToastSendEbookToDeviceSuccess": "电子书已经发送到设备 \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "更新系列失败",
|
||||
"ToastSeriesUpdateSuccess": "系列已更新",
|
||||
"ToastServerSettingsUpdateFailed": "无法更新服务器设置",
|
||||
"ToastServerSettingsUpdateSuccess": "服务器设置已更新",
|
||||
"ToastSessionCloseFailed": "关闭会话失败",
|
||||
"ToastSessionDeleteFailed": "删除会话失败",
|
||||
"ToastSessionDeleteSuccess": "会话已删除",
|
||||
"ToastSlugMustChange": "Slug 包含无效字符",
|
||||
"ToastSlugRequired": "Slug 是必填项",
|
||||
"ToastSocketConnected": "网络已连接",
|
||||
"ToastSocketDisconnected": "网络已断开",
|
||||
"ToastSocketFailedToConnect": "网络连接失败",
|
||||
"ToastSortingPrefixesEmptyError": "必须至少有 1 个排序前缀",
|
||||
"ToastSortingPrefixesUpdateFailed": "无法更新排序前缀",
|
||||
"ToastSortingPrefixesUpdateSuccess": "排序前缀已更新 ({0} 项)",
|
||||
"ToastTitleRequired": "标题为必填项",
|
||||
"ToastUnknownError": "未知错误",
|
||||
"ToastUnlinkOpenIdFailed": "无法取消用户与 OpenID 的关联",
|
||||
"ToastUnlinkOpenIdSuccess": "用户已取消与 OpenID 的关联",
|
||||
"ToastUserDeleteFailed": "删除用户失败",
|
||||
"ToastUserDeleteSuccess": "用户已删除"
|
||||
"ToastUserDeleteSuccess": "用户已删除",
|
||||
"ToastUserPasswordChangeSuccess": "密码修改成功",
|
||||
"ToastUserPasswordMismatch": "密码不匹配",
|
||||
"ToastUserPasswordMustChange": "新密码不能与旧密码相同",
|
||||
"ToastUserRootRequireName": "必须输入 root 用户名"
|
||||
}
|
||||
|
||||
205
package-lock.json
generated
205
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.13.3",
|
||||
"version": "2.13.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.13.3",
|
||||
"version": "2.13.4",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
@@ -21,6 +21,7 @@
|
||||
"p-throttle": "^4.1.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"semver": "^7.6.3",
|
||||
"sequelize": "^6.35.2",
|
||||
"socket.io": "^4.5.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
@@ -173,6 +174,15 @@
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz",
|
||||
@@ -213,6 +223,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@@ -586,17 +605,6 @@
|
||||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
@@ -611,20 +619,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
|
||||
"integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/fs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
|
||||
@@ -635,33 +629,6 @@
|
||||
"semver": "^7.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/fs/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/fs/node_modules/semver": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
|
||||
"integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/move-file": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
|
||||
@@ -2576,6 +2543,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-instrument/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-processinfo": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz",
|
||||
@@ -2628,18 +2604,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report/node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
@@ -2655,21 +2619,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report/node_modules/semver": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -2804,36 +2753,11 @@
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/semver": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/just-extend": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz",
|
||||
@@ -2970,6 +2894,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/make-fetch-happen": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz",
|
||||
@@ -3585,18 +3517,6 @@
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
@@ -3627,21 +3547,6 @@
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/semver": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
|
||||
"integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-preload": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
|
||||
@@ -4336,11 +4241,14 @@
|
||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
||||
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
@@ -4456,36 +4364,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sequelize/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sequelize/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/sequelize/node_modules/semver": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.13.3",
|
||||
"version": "2.13.4",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
@@ -47,6 +47,7 @@
|
||||
"p-throttle": "^4.1.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"semver": "^7.6.3",
|
||||
"sequelize": "^6.35.2",
|
||||
"socket.io": "^4.5.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
|
||||
@@ -8,6 +8,8 @@ const Logger = require('./Logger')
|
||||
const dbMigration = require('./utils/migrations/dbMigration')
|
||||
const Auth = require('./Auth')
|
||||
|
||||
const MigrationManager = require('./managers/MigrationManager')
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
this.sequelize = null
|
||||
@@ -142,6 +144,11 @@ class Database {
|
||||
return this.models.mediaItemShare
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Device')} */
|
||||
get deviceModel() {
|
||||
return this.models.device
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if db file exists
|
||||
* @returns {boolean}
|
||||
@@ -168,6 +175,15 @@ class Database {
|
||||
throw new Error('Database connection failed')
|
||||
}
|
||||
|
||||
try {
|
||||
const migrationManager = new MigrationManager(this.sequelize, global.ConfigPath)
|
||||
await migrationManager.init(packageJson.version)
|
||||
if (!this.isNew) await migrationManager.runMigrations()
|
||||
} catch (error) {
|
||||
Logger.error(`[Database] Failed to run migrations`, error)
|
||||
throw new Error('Database migration failed')
|
||||
}
|
||||
|
||||
await this.buildModels(force)
|
||||
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
||||
|
||||
@@ -478,21 +494,6 @@ class Database {
|
||||
return this.models.playbackSession.removeById(sessionId)
|
||||
}
|
||||
|
||||
getDeviceByDeviceId(deviceId) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.device.getOldDeviceByDeviceId(deviceId)
|
||||
}
|
||||
|
||||
updateDevice(oldDevice) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.device.updateFromOld(oldDevice)
|
||||
}
|
||||
|
||||
createDevice(oldDevice) {
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const date = require('./libs/dateAndTime')
|
||||
const { LogLevel } = require('./utils/constants')
|
||||
const util = require('util')
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
@@ -69,27 +70,29 @@ class Logger {
|
||||
/**
|
||||
*
|
||||
* @param {number} level
|
||||
* @param {string} levelName
|
||||
* @param {string[]} args
|
||||
* @param {string} src
|
||||
*/
|
||||
async handleLog(level, args, src) {
|
||||
async #logToFileAndListeners(level, levelName, args, src) {
|
||||
const expandedArgs = args.map((arg) => (typeof arg !== 'string' ? util.inspect(arg) : arg))
|
||||
const logObj = {
|
||||
timestamp: this.timestamp,
|
||||
source: src,
|
||||
message: args.join(' '),
|
||||
levelName: this.getLogLevelString(level),
|
||||
message: expandedArgs.join(' '),
|
||||
levelName,
|
||||
level
|
||||
}
|
||||
|
||||
// Emit log to sockets that are listening to log events
|
||||
this.socketListeners.forEach((socketListener) => {
|
||||
if (socketListener.level <= level) {
|
||||
if (level >= LogLevel.FATAL || level >= socketListener.level) {
|
||||
socketListener.socket.emit('log', logObj)
|
||||
}
|
||||
})
|
||||
|
||||
// Save log to file
|
||||
if (level >= this.logLevel) {
|
||||
if (level >= LogLevel.FATAL || level >= this.logLevel) {
|
||||
await this.logManager?.logToFile(logObj)
|
||||
}
|
||||
}
|
||||
@@ -99,50 +102,50 @@ class Logger {
|
||||
this.debug(`Set Log Level to ${this.levelString}`)
|
||||
}
|
||||
|
||||
static ConsoleMethods = {
|
||||
TRACE: 'trace',
|
||||
DEBUG: 'debug',
|
||||
INFO: 'info',
|
||||
WARN: 'warn',
|
||||
ERROR: 'error',
|
||||
FATAL: 'error',
|
||||
NOTE: 'log'
|
||||
}
|
||||
|
||||
#log(levelName, source, ...args) {
|
||||
const level = LogLevel[levelName]
|
||||
if (level < LogLevel.FATAL && level < this.logLevel) return
|
||||
const consoleMethod = Logger.ConsoleMethods[levelName]
|
||||
console[consoleMethod](`[${this.timestamp}] ${levelName}:`, ...args)
|
||||
this.#logToFileAndListeners(level, levelName, args, source)
|
||||
}
|
||||
|
||||
trace(...args) {
|
||||
if (this.logLevel > LogLevel.TRACE) return
|
||||
console.trace(`[${this.timestamp}] TRACE:`, ...args)
|
||||
this.handleLog(LogLevel.TRACE, args, this.source)
|
||||
this.#log('TRACE', this.source, ...args)
|
||||
}
|
||||
|
||||
debug(...args) {
|
||||
if (this.logLevel > LogLevel.DEBUG) return
|
||||
console.debug(`[${this.timestamp}] DEBUG:`, ...args, `(${this.source})`)
|
||||
this.handleLog(LogLevel.DEBUG, args, this.source)
|
||||
this.#log('DEBUG', this.source, ...args)
|
||||
}
|
||||
|
||||
info(...args) {
|
||||
if (this.logLevel > LogLevel.INFO) return
|
||||
console.info(`[${this.timestamp}] INFO:`, ...args)
|
||||
this.handleLog(LogLevel.INFO, args, this.source)
|
||||
this.#log('INFO', this.source, ...args)
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
if (this.logLevel > LogLevel.WARN) return
|
||||
console.warn(`[${this.timestamp}] WARN:`, ...args, `(${this.source})`)
|
||||
this.handleLog(LogLevel.WARN, args, this.source)
|
||||
this.#log('WARN', this.source, ...args)
|
||||
}
|
||||
|
||||
error(...args) {
|
||||
if (this.logLevel > LogLevel.ERROR) return
|
||||
console.error(`[${this.timestamp}] ERROR:`, ...args, `(${this.source})`)
|
||||
this.handleLog(LogLevel.ERROR, args, this.source)
|
||||
this.#log('ERROR', this.source, ...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fatal errors are ones that exit the process
|
||||
* Fatal logs are saved to crash_logs.txt
|
||||
*
|
||||
* @param {...any} args
|
||||
*/
|
||||
fatal(...args) {
|
||||
console.error(`[${this.timestamp}] FATAL:`, ...args, `(${this.source})`)
|
||||
return this.handleLog(LogLevel.FATAL, args, this.source)
|
||||
this.#log('FATAL', this.source, ...args)
|
||||
}
|
||||
|
||||
note(...args) {
|
||||
console.log(`[${this.timestamp}] NOTE:`, ...args)
|
||||
this.handleLog(LogLevel.NOTE, args, this.source)
|
||||
this.#log('NOTE', this.source, ...args)
|
||||
}
|
||||
}
|
||||
module.exports = new Logger()
|
||||
|
||||
@@ -384,7 +384,7 @@ class LibraryItemController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
startPlaybackSession(req, res) {
|
||||
if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') {
|
||||
if (!req.libraryItem.media.numTracks) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ const Logger = require('../Logger')
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
const PodcastFinder = require('../finders/PodcastFinder')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
const MusicFinder = require('../finders/MusicFinder')
|
||||
const Database = require('../Database')
|
||||
const { isValidASIN } = require('../utils')
|
||||
|
||||
|
||||
@@ -202,10 +202,14 @@ class BookFinder {
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async getCustomProviderResults(title, author, isbn, providerSlug) {
|
||||
const books = await this.customProviderAdapter.search(title, author, isbn, providerSlug, 'book', this.#providerResponseTimeout)
|
||||
if (this.verbose) Logger.debug(`Custom provider '${providerSlug}' Search Results: ${books.length || 0}`)
|
||||
|
||||
return books
|
||||
try {
|
||||
const books = await this.customProviderAdapter.search(title, author, isbn, providerSlug, 'book', this.#providerResponseTimeout)
|
||||
if (this.verbose) Logger.debug(`Custom provider '${providerSlug}' Search Results: ${books.length || 0}`)
|
||||
return books
|
||||
} catch (error) {
|
||||
Logger.error(`Error searching Custom provider '${providerSlug}':`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
static TitleCandidates = class {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
const MusicBrainz = require('../providers/MusicBrainz')
|
||||
|
||||
class MusicFinder {
|
||||
constructor() {
|
||||
this.musicBrainz = new MusicBrainz()
|
||||
}
|
||||
|
||||
searchTrack(options) {
|
||||
return this.musicBrainz.searchTrack(options)
|
||||
}
|
||||
}
|
||||
module.exports = new MusicFinder()
|
||||
21
server/libs/umzug/LICENSE
Normal file
21
server/libs/umzug/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2017 Sequelize contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
31
server/libs/umzug/index.js
Normal file
31
server/libs/umzug/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
'use strict'
|
||||
var __createBinding =
|
||||
(this && this.__createBinding) ||
|
||||
(Object.create
|
||||
? function (o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k)
|
||||
if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return m[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
Object.defineProperty(o, k2, desc)
|
||||
}
|
||||
: function (o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k
|
||||
o[k2] = m[k]
|
||||
})
|
||||
var __exportStar =
|
||||
(this && this.__exportStar) ||
|
||||
function (m, exports) {
|
||||
for (var p in m) if (p !== 'default' && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p)
|
||||
}
|
||||
Object.defineProperty(exports, '__esModule', { value: true })
|
||||
__exportStar(require('./umzug'), exports)
|
||||
__exportStar(require('./storage'), exports)
|
||||
__exportStar(require('./types'), exports)
|
||||
//# sourceMappingURL=index.js.map
|
||||
18
server/libs/umzug/storage/contract.js
Normal file
18
server/libs/umzug/storage/contract.js
Normal file
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.verifyUmzugStorage = exports.isUmzugStorage = void 0;
|
||||
function isUmzugStorage(arg) {
|
||||
return (arg &&
|
||||
typeof arg.logMigration === 'function' &&
|
||||
typeof arg.unlogMigration === 'function' &&
|
||||
typeof arg.executed === 'function');
|
||||
}
|
||||
exports.isUmzugStorage = isUmzugStorage;
|
||||
const verifyUmzugStorage = (arg) => {
|
||||
if (!isUmzugStorage(arg)) {
|
||||
throw new Error(`Invalid umzug storage`);
|
||||
}
|
||||
return arg;
|
||||
};
|
||||
exports.verifyUmzugStorage = verifyUmzugStorage;
|
||||
//# sourceMappingURL=contract.js.map
|
||||
24
server/libs/umzug/storage/index.js
Normal file
24
server/libs/umzug/storage/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
// codegen:start {preset: barrel}
|
||||
__exportStar(require("./contract"), exports);
|
||||
__exportStar(require("./json"), exports);
|
||||
__exportStar(require("./memory"), exports);
|
||||
__exportStar(require("./mongodb"), exports);
|
||||
__exportStar(require("./sequelize"), exports);
|
||||
// codegen:end
|
||||
//# sourceMappingURL=index.js.map
|
||||
61
server/libs/umzug/storage/json.js
Normal file
61
server/libs/umzug/storage/json.js
Normal file
@@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.JSONStorage = void 0;
|
||||
const fs_1 = require("fs");
|
||||
const path = __importStar(require("path"));
|
||||
const filesystem = {
|
||||
/** reads a file as a string or returns null if file doesn't exist */
|
||||
async readAsync(filepath) {
|
||||
return fs_1.promises.readFile(filepath).then(c => c.toString(), () => null);
|
||||
},
|
||||
/** writes a string as file contents, creating its parent directory if necessary */
|
||||
async writeAsync(filepath, content) {
|
||||
await fs_1.promises.mkdir(path.dirname(filepath), { recursive: true });
|
||||
await fs_1.promises.writeFile(filepath, content);
|
||||
},
|
||||
};
|
||||
class JSONStorage {
|
||||
constructor(options) {
|
||||
var _a;
|
||||
this.path = (_a = options === null || options === void 0 ? void 0 : options.path) !== null && _a !== void 0 ? _a : path.join(process.cwd(), 'umzug.json');
|
||||
}
|
||||
async logMigration({ name: migrationName }) {
|
||||
const loggedMigrations = await this.executed();
|
||||
loggedMigrations.push(migrationName);
|
||||
await filesystem.writeAsync(this.path, JSON.stringify(loggedMigrations, null, 2));
|
||||
}
|
||||
async unlogMigration({ name: migrationName }) {
|
||||
const loggedMigrations = await this.executed();
|
||||
const updatedMigrations = loggedMigrations.filter(name => name !== migrationName);
|
||||
await filesystem.writeAsync(this.path, JSON.stringify(updatedMigrations, null, 2));
|
||||
}
|
||||
async executed() {
|
||||
const content = await filesystem.readAsync(this.path);
|
||||
return content ? JSON.parse(content) : [];
|
||||
}
|
||||
}
|
||||
exports.JSONStorage = JSONStorage;
|
||||
//# sourceMappingURL=json.js.map
|
||||
17
server/libs/umzug/storage/memory.js
Normal file
17
server/libs/umzug/storage/memory.js
Normal file
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.memoryStorage = void 0;
|
||||
const memoryStorage = () => {
|
||||
let executed = [];
|
||||
return {
|
||||
async logMigration({ name }) {
|
||||
executed.push(name);
|
||||
},
|
||||
async unlogMigration({ name }) {
|
||||
executed = executed.filter(n => n !== name);
|
||||
},
|
||||
executed: async () => [...executed],
|
||||
};
|
||||
};
|
||||
exports.memoryStorage = memoryStorage;
|
||||
//# sourceMappingURL=memory.js.map
|
||||
31
server/libs/umzug/storage/mongodb.js
Normal file
31
server/libs/umzug/storage/mongodb.js
Normal file
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MongoDBStorage = void 0;
|
||||
function isMongoDBCollectionOptions(arg) {
|
||||
return Boolean(arg.collection);
|
||||
}
|
||||
class MongoDBStorage {
|
||||
constructor(options) {
|
||||
var _a, _b;
|
||||
if (!options || (!options.collection && !options.connection)) {
|
||||
throw new Error('MongoDB Connection or Collection required');
|
||||
}
|
||||
this.collection = isMongoDBCollectionOptions(options)
|
||||
? options.collection
|
||||
: options.connection.collection((_a = options.collectionName) !== null && _a !== void 0 ? _a : 'migrations');
|
||||
this.connection = options.connection; // TODO remove this
|
||||
this.collectionName = (_b = options.collectionName) !== null && _b !== void 0 ? _b : 'migrations'; // TODO remove this
|
||||
}
|
||||
async logMigration({ name: migrationName }) {
|
||||
await this.collection.insertOne({ migrationName });
|
||||
}
|
||||
async unlogMigration({ name: migrationName }) {
|
||||
await this.collection.deleteOne({ migrationName });
|
||||
}
|
||||
async executed() {
|
||||
const records = await this.collection.find({}).sort({ migrationName: 1 }).toArray();
|
||||
return records.map(r => r.migrationName);
|
||||
}
|
||||
}
|
||||
exports.MongoDBStorage = MongoDBStorage;
|
||||
//# sourceMappingURL=mongodb.js.map
|
||||
85
server/libs/umzug/storage/sequelize.js
Normal file
85
server/libs/umzug/storage/sequelize.js
Normal file
@@ -0,0 +1,85 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SequelizeStorage = void 0;
|
||||
const DIALECTS_WITH_CHARSET_AND_COLLATE = new Set(['mysql', 'mariadb']);
|
||||
class SequelizeStorage {
|
||||
/**
|
||||
Constructs Sequelize based storage. Migrations will be stored in a SequelizeMeta table using the given instance of Sequelize.
|
||||
|
||||
If a model is given, it will be used directly as the model for the SequelizeMeta table. Otherwise, it will be created automatically according to the given options.
|
||||
|
||||
If the table does not exist it will be created automatically upon the logging of the first migration.
|
||||
*/
|
||||
constructor(options) {
|
||||
var _a, _b, _c, _d, _e, _f;
|
||||
if (!options || (!options.model && !options.sequelize)) {
|
||||
throw new Error('One of "sequelize" or "model" storage option is required');
|
||||
}
|
||||
this.sequelize = (_a = options.sequelize) !== null && _a !== void 0 ? _a : options.model.sequelize;
|
||||
this.columnType = (_b = options.columnType) !== null && _b !== void 0 ? _b : this.sequelize.constructor.DataTypes.STRING;
|
||||
this.columnName = (_c = options.columnName) !== null && _c !== void 0 ? _c : 'name';
|
||||
this.timestamps = (_d = options.timestamps) !== null && _d !== void 0 ? _d : false;
|
||||
this.modelName = (_e = options.modelName) !== null && _e !== void 0 ? _e : 'SequelizeMeta';
|
||||
this.tableName = options.tableName;
|
||||
this.schema = options.schema;
|
||||
this.model = (_f = options.model) !== null && _f !== void 0 ? _f : this.getModel();
|
||||
}
|
||||
getModel() {
|
||||
var _a;
|
||||
if (this.sequelize.isDefined(this.modelName)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return this.sequelize.model(this.modelName);
|
||||
}
|
||||
const dialectName = (_a = this.sequelize.dialect) === null || _a === void 0 ? void 0 : _a.name;
|
||||
const hasCharsetAndCollate = dialectName && DIALECTS_WITH_CHARSET_AND_COLLATE.has(dialectName);
|
||||
return this.sequelize.define(this.modelName, {
|
||||
[this.columnName]: {
|
||||
type: this.columnType,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
primaryKey: true,
|
||||
autoIncrement: false,
|
||||
},
|
||||
}, {
|
||||
tableName: this.tableName,
|
||||
schema: this.schema,
|
||||
timestamps: this.timestamps,
|
||||
charset: hasCharsetAndCollate ? 'utf8' : undefined,
|
||||
collate: hasCharsetAndCollate ? 'utf8_unicode_ci' : undefined,
|
||||
});
|
||||
}
|
||||
async syncModel() {
|
||||
await this.model.sync();
|
||||
}
|
||||
async logMigration({ name: migrationName }) {
|
||||
await this.syncModel();
|
||||
await this.model.create({
|
||||
[this.columnName]: migrationName,
|
||||
});
|
||||
}
|
||||
async unlogMigration({ name: migrationName }) {
|
||||
await this.syncModel();
|
||||
await this.model.destroy({
|
||||
where: {
|
||||
[this.columnName]: migrationName,
|
||||
},
|
||||
});
|
||||
}
|
||||
async executed() {
|
||||
await this.syncModel();
|
||||
const migrations = await this.model.findAll({ order: [[this.columnName, 'ASC']] });
|
||||
return migrations.map(migration => {
|
||||
const name = migration[this.columnName];
|
||||
if (typeof name !== 'string') {
|
||||
throw new TypeError(`Unexpected migration name type: expected string, got ${typeof name}`);
|
||||
}
|
||||
return name;
|
||||
});
|
||||
}
|
||||
// TODO remove this
|
||||
_model() {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
exports.SequelizeStorage = SequelizeStorage;
|
||||
//# sourceMappingURL=sequelize.js.map
|
||||
32
server/libs/umzug/templates.js
Normal file
32
server/libs/umzug/templates.js
Normal file
@@ -0,0 +1,32 @@
|
||||
'use strict'
|
||||
/* eslint-disable unicorn/template-indent */
|
||||
// templates for migration file creation
|
||||
Object.defineProperty(exports, '__esModule', { value: true })
|
||||
exports.sqlDown = exports.sqlUp = exports.mjs = exports.ts = exports.js = void 0
|
||||
exports.js = `
|
||||
/** @type {import('umzug').MigrationFn<any>} */
|
||||
exports.up = async params => {};
|
||||
|
||||
/** @type {import('umzug').MigrationFn<any>} */
|
||||
exports.down = async params => {};
|
||||
`.trimStart()
|
||||
exports.ts = `
|
||||
import type { MigrationFn } from 'umzug';
|
||||
|
||||
export const up: MigrationFn = async params => {};
|
||||
export const down: MigrationFn = async params => {};
|
||||
`.trimStart()
|
||||
exports.mjs = `
|
||||
/** @type {import('umzug').MigrationFn<any>} */
|
||||
export const up = async params => {};
|
||||
|
||||
/** @type {import('umzug').MigrationFn<any>} */
|
||||
export const down = async params => {};
|
||||
`.trimStart()
|
||||
exports.sqlUp = `
|
||||
-- up migration
|
||||
`.trimStart()
|
||||
exports.sqlDown = `
|
||||
-- down migration
|
||||
`.trimStart()
|
||||
//# sourceMappingURL=templates.js.map
|
||||
12
server/libs/umzug/types.js
Normal file
12
server/libs/umzug/types.js
Normal file
@@ -0,0 +1,12 @@
|
||||
'use strict'
|
||||
Object.defineProperty(exports, '__esModule', { value: true })
|
||||
exports.RerunBehavior = void 0
|
||||
exports.RerunBehavior = {
|
||||
/** Hard error if an up migration that has already been run, or a down migration that hasn't, is encountered */
|
||||
THROW: 'THROW',
|
||||
/** Silently skip up migrations that have already been run, or down migrations that haven't */
|
||||
SKIP: 'SKIP',
|
||||
/** Re-run up migrations that have already been run, or down migrations that haven't */
|
||||
ALLOW: 'ALLOW'
|
||||
}
|
||||
//# sourceMappingURL=types.js.map
|
||||
386
server/libs/umzug/umzug.js
Normal file
386
server/libs/umzug/umzug.js
Normal file
@@ -0,0 +1,386 @@
|
||||
'use strict'
|
||||
var __createBinding =
|
||||
(this && this.__createBinding) ||
|
||||
(Object.create
|
||||
? function (o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k)
|
||||
if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return m[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
Object.defineProperty(o, k2, desc)
|
||||
}
|
||||
: function (o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k
|
||||
o[k2] = m[k]
|
||||
})
|
||||
var __setModuleDefault =
|
||||
(this && this.__setModuleDefault) ||
|
||||
(Object.create
|
||||
? function (o, v) {
|
||||
Object.defineProperty(o, 'default', { enumerable: true, value: v })
|
||||
}
|
||||
: function (o, v) {
|
||||
o['default'] = v
|
||||
})
|
||||
var __importStar =
|
||||
(this && this.__importStar) ||
|
||||
function (mod) {
|
||||
if (mod && mod.__esModule) return mod
|
||||
var result = {}
|
||||
if (mod != null) for (var k in mod) if (k !== 'default' && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k)
|
||||
__setModuleDefault(result, mod)
|
||||
return result
|
||||
}
|
||||
var __importDefault =
|
||||
(this && this.__importDefault) ||
|
||||
function (mod) {
|
||||
return mod && mod.__esModule ? mod : { default: mod }
|
||||
}
|
||||
var _a
|
||||
Object.defineProperty(exports, '__esModule', { value: true })
|
||||
exports.Umzug = exports.MigrationError = void 0
|
||||
const fs = __importStar(require('fs'))
|
||||
const path = __importStar(require('path'))
|
||||
const storage_1 = require('./storage')
|
||||
const templates = __importStar(require('./templates'))
|
||||
const types_1 = require('./types')
|
||||
class MigrationError extends Error {
|
||||
// TODO [>=4.0.0] Take a `{ cause: ... }` options bag like the default `Error`, it looks like this because of verror backwards-compatibility.
|
||||
constructor(migration, original) {
|
||||
super(`Migration ${migration.name} (${migration.direction}) failed: ${MigrationError.errorString(original)}`, {
|
||||
cause: original
|
||||
})
|
||||
this.name = 'MigrationError'
|
||||
this.migration = migration
|
||||
}
|
||||
// TODO [>=4.0.0] Remove this backwards-compatibility alias
|
||||
get info() {
|
||||
return this.migration
|
||||
}
|
||||
static errorString(cause) {
|
||||
return cause instanceof Error ? `Original error: ${cause.message}` : `Non-error value thrown. See info for full props: ${cause}`
|
||||
}
|
||||
}
|
||||
exports.MigrationError = MigrationError
|
||||
class Umzug {
|
||||
/** creates a new Umzug instance */
|
||||
constructor(options) {
|
||||
var _b
|
||||
this.options = options
|
||||
this.storage = (0, storage_1.verifyUmzugStorage)((_b = options.storage) !== null && _b !== void 0 ? _b : new storage_1.JSONStorage())
|
||||
this.migrations = this.getMigrationsResolver(this.options.migrations)
|
||||
}
|
||||
logging(message) {
|
||||
var _b
|
||||
;(_b = this.options.logger) === null || _b === void 0 ? void 0 : _b.info(message)
|
||||
}
|
||||
/** Get the list of migrations which have already been applied */
|
||||
async executed() {
|
||||
return this.runCommand('executed', async ({ context }) => {
|
||||
const list = await this._executed(context)
|
||||
// We do the following to not expose the `up` and `down` functions to the user
|
||||
return list.map((m) => ({ name: m.name, path: m.path }))
|
||||
})
|
||||
}
|
||||
/** Get the list of migrations which have already been applied */
|
||||
async _executed(context) {
|
||||
const [migrations, executedNames] = await Promise.all([this.migrations(context), this.storage.executed({ context })])
|
||||
const executedSet = new Set(executedNames)
|
||||
return migrations.filter((m) => executedSet.has(m.name))
|
||||
}
|
||||
/** Get the list of migrations which are yet to be applied */
|
||||
async pending() {
|
||||
return this.runCommand('pending', async ({ context }) => {
|
||||
const list = await this._pending(context)
|
||||
// We do the following to not expose the `up` and `down` functions to the user
|
||||
return list.map((m) => ({ name: m.name, path: m.path }))
|
||||
})
|
||||
}
|
||||
async _pending(context) {
|
||||
const [migrations, executedNames] = await Promise.all([this.migrations(context), this.storage.executed({ context })])
|
||||
const executedSet = new Set(executedNames)
|
||||
return migrations.filter((m) => !executedSet.has(m.name))
|
||||
}
|
||||
async runCommand(command, cb) {
|
||||
const context = await this.getContext()
|
||||
return await cb({ context })
|
||||
}
|
||||
/**
|
||||
* Apply migrations. By default, runs all pending migrations.
|
||||
* @see MigrateUpOptions for other use cases using `to`, `migrations` and `rerun`.
|
||||
*/
|
||||
async up(options = {}) {
|
||||
const eligibleMigrations = async (context) => {
|
||||
var _b
|
||||
if (options.migrations && options.rerun === types_1.RerunBehavior.ALLOW) {
|
||||
// Allow rerun means the specified migrations should be run even if they've run before - so get all migrations, not just pending
|
||||
const list = await this.migrations(context)
|
||||
return this.findMigrations(list, options.migrations)
|
||||
}
|
||||
if (options.migrations && options.rerun === types_1.RerunBehavior.SKIP) {
|
||||
const executedNames = new Set((await this._executed(context)).map((m) => m.name))
|
||||
const filteredMigrations = options.migrations.filter((m) => !executedNames.has(m))
|
||||
return this.findMigrations(await this.migrations(context), filteredMigrations)
|
||||
}
|
||||
if (options.migrations) {
|
||||
return this.findMigrations(await this._pending(context), options.migrations)
|
||||
}
|
||||
const allPending = await this._pending(context)
|
||||
let sliceIndex = (_b = options.step) !== null && _b !== void 0 ? _b : allPending.length
|
||||
if (options.to) {
|
||||
sliceIndex = this.findNameIndex(allPending, options.to) + 1
|
||||
}
|
||||
return allPending.slice(0, sliceIndex)
|
||||
}
|
||||
return this.runCommand('up', async ({ context }) => {
|
||||
const toBeApplied = await eligibleMigrations(context)
|
||||
for (const m of toBeApplied) {
|
||||
const start = Date.now()
|
||||
const params = { name: m.name, path: m.path, context }
|
||||
this.logging({ event: 'migrating', name: m.name })
|
||||
try {
|
||||
await m.up(params)
|
||||
} catch (e) {
|
||||
throw new MigrationError({ direction: 'up', ...params }, e)
|
||||
}
|
||||
await this.storage.logMigration(params)
|
||||
const duration = (Date.now() - start) / 1000
|
||||
this.logging({ event: 'migrated', name: m.name, durationSeconds: duration })
|
||||
}
|
||||
return toBeApplied.map((m) => ({ name: m.name, path: m.path }))
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Revert migrations. By default, the last executed migration is reverted.
|
||||
* @see MigrateDownOptions for other use cases using `to`, `migrations` and `rerun`.
|
||||
*/
|
||||
async down(options = {}) {
|
||||
const eligibleMigrations = async (context) => {
|
||||
var _b
|
||||
if (options.migrations && options.rerun === types_1.RerunBehavior.ALLOW) {
|
||||
const list = await this.migrations(context)
|
||||
return this.findMigrations(list, options.migrations)
|
||||
}
|
||||
if (options.migrations && options.rerun === types_1.RerunBehavior.SKIP) {
|
||||
const pendingNames = new Set((await this._pending(context)).map((m) => m.name))
|
||||
const filteredMigrations = options.migrations.filter((m) => !pendingNames.has(m))
|
||||
return this.findMigrations(await this.migrations(context), filteredMigrations)
|
||||
}
|
||||
if (options.migrations) {
|
||||
return this.findMigrations(await this._executed(context), options.migrations)
|
||||
}
|
||||
const executedReversed = (await this._executed(context)).slice().reverse()
|
||||
let sliceIndex = (_b = options.step) !== null && _b !== void 0 ? _b : 1
|
||||
if (options.to === 0 || options.migrations) {
|
||||
sliceIndex = executedReversed.length
|
||||
} else if (options.to) {
|
||||
sliceIndex = this.findNameIndex(executedReversed, options.to) + 1
|
||||
}
|
||||
return executedReversed.slice(0, sliceIndex)
|
||||
}
|
||||
return this.runCommand('down', async ({ context }) => {
|
||||
var _b
|
||||
const toBeReverted = await eligibleMigrations(context)
|
||||
for (const m of toBeReverted) {
|
||||
const start = Date.now()
|
||||
const params = { name: m.name, path: m.path, context }
|
||||
this.logging({ event: 'reverting', name: m.name })
|
||||
try {
|
||||
await ((_b = m.down) === null || _b === void 0 ? void 0 : _b.call(m, params))
|
||||
} catch (e) {
|
||||
throw new MigrationError({ direction: 'down', ...params }, e)
|
||||
}
|
||||
await this.storage.unlogMigration(params)
|
||||
const duration = Number.parseFloat(((Date.now() - start) / 1000).toFixed(3))
|
||||
this.logging({ event: 'reverted', name: m.name, durationSeconds: duration })
|
||||
}
|
||||
return toBeReverted.map((m) => ({ name: m.name, path: m.path }))
|
||||
})
|
||||
}
|
||||
async create(options) {
|
||||
await this.runCommand('create', async ({ context }) => {
|
||||
var _b, _c, _d, _e
|
||||
const isoDate = new Date().toISOString()
|
||||
const prefixes = {
|
||||
TIMESTAMP: isoDate.replace(/\.\d{3}Z$/, '').replace(/\W/g, '.'),
|
||||
DATE: isoDate.split('T')[0].replace(/\W/g, '.'),
|
||||
NONE: ''
|
||||
}
|
||||
const prefixType = (_b = options.prefix) !== null && _b !== void 0 ? _b : 'TIMESTAMP'
|
||||
const fileBasename = [prefixes[prefixType], options.name].filter(Boolean).join('.')
|
||||
const allowedExtensions = options.allowExtension ? [options.allowExtension] : ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.sql']
|
||||
const existing = await this.migrations(context)
|
||||
const last = existing.slice(-1)[0]
|
||||
const folder = options.folder || ((_c = this.options.create) === null || _c === void 0 ? void 0 : _c.folder) || ((last === null || last === void 0 ? void 0 : last.path) && path.dirname(last.path))
|
||||
if (!folder) {
|
||||
throw new Error(`Couldn't infer a directory to generate migration file in. Pass folder explicitly`)
|
||||
}
|
||||
const filepath = path.join(folder, fileBasename)
|
||||
if (!options.allowConfusingOrdering) {
|
||||
const confusinglyOrdered = existing.find((e) => e.path && e.path >= filepath)
|
||||
if (confusinglyOrdered) {
|
||||
throw new Error(`Can't create ${fileBasename}, since it's unclear if it should run before or after existing migration ${confusinglyOrdered.name}. Use allowConfusingOrdering to bypass this error.`)
|
||||
}
|
||||
}
|
||||
const template =
|
||||
typeof options.content === 'string'
|
||||
? async () => [[filepath, options.content]]
|
||||
: // eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
(_e = (_d = this.options.create) === null || _d === void 0 ? void 0 : _d.template) !== null && _e !== void 0
|
||||
? _e
|
||||
: Umzug.defaultCreationTemplate
|
||||
const toWrite = await template(filepath)
|
||||
if (toWrite.length === 0) {
|
||||
toWrite.push([filepath, ''])
|
||||
}
|
||||
toWrite.forEach((pair) => {
|
||||
if (!Array.isArray(pair) || pair.length !== 2) {
|
||||
throw new Error(`Expected [filepath, content] pair. Check that the file template function returns an array of pairs.`)
|
||||
}
|
||||
const ext = path.extname(pair[0])
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
const allowStr = allowedExtensions.join(', ')
|
||||
const message = `Extension ${ext} not allowed. Allowed extensions are ${allowStr}. See help for allowExtension to avoid this error.`
|
||||
throw new Error(message)
|
||||
}
|
||||
fs.mkdirSync(path.dirname(pair[0]), { recursive: true })
|
||||
fs.writeFileSync(pair[0], pair[1])
|
||||
this.logging({ event: 'created', path: pair[0] })
|
||||
})
|
||||
if (!options.skipVerify) {
|
||||
const [firstFilePath] = toWrite[0]
|
||||
const pending = await this._pending(context)
|
||||
if (!pending.some((p) => p.path && path.resolve(p.path) === path.resolve(firstFilePath))) {
|
||||
const paths = pending.map((p) => p.path).join(', ')
|
||||
throw new Error(`Expected ${firstFilePath} to be a pending migration but it wasn't! Pending migration paths: ${paths}. You should investigate this. Use skipVerify to bypass this error.`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
static defaultCreationTemplate(filepath) {
|
||||
const ext = path.extname(filepath)
|
||||
if ((ext === '.js' && typeof require.main === 'object') || ext === '.cjs') {
|
||||
return [[filepath, templates.js]]
|
||||
}
|
||||
if (ext === '.ts' || ext === '.mts' || ext === '.cts') {
|
||||
return [[filepath, templates.ts]]
|
||||
}
|
||||
if ((ext === '.js' && require.main === undefined) || ext === '.mjs') {
|
||||
return [[filepath, templates.mjs]]
|
||||
}
|
||||
if (ext === '.sql') {
|
||||
const downFilepath = path.join(path.dirname(filepath), 'down', path.basename(filepath))
|
||||
return [
|
||||
[filepath, templates.sqlUp],
|
||||
[downFilepath, templates.sqlDown]
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
findNameIndex(migrations, name) {
|
||||
const index = migrations.findIndex((m) => m.name === name)
|
||||
if (index === -1) {
|
||||
throw new Error(`Couldn't find migration to apply with name ${JSON.stringify(name)}`)
|
||||
}
|
||||
return index
|
||||
}
|
||||
findMigrations(migrations, names) {
|
||||
const map = new Map(migrations.map((m) => [m.name, m]))
|
||||
return names.map((name) => {
|
||||
const migration = map.get(name)
|
||||
if (!migration) {
|
||||
throw new Error(`Couldn't find migration to apply with name ${JSON.stringify(name)}`)
|
||||
}
|
||||
return migration
|
||||
})
|
||||
}
|
||||
async getContext() {
|
||||
const { context = {} } = this.options
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return typeof context === 'function' ? context() : context
|
||||
}
|
||||
/** helper for parsing input migrations into a callback returning a list of ready-to-run migrations */
|
||||
getMigrationsResolver(inputMigrations) {
|
||||
var _b
|
||||
if (Array.isArray(inputMigrations)) {
|
||||
return async () => inputMigrations
|
||||
}
|
||||
if (typeof inputMigrations === 'function') {
|
||||
// Lazy migrations definition, recurse.
|
||||
return async (ctx) => {
|
||||
const resolved = await inputMigrations(ctx)
|
||||
return this.getMigrationsResolver(resolved)(ctx)
|
||||
}
|
||||
}
|
||||
const paths = inputMigrations.files
|
||||
const resolver = (_b = inputMigrations.resolve) !== null && _b !== void 0 ? _b : Umzug.defaultResolver
|
||||
return async (context) => {
|
||||
paths.sort()
|
||||
return paths.map((unresolvedPath) => {
|
||||
const filepath = path.resolve(unresolvedPath)
|
||||
const name = path.basename(filepath)
|
||||
return {
|
||||
path: filepath,
|
||||
...resolver({ name, path: filepath, context })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.Umzug = Umzug
|
||||
_a = Umzug
|
||||
Umzug.defaultResolver = ({ name, path: filepath }) => {
|
||||
if (!filepath) {
|
||||
throw new Error(`Can't use default resolver for non-filesystem migrations`)
|
||||
}
|
||||
const ext = path.extname(filepath)
|
||||
const languageSpecificHelp = {
|
||||
'.ts': "TypeScript files can be required by adding `ts-node` as a dependency and calling `require('ts-node/register')` at the program entrypoint before running migrations.",
|
||||
'.sql': 'Try writing a resolver which reads file content and executes it as a sql query.'
|
||||
}
|
||||
languageSpecificHelp['.cts'] = languageSpecificHelp['.ts']
|
||||
languageSpecificHelp['.mts'] = languageSpecificHelp['.ts']
|
||||
let loadModule
|
||||
const jsExt = ext.replace(/\.([cm]?)ts$/, '.$1js')
|
||||
const getModule = async () => {
|
||||
try {
|
||||
return await loadModule()
|
||||
} catch (e) {
|
||||
if ((e instanceof SyntaxError || e instanceof MissingResolverError) && ext in languageSpecificHelp) {
|
||||
e.message += '\n\n' + languageSpecificHelp[ext]
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
if ((jsExt === '.js' && typeof require.main === 'object') || jsExt === '.cjs') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
loadModule = async () => require(filepath)
|
||||
} else if (jsExt === '.js' || jsExt === '.mjs') {
|
||||
loadModule = async () => import(filepath)
|
||||
} else {
|
||||
loadModule = async () => {
|
||||
throw new MissingResolverError(filepath)
|
||||
}
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: filepath,
|
||||
up: async ({ context }) => (await getModule()).up({ path: filepath, name, context }),
|
||||
down: async ({ context }) => {
|
||||
var _b, _c
|
||||
return (_c = (_b = await getModule()).down) === null || _c === void 0 ? void 0 : _c.call(_b, { path: filepath, name, context })
|
||||
}
|
||||
}
|
||||
}
|
||||
class MissingResolverError extends Error {
|
||||
constructor(filepath) {
|
||||
super(`No resolver specified for file ${filepath}. See docs for guidance on how to write a custom resolver.`)
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=umzug.js.map
|
||||
278
server/managers/MigrationManager.js
Normal file
278
server/managers/MigrationManager.js
Normal file
@@ -0,0 +1,278 @@
|
||||
const { Umzug, SequelizeStorage } = require('../libs/umzug')
|
||||
const { Sequelize, DataTypes } = require('sequelize')
|
||||
const semver = require('semver')
|
||||
const path = require('path')
|
||||
const Module = require('module')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class MigrationManager {
|
||||
static MIGRATIONS_META_TABLE = 'migrationsMeta'
|
||||
|
||||
/**
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
* @param {string} [configPath]
|
||||
*/
|
||||
constructor(sequelize, configPath = global.configPath) {
|
||||
if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.')
|
||||
this.sequelize = sequelize
|
||||
if (!configPath) throw new Error('Config path is required for MigrationManager.')
|
||||
this.configPath = configPath
|
||||
this.migrationsSourceDir = path.join(__dirname, '..', 'migrations')
|
||||
this.initialized = false
|
||||
this.migrationsDir = null
|
||||
this.maxVersion = null
|
||||
this.databaseVersion = null
|
||||
this.serverVersion = null
|
||||
this.umzug = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Init version vars and copy migration files to config dir if necessary
|
||||
*
|
||||
* @param {string} serverVersion
|
||||
*/
|
||||
async init(serverVersion) {
|
||||
if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`)
|
||||
|
||||
this.migrationsDir = path.join(this.configPath, 'migrations')
|
||||
|
||||
this.serverVersion = this.extractVersionFromTag(serverVersion)
|
||||
if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`)
|
||||
|
||||
await this.fetchVersionsFromDatabase()
|
||||
if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.')
|
||||
Logger.debug(`[MigrationManager] Database version: ${this.databaseVersion}, Max version: ${this.maxVersion}, Server version: ${this.serverVersion}`)
|
||||
|
||||
if (semver.gt(this.serverVersion, this.maxVersion)) {
|
||||
try {
|
||||
await this.copyMigrationsToConfigDir()
|
||||
} catch (error) {
|
||||
throw new Error('Failed to copy migrations to the config directory.', { cause: error })
|
||||
}
|
||||
|
||||
try {
|
||||
await this.updateMaxVersion()
|
||||
} catch (error) {
|
||||
throw new Error('Failed to update max version in the database.', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
async runMigrations() {
|
||||
if (!this.initialized) throw new Error('MigrationManager is not initialized. Call init() first.')
|
||||
|
||||
const versionCompare = semver.compare(this.serverVersion, this.databaseVersion)
|
||||
if (versionCompare == 0) {
|
||||
Logger.info('[MigrationManager] Database is already up to date.')
|
||||
return
|
||||
}
|
||||
|
||||
await this.initUmzug()
|
||||
const migrations = await this.umzug.migrations()
|
||||
const executedMigrations = (await this.umzug.executed()).map((m) => m.name)
|
||||
|
||||
const migrationDirection = versionCompare == 1 ? 'up' : 'down'
|
||||
|
||||
let migrationsToRun = []
|
||||
migrationsToRun = this.findMigrationsToRun(migrations, executedMigrations, migrationDirection)
|
||||
|
||||
// Only proceed with migration if there are migrations to run
|
||||
if (migrationsToRun.length > 0) {
|
||||
const originalDbPath = path.join(this.configPath, 'absdatabase.sqlite')
|
||||
const backupDbPath = path.join(this.configPath, 'absdatabase.backup.sqlite')
|
||||
try {
|
||||
Logger.info(`[MigrationManager] Migrating database ${migrationDirection} to version ${this.serverVersion}`)
|
||||
Logger.info(`[MigrationManager] Migrations to run: ${migrationsToRun.join(', ')}`)
|
||||
// Create a backup copy of the SQLite database before starting migrations
|
||||
await fs.copy(originalDbPath, backupDbPath)
|
||||
Logger.info('Created a backup of the original database.')
|
||||
|
||||
// Run migrations
|
||||
await this.umzug[migrationDirection]({ migrations: migrationsToRun, rerun: 'ALLOW' })
|
||||
|
||||
// Clean up the backup
|
||||
await fs.remove(backupDbPath)
|
||||
|
||||
Logger.info('[MigrationManager] Migrations successfully applied to the original database.')
|
||||
} catch (error) {
|
||||
Logger.error('[MigrationManager] Migration failed:', error)
|
||||
|
||||
await this.sequelize.close()
|
||||
|
||||
// Step 3: If migration fails, save the failed original and restore the backup
|
||||
const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite')
|
||||
await fs.move(originalDbPath, failedDbPath, { overwrite: true })
|
||||
Logger.info('[MigrationManager] Saved the failed database as absdatabase.failed.sqlite.')
|
||||
|
||||
await fs.move(backupDbPath, originalDbPath, { overwrite: true })
|
||||
Logger.info('[MigrationManager] Restored the original database from the backup.')
|
||||
|
||||
Logger.info('[MigrationManager] Migration failed. Exiting Audiobookshelf with code 1.')
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
Logger.info('[MigrationManager] No migrations to run.')
|
||||
}
|
||||
|
||||
await this.updateDatabaseVersion()
|
||||
}
|
||||
|
||||
async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {
|
||||
// This check is for dependency injection in tests
|
||||
const files = (await fs.readdir(this.migrationsDir)).map((file) => path.join(this.migrationsDir, file))
|
||||
|
||||
const parent = new Umzug({
|
||||
migrations: {
|
||||
files,
|
||||
resolve: (params) => {
|
||||
// make script think it's in migrationsSourceDir
|
||||
const migrationPath = params.path
|
||||
const migrationName = params.name
|
||||
const contents = fs.readFileSync(migrationPath, 'utf8')
|
||||
const fakePath = path.join(this.migrationsSourceDir, path.basename(migrationPath))
|
||||
const module = new Module(fakePath)
|
||||
module.filename = fakePath
|
||||
module.paths = Module._nodeModulePaths(this.migrationsSourceDir)
|
||||
module._compile(contents, fakePath)
|
||||
const script = module.exports
|
||||
return {
|
||||
name: migrationName,
|
||||
path: migrationPath,
|
||||
up: script.up,
|
||||
down: script.down
|
||||
}
|
||||
}
|
||||
},
|
||||
context: { queryInterface: this.sequelize.getQueryInterface(), logger: Logger },
|
||||
storage: umzugStorage,
|
||||
logger: Logger
|
||||
})
|
||||
|
||||
// Sort migrations by version
|
||||
this.umzug = new Umzug({
|
||||
...parent.options,
|
||||
migrations: async () =>
|
||||
(await parent.migrations()).sort((a, b) => {
|
||||
const versionA = this.extractVersionFromTag(a.name)
|
||||
const versionB = this.extractVersionFromTag(b.name)
|
||||
return semver.compare(versionA, versionB)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fetchVersionsFromDatabase() {
|
||||
await this.checkOrCreateMigrationsMetaTable()
|
||||
|
||||
const [{ version }] = await this.sequelize.query("SELECT value as version FROM :migrationsMeta WHERE key = 'version'", {
|
||||
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
|
||||
type: Sequelize.QueryTypes.SELECT
|
||||
})
|
||||
this.databaseVersion = version
|
||||
|
||||
const [{ maxVersion }] = await this.sequelize.query("SELECT value as maxVersion FROM :migrationsMeta WHERE key = 'maxVersion'", {
|
||||
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
|
||||
type: Sequelize.QueryTypes.SELECT
|
||||
})
|
||||
this.maxVersion = maxVersion
|
||||
}
|
||||
|
||||
async checkOrCreateMigrationsMetaTable() {
|
||||
const queryInterface = this.sequelize.getQueryInterface()
|
||||
if (!(await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE))) {
|
||||
await queryInterface.createTable(MigrationManager.MIGRATIONS_META_TABLE, {
|
||||
key: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
value: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
}
|
||||
})
|
||||
await this.sequelize.query("INSERT INTO :migrationsMeta (key, value) VALUES ('version', '0.0.0'), ('maxVersion', '0.0.0')", {
|
||||
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
|
||||
type: Sequelize.QueryTypes.INSERT
|
||||
})
|
||||
Logger.debug(`[MigrationManager] Created migrationsMeta table: "${MigrationManager.MIGRATIONS_META_TABLE}"`)
|
||||
}
|
||||
}
|
||||
|
||||
extractVersionFromTag(tag) {
|
||||
if (!tag) return null
|
||||
const versionMatch = tag.match(/^v?(\d+\.\d+\.\d+)/)
|
||||
return versionMatch ? versionMatch[1] : null
|
||||
}
|
||||
|
||||
async copyMigrationsToConfigDir() {
|
||||
await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists
|
||||
|
||||
if (!(await fs.pathExists(this.migrationsSourceDir))) return
|
||||
|
||||
const files = await fs.readdir(this.migrationsSourceDir)
|
||||
await Promise.all(
|
||||
files
|
||||
.filter((file) => path.extname(file) === '.js')
|
||||
.map(async (file) => {
|
||||
const sourceFile = path.join(this.migrationsSourceDir, file)
|
||||
const targetFile = path.join(this.migrationsDir, file)
|
||||
await fs.copy(sourceFile, targetFile) // Asynchronously copy the files
|
||||
})
|
||||
)
|
||||
Logger.debug(`[MigrationManager] Copied migrations to the config directory: "${this.migrationsDir}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{ name: string }[]} migrations
|
||||
* @param {string[]} executedMigrations - names of executed migrations
|
||||
* @param {string} direction - 'up' or 'down'
|
||||
* @returns {string[]} - names of migrations to run
|
||||
*/
|
||||
findMigrationsToRun(migrations, executedMigrations, direction) {
|
||||
const migrationsToRun = migrations
|
||||
.filter((migration) => {
|
||||
const migrationVersion = this.extractVersionFromTag(migration.name)
|
||||
if (direction === 'up') {
|
||||
return semver.gt(migrationVersion, this.databaseVersion) && semver.lte(migrationVersion, this.serverVersion) && !executedMigrations.includes(migration.name)
|
||||
} else {
|
||||
// A down migration should be run even if the associated up migration wasn't executed before
|
||||
return semver.lte(migrationVersion, this.databaseVersion) && semver.gt(migrationVersion, this.serverVersion)
|
||||
}
|
||||
})
|
||||
.map((migration) => migration.name)
|
||||
if (direction === 'down') {
|
||||
return migrationsToRun.reverse()
|
||||
} else {
|
||||
return migrationsToRun
|
||||
}
|
||||
}
|
||||
|
||||
async updateMaxVersion() {
|
||||
try {
|
||||
await this.sequelize.query("UPDATE :migrationsMeta SET value = :maxVersion WHERE key = 'maxVersion'", {
|
||||
replacements: { maxVersion: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
|
||||
type: Sequelize.QueryTypes.UPDATE
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error('Failed to update maxVersion in the migrationsMeta table.', { cause: error })
|
||||
}
|
||||
this.maxVersion = this.serverVersion
|
||||
}
|
||||
|
||||
async updateDatabaseVersion() {
|
||||
try {
|
||||
await this.sequelize.query("UPDATE :migrationsMeta SET value = :version WHERE key = 'version'", {
|
||||
replacements: { version: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
|
||||
type: Sequelize.QueryTypes.UPDATE
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error('Failed to update version in the migrationsMeta table.', { cause: error })
|
||||
}
|
||||
this.databaseVersion = this.serverVersion
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MigrationManager
|
||||
@@ -51,16 +51,16 @@ class PlaybackSessionManager {
|
||||
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user?.id)
|
||||
|
||||
if (clientDeviceInfo?.deviceId) {
|
||||
const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId)
|
||||
const existingDevice = await Database.deviceModel.getOldDeviceByDeviceId(clientDeviceInfo.deviceId)
|
||||
if (existingDevice) {
|
||||
if (existingDevice.update(deviceInfo)) {
|
||||
await Database.updateDevice(existingDevice)
|
||||
await Database.deviceModel.updateFromOld(existingDevice)
|
||||
}
|
||||
return existingDevice
|
||||
}
|
||||
}
|
||||
|
||||
await Database.createDevice(deviceInfo)
|
||||
await Database.deviceModel.createFromOld(deviceInfo)
|
||||
|
||||
return deviceInfo
|
||||
}
|
||||
@@ -164,6 +164,7 @@ class PlaybackSessionManager {
|
||||
// New session from local
|
||||
session = new PlaybackSession(sessionJson)
|
||||
session.deviceInfo = deviceInfo
|
||||
session.setDuration(libraryItem, sessionJson.episodeId)
|
||||
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
|
||||
await Database.createPlaybackSession(session)
|
||||
} else {
|
||||
@@ -293,37 +294,27 @@ class PlaybackSessionManager {
|
||||
const newPlaybackSession = new PlaybackSession()
|
||||
newPlaybackSession.setData(libraryItem, user.id, mediaPlayer, deviceInfo, userStartTime, episodeId)
|
||||
|
||||
if (libraryItem.mediaType === 'video') {
|
||||
if (shouldDirectPlay) {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id}`)
|
||||
newPlaybackSession.videoTrack = libraryItem.media.getVideoTrack()
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
} else {
|
||||
// HLS not supported for video yet
|
||||
}
|
||||
let audioTracks = []
|
||||
if (shouldDirectPlay) {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
} else {
|
||||
let audioTracks = []
|
||||
if (shouldDirectPlay) {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
} else {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime)
|
||||
await stream.generatePlaylist()
|
||||
stream.start() // Start transcode
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime)
|
||||
await stream.generatePlaylist()
|
||||
stream.start() // Start transcode
|
||||
|
||||
audioTracks = [stream.getAudioTrack()]
|
||||
newPlaybackSession.stream = stream
|
||||
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
|
||||
audioTracks = [stream.getAudioTrack()]
|
||||
newPlaybackSession.stream = stream
|
||||
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
|
||||
|
||||
stream.on('closed', () => {
|
||||
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}" (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
newPlaybackSession.stream = null
|
||||
})
|
||||
}
|
||||
newPlaybackSession.audioTracks = audioTracks
|
||||
stream.on('closed', () => {
|
||||
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}" (Device: ${newPlaybackSession.deviceDescription})`)
|
||||
newPlaybackSession.stream = null
|
||||
})
|
||||
}
|
||||
newPlaybackSession.audioTracks = audioTracks
|
||||
|
||||
this.sessions.push(newPlaybackSession)
|
||||
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
|
||||
|
||||
7
server/migrations/changelog.md
Normal file
7
server/migrations/changelog.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Migrations Changelog
|
||||
|
||||
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
|
||||
|
||||
| Server Version | Migration Script Name | Description |
|
||||
| -------------- | --------------------- | ----------- |
|
||||
| | | |
|
||||
49
server/migrations/readme.md
Normal file
49
server/migrations/readme.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Database Migrations
|
||||
|
||||
This directory contains all the database migration scripts for the server.
|
||||
|
||||
## What is a migration?
|
||||
|
||||
A migration is a script that changes the structure of the database. This can include creating tables, adding columns, or modifying existing columns. A migration script consists of two parts: an "up" script that applies the changes to the database, and a "down" script that undoes the changes.
|
||||
|
||||
## Guidelines for writing migrations
|
||||
|
||||
When writing a migration, keep the following guidelines in mind:
|
||||
|
||||
- You **_must_** name your migration script according to the following convention: `<server_version>-<migration_name>.js`. For example, `v2.14.0-create-users-table.js`.
|
||||
|
||||
- `server_version` should be the version of the server that the migration was created for (this should usually be the next server release).
|
||||
- `migration_name` should be a short description of the changes that the migration makes.
|
||||
|
||||
- The script should export two async functions: `up` and `down`. The `up` function should contain the script that applies the changes to the database, and the `down` function should contain the script that undoes the changes. The `up` and `down` functions should accept a single object parameter with a `context` property that contains a reference to a Sequelize [`QueryInterface`](https://sequelize.org/docs/v6/other-topics/query-interface/) object, and a [Logger](https://github.com/advplyr/audiobookshelf/blob/423a2129d10c6d8aaac9e8c75941fa6283889602/server/Logger.js#L4) object for logging. A typical migration script might look like this:
|
||||
|
||||
```javascript
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info('migrating ...');
|
||||
...
|
||||
}
|
||||
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info('reverting ...');
|
||||
...
|
||||
}
|
||||
|
||||
module.exports = {up, down}
|
||||
```
|
||||
|
||||
- Always implement both the `up` and `down` functions.
|
||||
- The `up` and `down` functions should be idempotent (i.e., they should be safe to run multiple times).
|
||||
- Prefer using only `queryInterface` and `logger` parameters, the `sequelize` module, and node.js built-in modules in your migration scripts. You can require other modules, but be aware that they might not be available or change from they ones you tested with.
|
||||
- It's your responsibility to make sure that the down migration reverts the changes made by the up migration.
|
||||
- Log detailed information on every step of the migration. Use `Logger.info()` and `Logger.error()`.
|
||||
- Test tour migrations thoroughly before committing them.
|
||||
- write unit tests for your migrations (see `test/server/migrations` for an example)
|
||||
- you can force a server version change by modifying the `version` field in `package.json` on your dev environment (but don't forget to revert it back before committing)
|
||||
|
||||
## How migrations are run
|
||||
|
||||
Migrations are run automatically when the server starts, when the server detects that the server version has changed. Migrations are always run in server version order (from oldest to newest up migrations if the server version increased, and from newest to oldest down migrations if the server version decreased). Only the relevant migrations are run, based on the new and old server versions.
|
||||
|
||||
This means that you can switch between server releases without having to worry about running migrations manually. The server will automatically apply the necessary migrations when it starts.
|
||||
@@ -1,5 +1,6 @@
|
||||
const { DataTypes, Model, where, fn, col } = require('sequelize')
|
||||
const parseNameString = require('../utils/parsers/parseNameString')
|
||||
const { asciiOnlyToLowerCase } = require('../utils/index')
|
||||
|
||||
class Author extends Model {
|
||||
constructor(values, options) {
|
||||
@@ -55,7 +56,7 @@ class Author extends Model {
|
||||
static async getByNameAndLibrary(authorName, libraryId) {
|
||||
return this.findOne({
|
||||
where: [
|
||||
where(fn('lower', col('name')), authorName.toLowerCase()),
|
||||
where(fn('lower', col('name')), asciiOnlyToLowerCase(authorName)),
|
||||
{
|
||||
libraryId
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class Collection extends Model {
|
||||
|
||||
// Optionally include rssfeed for collection
|
||||
const collectionIncludes = []
|
||||
if (include.includes('rssfeed')) {
|
||||
if (include?.includes('rssfeed')) {
|
||||
collectionIncludes.push({
|
||||
model: this.sequelize.models.feed
|
||||
})
|
||||
@@ -115,78 +115,6 @@ class Collection extends Model {
|
||||
.filter((c) => c)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old collection toJSONExpanded, items filtered for user permissions
|
||||
*
|
||||
* @param {import('./User')|null} user
|
||||
* @param {string[]} [include]
|
||||
* @returns {Promise<oldCollection>} 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
|
||||
}) || []
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
return collectionExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old collection from Collection
|
||||
* @param {Collection} collectionExpanded
|
||||
@@ -250,36 +178,6 @@ class Collection extends Model {
|
||||
return this.getOldCollection(collection)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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']
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [Sequelize.literal('`collectionBook.order` ASC')]
|
||||
})) || []
|
||||
|
||||
return this.sequelize.models.collection.getOldCollection(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all collections belonging to library
|
||||
* @param {string} libraryId
|
||||
@@ -320,6 +218,109 @@ class Collection extends Model {
|
||||
library.hasMany(Collection)
|
||||
Collection.belongsTo(library)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old collection toJSONExpanded, items filtered for user permissions
|
||||
*
|
||||
* @param {import('./User')|null} user
|
||||
* @param {string[]} [include]
|
||||
* @returns {Promise<oldCollection>} 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')]
|
||||
})) || []
|
||||
|
||||
// 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
|
||||
}) || []
|
||||
|
||||
// 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 && this.books.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const collectionExpanded = this.toOldJSONExpanded(libraryItems)
|
||||
|
||||
if (include?.includes('rssfeed')) {
|
||||
const feeds = await this.getFeeds()
|
||||
if (feeds?.length) {
|
||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
|
||||
}
|
||||
}
|
||||
|
||||
return collectionExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string[]} libraryItemIds
|
||||
* @returns
|
||||
*/
|
||||
toOldJSON(libraryItemIds) {
|
||||
return {
|
||||
id: this.id,
|
||||
libraryId: this.libraryId,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
books: [...libraryItemIds],
|
||||
lastUpdate: this.updatedAt.valueOf(),
|
||||
createdAt: this.createdAt.valueOf()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/LibraryItem')} oldLibraryItems
|
||||
* @returns
|
||||
*/
|
||||
toOldJSONExpanded(oldLibraryItems) {
|
||||
const json = this.toOldJSON(oldLibraryItems.map((li) => li.id))
|
||||
json.books = json.books
|
||||
.map((libraryItemId) => {
|
||||
const book = oldLibraryItems.find((li) => li.id === libraryItemId)
|
||||
return book ? book.toJSONExpanded() : null
|
||||
})
|
||||
.filter((b) => !!b)
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Collection
|
||||
|
||||
@@ -30,6 +30,61 @@ class CustomMetadataProvider extends Model {
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Get providers for client by media type
|
||||
* Currently only available for "book" media type
|
||||
*
|
||||
* @param {string} mediaType
|
||||
* @returns {Promise<ClientCustomMetadataProvider[]>}
|
||||
*/
|
||||
static async getForClientByMediaType(mediaType) {
|
||||
if (mediaType !== 'book') return []
|
||||
const customMetadataProviders = await this.findAll({
|
||||
where: {
|
||||
mediaType
|
||||
}
|
||||
})
|
||||
return customMetadataProviders.map((cmp) => cmp.toClientJson())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider exists by slug
|
||||
*
|
||||
* @param {string} providerSlug
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async checkExistsBySlug(providerSlug) {
|
||||
const providerId = providerSlug?.split?.('custom-')[1]
|
||||
if (!providerId) return false
|
||||
|
||||
return (await this.count({ where: { id: providerId } })) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
mediaType: DataTypes.STRING,
|
||||
url: DataTypes.STRING,
|
||||
authHeaderValue: DataTypes.STRING,
|
||||
extraData: DataTypes.JSON
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'customMetadataProvider'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
getSlug() {
|
||||
return `custom-${this.id}`
|
||||
}
|
||||
@@ -46,58 +101,6 @@ class CustomMetadataProvider extends Model {
|
||||
slug: this.getSlug()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get providers for client by media type
|
||||
* Currently only available for "book" media type
|
||||
*
|
||||
* @param {string} mediaType
|
||||
* @returns {Promise<ClientCustomMetadataProvider[]>}
|
||||
*/
|
||||
static async getForClientByMediaType(mediaType) {
|
||||
if (mediaType !== 'book') return []
|
||||
const customMetadataProviders = await this.findAll({
|
||||
where: {
|
||||
mediaType
|
||||
}
|
||||
})
|
||||
return customMetadataProviders.map(cmp => cmp.toClientJson())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider exists by slug
|
||||
*
|
||||
* @param {string} providerSlug
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async checkExistsBySlug(providerSlug) {
|
||||
const providerId = providerSlug?.split?.('custom-')[1]
|
||||
if (!providerId) return false
|
||||
|
||||
return (await this.count({ where: { id: providerId } })) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
mediaType: DataTypes.STRING,
|
||||
url: DataTypes.STRING,
|
||||
authHeaderValue: DataTypes.STRING,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'customMetadataProvider'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CustomMetadataProvider
|
||||
module.exports = CustomMetadataProvider
|
||||
|
||||
@@ -29,33 +29,6 @@ class Device extends Model {
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
getOldDevice() {
|
||||
let browserVersion = null
|
||||
let sdkVersion = null
|
||||
if (this.clientName === 'Abs Android') {
|
||||
sdkVersion = this.deviceVersion || null
|
||||
} else {
|
||||
browserVersion = this.deviceVersion || null
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -145,6 +118,60 @@ class Device extends Model {
|
||||
})
|
||||
Device.belongsTo(user)
|
||||
}
|
||||
|
||||
toOldJSON() {
|
||||
let browserVersion = null
|
||||
let sdkVersion = null
|
||||
if (this.clientName === 'Abs Android') {
|
||||
sdkVersion = this.deviceVersion || null
|
||||
} else {
|
||||
browserVersion = this.deviceVersion || null
|
||||
}
|
||||
|
||||
return {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
getOldDevice() {
|
||||
let browserVersion = null
|
||||
let sdkVersion = null
|
||||
if (this.clientName === 'Abs Android') {
|
||||
sdkVersion = this.deviceVersion || null
|
||||
} else {
|
||||
browserVersion = this.deviceVersion || null
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Device
|
||||
|
||||
@@ -365,7 +365,23 @@ class LibraryItem extends Model {
|
||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
||||
|
||||
if (!areEquivalent(updatedMedia[key], existingValue, true)) {
|
||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`)
|
||||
if (key === 'chapters') {
|
||||
// Handle logging of chapters separately because the object is large
|
||||
const chaptersRemoved = libraryItemExpanded.media.chapters.filter((ch) => !updatedMedia.chapters.some((uch) => uch.id === ch.id))
|
||||
if (chaptersRemoved.length) {
|
||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters removed: ${chaptersRemoved.map((ch) => ch.title).join(', ')}`)
|
||||
}
|
||||
const chaptersAdded = updatedMedia.chapters.filter((uch) => !libraryItemExpanded.media.chapters.some((ch) => ch.id === uch.id))
|
||||
if (chaptersAdded.length) {
|
||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters added: ${chaptersAdded.map((ch) => ch.title).join(', ')}`)
|
||||
}
|
||||
if (!chaptersRemoved.length && !chaptersAdded.length) {
|
||||
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters updated`)
|
||||
}
|
||||
} else {
|
||||
Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from %j to %j`, existingValue, updatedMedia[key]))
|
||||
}
|
||||
|
||||
hasMediaUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { DataTypes, Model, where, fn, col } = require('sequelize')
|
||||
|
||||
const { getTitlePrefixAtEnd } = require('../utils/index')
|
||||
const { asciiOnlyToLowerCase } = require('../utils/index')
|
||||
|
||||
class Series extends Model {
|
||||
constructor(values, options) {
|
||||
@@ -41,7 +42,7 @@ class Series extends Model {
|
||||
static async getByNameAndLibrary(seriesName, libraryId) {
|
||||
return this.findOne({
|
||||
where: [
|
||||
where(fn('lower', col('name')), seriesName.toLowerCase()),
|
||||
where(fn('lower', col('name')), asciiOnlyToLowerCase(seriesName)),
|
||||
{
|
||||
libraryId
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ class User extends Model {
|
||||
upload: type === 'root' || type === 'admin',
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
accessExplicitContent: true,
|
||||
accessExplicitContent: type === 'root' || type === 'admin',
|
||||
selectedTagsNotAccessible: false,
|
||||
librariesAccessible: [],
|
||||
itemTagsSelected: []
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
const uuidv4 = require("uuid").v4
|
||||
const uuidv4 = require('uuid').v4
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const LibraryFile = require('./files/LibraryFile')
|
||||
const Book = require('./mediaTypes/Book')
|
||||
const Podcast = require('./mediaTypes/Podcast')
|
||||
const Video = require('./mediaTypes/Video')
|
||||
const Music = require('./mediaTypes/Music')
|
||||
const { areEquivalent, copyValue } = require('../utils/index')
|
||||
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
|
||||
@@ -74,14 +72,10 @@ class LibraryItem {
|
||||
this.media = new Book(libraryItem.media)
|
||||
} else if (this.mediaType === 'podcast') {
|
||||
this.media = new Podcast(libraryItem.media)
|
||||
} else if (this.mediaType === 'video') {
|
||||
this.media = new Video(libraryItem.media)
|
||||
} else if (this.mediaType === 'music') {
|
||||
this.media = new Music(libraryItem.media)
|
||||
}
|
||||
this.media.libraryItemId = this.id
|
||||
|
||||
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
|
||||
this.libraryFiles = libraryItem.libraryFiles.map((f) => new LibraryFile(f))
|
||||
|
||||
// Migration for v2.2.23 to set ebook library files as supplementary
|
||||
if (this.isBook && this.media.ebookFile) {
|
||||
@@ -91,7 +85,6 @@ class LibraryItem {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -115,7 +108,7 @@ class LibraryItem {
|
||||
isInvalid: !!this.isInvalid,
|
||||
mediaType: this.mediaType,
|
||||
media: this.media.toJSON(),
|
||||
libraryFiles: this.libraryFiles.map(f => f.toJSON())
|
||||
libraryFiles: this.libraryFiles.map((f) => f.toJSON())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,21 +158,24 @@ class LibraryItem {
|
||||
isInvalid: !!this.isInvalid,
|
||||
mediaType: this.mediaType,
|
||||
media: this.media.toJSONExpanded(),
|
||||
libraryFiles: this.libraryFiles.map(f => f.toJSON()),
|
||||
libraryFiles: this.libraryFiles.map((f) => f.toJSON()),
|
||||
size: this.size
|
||||
}
|
||||
}
|
||||
|
||||
get isPodcast() { return this.mediaType === 'podcast' }
|
||||
get isBook() { return this.mediaType === 'book' }
|
||||
get isMusic() { return this.mediaType === 'music' }
|
||||
get isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
}
|
||||
get isBook() {
|
||||
return this.mediaType === 'book'
|
||||
}
|
||||
get size() {
|
||||
let total = 0
|
||||
this.libraryFiles.forEach((lf) => total += lf.metadata.size)
|
||||
this.libraryFiles.forEach((lf) => (total += lf.metadata.size))
|
||||
return total
|
||||
}
|
||||
get hasAudioFiles() {
|
||||
return this.libraryFiles.some(lf => lf.fileType === 'audio')
|
||||
return this.libraryFiles.some((lf) => lf.fileType === 'audio')
|
||||
}
|
||||
get hasMediaEntities() {
|
||||
return this.media.hasMediaEntities
|
||||
@@ -201,17 +197,16 @@ class LibraryItem {
|
||||
|
||||
for (const key in payload) {
|
||||
if (key === 'libraryFiles') {
|
||||
this.libraryFiles = payload.libraryFiles.map(lf => lf.clone())
|
||||
this.libraryFiles = payload.libraryFiles.map((lf) => lf.clone())
|
||||
|
||||
// Set cover image
|
||||
const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
|
||||
const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
const imageFiles = this.libraryFiles.filter((lf) => lf.fileType === 'image')
|
||||
const coverMatch = imageFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
if (coverMatch) {
|
||||
this.media.coverPath = coverMatch.metadata.path
|
||||
} else if (imageFiles.length) {
|
||||
this.media.coverPath = imageFiles[0].metadata.path
|
||||
}
|
||||
|
||||
} else if (this[key] !== undefined && key !== 'media') {
|
||||
this[key] = payload[key]
|
||||
}
|
||||
@@ -283,46 +278,50 @@ class LibraryItem {
|
||||
|
||||
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
||||
|
||||
return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => {
|
||||
// Add metadata.json to libraryFiles array if it is new
|
||||
let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
||||
if (storeMetadataWithItem) {
|
||||
if (!metadataLibraryFile) {
|
||||
metadataLibraryFile = new LibraryFile()
|
||||
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
||||
this.libraryFiles.push(metadataLibraryFile)
|
||||
} else {
|
||||
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
||||
if (fileTimestamps) {
|
||||
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
||||
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
||||
metadataLibraryFile.metadata.size = fileTimestamps.size
|
||||
metadataLibraryFile.ino = fileTimestamps.ino
|
||||
return fs
|
||||
.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2))
|
||||
.then(async () => {
|
||||
// Add metadata.json to libraryFiles array if it is new
|
||||
let metadataLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
||||
if (storeMetadataWithItem) {
|
||||
if (!metadataLibraryFile) {
|
||||
metadataLibraryFile = new LibraryFile()
|
||||
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
||||
this.libraryFiles.push(metadataLibraryFile)
|
||||
} else {
|
||||
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
||||
if (fileTimestamps) {
|
||||
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
||||
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
||||
metadataLibraryFile.metadata.size = fileTimestamps.size
|
||||
metadataLibraryFile.ino = fileTimestamps.ino
|
||||
}
|
||||
}
|
||||
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
|
||||
if (libraryItemDirTimestamps) {
|
||||
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
|
||||
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
|
||||
}
|
||||
}
|
||||
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
|
||||
if (libraryItemDirTimestamps) {
|
||||
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
|
||||
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
|
||||
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
|
||||
|
||||
return metadataLibraryFile
|
||||
}).catch((error) => {
|
||||
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
|
||||
return null
|
||||
}).finally(() => {
|
||||
this.isSavingMetadata = false
|
||||
})
|
||||
return metadataLibraryFile
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
this.isSavingMetadata = false
|
||||
})
|
||||
}
|
||||
|
||||
removeLibraryFile(ino) {
|
||||
if (!ino) return false
|
||||
const libraryFile = this.libraryFiles.find(lf => lf.ino === ino)
|
||||
const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino)
|
||||
if (libraryFile) {
|
||||
this.libraryFiles = this.libraryFiles.filter(lf => lf.ino !== ino)
|
||||
this.libraryFiles = this.libraryFiles.filter((lf) => lf.ino !== ino)
|
||||
this.updatedAt = Date.now()
|
||||
return true
|
||||
}
|
||||
@@ -333,15 +332,15 @@ class LibraryItem {
|
||||
* Set the EBookFile from a LibraryFile
|
||||
* If null then ebookFile will be removed from the book
|
||||
* all ebook library files that are not primary are marked as supplementary
|
||||
*
|
||||
* @param {LibraryFile} [libraryFile]
|
||||
*
|
||||
* @param {LibraryFile} [libraryFile]
|
||||
*/
|
||||
setPrimaryEbook(ebookLibraryFile = null) {
|
||||
const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile)
|
||||
const ebookLibraryFiles = this.libraryFiles.filter((lf) => lf.isEBookFile)
|
||||
for (const libraryFile of ebookLibraryFiles) {
|
||||
libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino
|
||||
}
|
||||
this.media.setEbookFile(ebookLibraryFile)
|
||||
}
|
||||
}
|
||||
module.exports = LibraryItem
|
||||
module.exports = LibraryItem
|
||||
|
||||
@@ -4,7 +4,6 @@ const serverVersion = require('../../package.json').version
|
||||
const BookMetadata = require('./metadata/BookMetadata')
|
||||
const PodcastMetadata = require('./metadata/PodcastMetadata')
|
||||
const DeviceInfo = require('./DeviceInfo')
|
||||
const VideoMetadata = require('./metadata/VideoMetadata')
|
||||
|
||||
class PlaybackSession {
|
||||
constructor(session) {
|
||||
@@ -41,7 +40,6 @@ class PlaybackSession {
|
||||
// Not saved in DB
|
||||
this.lastSave = 0
|
||||
this.audioTracks = []
|
||||
this.videoTrack = null
|
||||
this.stream = null
|
||||
// Used for share sessions
|
||||
this.shareSessionId = null
|
||||
@@ -84,8 +82,8 @@ class PlaybackSession {
|
||||
|
||||
/**
|
||||
* Session data to send to clients
|
||||
* @param {[oldLibraryItem]} libraryItem optional
|
||||
* @returns {object}
|
||||
* @param {Object} [libraryItem] - old library item
|
||||
* @returns
|
||||
*/
|
||||
toJSONForClient(libraryItem) {
|
||||
return {
|
||||
@@ -114,7 +112,6 @@ class PlaybackSession {
|
||||
startedAt: this.startedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),
|
||||
videoTrack: this.videoTrack?.toJSON() || null,
|
||||
libraryItem: libraryItem?.toJSONExpanded() || null
|
||||
}
|
||||
}
|
||||
@@ -157,8 +154,6 @@ class PlaybackSession {
|
||||
this.mediaMetadata = new BookMetadata(session.mediaMetadata)
|
||||
} else if (this.mediaType === 'podcast') {
|
||||
this.mediaMetadata = new PodcastMetadata(session.mediaMetadata)
|
||||
} else if (this.mediaType === 'video') {
|
||||
this.mediaMetadata = new VideoMetadata(session.mediaMetadata)
|
||||
}
|
||||
}
|
||||
this.displayTitle = session.displayTitle || ''
|
||||
@@ -224,11 +219,7 @@ class PlaybackSession {
|
||||
this.displayAuthor = libraryItem.media.getPlaybackAuthor()
|
||||
this.coverPath = libraryItem.media.coverPath
|
||||
|
||||
if (episodeId) {
|
||||
this.duration = libraryItem.media.getEpisodeDuration(episodeId)
|
||||
} else {
|
||||
this.duration = libraryItem.media.duration
|
||||
}
|
||||
this.setDuration(libraryItem, episodeId)
|
||||
|
||||
this.mediaPlayer = mediaPlayer
|
||||
this.deviceInfo = deviceInfo || new DeviceInfo()
|
||||
@@ -244,6 +235,14 @@ class PlaybackSession {
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
setDuration(libraryItem, episodeId) {
|
||||
if (episodeId) {
|
||||
this.duration = libraryItem.media.getEpisodeDuration(episodeId)
|
||||
} else {
|
||||
this.duration = libraryItem.media.duration
|
||||
}
|
||||
}
|
||||
|
||||
addListeningTime(timeListened) {
|
||||
if (!timeListened || isNaN(timeListened)) return
|
||||
|
||||
@@ -256,11 +255,5 @@ class PlaybackSession {
|
||||
this.timeListening += Number.parseFloat(timeListened)
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
// New date since start of listening session
|
||||
checkDateRollover() {
|
||||
if (!this.date) return false
|
||||
return date.format(new Date(), 'YYYY-MM-DD') !== this.date
|
||||
}
|
||||
}
|
||||
module.exports = PlaybackSession
|
||||
|
||||
@@ -43,14 +43,13 @@ class LibraryFile {
|
||||
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
|
||||
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
|
||||
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
|
||||
if (globals.SupportedVideoTypes.includes(this.metadata.format)) return 'video'
|
||||
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
|
||||
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
get isMediaFile() {
|
||||
return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video'
|
||||
return this.fileType === 'audio' || this.fileType === 'ebook'
|
||||
}
|
||||
|
||||
get isEBookFile() {
|
||||
@@ -75,4 +74,4 @@ class LibraryFile {
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
}
|
||||
module.exports = LibraryFile
|
||||
module.exports = LibraryFile
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
const { VideoMimeType } = require('../../utils/constants')
|
||||
const FileMetadata = require('../metadata/FileMetadata')
|
||||
|
||||
class VideoFile {
|
||||
constructor(data) {
|
||||
this.index = null
|
||||
this.ino = null
|
||||
this.metadata = null
|
||||
this.addedAt = null
|
||||
this.updatedAt = null
|
||||
|
||||
this.format = null
|
||||
this.duration = null
|
||||
this.bitRate = null
|
||||
this.language = null
|
||||
this.codec = null
|
||||
this.timeBase = null
|
||||
this.frameRate = null
|
||||
this.width = null
|
||||
this.height = null
|
||||
this.embeddedCoverArt = null
|
||||
|
||||
this.invalid = false
|
||||
this.error = null
|
||||
|
||||
if (data) {
|
||||
this.construct(data)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
index: this.index,
|
||||
ino: this.ino,
|
||||
metadata: this.metadata.toJSON(),
|
||||
addedAt: this.addedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
invalid: !!this.invalid,
|
||||
error: this.error || null,
|
||||
format: this.format,
|
||||
duration: this.duration,
|
||||
bitRate: this.bitRate,
|
||||
language: this.language,
|
||||
codec: this.codec,
|
||||
timeBase: this.timeBase,
|
||||
frameRate: this.frameRate,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
embeddedCoverArt: this.embeddedCoverArt,
|
||||
mimeType: this.mimeType
|
||||
}
|
||||
}
|
||||
|
||||
construct(data) {
|
||||
this.index = data.index
|
||||
this.ino = data.ino
|
||||
this.metadata = new FileMetadata(data.metadata || {})
|
||||
this.addedAt = data.addedAt
|
||||
this.updatedAt = data.updatedAt
|
||||
this.invalid = !!data.invalid
|
||||
this.error = data.error || null
|
||||
|
||||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.bitRate = data.bitRate
|
||||
this.language = data.language
|
||||
this.codec = data.codec || null
|
||||
this.timeBase = data.timeBase
|
||||
this.frameRate = data.frameRate
|
||||
this.width = data.width
|
||||
this.height = data.height
|
||||
this.embeddedCoverArt = data.embeddedCoverArt || null
|
||||
}
|
||||
|
||||
get mimeType() {
|
||||
var format = this.metadata.format.toUpperCase()
|
||||
if (VideoMimeType[format]) {
|
||||
return VideoMimeType[format]
|
||||
} else {
|
||||
return VideoMimeType.MP4
|
||||
}
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new VideoFile(this.toJSON())
|
||||
}
|
||||
|
||||
setDataFromProbe(libraryFile, probeData) {
|
||||
this.ino = libraryFile.ino || null
|
||||
|
||||
this.metadata = libraryFile.metadata.clone()
|
||||
this.addedAt = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
|
||||
const videoStream = probeData.videoStream
|
||||
|
||||
this.format = probeData.format
|
||||
this.duration = probeData.duration
|
||||
this.bitRate = videoStream.bit_rate || probeData.bitRate || null
|
||||
this.language = probeData.language
|
||||
this.codec = videoStream.codec || null
|
||||
this.timeBase = videoStream.time_base
|
||||
this.frameRate = videoStream.frame_rate || null
|
||||
this.width = videoStream.width || null
|
||||
this.height = videoStream.height || null
|
||||
this.embeddedCoverArt = probeData.embeddedCoverArt
|
||||
}
|
||||
}
|
||||
module.exports = VideoFile
|
||||
@@ -1,45 +0,0 @@
|
||||
const Path = require('path')
|
||||
const { encodeUriPath } = require('../../utils/fileUtils')
|
||||
|
||||
class VideoTrack {
|
||||
constructor() {
|
||||
this.index = null
|
||||
this.duration = null
|
||||
this.title = null
|
||||
this.contentUrl = null
|
||||
this.mimeType = null
|
||||
this.codec = null
|
||||
this.metadata = null
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
index: this.index,
|
||||
duration: this.duration,
|
||||
title: this.title,
|
||||
contentUrl: this.contentUrl,
|
||||
mimeType: this.mimeType,
|
||||
codec: this.codec,
|
||||
metadata: this.metadata ? this.metadata.toJSON() : null
|
||||
}
|
||||
}
|
||||
|
||||
setData(itemId, videoFile) {
|
||||
this.index = videoFile.index
|
||||
this.duration = videoFile.duration
|
||||
this.title = videoFile.metadata.filename || ''
|
||||
this.contentUrl = Path.join(`${global.RouterBasePath}/api/items/${itemId}/file/${videoFile.ino}`, encodeUriPath(videoFile.metadata.relPath))
|
||||
this.mimeType = videoFile.mimeType
|
||||
this.codec = videoFile.codec
|
||||
this.metadata = videoFile.metadata.clone()
|
||||
}
|
||||
|
||||
setFromStream(title, duration, contentUrl) {
|
||||
this.index = 1
|
||||
this.duration = duration
|
||||
this.title = title
|
||||
this.contentUrl = contentUrl
|
||||
this.mimeType = 'application/vnd.apple.mpegurl'
|
||||
}
|
||||
}
|
||||
module.exports = VideoTrack
|
||||
@@ -1,145 +0,0 @@
|
||||
const Logger = require('../../Logger')
|
||||
const AudioFile = require('../files/AudioFile')
|
||||
const AudioTrack = require('../files/AudioTrack')
|
||||
const MusicMetadata = require('../metadata/MusicMetadata')
|
||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
||||
const { filePathToPOSIX } = require('../../utils/fileUtils')
|
||||
|
||||
class Music {
|
||||
constructor(music) {
|
||||
this.libraryItemId = null
|
||||
this.metadata = null
|
||||
this.coverPath = null
|
||||
this.tags = []
|
||||
this.audioFile = null
|
||||
|
||||
if (music) {
|
||||
this.construct(music)
|
||||
}
|
||||
}
|
||||
|
||||
construct(music) {
|
||||
this.libraryItemId = music.libraryItemId
|
||||
this.metadata = new MusicMetadata(music.metadata)
|
||||
this.coverPath = music.coverPath
|
||||
this.tags = [...music.tags]
|
||||
this.audioFile = new AudioFile(music.audioFile)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
libraryItemId: this.libraryItemId,
|
||||
metadata: this.metadata.toJSON(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
audioFile: this.audioFile.toJSON(),
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
metadata: this.metadata.toJSONMinified(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
audioFile: this.audioFile.toJSON(),
|
||||
duration: this.duration,
|
||||
size: this.size
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return {
|
||||
libraryItemId: this.libraryItemId,
|
||||
metadata: this.metadata.toJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
audioFile: this.audioFile.toJSON(),
|
||||
duration: this.duration,
|
||||
size: this.size
|
||||
}
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.audioFile.metadata.size
|
||||
}
|
||||
get hasMediaEntities() {
|
||||
return !!this.audioFile
|
||||
}
|
||||
get duration() {
|
||||
return this.audioFile.duration || 0
|
||||
}
|
||||
get audioTrack() {
|
||||
const audioTrack = new AudioTrack()
|
||||
audioTrack.setData(this.libraryItemId, this.audioFile, 0)
|
||||
return audioTrack
|
||||
}
|
||||
get numTracks() {
|
||||
return 1
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
const json = this.toJSON()
|
||||
delete json.episodes // do not update media entities here
|
||||
let hasUpdates = false
|
||||
for (const key in json) {
|
||||
if (payload[key] !== undefined) {
|
||||
if (key === 'metadata') {
|
||||
if (this.metadata.update(payload.metadata)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (!areEquivalent(payload[key], json[key])) {
|
||||
this[key] = copyValue(payload[key])
|
||||
Logger.debug('[Podcast] Key updated', key, this[key])
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateCover(coverPath) {
|
||||
coverPath = filePathToPOSIX(coverPath)
|
||||
if (this.coverPath === coverPath) return false
|
||||
this.coverPath = coverPath
|
||||
return true
|
||||
}
|
||||
|
||||
removeFileWithInode(inode) {
|
||||
return false
|
||||
}
|
||||
|
||||
findFileWithInode(inode) {
|
||||
return (this.audioFile && this.audioFile.ino === inode) ? this.audioFile : null
|
||||
}
|
||||
|
||||
setData(mediaData) {
|
||||
this.metadata = new MusicMetadata()
|
||||
if (mediaData.metadata) {
|
||||
this.metadata.setData(mediaData.metadata)
|
||||
}
|
||||
|
||||
this.coverPath = mediaData.coverPath || null
|
||||
}
|
||||
|
||||
setAudioFile(audioFile) {
|
||||
this.audioFile = audioFile
|
||||
}
|
||||
|
||||
// Only checks container format
|
||||
checkCanDirectPlay(payload) {
|
||||
return true
|
||||
}
|
||||
|
||||
getDirectPlayTracklist() {
|
||||
return [this.audioTrack]
|
||||
}
|
||||
|
||||
getPlaybackTitle() {
|
||||
return this.metadata.title
|
||||
}
|
||||
|
||||
getPlaybackAuthor() {
|
||||
return this.metadata.artist
|
||||
}
|
||||
}
|
||||
module.exports = Music
|
||||
@@ -233,15 +233,6 @@ class Podcast {
|
||||
this.episodes.push(podcastEpisode)
|
||||
}
|
||||
|
||||
addNewEpisodeFromAudioFile(audioFile, index) {
|
||||
const pe = new PodcastEpisode()
|
||||
pe.libraryItemId = this.libraryItemId
|
||||
pe.podcastId = this.id
|
||||
audioFile.index = 1 // Only 1 audio file per episode
|
||||
pe.setDataFromAudioFile(audioFile, index)
|
||||
this.episodes.push(pe)
|
||||
}
|
||||
|
||||
removeEpisode(episodeId) {
|
||||
const episode = this.episodes.find((ep) => ep.id === episodeId)
|
||||
if (episode) {
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
const Logger = require('../../Logger')
|
||||
const VideoFile = require('../files/VideoFile')
|
||||
const VideoTrack = require('../files/VideoTrack')
|
||||
const VideoMetadata = require('../metadata/VideoMetadata')
|
||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
||||
const { filePathToPOSIX } = require('../../utils/fileUtils')
|
||||
|
||||
class Video {
|
||||
constructor(video) {
|
||||
this.libraryItemId = null
|
||||
this.metadata = null
|
||||
this.coverPath = null
|
||||
this.tags = []
|
||||
this.episodes = []
|
||||
|
||||
this.autoDownloadEpisodes = false
|
||||
this.lastEpisodeCheck = 0
|
||||
|
||||
this.lastCoverSearch = null
|
||||
this.lastCoverSearchQuery = null
|
||||
|
||||
if (video) {
|
||||
this.construct(video)
|
||||
}
|
||||
}
|
||||
|
||||
construct(video) {
|
||||
this.libraryItemId = video.libraryItemId
|
||||
this.metadata = new VideoMetadata(video.metadata)
|
||||
this.coverPath = video.coverPath
|
||||
this.tags = [...video.tags]
|
||||
this.videoFile = new VideoFile(video.videoFile)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
libraryItemId: this.libraryItemId,
|
||||
metadata: this.metadata.toJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
videoFile: this.videoFile.toJSON()
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
metadata: this.metadata.toJSONMinified(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
videoFile: this.videoFile.toJSON(),
|
||||
size: this.size
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return {
|
||||
libraryItemId: this.libraryItemId,
|
||||
metadata: this.metadata.toJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
videoFile: this.videoFile.toJSON(),
|
||||
size: this.size
|
||||
}
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.videoFile.metadata.size
|
||||
}
|
||||
get hasMediaEntities() {
|
||||
return true
|
||||
}
|
||||
get duration() {
|
||||
return 0
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var json = this.toJSON()
|
||||
var hasUpdates = false
|
||||
for (const key in json) {
|
||||
if (payload[key] !== undefined) {
|
||||
if (key === 'metadata') {
|
||||
if (this.metadata.update(payload.metadata)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (!areEquivalent(payload[key], json[key])) {
|
||||
this[key] = copyValue(payload[key])
|
||||
Logger.debug('[Video] Key updated', key, this[key])
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateCover(coverPath) {
|
||||
coverPath = filePathToPOSIX(coverPath)
|
||||
if (this.coverPath === coverPath) return false
|
||||
this.coverPath = coverPath
|
||||
return true
|
||||
}
|
||||
|
||||
removeFileWithInode(inode) {
|
||||
|
||||
}
|
||||
|
||||
findFileWithInode(inode) {
|
||||
return null
|
||||
}
|
||||
|
||||
setVideoFile(videoFile) {
|
||||
this.videoFile = videoFile
|
||||
}
|
||||
|
||||
setData(mediaMetadata) {
|
||||
this.metadata = new VideoMetadata()
|
||||
if (mediaMetadata.metadata) {
|
||||
this.metadata.setData(mediaMetadata.metadata)
|
||||
}
|
||||
|
||||
this.coverPath = mediaMetadata.coverPath || null
|
||||
}
|
||||
|
||||
getPlaybackTitle() {
|
||||
return this.metadata.title
|
||||
}
|
||||
|
||||
getPlaybackAuthor() {
|
||||
return ''
|
||||
}
|
||||
|
||||
getVideoTrack() {
|
||||
var track = new VideoTrack()
|
||||
track.setData(this.libraryItemId, this.videoFile)
|
||||
return track
|
||||
}
|
||||
}
|
||||
module.exports = Video
|
||||
@@ -6,7 +6,7 @@ class BookMetadata {
|
||||
this.title = null
|
||||
this.subtitle = null
|
||||
this.authors = []
|
||||
this.narrators = [] // Array of strings
|
||||
this.narrators = [] // Array of strings
|
||||
this.series = []
|
||||
this.genres = [] // Array of strings
|
||||
this.publishedYear = null
|
||||
@@ -27,9 +27,9 @@ class BookMetadata {
|
||||
construct(metadata) {
|
||||
this.title = metadata.title
|
||||
this.subtitle = metadata.subtitle
|
||||
this.authors = (metadata.authors?.map) ? metadata.authors.map(a => ({ ...a })) : []
|
||||
this.narrators = metadata.narrators ? [...metadata.narrators].filter(n => n) : []
|
||||
this.series = (metadata.series?.map) ? metadata.series.map(s => ({ ...s })) : []
|
||||
this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : []
|
||||
this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : []
|
||||
this.series = metadata.series?.map ? metadata.series.map((s) => ({ ...s })) : []
|
||||
this.genres = metadata.genres ? [...metadata.genres] : []
|
||||
this.publishedYear = metadata.publishedYear || null
|
||||
this.publishedDate = metadata.publishedDate || null
|
||||
@@ -46,9 +46,9 @@ class BookMetadata {
|
||||
return {
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id
|
||||
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
|
||||
narrators: [...this.narrators],
|
||||
series: this.series.map(s => ({ ...s })), // Series JSONMinimal with name, id and sequence
|
||||
series: this.series.map((s) => ({ ...s })), // Series JSONMinimal with name, id and sequence
|
||||
genres: [...this.genres],
|
||||
publishedYear: this.publishedYear,
|
||||
publishedDate: this.publishedDate,
|
||||
@@ -89,9 +89,9 @@ class BookMetadata {
|
||||
title: this.title,
|
||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
||||
subtitle: this.subtitle,
|
||||
authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id
|
||||
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
|
||||
narrators: [...this.narrators],
|
||||
series: this.series.map(s => ({ ...s })),
|
||||
series: this.series.map((s) => ({ ...s })),
|
||||
genres: [...this.genres],
|
||||
publishedYear: this.publishedYear,
|
||||
publishedDate: this.publishedDate,
|
||||
@@ -111,8 +111,8 @@ class BookMetadata {
|
||||
|
||||
toJSONForMetadataFile() {
|
||||
const json = this.toJSON()
|
||||
json.authors = json.authors.map(au => au.name)
|
||||
json.series = json.series.map(se => {
|
||||
json.authors = json.authors.map((au) => au.name)
|
||||
json.series = json.series.map((se) => {
|
||||
if (!se.sequence) return se.name
|
||||
return `${se.name} #${se.sequence}`
|
||||
})
|
||||
@@ -131,36 +131,31 @@ class BookMetadata {
|
||||
}
|
||||
get authorName() {
|
||||
if (!this.authors.length) return ''
|
||||
return this.authors.map(au => au.name).join(', ')
|
||||
return this.authors.map((au) => au.name).join(', ')
|
||||
}
|
||||
get authorNameLF() { // Last, First
|
||||
get authorNameLF() {
|
||||
// Last, First
|
||||
if (!this.authors.length) return ''
|
||||
return this.authors.map(au => parseNameString.nameToLastFirst(au.name)).join(', ')
|
||||
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
|
||||
}
|
||||
get seriesName() {
|
||||
if (!this.series.length) return ''
|
||||
return this.series.map(se => {
|
||||
if (!se.sequence) return se.name
|
||||
return `${se.name} #${se.sequence}`
|
||||
}).join(', ')
|
||||
}
|
||||
get firstSeriesName() {
|
||||
if (!this.series.length) return ''
|
||||
return this.series[0].name
|
||||
}
|
||||
get firstSeriesSequence() {
|
||||
if (!this.series.length) return ''
|
||||
return this.series[0].sequence
|
||||
return this.series
|
||||
.map((se) => {
|
||||
if (!se.sequence) return se.name
|
||||
return `${se.name} #${se.sequence}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
get narratorName() {
|
||||
return this.narrators.join(', ')
|
||||
}
|
||||
|
||||
getSeries(seriesId) {
|
||||
return this.series.find(se => se.id == seriesId)
|
||||
return this.series.find((se) => se.id == seriesId)
|
||||
}
|
||||
getSeriesSequence(seriesId) {
|
||||
const series = this.series.find(se => se.id == seriesId)
|
||||
const series = this.series.find((se) => se.id == seriesId)
|
||||
if (!series) return null
|
||||
return series.sequence || ''
|
||||
}
|
||||
@@ -180,21 +175,5 @@ class BookMetadata {
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
// Updates author name
|
||||
updateAuthor(updatedAuthor) {
|
||||
const author = this.authors.find(au => au.id === updatedAuthor.id)
|
||||
if (!author || author.name == updatedAuthor.name) return false
|
||||
author.name = updatedAuthor.name
|
||||
return true
|
||||
}
|
||||
|
||||
replaceAuthor(oldAuthor, newAuthor) {
|
||||
this.authors = this.authors.filter(au => au.id !== oldAuthor.id) // Remove old author
|
||||
this.authors.push({
|
||||
id: newAuthor.id,
|
||||
name: newAuthor.name
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = BookMetadata
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
const Logger = require('../../Logger')
|
||||
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
|
||||
|
||||
class MusicMetadata {
|
||||
constructor(metadata) {
|
||||
this.title = null
|
||||
this.artists = [] // Array of strings
|
||||
this.album = null
|
||||
this.albumArtist = null
|
||||
this.genres = [] // Array of strings
|
||||
this.composer = null
|
||||
this.originalYear = null
|
||||
this.releaseDate = null
|
||||
this.releaseCountry = null
|
||||
this.releaseType = null
|
||||
this.releaseStatus = null
|
||||
this.recordLabel = null
|
||||
this.language = null
|
||||
this.explicit = false
|
||||
|
||||
this.discNumber = null
|
||||
this.discTotal = null
|
||||
this.trackNumber = null
|
||||
this.trackTotal = null
|
||||
|
||||
this.isrc = null
|
||||
this.musicBrainzTrackId = null
|
||||
this.musicBrainzAlbumId = null
|
||||
this.musicBrainzAlbumArtistId = null
|
||||
this.musicBrainzArtistId = null
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
construct(metadata) {
|
||||
this.title = metadata.title
|
||||
this.artists = metadata.artists ? [...metadata.artists] : []
|
||||
this.album = metadata.album
|
||||
this.albumArtist = metadata.albumArtist
|
||||
this.genres = metadata.genres ? [...metadata.genres] : []
|
||||
this.composer = metadata.composer || null
|
||||
this.originalYear = metadata.originalYear || null
|
||||
this.releaseDate = metadata.releaseDate || null
|
||||
this.releaseCountry = metadata.releaseCountry || null
|
||||
this.releaseType = metadata.releaseType || null
|
||||
this.releaseStatus = metadata.releaseStatus || null
|
||||
this.recordLabel = metadata.recordLabel || null
|
||||
this.language = metadata.language || null
|
||||
this.explicit = !!metadata.explicit
|
||||
this.discNumber = metadata.discNumber || null
|
||||
this.discTotal = metadata.discTotal || null
|
||||
this.trackNumber = metadata.trackNumber || null
|
||||
this.trackTotal = metadata.trackTotal || null
|
||||
this.isrc = metadata.isrc || null
|
||||
this.musicBrainzTrackId = metadata.musicBrainzTrackId || null
|
||||
this.musicBrainzAlbumId = metadata.musicBrainzAlbumId || null
|
||||
this.musicBrainzAlbumArtistId = metadata.musicBrainzAlbumArtistId || null
|
||||
this.musicBrainzArtistId = metadata.musicBrainzArtistId || null
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
title: this.title,
|
||||
artists: [...this.artists],
|
||||
album: this.album,
|
||||
albumArtist: this.albumArtist,
|
||||
genres: [...this.genres],
|
||||
composer: this.composer,
|
||||
originalYear: this.originalYear,
|
||||
releaseDate: this.releaseDate,
|
||||
releaseCountry: this.releaseCountry,
|
||||
releaseType: this.releaseType,
|
||||
releaseStatus: this.releaseStatus,
|
||||
recordLabel: this.recordLabel,
|
||||
language: this.language,
|
||||
explicit: this.explicit,
|
||||
discNumber: this.discNumber,
|
||||
discTotal: this.discTotal,
|
||||
trackNumber: this.trackNumber,
|
||||
trackTotal: this.trackTotal,
|
||||
isrc: this.isrc,
|
||||
musicBrainzTrackId: this.musicBrainzTrackId,
|
||||
musicBrainzAlbumId: this.musicBrainzAlbumId,
|
||||
musicBrainzAlbumArtistId: this.musicBrainzAlbumArtistId,
|
||||
musicBrainzArtistId: this.musicBrainzArtistId
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
title: this.title,
|
||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
||||
artists: [...this.artists],
|
||||
album: this.album,
|
||||
albumArtist: this.albumArtist,
|
||||
genres: [...this.genres],
|
||||
composer: this.composer,
|
||||
originalYear: this.originalYear,
|
||||
releaseDate: this.releaseDate,
|
||||
releaseCountry: this.releaseCountry,
|
||||
releaseType: this.releaseType,
|
||||
releaseStatus: this.releaseStatus,
|
||||
recordLabel: this.recordLabel,
|
||||
language: this.language,
|
||||
explicit: this.explicit,
|
||||
discNumber: this.discNumber,
|
||||
discTotal: this.discTotal,
|
||||
trackNumber: this.trackNumber,
|
||||
trackTotal: this.trackTotal,
|
||||
isrc: this.isrc,
|
||||
musicBrainzTrackId: this.musicBrainzTrackId,
|
||||
musicBrainzAlbumId: this.musicBrainzAlbumId,
|
||||
musicBrainzAlbumArtistId: this.musicBrainzAlbumArtistId,
|
||||
musicBrainzArtistId: this.musicBrainzArtistId
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return this.toJSONMinified()
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new MusicMetadata(this.toJSON())
|
||||
}
|
||||
|
||||
get titleIgnorePrefix() {
|
||||
return getTitleIgnorePrefix(this.title)
|
||||
}
|
||||
|
||||
get titlePrefixAtEnd() {
|
||||
return getTitlePrefixAtEnd(this.title)
|
||||
}
|
||||
|
||||
setData(mediaMetadata = {}) {
|
||||
this.title = mediaMetadata.title || null
|
||||
this.artist = mediaMetadata.artist || null
|
||||
this.album = mediaMetadata.album || null
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
const json = this.toJSON()
|
||||
let hasUpdates = false
|
||||
for (const key in json) {
|
||||
if (payload[key] !== undefined) {
|
||||
if (!areEquivalent(payload[key], json[key])) {
|
||||
this[key] = copyValue(payload[key])
|
||||
Logger.debug('[MusicMetadata] Key updated', key, this[key])
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
parseArtistsTag(artistsTag) {
|
||||
if (!artistsTag || !artistsTag.length) return []
|
||||
const separators = ['/', '//', ';']
|
||||
for (let i = 0; i < separators.length; i++) {
|
||||
if (artistsTag.includes(separators[i])) {
|
||||
return artistsTag.split(separators[i]).map(artist => artist.trim()).filter(a => !!a)
|
||||
}
|
||||
}
|
||||
return [artistsTag]
|
||||
}
|
||||
|
||||
parseGenresTag(genreTag) {
|
||||
if (!genreTag || !genreTag.length) return []
|
||||
const separators = ['/', '//', ';']
|
||||
for (let i = 0; i < separators.length; i++) {
|
||||
if (genreTag.includes(separators[i])) {
|
||||
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
|
||||
}
|
||||
}
|
||||
return [genreTag]
|
||||
}
|
||||
|
||||
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagTitle',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
tag: 'tagArtist',
|
||||
key: 'artists'
|
||||
},
|
||||
{
|
||||
tag: 'tagAlbumArtist',
|
||||
key: 'albumArtist'
|
||||
},
|
||||
{
|
||||
tag: 'tagAlbum',
|
||||
key: 'album',
|
||||
},
|
||||
{
|
||||
tag: 'tagPublisher',
|
||||
key: 'recordLabel'
|
||||
},
|
||||
{
|
||||
tag: 'tagComposer',
|
||||
key: 'composer'
|
||||
},
|
||||
{
|
||||
tag: 'tagDate',
|
||||
key: 'releaseDate'
|
||||
},
|
||||
{
|
||||
tag: 'tagReleaseCountry',
|
||||
key: 'releaseCountry'
|
||||
},
|
||||
{
|
||||
tag: 'tagReleaseType',
|
||||
key: 'releaseType'
|
||||
},
|
||||
{
|
||||
tag: 'tagReleaseStatus',
|
||||
key: 'releaseStatus'
|
||||
},
|
||||
{
|
||||
tag: 'tagOriginalYear',
|
||||
key: 'originalYear'
|
||||
},
|
||||
{
|
||||
tag: 'tagGenre',
|
||||
key: 'genres'
|
||||
},
|
||||
{
|
||||
tag: 'tagLanguage',
|
||||
key: 'language'
|
||||
},
|
||||
{
|
||||
tag: 'tagLanguage',
|
||||
key: 'language'
|
||||
},
|
||||
{
|
||||
tag: 'tagISRC',
|
||||
key: 'isrc'
|
||||
},
|
||||
{
|
||||
tag: 'tagMusicBrainzTrackId',
|
||||
key: 'musicBrainzTrackId'
|
||||
},
|
||||
{
|
||||
tag: 'tagMusicBrainzAlbumId',
|
||||
key: 'musicBrainzAlbumId'
|
||||
},
|
||||
{
|
||||
tag: 'tagMusicBrainzAlbumArtistId',
|
||||
key: 'musicBrainzAlbumArtistId'
|
||||
},
|
||||
{
|
||||
tag: 'tagMusicBrainzArtistId',
|
||||
key: 'musicBrainzArtistId'
|
||||
},
|
||||
{
|
||||
tag: 'trackNumber',
|
||||
key: 'trackNumber'
|
||||
},
|
||||
{
|
||||
tag: 'trackTotal',
|
||||
key: 'trackTotal'
|
||||
},
|
||||
{
|
||||
tag: 'discNumber',
|
||||
key: 'discNumber'
|
||||
},
|
||||
{
|
||||
tag: 'discTotal',
|
||||
key: 'discTotal'
|
||||
}
|
||||
]
|
||||
|
||||
const updatePayload = {}
|
||||
|
||||
// Metadata is only mapped to the music track if it is empty
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
let value = audioFileMetaTags[mapping.tag]
|
||||
|
||||
// let tagToUse = mapping.tag
|
||||
if (!value && mapping.altTag) {
|
||||
value = audioFileMetaTags[mapping.altTag]
|
||||
// tagToUse = mapping.altTag
|
||||
}
|
||||
|
||||
if (value && (typeof value === 'string' || typeof value === 'number')) {
|
||||
value = value.toString().trim() // Trim whitespace
|
||||
|
||||
if (mapping.key === 'artists' && (!this.artists.length || overrideExistingDetails)) {
|
||||
updatePayload.artists = this.parseArtistsTag(value)
|
||||
} else if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) {
|
||||
updatePayload.genres = this.parseGenresTag(value)
|
||||
} else if (!this[mapping.key] || overrideExistingDetails) {
|
||||
updatePayload[mapping.key] = value
|
||||
// Logger.debug(`[Book] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
return this.update(updatePayload)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
module.exports = MusicMetadata
|
||||
@@ -1,80 +0,0 @@
|
||||
const Logger = require('../../Logger')
|
||||
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
|
||||
|
||||
class VideoMetadata {
|
||||
constructor(metadata) {
|
||||
this.title = null
|
||||
this.description = null
|
||||
this.explicit = false
|
||||
this.language = null
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
construct(metadata) {
|
||||
this.title = metadata.title
|
||||
this.description = metadata.description
|
||||
this.explicit = metadata.explicit
|
||||
this.language = metadata.language || null
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
explicit: this.explicit,
|
||||
language: this.language
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
title: this.title,
|
||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
||||
description: this.description,
|
||||
explicit: this.explicit,
|
||||
language: this.language
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return this.toJSONMinified()
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new VideoMetadata(this.toJSON())
|
||||
}
|
||||
|
||||
get titleIgnorePrefix() {
|
||||
return getTitleIgnorePrefix(this.title)
|
||||
}
|
||||
|
||||
get titlePrefixAtEnd() {
|
||||
return getTitlePrefixAtEnd(this.title)
|
||||
}
|
||||
|
||||
setData(mediaMetadata = {}) {
|
||||
this.title = mediaMetadata.title || null
|
||||
this.description = mediaMetadata.description || null
|
||||
this.explicit = !!mediaMetadata.explicit
|
||||
this.language = mediaMetadata.language || null
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var json = this.toJSON()
|
||||
var hasUpdates = false
|
||||
for (const key in json) {
|
||||
if (payload[key] !== undefined) {
|
||||
if (!areEquivalent(payload[key], json[key])) {
|
||||
this[key] = copyValue(payload[key])
|
||||
Logger.debug('[VideoMetadata] Key updated', key, this[key])
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = VideoMetadata
|
||||
@@ -75,13 +75,14 @@ class LibraryScan {
|
||||
return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt'
|
||||
}
|
||||
get scanResultsString() {
|
||||
if (this.error) return this.error
|
||||
const strs = []
|
||||
if (this.resultsAdded) strs.push(`${this.resultsAdded} added`)
|
||||
if (this.resultsUpdated) strs.push(`${this.resultsUpdated} updated`)
|
||||
if (this.resultsMissing) strs.push(`${this.resultsMissing} missing`)
|
||||
if (!strs.length) return `Everything was up to date (${elapsedPretty(this.elapsed / 1000)})`
|
||||
return strs.join(', ') + ` (${elapsedPretty(this.elapsed / 1000)})`
|
||||
const changesDetected = strs.length > 0 ? strs.join(', ') : 'No changes detected'
|
||||
const timeElapsed = `(${elapsedPretty(this.elapsed / 1000)})`
|
||||
const error = this.error ? `${this.error}. ` : ''
|
||||
return `${error}${changesDetected} ${timeElapsed}`
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
||||
@@ -79,43 +79,39 @@ class LibraryScanner {
|
||||
|
||||
Logger.info(`[LibraryScanner] Starting${forceRescan ? ' (forced)' : ''} library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
||||
|
||||
const canceled = await this.scanLibrary(libraryScan, forceRescan)
|
||||
try {
|
||||
const canceled = await this.scanLibrary(libraryScan, forceRescan)
|
||||
libraryScan.setComplete()
|
||||
|
||||
if (canceled) {
|
||||
Logger.info(`[LibraryScanner] Library scan canceled for "${libraryScan.libraryName}"`)
|
||||
delete this.cancelLibraryScan[libraryScan.libraryId]
|
||||
Logger.info(`[LibraryScanner] Library scan "${libraryScan.id}" ${canceled ? 'canceled after' : 'completed in'} ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`)
|
||||
|
||||
if (!canceled) {
|
||||
library.lastScan = Date.now()
|
||||
library.lastScanVersion = packageJson.version
|
||||
if (library.isBook) {
|
||||
const newExtraData = library.extraData || {}
|
||||
newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence
|
||||
library.extraData = newExtraData
|
||||
library.changed('extraData', true)
|
||||
}
|
||||
await library.save()
|
||||
}
|
||||
|
||||
task.setFinished(`${canceled ? 'Canceled' : 'Completed'}. ${libraryScan.scanResultsString}`)
|
||||
} catch (err) {
|
||||
libraryScan.setComplete(err)
|
||||
|
||||
Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}.`, err)
|
||||
|
||||
task.setFailed(`Failed. ${libraryScan.scanResultsString}`)
|
||||
}
|
||||
|
||||
libraryScan.setComplete()
|
||||
|
||||
Logger.info(`[LibraryScanner] Library scan ${libraryScan.id} completed in ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`)
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) delete this.cancelLibraryScan[libraryScan.libraryId]
|
||||
this.librariesScanning = this.librariesScanning.filter((ls) => ls.id !== library.id)
|
||||
|
||||
if (canceled && !libraryScan.totalResults) {
|
||||
task.setFinished('Scan canceled')
|
||||
TaskManager.taskFinished(task)
|
||||
|
||||
const emitData = libraryScan.getScanEmitData
|
||||
emitData.results = null
|
||||
return
|
||||
}
|
||||
|
||||
library.lastScan = Date.now()
|
||||
library.lastScanVersion = packageJson.version
|
||||
if (library.isBook) {
|
||||
const newExtraData = library.extraData || {}
|
||||
newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence
|
||||
library.extraData = newExtraData
|
||||
library.changed('extraData', true)
|
||||
}
|
||||
await library.save()
|
||||
|
||||
task.setFinished(libraryScan.scanResultsString)
|
||||
TaskManager.taskFinished(task)
|
||||
|
||||
if (libraryScan.totalResults) {
|
||||
libraryScan.saveLog()
|
||||
}
|
||||
libraryScan.saveLog()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,7 +136,7 @@ class LibraryScanner {
|
||||
libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
|
||||
const existingLibraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
@@ -148,7 +144,7 @@ class LibraryScanner {
|
||||
}
|
||||
})
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
|
||||
const libraryItemIdsMissing = []
|
||||
let oldLibraryItemsUpdated = []
|
||||
@@ -216,7 +212,7 @@ class LibraryScanner {
|
||||
oldLibraryItemsUpdated = []
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
}
|
||||
// Emit item updates to client
|
||||
if (oldLibraryItemsUpdated.length) {
|
||||
@@ -247,7 +243,7 @@ class LibraryScanner {
|
||||
)
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
|
||||
// Add new library items
|
||||
if (libraryItemDataFound.length) {
|
||||
@@ -271,7 +267,7 @@ class LibraryScanner {
|
||||
newOldLibraryItems = []
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
}
|
||||
// Emit new items to client
|
||||
if (newOldLibraryItems.length) {
|
||||
@@ -282,6 +278,17 @@ class LibraryScanner {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`)
|
||||
return false
|
||||
}
|
||||
|
||||
shouldCancelScan(libraryScan) {
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Scan canceled. ${libraryScan.resultStats}`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const { getTitleIgnorePrefix } = require('../utils/index')
|
||||
|
||||
// Utils
|
||||
const { findMatchingEpisodesInFeed, getPodcastFeed } = require('../utils/podcastUtils')
|
||||
@@ -230,7 +231,7 @@ class Scanner {
|
||||
seriesItem = await Database.seriesModel.create({
|
||||
name: seriesMatchItem.series,
|
||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),
|
||||
libraryId
|
||||
libraryId: libraryItem.libraryId
|
||||
})
|
||||
// Update filter data
|
||||
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
|
||||
|
||||
@@ -51,7 +51,3 @@ module.exports.AudioMimeType = {
|
||||
AWB: 'audio/amr-wb',
|
||||
CAF: 'audio/x-caf'
|
||||
}
|
||||
|
||||
module.exports.VideoMimeType = {
|
||||
MP4: 'video/mp4'
|
||||
}
|
||||
@@ -299,6 +299,12 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF
|
||||
'-metadata:s:v',
|
||||
'comment=Cover' // add comment metadata to cover image stream
|
||||
])
|
||||
const ext = Path.extname(coverFilePath).toLowerCase()
|
||||
if (ext === '.webp') {
|
||||
ffmpeg.outputOptions([
|
||||
'-c:v mjpeg' // convert webp images to jpeg
|
||||
])
|
||||
}
|
||||
} else {
|
||||
ffmpeg.outputOptions([
|
||||
'-map 0:v?' // retain video stream from input file if exists
|
||||
|
||||
@@ -131,19 +131,6 @@ async function readTextFile(path) {
|
||||
}
|
||||
module.exports.readTextFile = readTextFile
|
||||
|
||||
function bytesPretty(bytes, decimals = 0) {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes'
|
||||
}
|
||||
const k = 1000
|
||||
var dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
if (i > 2 && dm === 0) dm = 1
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
module.exports.bytesPretty = bytesPretty
|
||||
|
||||
/**
|
||||
* Get array of files inside dir
|
||||
* @param {string} path
|
||||
|
||||
@@ -2,7 +2,6 @@ const globals = {
|
||||
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'],
|
||||
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
SupportedVideoTypes: ['mp4'],
|
||||
TextFileTypes: ['txt', 'nfo'],
|
||||
MetadataFileTypes: ['opf', 'abs', 'xml', 'json']
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ const parseNameString = require('./parsers/parseNameString')
|
||||
function isMediaFile(mediaType, ext, audiobooksOnly = false) {
|
||||
if (!ext) return false
|
||||
const extclean = ext.slice(1).toLowerCase()
|
||||
if (mediaType === 'podcast' || mediaType === 'music') return globals.SupportedAudioTypes.includes(extclean)
|
||||
else if (mediaType === 'video') return globals.SupportedVideoTypes.includes(extclean)
|
||||
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
|
||||
else if (audiobooksOnly) return globals.SupportedAudioTypes.includes(extclean)
|
||||
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
|
||||
}
|
||||
@@ -35,29 +34,33 @@ module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
|
||||
|
||||
/**
|
||||
* TODO: Function needs to be re-done
|
||||
* @param {string} mediaType
|
||||
* @param {string} mediaType
|
||||
* @param {string[]} paths array of relative file paths
|
||||
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
|
||||
*/
|
||||
function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||
// Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
|
||||
var nonMediaFilePaths = []
|
||||
var pathsFiltered = paths.map(path => {
|
||||
return path.startsWith('/') ? path.slice(1) : path
|
||||
}).filter(path => {
|
||||
let parsedPath = Path.parse(path)
|
||||
// Is not in root dir OR is a book media file
|
||||
if (parsedPath.dir) {
|
||||
if (!isMediaFile(mediaType, parsedPath.ext, false)) { // Seperate out non-media files
|
||||
nonMediaFilePaths.push(path)
|
||||
return false
|
||||
var pathsFiltered = paths
|
||||
.map((path) => {
|
||||
return path.startsWith('/') ? path.slice(1) : path
|
||||
})
|
||||
.filter((path) => {
|
||||
let parsedPath = Path.parse(path)
|
||||
// Is not in root dir OR is a book media file
|
||||
if (parsedPath.dir) {
|
||||
if (!isMediaFile(mediaType, parsedPath.ext, false)) {
|
||||
// Seperate out non-media files
|
||||
nonMediaFilePaths.push(path)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) {
|
||||
// (book media type supports single file audiobooks/ebooks in root dir)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
} else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) { // (book media type supports single file audiobooks/ebooks in root dir)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
// Step 2: Sort by least number of directories
|
||||
pathsFiltered.sort((a, b) => {
|
||||
@@ -69,7 +72,9 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||
// Step 3: Group files in dirs
|
||||
var itemGroup = {}
|
||||
pathsFiltered.forEach((path) => {
|
||||
var dirparts = Path.dirname(path).split('/').filter(p => !!p && p !== '.') // dirname returns . if no directory
|
||||
var dirparts = Path.dirname(path)
|
||||
.split('/')
|
||||
.filter((p) => !!p && p !== '.') // dirname returns . if no directory
|
||||
var numparts = dirparts.length
|
||||
var _path = ''
|
||||
|
||||
@@ -82,14 +87,17 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||
var dirpart = dirparts.shift()
|
||||
_path = Path.posix.join(_path, dirpart)
|
||||
|
||||
if (itemGroup[_path]) { // Directory already has files, add file
|
||||
if (itemGroup[_path]) {
|
||||
// Directory already has files, add file
|
||||
var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
|
||||
itemGroup[_path].push(relpath)
|
||||
return
|
||||
} else if (!dirparts.length) { // This is the last directory, create group
|
||||
} else if (!dirparts.length) {
|
||||
// This is the last directory, create group
|
||||
itemGroup[_path] = [Path.basename(path)]
|
||||
return
|
||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
|
||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) {
|
||||
// Next directory is the last and is a CD dir, create group
|
||||
itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
|
||||
return
|
||||
}
|
||||
@@ -99,7 +107,6 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||
|
||||
// Step 4: Add in non-media files if they fit into item group
|
||||
if (nonMediaFilePaths.length) {
|
||||
|
||||
for (const nonMediaFilePath of nonMediaFilePaths) {
|
||||
const pathDir = Path.dirname(nonMediaFilePath)
|
||||
const filename = Path.basename(nonMediaFilePath)
|
||||
@@ -111,7 +118,8 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||
for (let i = 0; i < numparts; i++) {
|
||||
const dirpart = dirparts.shift()
|
||||
_path = Path.posix.join(_path, dirpart)
|
||||
if (itemGroup[_path]) { // Directory is a group
|
||||
if (itemGroup[_path]) {
|
||||
// Directory is a group
|
||||
const relpath = Path.posix.join(dirparts.join('/'), filename)
|
||||
itemGroup[_path].push(relpath)
|
||||
} else if (!dirparts.length) {
|
||||
@@ -126,31 +134,22 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||
module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
||||
|
||||
/**
|
||||
* @param {string} mediaType
|
||||
* @param {string} mediaType
|
||||
* @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles)
|
||||
* @param {boolean} [audiobooksOnly=false]
|
||||
* @param {boolean} [audiobooksOnly=false]
|
||||
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
|
||||
*/
|
||||
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly = false) {
|
||||
// Handle music where every audio file is a library item
|
||||
if (mediaType === 'music') {
|
||||
const audioFileGroup = {}
|
||||
fileItems.filter(i => isMediaFile(mediaType, i.extension, audiobooksOnly)).forEach((item) => {
|
||||
audioFileGroup[item.path] = item.path
|
||||
})
|
||||
return audioFileGroup
|
||||
}
|
||||
|
||||
// Step 1: Filter out non-book-media files in root dir (with depth of 0)
|
||||
const itemsFiltered = fileItems.filter(i => {
|
||||
return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video' || mediaType === 'music') && isMediaFile(mediaType, i.extension, audiobooksOnly))
|
||||
const itemsFiltered = fileItems.filter((i) => {
|
||||
return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension, audiobooksOnly))
|
||||
})
|
||||
|
||||
// Step 2: Seperate media files and other files
|
||||
// - Directories without a media file will not be included
|
||||
const mediaFileItems = []
|
||||
const otherFileItems = []
|
||||
itemsFiltered.forEach(item => {
|
||||
itemsFiltered.forEach((item) => {
|
||||
if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item)
|
||||
else otherFileItems.push(item)
|
||||
})
|
||||
@@ -158,7 +157,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
|
||||
// Step 3: Group audio files in library items
|
||||
const libraryItemGroup = {}
|
||||
mediaFileItems.forEach((item) => {
|
||||
const dirparts = item.reldirpath.split('/').filter(p => !!p)
|
||||
const dirparts = item.reldirpath.split('/').filter((p) => !!p)
|
||||
const numparts = dirparts.length
|
||||
let _path = ''
|
||||
|
||||
@@ -171,14 +170,17 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
|
||||
const dirpart = dirparts.shift()
|
||||
_path = Path.posix.join(_path, dirpart)
|
||||
|
||||
if (libraryItemGroup[_path]) { // Directory already has files, add file
|
||||
if (libraryItemGroup[_path]) {
|
||||
// Directory already has files, add file
|
||||
const relpath = Path.posix.join(dirparts.join('/'), item.name)
|
||||
libraryItemGroup[_path].push(relpath)
|
||||
return
|
||||
} else if (!dirparts.length) { // This is the last directory, create group
|
||||
} else if (!dirparts.length) {
|
||||
// This is the last directory, create group
|
||||
libraryItemGroup[_path] = [item.name]
|
||||
return
|
||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
|
||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) {
|
||||
// Next directory is the last and is a CD dir, create group
|
||||
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
|
||||
return
|
||||
}
|
||||
@@ -196,7 +198,8 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
|
||||
for (let i = 0; i < numparts; i++) {
|
||||
const dirpart = dirparts.shift()
|
||||
_path = Path.posix.join(_path, dirpart)
|
||||
if (libraryItemGroup[_path]) { // Directory is audiobook group
|
||||
if (libraryItemGroup[_path]) {
|
||||
// Directory is audiobook group
|
||||
const relpath = Path.posix.join(dirparts.join('/'), item.name)
|
||||
libraryItemGroup[_path].push(relpath)
|
||||
return
|
||||
@@ -209,33 +212,35 @@ module.exports.groupFileItemsIntoLibraryItemDirs = groupFileItemsIntoLibraryItem
|
||||
|
||||
/**
|
||||
* Get LibraryFile from filepath
|
||||
* @param {string} libraryItemPath
|
||||
* @param {string[]} files
|
||||
* @param {string} libraryItemPath
|
||||
* @param {string[]} files
|
||||
* @returns {import('../objects/files/LibraryFile')}
|
||||
*/
|
||||
function buildLibraryFile(libraryItemPath, files) {
|
||||
return Promise.all(files.map(async (file) => {
|
||||
const filePath = Path.posix.join(libraryItemPath, file)
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(filePath, file)
|
||||
return newLibraryFile
|
||||
}))
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const filePath = Path.posix.join(libraryItemPath, file)
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(filePath, file)
|
||||
return newLibraryFile
|
||||
})
|
||||
)
|
||||
}
|
||||
module.exports.buildLibraryFile = buildLibraryFile
|
||||
|
||||
/**
|
||||
* Get details parsed from filenames
|
||||
*
|
||||
* @param {string} relPath
|
||||
* @param {boolean} parseSubtitle
|
||||
*
|
||||
* @param {string} relPath
|
||||
* @param {boolean} parseSubtitle
|
||||
* @returns {LibraryItemFilenameMetadata}
|
||||
*/
|
||||
function getBookDataFromDir(relPath, parseSubtitle = false) {
|
||||
const splitDir = relPath.split('/')
|
||||
|
||||
var folder = splitDir.pop() // Audio files will always be in the directory named for the title
|
||||
series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||
author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||
author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
|
||||
// The may contain various other pieces of metadata, these functions extract it.
|
||||
var [folder, asin] = getASIN(folder)
|
||||
@@ -244,7 +249,6 @@ function getBookDataFromDir(relPath, parseSubtitle = false) {
|
||||
var [folder, publishedYear] = getPublishedYear(folder)
|
||||
var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
|
||||
|
||||
|
||||
return {
|
||||
title,
|
||||
subtitle,
|
||||
@@ -260,8 +264,8 @@ module.exports.getBookDataFromDir = getBookDataFromDir
|
||||
|
||||
/**
|
||||
* Extract narrator from folder name
|
||||
*
|
||||
* @param {string} folder
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, narrator]
|
||||
*/
|
||||
function getNarrator(folder) {
|
||||
@@ -272,7 +276,7 @@ function getNarrator(folder) {
|
||||
|
||||
/**
|
||||
* Extract series sequence from folder name
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* 'Book 2 - Title - Subtitle'
|
||||
* 'Title - Subtitle - Vol 12'
|
||||
@@ -283,8 +287,8 @@ function getNarrator(folder) {
|
||||
* '100 - Book Title'
|
||||
* '6. Title'
|
||||
* '0.5 - Book Title'
|
||||
*
|
||||
* @param {string} folder
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, sequence]
|
||||
*/
|
||||
function getSequence(folder) {
|
||||
@@ -299,7 +303,9 @@ function getSequence(folder) {
|
||||
if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
|
||||
volumeNumber = isNaN(match.groups.sequence) ? match.groups.sequence : Number(match.groups.sequence).toString()
|
||||
parts[i] = match.groups.suffix
|
||||
if (!parts[i]) { parts.splice(i, 1) }
|
||||
if (!parts[i]) {
|
||||
parts.splice(i, 1)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -310,8 +316,8 @@ function getSequence(folder) {
|
||||
|
||||
/**
|
||||
* Extract published year from folder name
|
||||
*
|
||||
* @param {string} folder
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, publishedYear]
|
||||
*/
|
||||
function getPublishedYear(folder) {
|
||||
@@ -329,8 +335,8 @@ function getPublishedYear(folder) {
|
||||
|
||||
/**
|
||||
* Extract subtitle from folder name
|
||||
*
|
||||
* @param {string} folder
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, subtitle]
|
||||
*/
|
||||
function getSubtitle(folder) {
|
||||
@@ -341,8 +347,8 @@ function getSubtitle(folder) {
|
||||
|
||||
/**
|
||||
* Extract asin from folder name
|
||||
*
|
||||
* @param {string} folder
|
||||
*
|
||||
* @param {string} folder
|
||||
* @returns {[string, string]} [folder, asin]
|
||||
*/
|
||||
function getASIN(folder) {
|
||||
@@ -358,8 +364,8 @@ function getASIN(folder) {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} relPath
|
||||
*
|
||||
* @param {string} relPath
|
||||
* @returns {LibraryItemFilenameMetadata}
|
||||
*/
|
||||
function getPodcastDataFromDir(relPath) {
|
||||
@@ -373,10 +379,10 @@ function getPodcastDataFromDir(relPath) {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} libraryMediaType
|
||||
* @param {string} folderPath
|
||||
* @param {string} relPath
|
||||
*
|
||||
* @param {string} libraryMediaType
|
||||
* @param {string} folderPath
|
||||
* @param {string} relPath
|
||||
* @returns {{ mediaMetadata: LibraryItemFilenameMetadata, relPath: string, path: string}}
|
||||
*/
|
||||
function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
|
||||
@@ -386,7 +392,8 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
|
||||
|
||||
if (libraryMediaType === 'podcast') {
|
||||
mediaMetadata = getPodcastDataFromDir(relPath)
|
||||
} else { // book
|
||||
} else {
|
||||
// book
|
||||
mediaMetadata = getBookDataFromDir(relPath, !!global.ServerSettings.scannerParseSubtitle)
|
||||
}
|
||||
|
||||
|
||||
285
test/server/Logger.test.js
Normal file
285
test/server/Logger.test.js
Normal file
@@ -0,0 +1,285 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const Logger = require('../../server/Logger') // Adjust the path as needed
|
||||
const { LogLevel } = require('../../server/utils/constants')
|
||||
const date = require('../../server/libs/dateAndTime')
|
||||
const util = require('util')
|
||||
|
||||
describe('Logger', function () {
|
||||
let consoleTraceStub
|
||||
let consoleDebugStub
|
||||
let consoleInfoStub
|
||||
let consoleWarnStub
|
||||
let consoleErrorStub
|
||||
let consoleLogStub
|
||||
|
||||
beforeEach(function () {
|
||||
// Stub the date format function to return a consistent timestamp
|
||||
sinon.stub(date, 'format').returns('2024-09-10 12:34:56.789')
|
||||
// Stub the source getter to return a consistent source
|
||||
sinon.stub(Logger, 'source').get(() => 'some/source.js')
|
||||
// Stub the console methods used in Logger
|
||||
consoleTraceStub = sinon.stub(console, 'trace')
|
||||
consoleDebugStub = sinon.stub(console, 'debug')
|
||||
consoleInfoStub = sinon.stub(console, 'info')
|
||||
consoleWarnStub = sinon.stub(console, 'warn')
|
||||
consoleErrorStub = sinon.stub(console, 'error')
|
||||
consoleLogStub = sinon.stub(console, 'log')
|
||||
// Initialize the Logger's logManager as a mock object
|
||||
Logger.logManager = {
|
||||
logToFile: sinon.stub().resolves()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('logging methods', function () {
|
||||
it('should have a method for each log level defined in the static block', function () {
|
||||
const loggerMethods = Object.keys(LogLevel).map((key) => key.toLowerCase())
|
||||
|
||||
loggerMethods.forEach((method) => {
|
||||
expect(Logger).to.have.property(method).that.is.a('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call console.trace for trace logging', function () {
|
||||
// Arrange
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
Logger.trace('Test message')
|
||||
|
||||
// Assert
|
||||
expect(consoleTraceStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it('should call console.debug for debug logging', function () {
|
||||
// Arrange
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
Logger.debug('Test message')
|
||||
|
||||
// Assert
|
||||
expect(consoleDebugStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it('should call console.info for info logging', function () {
|
||||
// Arrange
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
Logger.info('Test message')
|
||||
|
||||
// Assert
|
||||
expect(consoleInfoStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it('should call console.warn for warn logging', function () {
|
||||
// Arrange
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
Logger.warn('Test message')
|
||||
|
||||
// Assert
|
||||
expect(consoleWarnStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it('should call console.error for error logging', function () {
|
||||
// Arrange
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
Logger.error('Test message')
|
||||
|
||||
// Assert
|
||||
expect(consoleErrorStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it('should call console.error for fatal logging', function () {
|
||||
// Arrange
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
Logger.fatal('Test message')
|
||||
|
||||
// Assert
|
||||
expect(consoleErrorStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it('should call console.log for note logging', function () {
|
||||
// Arrange
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
Logger.note('Test message')
|
||||
|
||||
// Assert
|
||||
expect(consoleLogStub.calledOnce).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('#log', function () {
|
||||
it('should log to console and file if level is high enough', async function () {
|
||||
// Arrange
|
||||
const logArgs = ['Test message']
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
Logger.debug(...logArgs)
|
||||
|
||||
expect(consoleDebugStub.calledOnce).to.be.true
|
||||
expect(consoleDebugStub.calledWithExactly('[2024-09-10 12:34:56.789] DEBUG:', ...logArgs)).to.be.true
|
||||
expect(Logger.logManager.logToFile.calledOnce).to.be.true
|
||||
expect(
|
||||
Logger.logManager.logToFile.calledWithExactly({
|
||||
timestamp: '2024-09-10 12:34:56.789',
|
||||
source: 'some/source.js',
|
||||
message: 'Test message',
|
||||
levelName: 'DEBUG',
|
||||
level: LogLevel.DEBUG
|
||||
})
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('should not log if log level is too low', function () {
|
||||
// Arrange
|
||||
const logArgs = ['This log should not appear']
|
||||
// Set log level to ERROR, so DEBUG log should be ignored
|
||||
Logger.logLevel = LogLevel.ERROR
|
||||
|
||||
// Act
|
||||
Logger.debug(...logArgs)
|
||||
|
||||
// Verify console.debug is not called
|
||||
expect(consoleDebugStub.called).to.be.false
|
||||
expect(Logger.logManager.logToFile.called).to.be.false
|
||||
})
|
||||
|
||||
it('should emit log to all connected sockets with appropriate log level', async function () {
|
||||
// Arrange
|
||||
const socket1 = { id: '1', emit: sinon.spy() }
|
||||
const socket2 = { id: '2', emit: sinon.spy() }
|
||||
Logger.addSocketListener(socket1, LogLevel.DEBUG)
|
||||
Logger.addSocketListener(socket2, LogLevel.ERROR)
|
||||
const logArgs = ['Socket test']
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
await Logger.debug(...logArgs)
|
||||
|
||||
// socket1 should receive the log, but not socket2
|
||||
expect(socket1.emit.calledOnce).to.be.true
|
||||
expect(
|
||||
socket1.emit.calledWithExactly('log', {
|
||||
timestamp: '2024-09-10 12:34:56.789',
|
||||
source: 'some/source.js',
|
||||
message: 'Socket test',
|
||||
levelName: 'DEBUG',
|
||||
level: LogLevel.DEBUG
|
||||
})
|
||||
).to.be.true
|
||||
|
||||
expect(socket2.emit.called).to.be.false
|
||||
})
|
||||
|
||||
it('should log fatal messages to console and file regardless of log level', async function () {
|
||||
// Arrange
|
||||
const logArgs = ['Fatal error']
|
||||
// Set log level to NOTE + 1, so nothing should be logged
|
||||
Logger.logLevel = LogLevel.NOTE + 1
|
||||
|
||||
// Act
|
||||
await Logger.fatal(...logArgs)
|
||||
|
||||
// Assert
|
||||
expect(consoleErrorStub.calledOnce).to.be.true
|
||||
expect(consoleErrorStub.calledWithExactly('[2024-09-10 12:34:56.789] FATAL:', ...logArgs)).to.be.true
|
||||
expect(Logger.logManager.logToFile.calledOnce).to.be.true
|
||||
expect(
|
||||
Logger.logManager.logToFile.calledWithExactly({
|
||||
timestamp: '2024-09-10 12:34:56.789',
|
||||
source: 'some/source.js',
|
||||
message: 'Fatal error',
|
||||
levelName: 'FATAL',
|
||||
level: LogLevel.FATAL
|
||||
})
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('should log note messages to console and file regardless of log level', async function () {
|
||||
// Arrange
|
||||
const logArgs = ['Note message']
|
||||
// Set log level to NOTE + 1, so nothing should be logged
|
||||
Logger.logLevel = LogLevel.NOTE + 1
|
||||
|
||||
// Act
|
||||
await Logger.note(...logArgs)
|
||||
|
||||
// Assert
|
||||
expect(consoleLogStub.calledOnce).to.be.true
|
||||
expect(consoleLogStub.calledWithExactly('[2024-09-10 12:34:56.789] NOTE:', ...logArgs)).to.be.true
|
||||
expect(Logger.logManager.logToFile.calledOnce).to.be.true
|
||||
expect(
|
||||
Logger.logManager.logToFile.calledWithExactly({
|
||||
timestamp: '2024-09-10 12:34:56.789',
|
||||
source: 'some/source.js',
|
||||
message: 'Note message',
|
||||
levelName: 'NOTE',
|
||||
level: LogLevel.NOTE
|
||||
})
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('should log util.inspect(arg) for non-string objects', async function () {
|
||||
// Arrange
|
||||
const obj = { key: 'value' }
|
||||
const logArgs = ['Logging object:', obj]
|
||||
Logger.logLevel = LogLevel.TRACE
|
||||
|
||||
// Act
|
||||
await Logger.debug(...logArgs)
|
||||
|
||||
// Assert
|
||||
expect(consoleDebugStub.calledOnce).to.be.true
|
||||
expect(consoleDebugStub.calledWithExactly('[2024-09-10 12:34:56.789] DEBUG:', 'Logging object:', obj)).to.be.true
|
||||
expect(Logger.logManager.logToFile.calledOnce).to.be.true
|
||||
expect(Logger.logManager.logToFile.firstCall.args[0].message).to.equal('Logging object: ' + util.inspect(obj))
|
||||
})
|
||||
})
|
||||
|
||||
describe('socket listeners', function () {
|
||||
it('should add and remove socket listeners', function () {
|
||||
// Arrange
|
||||
const socket1 = { id: '1', emit: sinon.spy() }
|
||||
const socket2 = { id: '2', emit: sinon.spy() }
|
||||
|
||||
// Act
|
||||
Logger.addSocketListener(socket1, LogLevel.DEBUG)
|
||||
Logger.addSocketListener(socket2, LogLevel.ERROR)
|
||||
Logger.removeSocketListener('1')
|
||||
|
||||
// Assert
|
||||
expect(Logger.socketListeners).to.have.lengthOf(1)
|
||||
expect(Logger.socketListeners[0].id).to.equal('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setLogLevel', function () {
|
||||
it('should change the log level and log the new level', function () {
|
||||
// Arrange
|
||||
const debugSpy = sinon.spy(Logger, 'debug')
|
||||
|
||||
// Act
|
||||
Logger.setLogLevel(LogLevel.WARN)
|
||||
|
||||
// Assert
|
||||
expect(Logger.logLevel).to.equal(LogLevel.WARN)
|
||||
expect(debugSpy.calledOnce).to.be.true
|
||||
expect(debugSpy.calledWithExactly('Set Log Level to WARN')).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,7 @@ describe('TitleCandidates', () => {
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
;[
|
||||
['adds candidate', 'anna karenina', ['anna karenina']],
|
||||
['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']],
|
||||
['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']],
|
||||
@@ -40,23 +40,27 @@ describe('TitleCandidates', () => {
|
||||
['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']],
|
||||
['does not add empty candidate', '', []],
|
||||
['does not add spaces-only candidate', ' ', []],
|
||||
['does not add empty variant', '1984', ['1984']],
|
||||
].forEach(([name, title, expected]) => it(name, () => {
|
||||
titleCandidates.add(title)
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
|
||||
}))
|
||||
['does not add empty variant', '1984', ['1984']]
|
||||
].forEach(([name, title, expected]) =>
|
||||
it(name, () => {
|
||||
titleCandidates.add(title)
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('multiple adds', () => {
|
||||
[
|
||||
;[
|
||||
['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']],
|
||||
['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']],
|
||||
['orders by position', ['title2', 'title1'], ['title2', 'title1']],
|
||||
['dedupes candidates', ['title1', 'title1'], ['title1']],
|
||||
].forEach(([name, titles, expected]) => it(name, () => {
|
||||
for (const title of titles) titleCandidates.add(title)
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
|
||||
}))
|
||||
['dedupes candidates', ['title1', 'title1'], ['title1']]
|
||||
].forEach(([name, titles, expected]) =>
|
||||
it(name, () => {
|
||||
for (const title of titles) titleCandidates.add(title)
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,12 +73,12 @@ describe('TitleCandidates', () => {
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
['adds a candidate', 'leo tolstoy', ['leo tolstoy']],
|
||||
].forEach(([name, title, expected]) => it(name, () => {
|
||||
titleCandidates.add(title)
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
|
||||
}))
|
||||
;[['adds a candidate', 'leo tolstoy', ['leo tolstoy']]].forEach(([name, title, expected]) =>
|
||||
it(name, () => {
|
||||
titleCandidates.add(title)
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -82,11 +86,7 @@ describe('TitleCandidates', () => {
|
||||
describe('AuthorCandidates', () => {
|
||||
let authorCandidates
|
||||
const audnexus = {
|
||||
authorASINsRequest: sinon.stub().resolves([
|
||||
{ name: 'Leo Tolstoy' },
|
||||
{ name: 'Nikolai Gogol' },
|
||||
{ name: 'J. K. Rowling' },
|
||||
]),
|
||||
authorASINsRequest: sinon.stub().resolves([{ name: 'Leo Tolstoy' }, { name: 'Nikolai Gogol' }, { name: 'J. K. Rowling' }])
|
||||
}
|
||||
|
||||
describe('cleanAuthor is null', () => {
|
||||
@@ -95,15 +95,15 @@ describe('AuthorCandidates', () => {
|
||||
})
|
||||
|
||||
describe('no adds', () => {
|
||||
[
|
||||
['returns empty author candidate', []],
|
||||
].forEach(([name, expected]) => it(name, async () => {
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
;[['returns empty author candidate', []]].forEach(([name, expected]) =>
|
||||
it(name, async () => {
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
;[
|
||||
['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']],
|
||||
['does not add unrecognized candidate', 'fyodor dostoevsky', []],
|
||||
['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']],
|
||||
@@ -112,21 +112,25 @@ describe('AuthorCandidates', () => {
|
||||
['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []],
|
||||
['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']],
|
||||
['adds normalized recognized candidate (et al removed)', 'nikolai gogol et al.', ['nikolai gogol']],
|
||||
['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']],
|
||||
].forEach(([name, author, expected]) => it(name, async () => {
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']]
|
||||
].forEach(([name, author, expected]) =>
|
||||
it(name, async () => {
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('multi add', () => {
|
||||
[
|
||||
;[
|
||||
['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']],
|
||||
['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']],
|
||||
].forEach(([name, authors, expected]) => it(name, async () => {
|
||||
for (const author of authors) authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']]
|
||||
].forEach(([name, authors, expected]) =>
|
||||
it(name, async () => {
|
||||
for (const author of authors) authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,21 +142,23 @@ describe('AuthorCandidates', () => {
|
||||
})
|
||||
|
||||
describe('no adds', () => {
|
||||
[
|
||||
['adds cleanAuthor as candidate', [cleanAuthor]],
|
||||
].forEach(([name, expected]) => it(name, async () => {
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
;[['adds cleanAuthor as candidate', [cleanAuthor]]].forEach(([name, expected]) =>
|
||||
it(name, async () => {
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
;[
|
||||
['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']],
|
||||
['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]],
|
||||
].forEach(([name, author, expected]) => it(name, async () => {
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]]
|
||||
].forEach(([name, author, expected]) =>
|
||||
it(name, async () => {
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,43 +170,47 @@ describe('AuthorCandidates', () => {
|
||||
})
|
||||
|
||||
describe('no adds', () => {
|
||||
[
|
||||
['adds cleanAuthor as candidate', [cleanAuthor]],
|
||||
].forEach(([name, expected]) => it(name, async () => {
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
;[['adds cleanAuthor as candidate', [cleanAuthor]]].forEach(([name, expected]) =>
|
||||
it(name, async () => {
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
;[
|
||||
['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']],
|
||||
['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]],
|
||||
].forEach(([name, author, expected]) => it(name, async () => {
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]]
|
||||
].forEach(([name, author, expected]) =>
|
||||
it(name, async () => {
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanAuthor is unrecognized and dirty', () => {
|
||||
describe('no adds', () => {
|
||||
[
|
||||
;[
|
||||
['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']],
|
||||
['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']],
|
||||
].forEach(([name, cleanAuthor, expected]) => it(name, async () => {
|
||||
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']]
|
||||
].forEach(([name, cleanAuthor, expected]) =>
|
||||
it(name, async () => {
|
||||
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']],
|
||||
].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => {
|
||||
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
;[['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']]].forEach(([name, cleanAuthor, author, expected]) =>
|
||||
it(name, async () => {
|
||||
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -211,16 +221,21 @@ describe('search', () => {
|
||||
const u = 'unrecognized'
|
||||
const r = ['book']
|
||||
|
||||
const runSearchStub = sinon.stub(bookFinder, 'runSearch')
|
||||
runSearchStub.resolves([])
|
||||
runSearchStub.withArgs(t, a).resolves(r)
|
||||
runSearchStub.withArgs(t, u).resolves(r)
|
||||
|
||||
const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest')
|
||||
audnexusStub.resolves([{ name: a }])
|
||||
let runSearchStub
|
||||
let audnexusStub
|
||||
|
||||
beforeEach(() => {
|
||||
bookFinder.runSearch.resetHistory()
|
||||
runSearchStub = sinon.stub(bookFinder, 'runSearch')
|
||||
runSearchStub.resolves([])
|
||||
runSearchStub.withArgs(t, a).resolves(r)
|
||||
runSearchStub.withArgs(t, u).resolves(r)
|
||||
|
||||
audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest')
|
||||
audnexusStub.resolves([{ name: a }])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('search title is empty', () => {
|
||||
@@ -238,50 +253,26 @@ describe('search', () => {
|
||||
})
|
||||
|
||||
describe('search title contains recognized title and search author is a recognized author', () => {
|
||||
[
|
||||
[`${t} -`],
|
||||
[`${t} - ${a}`],
|
||||
[`${a} - ${t}`],
|
||||
[`${t}- ${a}`],
|
||||
[`${t} -${a}`],
|
||||
[`${t} ${a}`],
|
||||
[`${a} - ${t} (unabridged)`],
|
||||
[`${a} - ${t} (subtitle) - mp3`],
|
||||
[`${t} {narrator} - series-01 64kbps 10:00:00`],
|
||||
[`${a} - ${t} (2006) narrated by narrator [unabridged]`],
|
||||
[`${t} - ${a} 2022 mp3`],
|
||||
[`01 ${t}`],
|
||||
[`2022_${t}_HQ`],
|
||||
].forEach(([searchTitle]) => {
|
||||
;[[`${t} -`], [`${t} - ${a}`], [`${a} - ${t}`], [`${t}- ${a}`], [`${t} -${a}`], [`${t} ${a}`], [`${a} - ${t} (unabridged)`], [`${a} - ${t} (subtitle) - mp3`], [`${t} {narrator} - series-01 64kbps 10:00:00`], [`${a} - ${t} (2006) narrated by narrator [unabridged]`], [`${t} - ${a} 2022 mp3`], [`01 ${t}`], [`2022_${t}_HQ`]].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
|
||||
[`${a} - series 01 - ${t}`],
|
||||
].forEach(([searchTitle]) => {
|
||||
})
|
||||
;[[`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`]].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => {
|
||||
expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 3)
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
[`${t}-${a}`],
|
||||
[`${t} junk`],
|
||||
].forEach(([searchTitle]) => {
|
||||
})
|
||||
;[[`${t}-${a}`], [`${t} junk`]].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns an empty result`, async () => {
|
||||
expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('maxFuzzySearches = 0', () => {
|
||||
[
|
||||
[`${t} - ${a}`],
|
||||
].forEach(([searchTitle]) => {
|
||||
;[[`${t} - ${a}`]].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => {
|
||||
expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([])
|
||||
sinon.assert.callCount(bookFinder.runSearch, 1)
|
||||
@@ -290,10 +281,7 @@ describe('search', () => {
|
||||
})
|
||||
|
||||
describe('maxFuzzySearches = 1', () => {
|
||||
[
|
||||
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
|
||||
[`${a} - series 01 - ${t}`],
|
||||
].forEach(([searchTitle]) => {
|
||||
;[[`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`]].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([])
|
||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||
@@ -303,21 +291,13 @@ describe('search', () => {
|
||||
})
|
||||
|
||||
describe('search title contains recognized title and search author is empty', () => {
|
||||
[
|
||||
[`${t} - ${a}`],
|
||||
[`${a} - ${t}`],
|
||||
].forEach(([searchTitle]) => {
|
||||
;[[`${t} - ${a}`], [`${a} - ${t}`]].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
[`${t}`],
|
||||
[`${t} - ${u}`],
|
||||
[`${u} - ${t}`]
|
||||
].forEach(([searchTitle]) => {
|
||||
})
|
||||
;[[`${t}`], [`${t} - ${u}`], [`${u} - ${t}`]].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '') returns an empty result`, async () => {
|
||||
expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal([])
|
||||
})
|
||||
@@ -325,19 +305,13 @@ describe('search', () => {
|
||||
})
|
||||
|
||||
describe('search title contains recognized title and search author is an unrecognized author', () => {
|
||||
[
|
||||
[`${t} - ${u}`],
|
||||
[`${u} - ${t}`]
|
||||
].forEach(([searchTitle]) => {
|
||||
;[[`${t} - ${u}`], [`${u} - ${t}`]].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
[`${t}`]
|
||||
].forEach(([searchTitle]) => {
|
||||
})
|
||||
;[[`${t}`]].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 1)
|
||||
@@ -346,16 +320,19 @@ describe('search', () => {
|
||||
})
|
||||
|
||||
describe('search provider results have duration', () => {
|
||||
const libraryItem = { media: { duration: 60 * 1000 } }
|
||||
const libraryItem = { media: { duration: 60 * 1000 } }
|
||||
const provider = 'audible'
|
||||
const unsorted = [{ duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
|
||||
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }]
|
||||
runSearchStub.withArgs(t, a, provider).resolves(unsorted)
|
||||
|
||||
beforeEach(() => {
|
||||
runSearchStub.withArgs(t, a, provider).resolves(unsorted)
|
||||
})
|
||||
|
||||
it('returns results sorted by library item duration diff', async () => {
|
||||
expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
|
||||
})
|
||||
|
||||
|
||||
it('returns unsorted results if library item is null', async () => {
|
||||
expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted)
|
||||
})
|
||||
@@ -365,10 +342,10 @@ describe('search', () => {
|
||||
})
|
||||
|
||||
it('returns unsorted results if library item media is undefined', async () => {
|
||||
expect(await bookFinder.search({ }, provider, t, a)).to.deep.equal(unsorted)
|
||||
expect(await bookFinder.search({}, provider, t, a)).to.deep.equal(unsorted)
|
||||
})
|
||||
|
||||
it ('should return a result last if it has no duration', async () => {
|
||||
it('should return a result last if it has no duration', async () => {
|
||||
const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
|
||||
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}]
|
||||
runSearchStub.withArgs(t, a, provider).resolves(unsorted)
|
||||
|
||||
503
test/server/managers/MigrationManager.test.js
Normal file
503
test/server/managers/MigrationManager.test.js
Normal file
@@ -0,0 +1,503 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const fs = require('../../../server/libs/fsExtra')
|
||||
const Logger = require('../../../server/Logger')
|
||||
const MigrationManager = require('../../../server/managers/MigrationManager')
|
||||
const path = require('path')
|
||||
const { Umzug, memoryStorage } = require('../../../server/libs/umzug')
|
||||
|
||||
describe('MigrationManager', () => {
|
||||
let sequelizeStub
|
||||
let umzugStub
|
||||
let migrationManager
|
||||
let loggerInfoStub
|
||||
let loggerErrorStub
|
||||
let fsCopyStub
|
||||
let fsMoveStub
|
||||
let fsRemoveStub
|
||||
let fsEnsureDirStub
|
||||
let processExitStub
|
||||
let configPath = '/path/to/config'
|
||||
|
||||
const serverVersion = '1.2.0'
|
||||
|
||||
beforeEach(() => {
|
||||
sequelizeStub = sinon.createStubInstance(Sequelize)
|
||||
umzugStub = {
|
||||
migrations: sinon.stub(),
|
||||
executed: sinon.stub(),
|
||||
up: sinon.stub(),
|
||||
down: sinon.stub()
|
||||
}
|
||||
sequelizeStub.getQueryInterface.returns({})
|
||||
migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
migrationManager.fetchVersionsFromDatabase = sinon.stub().resolves()
|
||||
migrationManager.copyMigrationsToConfigDir = sinon.stub().resolves()
|
||||
migrationManager.updateMaxVersion = sinon.stub().resolves()
|
||||
migrationManager.initUmzug = sinon.stub()
|
||||
migrationManager.umzug = umzugStub
|
||||
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||
loggerErrorStub = sinon.stub(Logger, 'error')
|
||||
fsCopyStub = sinon.stub(fs, 'copy').resolves()
|
||||
fsMoveStub = sinon.stub(fs, 'move').resolves()
|
||||
fsRemoveStub = sinon.stub(fs, 'remove').resolves()
|
||||
fsEnsureDirStub = sinon.stub(fs, 'ensureDir').resolves()
|
||||
fsPathExistsStub = sinon.stub(fs, 'pathExists').resolves(true)
|
||||
processExitStub = sinon.stub(process, 'exit')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('should initialize the MigrationManager', async () => {
|
||||
// arrange
|
||||
migrationManager.databaseVersion = '1.1.0'
|
||||
migrationManager.maxVersion = '1.1.0'
|
||||
migrationManager.umzug = null
|
||||
migrationManager.configPath = __dirname
|
||||
|
||||
// Act
|
||||
await migrationManager.init(serverVersion)
|
||||
|
||||
// Assert
|
||||
expect(migrationManager.serverVersion).to.equal(serverVersion)
|
||||
expect(migrationManager.sequelize).to.equal(sequelizeStub)
|
||||
expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations'))
|
||||
expect(migrationManager.copyMigrationsToConfigDir.calledOnce).to.be.true
|
||||
expect(migrationManager.updateMaxVersion.calledOnce).to.be.true
|
||||
expect(migrationManager.initialized).to.be.true
|
||||
})
|
||||
|
||||
it('should throw error if serverVersion is not provided', async () => {
|
||||
// Act
|
||||
try {
|
||||
const result = await migrationManager.init()
|
||||
expect.fail('Expected init to throw an error, but it did not.')
|
||||
} catch (error) {
|
||||
expect(error.message).to.equal('Invalid server version: undefined. Expected a version tag like v1.2.3.')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('runMigrations', () => {
|
||||
it('should run up migrations successfully', async () => {
|
||||
// Arrange
|
||||
migrationManager.databaseVersion = '1.1.0'
|
||||
migrationManager.maxVersion = '1.1.0'
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
migrationManager.initialized = true
|
||||
|
||||
umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])
|
||||
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }])
|
||||
|
||||
// Act
|
||||
await migrationManager.runMigrations()
|
||||
|
||||
// Assert
|
||||
expect(migrationManager.initUmzug.calledOnce).to.be.true
|
||||
expect(umzugStub.up.calledOnce).to.be.true
|
||||
expect(umzugStub.up.calledWith({ migrations: ['v1.1.1-migration.js', 'v1.2.0-migration.js'], rerun: 'ALLOW' })).to.be.true
|
||||
expect(fsCopyStub.calledOnce).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
|
||||
expect(fsRemoveStub.calledOnce).to.be.true
|
||||
expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
|
||||
expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true
|
||||
})
|
||||
|
||||
it('should run down migrations successfully', async () => {
|
||||
// Arrange
|
||||
migrationManager.databaseVersion = '1.2.0'
|
||||
migrationManager.maxVersion = '1.2.0'
|
||||
migrationManager.serverVersion = '1.1.0'
|
||||
migrationManager.initialized = true
|
||||
|
||||
umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])
|
||||
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])
|
||||
|
||||
// Act
|
||||
await migrationManager.runMigrations()
|
||||
|
||||
// Assert
|
||||
expect(migrationManager.initUmzug.calledOnce).to.be.true
|
||||
expect(umzugStub.down.calledOnce).to.be.true
|
||||
expect(umzugStub.down.calledWith({ migrations: ['v1.2.0-migration.js', 'v1.1.1-migration.js'], rerun: 'ALLOW' })).to.be.true
|
||||
expect(fsCopyStub.calledOnce).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
|
||||
expect(fsRemoveStub.calledOnce).to.be.true
|
||||
expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
|
||||
expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true
|
||||
})
|
||||
|
||||
it('should log that no migrations are needed if serverVersion equals databaseVersion', async () => {
|
||||
// Arrange
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
migrationManager.databaseVersion = '1.2.0'
|
||||
migrationManager.maxVersion = '1.2.0'
|
||||
migrationManager.initialized = true
|
||||
|
||||
// Act
|
||||
await migrationManager.runMigrations()
|
||||
|
||||
// Assert
|
||||
expect(umzugStub.up.called).to.be.false
|
||||
expect(loggerInfoStub.calledWith(sinon.match('Database is already up to date.'))).to.be.true
|
||||
})
|
||||
|
||||
it('should handle migration failure and restore the original database', async () => {
|
||||
// Arrange
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
migrationManager.databaseVersion = '1.1.0'
|
||||
migrationManager.maxVersion = '1.1.0'
|
||||
migrationManager.initialized = true
|
||||
|
||||
umzugStub.migrations.resolves([{ name: 'v1.2.0-migration.js' }])
|
||||
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }])
|
||||
umzugStub.up.rejects(new Error('Migration failed'))
|
||||
|
||||
const originalDbPath = path.join(configPath, 'absdatabase.sqlite')
|
||||
const backupDbPath = path.join(configPath, 'absdatabase.backup.sqlite')
|
||||
|
||||
// Act
|
||||
await migrationManager.runMigrations()
|
||||
|
||||
// Assert
|
||||
expect(migrationManager.initUmzug.calledOnce).to.be.true
|
||||
expect(umzugStub.up.calledOnce).to.be.true
|
||||
expect(loggerErrorStub.calledWith(sinon.match('Migration failed'))).to.be.true
|
||||
expect(fsMoveStub.calledWith(originalDbPath, sinon.match('absdatabase.failed.sqlite'), { overwrite: true })).to.be.true
|
||||
expect(fsMoveStub.calledWith(backupDbPath, originalDbPath, { overwrite: true })).to.be.true
|
||||
expect(loggerInfoStub.calledWith(sinon.match('Restored the original database'))).to.be.true
|
||||
expect(processExitStub.calledOnce).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchVersionsFromDatabase', () => {
|
||||
it('should fetch versions from the migrationsMeta table', async () => {
|
||||
// Arrange
|
||||
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
// Create a migrationsMeta table and populate it with version and maxVersion
|
||||
await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))')
|
||||
await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')")
|
||||
const migrationManager = new MigrationManager(sequelize, configPath)
|
||||
migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves()
|
||||
|
||||
// Act
|
||||
await migrationManager.fetchVersionsFromDatabase()
|
||||
|
||||
// Assert
|
||||
expect(migrationManager.maxVersion).to.equal('1.1.0')
|
||||
expect(migrationManager.databaseVersion).to.equal('1.1.0')
|
||||
})
|
||||
|
||||
it('should create the migrationsMeta table if it does not exist and fetch versions from it', async () => {
|
||||
// Arrange
|
||||
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
const migrationManager = new MigrationManager(sequelize, configPath)
|
||||
migrationManager.serverVersion = serverVersion
|
||||
|
||||
// Act
|
||||
await migrationManager.fetchVersionsFromDatabase()
|
||||
|
||||
// Assert
|
||||
const tableDescription = await sequelize.getQueryInterface().describeTable('migrationsMeta')
|
||||
expect(tableDescription).to.deep.equal({
|
||||
key: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false },
|
||||
value: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }
|
||||
})
|
||||
expect(migrationManager.maxVersion).to.equal('0.0.0')
|
||||
expect(migrationManager.databaseVersion).to.equal('0.0.0')
|
||||
})
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
// Arrange
|
||||
const sequelizeStub = sinon.createStubInstance(Sequelize)
|
||||
sequelizeStub.query.rejects(new Error('Database query failed'))
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves()
|
||||
|
||||
// Act
|
||||
try {
|
||||
await migrationManager.fetchVersionsFromDatabase()
|
||||
expect.fail('Expected fetchVersionsFromDatabase to throw an error, but it did not.')
|
||||
} catch (error) {
|
||||
// Assert
|
||||
expect(error.message).to.equal('Database query failed')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMaxVersion', () => {
|
||||
it('should update the maxVersion in the database', async () => {
|
||||
// Arrange
|
||||
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
// Create a migrationsMeta table and populate it with version and maxVersion
|
||||
await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))')
|
||||
await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')")
|
||||
const migrationManager = new MigrationManager(sequelize, configPath)
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
|
||||
// Act
|
||||
await migrationManager.updateMaxVersion()
|
||||
|
||||
// Assert
|
||||
const [{ maxVersion }] = await sequelize.query("SELECT value AS maxVersion FROM migrationsMeta WHERE key = 'maxVersion'", {
|
||||
type: Sequelize.QueryTypes.SELECT
|
||||
})
|
||||
expect(maxVersion).to.equal('1.2.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractVersionFromTag', () => {
|
||||
it('should return null if tag is not provided', () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
|
||||
// Act
|
||||
const result = migrationManager.extractVersionFromTag()
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it('should return null if tag does not match the version format', () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
const tag = 'invalid-tag'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.extractVersionFromTag(tag)
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it('should extract the version from the tag', () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
const tag = 'v1.2.3'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.extractVersionFromTag(tag)
|
||||
|
||||
// Assert
|
||||
expect(result).to.equal('1.2.3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyMigrationsToConfigDir', () => {
|
||||
it('should copy migrations to the config directory', async () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
migrationManager.migrationsDir = path.join(configPath, 'migrations')
|
||||
const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations')
|
||||
const targetDir = migrationManager.migrationsDir
|
||||
const files = ['migration1.js', 'migration2.js', 'readme.md']
|
||||
|
||||
const readdirStub = sinon.stub(fs, 'readdir').resolves(files)
|
||||
|
||||
// Act
|
||||
await migrationManager.copyMigrationsToConfigDir()
|
||||
|
||||
// Assert
|
||||
expect(fsEnsureDirStub.calledOnce).to.be.true
|
||||
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
|
||||
expect(readdirStub.calledOnce).to.be.true
|
||||
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
|
||||
expect(fsCopyStub.calledTwice).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true
|
||||
})
|
||||
|
||||
it('should throw an error if copying the migrations fails', async () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
migrationManager.migrationsDir = path.join(configPath, 'migrations')
|
||||
const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations')
|
||||
const targetDir = migrationManager.migrationsDir
|
||||
const files = ['migration1.js', 'migration2.js', 'readme.md']
|
||||
|
||||
const readdirStub = sinon.stub(fs, 'readdir').resolves(files)
|
||||
fsCopyStub.restore()
|
||||
fsCopyStub = sinon.stub(fs, 'copy').rejects()
|
||||
|
||||
// Act
|
||||
try {
|
||||
// Act
|
||||
await migrationManager.copyMigrationsToConfigDir()
|
||||
expect.fail('Expected copyMigrationsToConfigDir to throw an error, but it did not.')
|
||||
} catch (error) {}
|
||||
|
||||
// Assert
|
||||
expect(fsEnsureDirStub.calledOnce).to.be.true
|
||||
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
|
||||
expect(readdirStub.calledOnce).to.be.true
|
||||
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
|
||||
expect(fsCopyStub.calledTwice).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('findMigrationsToRun', () => {
|
||||
it('should return migrations to run when direction is "up"', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.0.0'
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
const direction = 'up'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.1.0-migration.js', 'v1.2.0-migration.js'])
|
||||
})
|
||||
|
||||
it('should return migrations to run when direction is "down"', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.2.0-migration.js', 'v1.3.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.3.0'
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.3.0-migration.js'])
|
||||
})
|
||||
|
||||
it('should return empty array when no migrations to run up', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.3.0'
|
||||
migrationManager.serverVersion = '1.4.0'
|
||||
const direction = 'up'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should return empty array when no migrations to run down', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = []
|
||||
migrationManager.databaseVersion = '1.4.0'
|
||||
migrationManager.serverVersion = '1.3.0'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should return down migrations to run when direction is "down" and up migration was not executed', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = []
|
||||
migrationManager.databaseVersion = '1.3.0'
|
||||
migrationManager.serverVersion = '1.0.0'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.3.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js'])
|
||||
})
|
||||
|
||||
it('should return empty array when direction is "down" and server version is higher than database version', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.0.0'
|
||||
migrationManager.serverVersion = '1.3.0'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should return empty array when direction is "up" and server version is lower than database version', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.3.0'
|
||||
migrationManager.serverVersion = '1.0.0'
|
||||
const direction = 'up'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should return up migrations to run when server version is between migrations', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.1.0'
|
||||
migrationManager.serverVersion = '1.2.3'
|
||||
const direction = 'up'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.2.0-migration.js'])
|
||||
})
|
||||
|
||||
it('should return down migrations to run when server version is between migrations', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.2.0'
|
||||
migrationManager.serverVersion = '1.1.3'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.2.0-migration.js'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('initUmzug', () => {
|
||||
it('should initialize the umzug instance with migrations in the proper order', async () => {
|
||||
// Arrange
|
||||
const readdirStub = sinon.stub(fs, 'readdir').resolves(['v1.0.0-migration.js', 'v1.10.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js'])
|
||||
const readFileSyncStub = sinon.stub(fs, 'readFileSync').returns('module.exports = { up: () => {}, down: () => {} }')
|
||||
const umzugStorage = memoryStorage()
|
||||
migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
migrationManager.migrationsDir = path.join(configPath, 'migrations')
|
||||
const resolvedMigrationNames = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js']
|
||||
const resolvedMigrationPaths = resolvedMigrationNames.map((name) => path.resolve(path.join(migrationManager.migrationsDir, name)))
|
||||
|
||||
// Act
|
||||
await migrationManager.initUmzug(umzugStorage)
|
||||
|
||||
// Assert
|
||||
expect(readdirStub.calledOnce).to.be.true
|
||||
expect(migrationManager.umzug).to.be.an.instanceOf(Umzug)
|
||||
const migrations = await migrationManager.umzug.migrations()
|
||||
expect(migrations.map((m) => m.name)).to.deep.equal(resolvedMigrationNames)
|
||||
expect(migrations.map((m) => m.path)).to.deep.equal(resolvedMigrationPaths)
|
||||
})
|
||||
})
|
||||
})
|
||||
9
test/server/managers/migrations/v1.0.0-migration.js
Normal file
9
test/server/managers/migrations/v1.0.0-migration.js
Normal file
@@ -0,0 +1,9 @@
|
||||
async function up() {
|
||||
console.log('v1.0.0 up')
|
||||
}
|
||||
|
||||
async function down() {
|
||||
console.log('v1.0.0 down')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
9
test/server/managers/migrations/v1.1.0-migration.js
Normal file
9
test/server/managers/migrations/v1.1.0-migration.js
Normal file
@@ -0,0 +1,9 @@
|
||||
async function up() {
|
||||
console.log('v1.1.0 up')
|
||||
}
|
||||
|
||||
async function down() {
|
||||
console.log('v1.1.0 down')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
9
test/server/managers/migrations/v1.10.0-migration.js
Normal file
9
test/server/managers/migrations/v1.10.0-migration.js
Normal file
@@ -0,0 +1,9 @@
|
||||
async function up() {
|
||||
console.log('v1.10.0 up')
|
||||
}
|
||||
|
||||
async function down() {
|
||||
console.log('v1.10.0 down')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
9
test/server/managers/migrations/v1.2.0-migration.js
Normal file
9
test/server/managers/migrations/v1.2.0-migration.js
Normal file
@@ -0,0 +1,9 @@
|
||||
async function up() {
|
||||
console.log('v1.2.0 up')
|
||||
}
|
||||
|
||||
async function down() {
|
||||
console.log('v1.2.0 down')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
50
test/server/migrations/v0.0.1-migration_example.js
Normal file
50
test/server/migrations/v0.0.1-migration_example.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const { DataTypes } = require('sequelize')
|
||||
|
||||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is an example of an upward migration script.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
logger.info('Running migration_example up...')
|
||||
logger.info('Creating example_table...')
|
||||
await queryInterface.createTable('example_table', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
}
|
||||
})
|
||||
logger.info('example_table created.')
|
||||
logger.info('migration_example up complete.')
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an example of a downward migration script.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
logger.info('Running migration_example down...')
|
||||
logger.info('Dropping example_table...')
|
||||
await queryInterface.dropTable('example_table')
|
||||
logger.info('example_table dropped.')
|
||||
logger.info('migration_example down complete.')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
53
test/server/migrations/v0.0.1-migration_example.test.js
Normal file
53
test/server/migrations/v0.0.1-migration_example.test.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { up, down } = require('./v0.0.1-migration_example')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('migration_example', () => {
|
||||
let sequelize
|
||||
let queryInterface
|
||||
let loggerInfoStub
|
||||
|
||||
beforeEach(() => {
|
||||
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
queryInterface = sequelize.getQueryInterface()
|
||||
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('up', () => {
|
||||
it('should create example_table', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
expect(loggerInfoStub.callCount).to.equal(4)
|
||||
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('Running migration_example up...'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('Creating example_table...'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('example_table created.'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('migration_example up complete.'))).to.be.true
|
||||
expect(await queryInterface.showAllTables()).to.include('example_table')
|
||||
const tableDescription = await queryInterface.describeTable('example_table')
|
||||
expect(tableDescription).to.deep.equal({
|
||||
id: { type: 'INTEGER', allowNull: true, defaultValue: undefined, primaryKey: true, unique: false },
|
||||
name: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('down', () => {
|
||||
it('should drop example_table', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
expect(loggerInfoStub.callCount).to.equal(8)
|
||||
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Running migration_example down...'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('Dropping example_table...'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('example_table dropped.'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(7).calledWith(sinon.match('migration_example down complete.'))).to.be.true
|
||||
expect(await queryInterface.showAllTables()).not.to.include('example_table')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user