Merge master

This commit is contained in:
advplyr 2024-12-01 13:27:20 -06:00
commit 33aa4f1952
21 changed files with 984 additions and 105 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée", "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUpdatedAt": "Mis à jour à", "LabelUpdatedAt": "Mis à jour à",
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers",
"LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série", "LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série",
"LabelUseAdvancedOptions": "Utiliser les options avancées", "LabelUseAdvancedOptions": "Utiliser les options avancées",
@ -869,10 +870,10 @@
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »", "MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »", "MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible nest pas accessible en écriture", "MessageTaskTargetDirectoryNotWritable": "Le répertoire cible nest pas accessible en écriture",
"MessageThinking": "Je cherche…", "MessageThinking": "À la recherche de…",
"MessageUploaderItemFailed": "Échec du téléversement", "MessageUploaderItemFailed": "Échec du téléversement",
"MessageUploaderItemSuccess": "Téléversement effectué !", "MessageUploaderItemSuccess": "Téléversement effectué !",
"MessageUploading": "Téléversement…", "MessageUploading": "Téléchargement…",
"MessageValidCronExpression": "Expression cron valide", "MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", "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", "LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
"LabelUpdatedAt": "Ažurirano", "LabelUpdatedAt": "Ažurirano",
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape", "LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
"LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke",
"LabelUploaderDropFiles": "Ispusti datoteke", "LabelUploaderDropFiles": "Ispusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal", "LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama", "LabelUseAdvancedOptions": "Koristi se naprednim opcijama",

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

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

View File

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

View File

@ -400,19 +400,48 @@ class LibraryController {
model: Database.podcastEpisodeModel, model: Database.podcastEpisodeModel,
attributes: ['id'] attributes: ['id']
} }
},
{
model: Database.bookModel,
attributes: ['id'],
include: [
{
model: Database.bookAuthorModel,
attributes: ['authorId']
},
{
model: Database.bookSeriesModel,
attributes: ['seriesId']
}
]
} }
] ]
}) })
Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`) Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
const seriesIds = []
const authorIds = []
for (const libraryItem of libraryItemsInFolder) { for (const libraryItem of libraryItemsInFolder) {
let mediaItemIds = [] let mediaItemIds = []
if (req.library.isPodcast) { if (req.library.isPodcast) {
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
} else { } else {
mediaItemIds.push(libraryItem.mediaId) mediaItemIds.push(libraryItem.mediaId)
if (libraryItem.media.bookAuthors.length) {
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
}
if (libraryItem.media.bookSeries.length) {
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
}
} }
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
} }
// Remove folder // Remove folder
@ -501,7 +530,7 @@ class LibraryController {
mediaItemIds.push(libraryItem.mediaId) mediaItemIds.push(libraryItem.mediaId)
} }
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
} }
// Set PlaybackSessions libraryId to null // Set PlaybackSessions libraryId to null
@ -580,6 +609,8 @@ class LibraryController {
* DELETE: /api/libraries/:id/issues * DELETE: /api/libraries/:id/issues
* Remove all library items missing or invalid * Remove all library items missing or invalid
* *
* @this {import('../routers/ApiRouter')}
*
* @param {LibraryControllerRequest} req * @param {LibraryControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
@ -605,6 +636,20 @@ class LibraryController {
model: Database.podcastEpisodeModel, model: Database.podcastEpisodeModel,
attributes: ['id'] attributes: ['id']
} }
},
{
model: Database.bookModel,
attributes: ['id'],
include: [
{
model: Database.bookAuthorModel,
attributes: ['authorId']
},
{
model: Database.bookSeriesModel,
attributes: ['seriesId']
}
]
} }
] ]
}) })
@ -615,15 +660,30 @@ class LibraryController {
} }
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
const authorIds = []
const seriesIds = []
for (const libraryItem of libraryItemsWithIssues) { for (const libraryItem of libraryItemsWithIssues) {
let mediaItemIds = [] let mediaItemIds = []
if (req.library.isPodcast) { if (req.library.isPodcast) {
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
} else { } else {
mediaItemIds.push(libraryItem.mediaId) mediaItemIds.push(libraryItem.mediaId)
if (libraryItem.media.bookAuthors.length) {
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
}
if (libraryItem.media.bookSeries.length) {
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
}
} }
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
} }
// Set numIssues to 0 for library filter data // Set numIssues to 0 for library filter data

View File

@ -96,6 +96,8 @@ class LibraryItemController {
* Optional query params: * Optional query params:
* ?hard=1 * ?hard=1
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@ -103,14 +105,36 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete from file system const hardDelete = req.query.hard == 1 // Delete from file system
const libraryItemPath = req.libraryItem.path const libraryItemPath = req.libraryItem.path
const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id] const mediaItemIds = []
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds) const authorIds = []
const seriesIds = []
if (req.libraryItem.isPodcast) {
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(req.libraryItem.media.id)
if (req.libraryItem.media.metadata.authors?.length) {
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
}
if (req.libraryItem.media.metadata.series?.length) {
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
}
}
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
if (hardDelete) { if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => { await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
}) })
} }
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId) await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.sendStatus(200) res.sendStatus(200)
} }
@ -212,15 +236,6 @@ class LibraryItemController {
if (hasUpdates) { if (hasUpdates) {
libraryItem.updatedAt = Date.now() libraryItem.updatedAt = Date.now()
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(
libraryItem.media.id,
seriesRemoved.map((se) => se.id)
)
}
if (isPodcastAutoDownloadUpdated) { if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem) this.cronManager.checkUpdatePodcastCron(libraryItem)
} }
@ -232,10 +247,12 @@ class LibraryItemController {
if (authorsRemoved.length) { if (authorsRemoved.length) {
// Check remove empty authors // Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks( await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
libraryItem.libraryId, }
authorsRemoved.map((au) => au.id) if (seriesRemoved.length) {
) // Check remove empty series
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
} }
} }
res.json({ res.json({
@ -450,6 +467,8 @@ class LibraryItemController {
* Optional query params: * Optional query params:
* ?hard=1 * ?hard=1
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@ -477,14 +496,33 @@ class LibraryItemController {
for (const libraryItem of itemsToDelete) { for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id] const mediaItemIds = []
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) const seriesIds = []
const authorIds = []
if (libraryItem.isPodcast) {
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(libraryItem.media.id)
if (libraryItem.media.metadata.series?.length) {
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
}
if (libraryItem.media.metadata.authors?.length) {
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
}
}
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
if (hardDelete) { if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => { await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
}) })
} }
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
} }
await Database.resetLibraryIssuesFilterData(libraryId) await Database.resetLibraryIssuesFilterData(libraryId)
@ -494,48 +532,74 @@ class LibraryItemController {
/** /**
* POST: /api/items/batch/update * POST: /api/items/batch/update
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
async batchUpdate(req, res) { async batchUpdate(req, res) {
const updatePayloads = req.body const updatePayloads = req.body
if (!updatePayloads?.length) { if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
return res.sendStatus(500) Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
return res.sendStatus(400)
}
// Ensure that each update payload has a unique library item id
const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]
if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)
return res.sendStatus(400)
}
// Get all library items to update
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
if (updatePayloads.length !== libraryItems.length) {
Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)
return res.sendStatus(404)
} }
let itemsUpdated = 0 let itemsUpdated = 0
const seriesIdsRemoved = []
const authorIdsRemoved = []
for (const updatePayload of updatePayloads) { for (const updatePayload of updatePayloads) {
const mediaPayload = updatePayload.mediaPayload const mediaPayload = updatePayload.mediaPayload
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id) const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
if (!libraryItem) return null
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
let seriesRemoved = [] if (libraryItem.isBook) {
if (libraryItem.isBook && mediaPayload.metadata?.series) { if (Array.isArray(mediaPayload.metadata?.series)) {
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id) const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
}
if (Array.isArray(mediaPayload.metadata?.authors)) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
}
} }
if (libraryItem.media.update(mediaPayload)) { if (libraryItem.media.update(mediaPayload)) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(
libraryItem.media.id,
seriesRemoved.map((se) => se.id)
)
}
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++ itemsUpdated++
} }
} }
if (seriesIdsRemoved.length) {
await this.checkRemoveEmptySeries(seriesIdsRemoved)
}
if (authorIdsRemoved.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
}
res.json({ res.json({
success: true, success: true,
updates: itemsUpdated updates: itemsUpdated

View File

@ -86,6 +86,7 @@ class CacheManager {
} }
async purgeEntityCache(entityId, cachePath) { async purgeEntityCache(entityId, cachePath) {
if (!entityId || !cachePath) return []
return Promise.all( return Promise.all(
(await fs.readdir(cachePath)).reduce((promises, file) => { (await fs.readdir(cachePath)).reduce((promises, file) => {
if (file.startsWith(entityId)) { if (file.startsWith(entityId)) {

View File

@ -2,10 +2,11 @@
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. 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 | | 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.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.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.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.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | | 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 |
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |

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

@ -262,7 +262,7 @@ class LibraryItem {
* @returns {Promise<LibraryFile>} null if not saved * @returns {Promise<LibraryFile>} null if not saved
*/ */
async saveMetadata() { async saveMetadata() {
if (this.isSavingMetadata) return null if (this.isSavingMetadata || !global.MetadataPath) return null
this.isSavingMetadata = true this.isSavingMetadata = true

View File

@ -348,11 +348,10 @@ class ApiRouter {
// //
/** /**
* Remove library item and associated entities * Remove library item and associated entities
* @param {string} mediaType
* @param {string} libraryItemId * @param {string} libraryItemId
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
*/ */
async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) { async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
const numProgressRemoved = await Database.mediaProgressModel.destroy({ const numProgressRemoved = await Database.mediaProgressModel.destroy({
where: { where: {
mediaItemId: mediaItemIds mediaItemId: mediaItemIds
@ -362,29 +361,6 @@ class ApiRouter {
Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`) Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`)
} }
// TODO: Remove open sessions for library item
// Remove series if empty
if (mediaType === 'book') {
// TODO: update filter data
const bookSeries = await Database.bookSeriesModel.findAll({
where: {
bookId: mediaItemIds[0]
},
include: {
model: Database.seriesModel,
include: {
model: Database.bookModel
}
}
})
for (const bs of bookSeries) {
if (bs.series.books.length === 1) {
await this.removeEmptySeries(bs.series)
}
}
}
// remove item from playlists // remove item from playlists
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds) const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
for (const playlist of playlistsWithItem) { for (const playlist of playlistsWithItem) {
@ -423,10 +399,13 @@ class ApiRouter {
// purge cover cache // purge cover cache
await CacheManager.purgeCoverCache(libraryItemId) await CacheManager.purgeCoverCache(libraryItemId)
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) // Remove metadata file if in /metadata/items dir
if (await fs.pathExists(itemMetadataPath)) { if (global.MetadataPath) {
Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
await fs.remove(itemMetadataPath) if (await fs.pathExists(itemMetadataPath)) {
Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
await fs.remove(itemMetadataPath)
}
} }
await Database.libraryItemModel.removeById(libraryItemId) await Database.libraryItemModel.removeById(libraryItemId)
@ -437,32 +416,27 @@ class ApiRouter {
} }
/** /**
* Used when a series is removed from a book * After deleting book(s), remove empty series
* Series is removed if it only has 1 book
* *
* @param {string} bookId
* @param {string[]} seriesIds * @param {string[]} seriesIds
*/ */
async checkRemoveEmptySeries(bookId, seriesIds) { async checkRemoveEmptySeries(seriesIds) {
if (!seriesIds?.length) return if (!seriesIds?.length) return
const bookSeries = await Database.bookSeriesModel.findAll({ const series = await Database.seriesModel.findAll({
where: { where: {
bookId, id: seriesIds
seriesId: seriesIds
}, },
include: [ attributes: ['id', 'name', 'libraryId'],
{ include: {
model: Database.seriesModel, model: Database.bookModel,
include: { attributes: ['id']
model: Database.bookModel }
}
}
]
}) })
for (const bs of bookSeries) {
if (bs.series.books.length === 1) { for (const s of series) {
await this.removeEmptySeries(bs.series) if (!s.books.length) {
await this.removeEmptySeries(s)
} }
} }
} }
@ -471,11 +445,10 @@ class ApiRouter {
* Remove authors with no books and unset asin, description and imagePath * Remove authors with no books and unset asin, description and imagePath
* Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged) * Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)
* *
* @param {string} libraryId
* @param {string[]} authorIds * @param {string[]} authorIds
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) { async checkRemoveAuthorsWithNoBooks(authorIds) {
if (!authorIds?.length) return if (!authorIds?.length) return
const bookAuthorsToRemove = ( const bookAuthorsToRemove = (
@ -495,10 +468,10 @@ class ApiRouter {
}, },
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
], ],
attributes: ['id', 'name'], attributes: ['id', 'name', 'libraryId'],
raw: true raw: true
}) })
).map((au) => ({ id: au.id, name: au.name })) ).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId }))
if (bookAuthorsToRemove.length) { if (bookAuthorsToRemove.length) {
await Database.authorModel.destroy({ await Database.authorModel.destroy({
@ -506,7 +479,7 @@ class ApiRouter {
id: bookAuthorsToRemove.map((au) => au.id) id: bookAuthorsToRemove.map((au) => au.id)
} }
}) })
bookAuthorsToRemove.forEach(({ id, name }) => { bookAuthorsToRemove.forEach(({ id, name, libraryId }) => {
Database.removeAuthorFromFilterData(libraryId, id) Database.removeAuthorFromFilterData(libraryId, id)
// TODO: Clients were expecting full author in payload but its unnecessary // TODO: Clients were expecting full author in payload but its unnecessary
SocketAuthority.emitter('author_removed', { id, libraryId }) SocketAuthority.emitter('author_removed', { id, libraryId })

View File

@ -0,0 +1,202 @@
const { expect } = require('chai')
const { Sequelize } = require('sequelize')
const sinon = require('sinon')
const Database = require('../../../server/Database')
const ApiRouter = require('../../../server/routers/ApiRouter')
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
const RssFeedManager = require('../../../server/managers/RssFeedManager')
const Logger = require('../../../server/Logger')
describe('LibraryItemController', () => {
/** @type {ApiRouter} */
let apiRouter
beforeEach(async () => {
global.ServerSettings = {}
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
await Database.buildModels()
apiRouter = new ApiRouter({
apiCacheManager: new ApiCacheManager(),
rssFeedManager: new RssFeedManager()
})
sinon.stub(Logger, 'info')
})
afterEach(async () => {
sinon.restore()
// Clear all tables
await Database.sequelize.sync({ force: true })
})
describe('checkRemoveAuthorsAndSeries', () => {
let libraryItem1Id
let libraryItem2Id
let author1Id
let author2Id
let author3Id
let series1Id
let series2Id
beforeEach(async () => {
const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const newLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
libraryItem1Id = newLibraryItem.id
const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const newLibraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook2.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
libraryItem2Id = newLibraryItem2.id
const newAuthor = await Database.authorModel.create({ name: 'Test Author', libraryId: newLibrary.id })
author1Id = newAuthor.id
const newAuthor2 = await Database.authorModel.create({ name: 'Test Author 2', libraryId: newLibrary.id })
author2Id = newAuthor2.id
const newAuthor3 = await Database.authorModel.create({ name: 'Test Author 3', imagePath: '/fake/path/author.png', libraryId: newLibrary.id })
author3Id = newAuthor3.id
// Book 1 has Author 1, Author 2 and Author 3
await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor.id })
await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor2.id })
await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor3.id })
// Book 2 has Author 2
await Database.bookAuthorModel.create({ bookId: newBook2.id, authorId: newAuthor2.id })
const newSeries = await Database.seriesModel.create({ name: 'Test Series', libraryId: newLibrary.id })
series1Id = newSeries.id
const newSeries2 = await Database.seriesModel.create({ name: 'Test Series 2', libraryId: newLibrary.id })
series2Id = newSeries2.id
// Book 1 is in Series 1 and Series 2
await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries.id })
await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries2.id })
// Book 2 is in Series 2
await Database.bookSeriesModel.create({ bookId: newBook2.id, seriesId: newSeries2.id })
})
it('should remove authors and series with no books on library item delete', async () => {
const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id)
const fakeReq = {
query: {},
libraryItem: oldLibraryItem
}
const fakeRes = {
sendStatus: sinon.spy()
}
await LibraryItemController.delete.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
// Author 1 should be removed because it has no books
const author1Exists = await Database.authorModel.checkExistsById(author1Id)
expect(author1Exists).to.be.false
// Author 2 should not be removed because it still has Book 2
const author2Exists = await Database.authorModel.checkExistsById(author2Id)
expect(author2Exists).to.be.true
// Author 3 should not be removed because it has an image
const author3Exists = await Database.authorModel.checkExistsById(author3Id)
expect(author3Exists).to.be.true
// Series 1 should be removed because it has no books
const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
expect(series1Exists).to.be.false
// Series 2 should not be removed because it still has Book 2
const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
expect(series2Exists).to.be.true
})
it('should remove authors and series with no books on library item batch delete', async () => {
// Batch delete library item 1
const fakeReq = {
query: {},
user: {
canDelete: true
},
body: {
libraryItemIds: [libraryItem1Id]
}
}
const fakeRes = {
sendStatus: sinon.spy()
}
await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
// Author 1 should be removed because it has no books
const author1Exists = await Database.authorModel.checkExistsById(author1Id)
expect(author1Exists).to.be.false
// Author 2 should not be removed because it still has Book 2
const author2Exists = await Database.authorModel.checkExistsById(author2Id)
expect(author2Exists).to.be.true
// Author 3 should not be removed because it has an image
const author3Exists = await Database.authorModel.checkExistsById(author3Id)
expect(author3Exists).to.be.true
// Series 1 should be removed because it has no books
const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
expect(series1Exists).to.be.false
// Series 2 should not be removed because it still has Book 2
const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
expect(series2Exists).to.be.true
})
it('should remove authors and series with no books on library item update media', async () => {
const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id)
// Update library item 1 remove all authors and series
const fakeReq = {
query: {},
body: {
metadata: {
authors: [],
series: []
}
},
libraryItem: oldLibraryItem
}
const fakeRes = {
json: sinon.spy()
}
await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
// Author 1 should be removed because it has no books
const author1Exists = await Database.authorModel.checkExistsById(author1Id)
expect(author1Exists).to.be.false
// Author 2 should not be removed because it still has Book 2
const author2Exists = await Database.authorModel.checkExistsById(author2Id)
expect(author2Exists).to.be.true
// Author 3 should not be removed because it has an image
const author3Exists = await Database.authorModel.checkExistsById(author3Id)
expect(author3Exists).to.be.true
// Series 1 should be removed because it has no books
const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
expect(series1Exists).to.be.false
// Series 2 should not be removed because it still has Book 2
const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
expect(series2Exists).to.be.true
})
})
})

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)
})
})
})