Compare commits

...

27 Commits

Author SHA1 Message Date
advplyr
ea4d5ff665 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-11-30 16:26:52 -06:00
advplyr
468a547864 Version bump v2.17.3 2024-11-30 16:26:48 -06:00
advplyr
cd9999d192 Merge pull request #3643 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-11-30 16:23:45 -06:00
Charlie
31e302ea59 Translated using Weblate (French)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-11-30 23:10:44 +01:00
Dmitry
1ff1ba66fd Translated using Weblate (Russian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2024-11-30 23:10:43 +01:00
Pierrick Guillaume
a5457d7e22 Translated using Weblate (French)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-11-30 23:10:43 +01:00
Soaibuzzaman
ddcbfd4500 Translated using Weblate (Bengali)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/
2024-11-30 23:10:42 +01:00
biuklija
293e530297 Translated using Weblate (Croatian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-11-30 23:10:41 +01:00
thehijacker
7278ad4ee7 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-11-30 23:10:40 +01:00
Bezruchenko Simon
0449fb5ef9 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-11-30 23:10:40 +01:00
gallegonovato
d2c28fc69c Translated using Weblate (Spanish)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-11-30 23:10:39 +01:00
Vito0912
60ba0163af Translated using Weblate (German)
Currently translated at 99.9% (1071 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-11-30 23:10:39 +01:00
advplyr
02ca926d88 Merge pull request #3664 from advplyr/v2.17.3-fk-constraints-migration
Add migration to fix dropped foreign key constraints dropped in v2.17.0 migration
2024-11-30 16:10:30 -06:00
advplyr
4b52f31d58 Update v2.17.3 migration file to first check if constraints need to be updated, add unit test 2024-11-30 15:48:20 -06:00
advplyr
70f466d03c Add migration for v2.17.3 to fix dropped fk constraints 2024-11-28 17:18:34 -06:00
advplyr
ef82e8b0d0 Fix:Server crash deleting user with sessions 2024-11-27 16:48:07 -06:00
advplyr
c643d4cec8 Merge pull request #3655 from glorenzen/fix/player-settings-modal
Fix player settings modal on share page
2024-11-26 17:12:17 -06:00
advplyr
718d8b5999 Update jump backward amount for share player 2024-11-26 17:05:50 -06:00
advplyr
2ba0f9157d Update share player to load user settings 2024-11-26 17:03:01 -06:00
Greg Lorenzen
53fdb5273c Remove player settings modal from MediaPlayerContainer 2024-11-26 04:04:55 +00:00
Greg Lorenzen
fabdfd5517 Add player settings modal to PlayerUi 2024-11-26 04:04:44 +00:00
advplyr
950993f652 Update:View episode modal includes audio filename and size #3648 2024-11-25 17:26:06 -06:00
advplyr
5a968b002a Update readme.md 2024-11-25 13:29:06 -06:00
advplyr
3acd29fab3 Update readme.md 2024-11-25 13:27:33 -06:00
advplyr
315b21db00 Fix:API get media progress for episode 2024-11-24 15:05:19 -06:00
advplyr
f9aaeb3a34 Update:Set Content-Security-Policy header to disallow iframes 2024-11-23 11:17:13 -06:00
advplyr
d19bb909b3 Fix:Server crash deleting library that has playback sessions #3634 2024-11-22 17:20:31 -06:00
27 changed files with 691 additions and 56 deletions

View File

@@ -53,7 +53,6 @@
@showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
@showPlayerSettings="showPlayerSettingsModal = true"
/>
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
@@ -61,8 +60,6 @@
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div>
</template>
@@ -81,7 +78,6 @@ export default {
currentTime: 0,
showSleepTimerModal: false,
showPlayerQueueItemsModal: false,
showPlayerSettingsModal: false,
sleepTimerSet: false,
sleepTimerRemaining: 0,
sleepTimerType: null,

View File

@@ -59,12 +59,19 @@ export default {
setJumpBackwardAmount(val) {
this.jumpBackwardAmount = val
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
},
settingsUpdated() {
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
}
},
mounted() {
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
this.settingsUpdated()
this.$eventBus.$on('user-settings', this.settingsUpdated)
},
beforeDestroy() {
this.$eventBus.$off('user-settings', this.settingsUpdated)
}
}
</script>

View File

@@ -18,6 +18,23 @@
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" dir="auto" class="default-style" v-html="description" />
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
<div class="w-full h-px bg-white/5 my-4" />
<div class="flex items-center">
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelFilename }}</p>
<p class="mb-2 text-xs">
{{ audioFileFilename }}
</p>
</div>
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelSize }}</p>
<p class="mb-2 text-xs">
{{ audioFileSize }}
</p>
</div>
</div>
</div>
</modals-modal>
</template>
@@ -54,7 +71,7 @@ export default {
return this.episode.description || ''
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
@@ -65,6 +82,14 @@ export default {
podcastAuthor() {
return this.mediaMetadata.author
},
audioFileFilename() {
return this.episode.audioFile?.metadata?.filename || ''
},
audioFileSize() {
const size = this.episode.audioFile?.metadata?.size || 0
return this.$bytesPretty(size)
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
}

View File

@@ -37,7 +37,7 @@
</ui-tooltip>
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')">
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="showPlayerSettings">
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
</button>
</ui-tooltip>
@@ -64,6 +64,8 @@
</div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div>
</template>
@@ -96,6 +98,7 @@ export default {
audioEl: null,
seekLoading: false,
showChaptersModal: false,
showPlayerSettingsModal: false,
currentTime: 0,
duration: 0
}
@@ -315,6 +318,9 @@ export default {
if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal
},
showPlayerSettings() {
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.17.2",
"version": "2.17.3",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",

View File

@@ -126,12 +126,14 @@ export default {
if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime()
const duration = this.localAudioPlayer.getDuration()
this.seek(Math.min(currentTime + 10, duration))
const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
},
jumpBackward() {
if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime()
this.seek(Math.max(currentTime - 10, 0))
const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
},
setVolume(volume) {
if (!this.localAudioPlayer || !this.hasLoaded) return
@@ -248,6 +250,8 @@ export default {
}
},
mounted() {
this.$store.dispatch('user/loadUserSettings')
this.resize()
window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown)

View File

@@ -66,6 +66,7 @@
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
"ButtonQueueAddItem": "সারিতে যোগ করুন",
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
"ButtonQuickEmbed": "দ্রুত এম্বেড করুন",
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
"ButtonQuickMatch": "দ্রুত ম্যাচ",
"ButtonReScan": "পুনরায় স্ক্যান",
@@ -162,6 +163,7 @@
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
"HeaderNotifications": "বিজ্ঞপ্তি",
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
"HeaderOpenListeningSessions": "শোনার সেশন খুলুন",
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
"HeaderOtherFiles": "অন্যান্য ফাইল",
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
@@ -179,6 +181,7 @@
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
"HeaderSchedule": "সময়সূচী",
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
"HeaderSession": "সেশন",
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
@@ -224,7 +227,11 @@
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
"LabelApiToken": "API টোকেন",
"LabelAppend": "সংযোজন",
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
"LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)",
"LabelAudioCodec": "অডিও কোডেক",
"LabelAuthor": "লেখক",
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
@@ -237,6 +244,7 @@
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
"LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ",
"LabelBackupLocation": "ব্যাকআপ অবস্থান",
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
@@ -245,15 +253,18 @@
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
"LabelBitrate": "বিটরেট",
"LabelBonus": "উপরিলাভ",
"LabelBooks": "বইগুলো",
"LabelButtonText": "ঘর পাঠ্য",
"LabelByAuthor": "দ্বারা {0}",
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
"LabelChannels": "চ্যানেল",
"LabelChapterCount": "{0} অধ্যায়",
"LabelChapterTitle": "অধ্যায়ের শিরোনাম",
"LabelChapters": "অধ্যায়",
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
"LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন",
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
"LabelCodec": "কোডেক",
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
@@ -303,12 +314,25 @@
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
"LabelEmbeddedCover": "এম্বেডেড কভার",
"LabelEnable": "সক্ষম করুন",
"LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:",
"LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।",
"LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।",
"LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:",
"LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।",
"LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।",
"LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।",
"LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।",
"LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।",
"LabelEnd": "সমাপ্ত",
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
"LabelEpisode": "পর্ব",
"LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি",
"LabelEpisodeNumber": "পর্ব #{0}",
"LabelEpisodeTitle": "পর্বের শিরোনাম",
"LabelEpisodeType": "পর্বের ধরন",
"LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL",
"LabelEpisodes": "পর্বগুলো",
"LabelEpisodic": "প্রাসঙ্গিক",
"LabelExample": "উদাহরণ",
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
@@ -336,6 +360,7 @@
"LabelFontScale": "ফন্ট স্কেল",
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
"LabelFormat": "ফরম্যাট",
"LabelFull": "পূর্ণ",
"LabelGenre": "ঘরানা",
"LabelGenres": "ঘরানাগুলো",
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
@@ -391,6 +416,10 @@
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
"LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে",
"LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে",
"LabelMaxEpisodesToKeepHelp": " কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।",
"LabelMediaPlayer": "মিডিয়া প্লেয়ার",
"LabelMediaType": "মিডিয়ার ধরন",
"LabelMetaTag": "মেটা ট্যাগ",
@@ -436,12 +465,14 @@
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
"LabelOverwrite": "পুনঃলিখিত",
"LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা",
"LabelPassword": "পাসওয়ার্ড",
"LabelPath": "পথ",
"LabelPermanent": "স্থায়ী",
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
"LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন",
"LabelPermissionsDelete": "মুছে দিতে পারবে",
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
"LabelPermissionsUpdate": "আপডেট করতে পারবে",
@@ -465,6 +496,8 @@
"LabelPubDate": "প্রকাশের তারিখ",
"LabelPublishYear": "প্রকাশের বছর",
"LabelPublishedDate": "প্রকাশিত {0}",
"LabelPublishedDecade": "প্রকাশনার দশক",
"LabelPublishedDecades": "প্রকাশনার দশকগুলো",
"LabelPublisher": "প্রকাশক",
"LabelPublishers": "প্রকাশকরা",
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
@@ -484,21 +517,28 @@
"LabelRedo": "পুনরায় করুন",
"LabelRegion": "অঞ্চল",
"LabelReleaseDate": "উন্মোচনের তারিখ",
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
"LabelRemoveCover": "কভার সরান",
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
"LabelSearchTerm": "অনুসন্ধান শব্দ",
"LabelSearchTitle": "অনুসন্ধান শিরোনাম",
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
"LabelSeason": "সেশন",
"LabelSeasonNumber": "মরসুম #{0}",
"LabelSelectAll": "সব নির্বাচন করুন",
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
"LabelSendEbookToDevice": "ই-বই পাঠান...",
"LabelSequence": "ক্রম",
"LabelSerial": "ধারাবাহিক",
"LabelSeries": "সিরিজ",
"LabelSeriesName": "সিরিজের নাম",
"LabelSeriesProgress": "সিরিজের অগ্রগতি",
"LabelServerLogLevel": "সার্ভার লগ লেভেল",
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
@@ -523,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম",
"LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
@@ -587,6 +630,7 @@
"LabelTimeDurationXMinutes": "{0} মিনিট",
"LabelTimeDurationXSeconds": "{0} সেকেন্ড",
"LabelTimeInMinutes": "মিনিটে সময়",
"LabelTimeLeft": "{0} বাকি",
"LabelTimeListened": "সময় শোনা হয়েছে",
"LabelTimeListenedToday": "আজ শোনার সময়",
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
@@ -594,6 +638,7 @@
"LabelTitle": "শিরোনাম",
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
"LabelToolsM4bEncoder": "M4B এনকোডার",
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
@@ -606,6 +651,7 @@
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
"LabelTracksNone": "কোন ট্র্যাক নেই",
"LabelTracksSingleTrack": "একক-ট্র্যাক",
"LabelTrailer": "আনুগমিক",
"LabelType": "টাইপ",
"LabelUnabridged": "অসংলগ্ন",
"LabelUndo": "পূর্বাবস্থা",
@@ -617,10 +663,13 @@
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
"LabelUpdatedAt": "আপডেট করা হয়েছে",
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
"LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন",
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
"LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন",
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
"LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন",
"LabelUser": "ব্যবহারকারী",
"LabelUsername": "ব্যবহারকারীর নাম",
"LabelValue": "মান",
@@ -667,6 +716,7 @@
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
@@ -678,6 +728,7 @@
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
"MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?",
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
@@ -685,6 +736,7 @@
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
@@ -700,6 +752,7 @@
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
"MessageEmbedFinished": "এম্বেড করা শেষ!",
"MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)",
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
@@ -744,6 +797,7 @@
"MessageNoLogs": "কোনও লগ নেই",
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
"MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই",
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
"MessageNoResults": "কোন ফলাফল নেই",
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
@@ -760,6 +814,10 @@
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
"MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন",
"MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে",
"MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)",
"MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব",
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
"MessageRemoveChapter": "অধ্যায় সরান",
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
@@ -802,6 +860,9 @@
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
"MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে",
"MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল <opml> ট্যাগ পাওয়া যায়নি বা একটি <outline> ট্যাগ পাওয়া যায়নি",
"MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি",
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
@@ -826,6 +887,10 @@
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
@@ -851,6 +916,7 @@
"StatsYearInReview": "বাৎসরিক পর্যালোচনা",
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
"ToastAsinRequired": "ASIN প্রয়োজন",
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
@@ -870,6 +936,8 @@
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
"ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!",
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
@@ -881,6 +949,7 @@
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
@@ -898,11 +967,14 @@
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
@@ -920,14 +992,22 @@
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
"ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে",
"ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল",
"ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে",
"ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে",
"ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে",
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
"ToastNameRequired": "নাম আবশ্যক",
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
@@ -946,6 +1026,7 @@
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
"ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন",
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
@@ -972,6 +1053,7 @@
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
"ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz",
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
"ToastSlugRequired": "স্লাগ আবশ্যক",
"ToastSocketConnected": "সকেট সংযুক্ত",

View File

@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
"LabelUpdatedAt": "Aktualisiert am",
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
"LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen",
"LabelUploaderDropFiles": "Dateien löschen",
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",

View File

@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
"LabelUpdatedAt": "Actualizado En",
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
"LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos",
"LabelUploaderDropFiles": "Suelte los Archivos",
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
"LabelUseAdvancedOptions": "Usar opciones avanzadas",

View File

@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUpdatedAt": "Mis à jour à",
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers",
"LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série",
"LabelUseAdvancedOptions": "Utiliser les options avancées",
@@ -869,10 +870,10 @@
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible nest pas accessible en écriture",
"MessageThinking": "Je cherche…",
"MessageThinking": "À la recherche de…",
"MessageUploaderItemFailed": "Échec du téléversement",
"MessageUploaderItemSuccess": "Téléversement effectué !",
"MessageUploading": "Téléversement…",
"MessageUploading": "Téléchargement…",
"MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",

View File

@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
"LabelUpdatedAt": "Ažurirano",
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
"LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke",
"LabelUploaderDropFiles": "Ispusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",

View File

@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
"LabelUpdatedAt": "Обновлено в",
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
"LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов",
"LabelUploaderDropFiles": "Перетащите файлы",
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
"LabelUseAdvancedOptions": "Используйте расширенные опции",

View File

@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
"LabelUpdatedAt": "Posodobljeno ob",
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
"LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke",
"LabelUploaderDropFiles": "Spusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
"LabelUseAdvancedOptions": "Uporabi napredne možnosti",

View File

@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
"LabelUpdatedAt": "Оновлення",
"LabelUploaderDragAndDrop": "Перетягніть файли або теки",
"LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли",
"LabelUploaderDropFiles": "Перетягніть файли",
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
"LabelUseAdvancedOptions": "Використовувати розширені налаштування",

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.17.2",
"version": "2.17.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.17.2",
"version": "2.17.3",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.17.2",
"version": "2.17.3",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",

View File

@@ -41,6 +41,13 @@ Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/
Join us on [Discord](https://discord.gg/HQgCbd6E75)
### Demo
Check out the web client demo: https://audiobooks.dev/ (thanks for hosting [@Vito0912](https://github.com/Vito0912)!)
Username/password: `demo`/`demo` (user account)
### Android App (beta)
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)

View File

@@ -406,11 +406,6 @@ class Database {
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
}
removeLibrary(libraryId) {
if (!this.sequelize) return false
return this.models.library.removeById(libraryId)
}
createBulkCollectionBooks(collectionBooks) {
if (!this.sequelize) return false
return this.models.collectionBook.bulkCreate(collectionBooks)

View File

@@ -194,18 +194,21 @@ class Server {
const app = express()
/**
* @temporary
* This is necessary for the ebook & cover API endpoint in the mobile apps
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
* @see https://ionicframework.com/docs/troubleshooting/cors
*
* Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1'
*/
app.use((req, res, next) => {
// Prevent clickjacking by disallowing iframes
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
/**
* @temporary
* This is necessary for the ebook & cover API endpoint in the mobile apps
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
* @see https://ionicframework.com/docs/troubleshooting/cors
*
* Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1'
*/
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {

View File

@@ -504,8 +504,21 @@ class LibraryController {
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
}
// Set PlaybackSessions libraryId to null
const [sessionsUpdated] = await Database.playbackSessionModel.update(
{
libraryId: null
},
{
where: {
libraryId: req.library.id
}
}
)
Logger.info(`[LibraryController] Updated ${sessionsUpdated} playback sessions to remove library id`)
const libraryJson = req.library.toOldJSON()
await Database.removeLibrary(req.library.id)
await req.library.destroy()
// Re-order libraries
await Database.libraryModel.resetDisplayOrder()

View File

@@ -368,6 +368,19 @@ class UserController {
await playlist.destroy()
}
// Set PlaybackSessions userId to null
const [sessionsUpdated] = await Database.playbackSessionModel.update(
{
userId: null
},
{
where: {
userId: user.id
}
}
)
Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`)
const userJson = user.toOldJSONForBrowser()
await user.destroy()
SocketAuthority.adminEmitter('user_removed', userJson)

View File

@@ -2,9 +2,10 @@
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 |
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
| Server Version | Migration Script Name | Description |
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |

View File

@@ -0,0 +1,259 @@
/**
* @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 upward migration script changes foreign key constraints for the
* libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables.
*
* @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 } }) {
// Upwards migration script
logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints')
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
// Disable foreign key constraints for the next sequence of operations
await execQuery(`PRAGMA foreign_keys = OFF;`)
try {
await execQuery(`BEGIN TRANSACTION;`)
logger.info('[2.17.3 migration] Updating libraryItems constraints')
const libraryItemsConstraints = [
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
]
if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) {
logger.info('[2.17.3 migration] Finished updating libraryItems constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for libraryItems constraints')
}
logger.info('[2.17.3 migration] Updating feeds constraints')
const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) {
logger.info('[2.17.3 migration] Finished updating feeds constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for feeds constraints')
}
if (await queryInterface.tableExists('mediaItemShares')) {
logger.info('[2.17.3 migration] Updating mediaItemShares constraints')
const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) {
logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints')
}
} else {
logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change')
}
logger.info('[2.17.3 migration] Updating playbackSessions constraints')
const playbackSessionsConstraints = [
{ field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
]
if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) {
logger.info('[2.17.3 migration] Finished updating playbackSessions constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints')
}
logger.info('[2.17.3 migration] Updating playlistMediaItems constraints')
const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) {
logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints')
}
logger.info('[2.17.3 migration] Updating mediaProgresses constraints')
const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) {
logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints')
}
await execQuery(`COMMIT;`)
} catch (error) {
logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error)
await execQuery(`ROLLBACK;`)
}
await execQuery(`PRAGMA foreign_keys = ON;`)
// Completed migration
logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints')
}
/**
* This downward migration script is a no-op.
*
* @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 } }) {
// Downward migration script
logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints')
// This migration is a no-op
logger.info('[2.17.3 migration] No action required for downgrade')
// Completed migration
logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints')
}
/**
* @typedef ConstraintUpdateObj
* @property {string} field - The field to update
* @property {string} onDelete - The onDelete constraint
* @property {string} onUpdate - The onUpdate constraint
*/
/**
* @typedef SequelizeFKObj
* @property {{ model: string, key: string }} references
* @property {string} onDelete
* @property {string} onUpdate
*/
/**
* @param {Object} fk - The foreign key object from PRAGMA foreign_key_list
* @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize
*/
const formatFKsPragmaToSequelizeFK = (fk) => {
return {
references: {
model: fk.table,
key: fk.to
},
onDelete: fk['on_delete'],
onUpdate: fk['on_update']
}
}
/**
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {string} tableName
* @param {ConstraintUpdateObj[]} constraints
* @returns {Promise<Record<string, SequelizeFKObj>|null>}
*/
async function getUpdatedForeignKeys(queryInterface, tableName, constraints) {
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
const quotedTableName = queryInterface.quoteIdentifier(tableName)
const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`)
let hasUpdates = false
const foreignKeysByColName = foreignKeys.reduce((prev, curr) => {
const fk = formatFKsPragmaToSequelizeFK(curr)
const constraint = constraints.find((c) => c.field === curr.from)
if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) {
fk.onDelete = constraint.onDelete
fk.onUpdate = constraint.onUpdate
hasUpdates = true
}
return { ...prev, [curr.from]: fk }
}, {})
return hasUpdates ? foreignKeysByColName : null
}
/**
* Extends the Sequelize describeTable function to include the updated foreign key constraints
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {String} tableName
* @param {Record<string, SequelizeFKObj>} updatedForeignKeys
*/
async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) {
const tableDescription = await queryInterface.describeTable(tableName)
const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => {
let extendedAttributes = attributes
if (updatedForeignKeys[col]) {
extendedAttributes = {
...extendedAttributes,
...updatedForeignKeys[col]
}
}
return { ...prev, [col]: extendedAttributes }
}, {})
return tableDescriptionWithFks
}
/**
* @see https://www.sqlite.org/lang_altertable.html#otheralter
* @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {string} tableName
* @param {ConstraintUpdateObj[]} constraints
* @returns {Promise<boolean>} - Return false if no changes are needed, true otherwise
*/
async function changeConstraints(queryInterface, tableName, constraints) {
const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints)
if (!updatedForeignKeys) {
return false
}
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
const quotedTableName = queryInterface.quoteIdentifier(tableName)
const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup`
const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName)
try {
const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys)
const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks)
// Create the backup table
await queryInterface.createTable(backupTableName, attributes)
const attributeNames = Object.keys(attributes)
.map((attr) => queryInterface.quoteIdentifier(attr))
.join(', ')
// Copy all data from the target table to the backup table
await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`)
// Drop the old (original) table
await queryInterface.dropTable(tableName)
// Rename the backup table to the original table's name
await queryInterface.renameTable(backupTableName, tableName)
// Validate that all foreign key constraints are correct
const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, {
type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT
})
// There are foreign key violations, exit
if (result.length) {
return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`)
}
return true
} catch (error) {
return Promise.reject(error)
}
}
module.exports = { up, down }

View File

@@ -107,19 +107,6 @@ class Library extends Model {
})
}
/**
* Destroy library by id
* @param {string} libraryId
* @returns
*/
static removeById(libraryId) {
return this.destroy({
where: {
id: libraryId
}
})
}
/**
* Get all library ids
* @returns {Promise<string[]>} array of library ids

View File

@@ -611,7 +611,7 @@ class User extends Model {
*/
getOldMediaProgress(libraryItemId, episodeId = null) {
const mediaProgress = this.mediaProgresses?.find((mp) => {
if (episodeId && mp.mediaItemId === episodeId) return true
if (episodeId && mp.mediaItemId !== episodeId) return false
return mp.extraData?.libraryItemId === libraryItemId
})
return mediaProgress?.getOldMediaProgress() || null

View File

@@ -0,0 +1,230 @@
const { expect } = require('chai')
const sinon = require('sinon')
const { up } = require('../../../server/migrations/v2.17.3-fk-constraints')
const { Sequelize, QueryInterface } = require('sequelize')
const Logger = require('../../../server/Logger')
describe('migration-v2.17.3-fk-constraints', () => {
let sequelize
/** @type {QueryInterface} */
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', () => {
beforeEach(async () => {
// Create associated tables: Users, libraries, libraryFolders, playlists, devices
await queryInterface.sequelize.query('CREATE TABLE `users` (`id` UUID PRIMARY KEY);')
await queryInterface.sequelize.query('CREATE TABLE `libraries` (`id` UUID PRIMARY KEY);')
await queryInterface.sequelize.query('CREATE TABLE `libraryFolders` (`id` UUID PRIMARY KEY);')
await queryInterface.sequelize.query('CREATE TABLE `playlists` (`id` UUID PRIMARY KEY);')
await queryInterface.sequelize.query('CREATE TABLE `devices` (`id` UUID PRIMARY KEY);')
})
afterEach(async () => {
await queryInterface.dropAllTables()
})
it('should fix table foreign key constraints', async () => {
// Create tables with missing foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses
await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID UNIQUE PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`), `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`));')
await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));')
await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));')
await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`), `deviceId` UUID REFERENCES `devices` (`id`), `libraryId` UUID REFERENCES `libraries` (`id`));')
await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID UNIQUE PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`));')
await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));')
//
// Validate that foreign key constraints are missing
//
let libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`)
expect(libraryItemsForeignKeys).to.have.deep.members([
{ id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' },
{ id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }
])
let feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`)
expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])
let mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`)
expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])
let playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`)
expect(playbackSessionForeignKeys).to.deep.equal([
{ id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' },
{ id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' },
{ id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }
])
let playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`)
expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])
let mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`)
expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])
//
// Insert test data into tables
//
await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
//
// Query data before migration
//
const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')
const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;')
const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')
const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')
const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')
const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')
//
// Run migration
//
await up({ context: { queryInterface, logger: Logger } })
//
// Validate that foreign key constraints are updated
//
libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`)
expect(libraryItemsForeignKeys).to.have.deep.members([
{ id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' },
{ id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }
])
feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`)
expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }])
mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`)
expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }])
playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`)
expect(playbackSessionForeignKeys).to.deep.equal([
{ id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' },
{ id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' },
{ id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }
])
playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`)
expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }])
mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`)
expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }])
//
// Validate that data is not changed
//
const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')
expect(libraryItemsAfter).to.deep.equal(libraryItems)
const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;')
expect(feedsAfter).to.deep.equal(feeds)
const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')
expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares)
const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')
expect(playbackSessionsAfter).to.deep.equal(playbackSessions)
const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')
expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems)
const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')
expect(mediaProgressesAfter).to.deep.equal(mediaProgresses)
})
it('should keep correct table foreign key constraints', async () => {
// Create tables with correct foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses
await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')
await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')
await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')
await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `deviceId` UUID REFERENCES `devices` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')
await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);')
await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);')
//
// Insert test data into tables
//
await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])
await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
//
// Query data before migration
//
const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')
const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;')
const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')
const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')
const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')
const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')
await up({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(14)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints'))).to.be.true
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.17.3 migration] Updating libraryItems constraints'))).to.be.true
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.17.3 migration] No changes needed for libraryItems constraints'))).to.be.true
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.17.3 migration] Updating feeds constraints'))).to.be.true
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.17.3 migration] No changes needed for feeds constraints'))).to.be.true
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.17.3 migration] Updating mediaItemShares constraints'))).to.be.true
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaItemShares constraints'))).to.be.true
expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.17.3 migration] Updating playbackSessions constraints'))).to.be.true
expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.17.3 migration] No changes needed for playbackSessions constraints'))).to.be.true
expect(loggerInfoStub.getCall(9).calledWith(sinon.match('[2.17.3 migration] Updating playlistMediaItems constraints'))).to.be.true
expect(loggerInfoStub.getCall(10).calledWith(sinon.match('[2.17.3 migration] No changes needed for playlistMediaItems constraints'))).to.be.true
expect(loggerInfoStub.getCall(11).calledWith(sinon.match('[2.17.3 migration] Updating mediaProgresses constraints'))).to.be.true
expect(loggerInfoStub.getCall(12).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaProgresses constraints'))).to.be.true
expect(loggerInfoStub.getCall(13).calledWith(sinon.match('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints'))).to.be.true
//
// Validate that data is not changed
//
const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')
expect(libraryItemsAfter).to.deep.equal(libraryItems)
const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;')
expect(feedsAfter).to.deep.equal(feeds)
const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')
expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares)
const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')
expect(playbackSessionsAfter).to.deep.equal(playbackSessions)
const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')
expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems)
const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')
expect(mediaProgressesAfter).to.deep.equal(mediaProgresses)
})
})
})