Compare commits

...

199 Commits

Author SHA1 Message Date
advplyr 128c554543 Version bump 2.2.19 2023-04-16 16:34:09 -05:00
advplyr 1b5ab6c378 Update xml2js 0.5.0 2023-04-16 16:33:28 -05:00
advplyr e4961feffb Update:Remove item metadata path when removing item #1561 2023-04-16 16:23:13 -05:00
advplyr eb5f257b8c Merge pull request #1680 from lukeIam/region_authors
Use region for author queries
2023-04-16 15:54:49 -05:00
advplyr e271e89835 Author API requests to use region from library provider 2023-04-16 15:53:46 -05:00
advplyr f5009f76f4 Update proper lockfile settings #1326 2023-04-16 15:21:04 -05:00
lukeIam a3e63e03d2 Use region for author queries 2023-04-16 13:36:50 +02:00
advplyr 2ae3ea346f Update:Show abridged icon next to title #1656 2023-04-15 18:28:06 -05:00
advplyr 8542d433a2 Add:Audio file info modal #1667 2023-04-15 18:09:49 -05:00
advplyr 03984f96d4 Remove experimental tone probe 2023-04-15 16:21:16 -05:00
advplyr eab019c577 Use horizontal kebab icon 2023-04-14 16:48:24 -05:00
advplyr 179f11f55d Add:Delete library items from file system #1439 2023-04-14 16:44:41 -05:00
advplyr 5a21e63d0b Add:Delete library files, condense item options in more menu #1439 2023-04-13 18:03:39 -05:00
advplyr 24ef105732 Fix:Empty podcasts marked as missing & removing episodes when deleted in folder #1671 2023-04-12 17:20:11 -05:00
advplyr 589c4f73d2 Cleanup scanner 2023-04-12 16:45:52 -05:00
advplyr 55fdc48d5d Merge pull request #1670 from divyangbw/feat-new-sortBy-last-book
Add sortBy Last Book Added and Updated to series
2023-04-12 16:23:52 -05:00
advplyr 4d45a902bb Add translations to other languages 2023-04-12 16:25:02 -05:00
Divyang Joshi 69bac2ec1e Add sorted by value to the series card 2023-04-12 12:55:59 -04:00
Divyang Joshi 122ec140e8 Add sortBy Last Book Added and Updated to series 2023-04-11 23:18:25 -04:00
advplyr 6a0adf7433 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-11 16:55:28 -05:00
advplyr c1b2aaec9f Fix:Set tone path for debian tone usage #1643 2023-04-11 16:55:22 -05:00
advplyr a49acdb2e4 Merge pull request #1669 from Nab0y/master
Fix Russian localization
2023-04-11 15:06:50 -05:00
Dmitry Naboychenko 9b67fbe8d9 Fix Russian localization 2023-04-11 21:09:18 +03:00
advplyr 2d13215f1f Merge pull request #1665 from tomazed/translation-fr
MessageConfirmRemoveAllChapters translation fr
2023-04-11 09:32:45 -05:00
Tomazed a77c3aae93 MessageConfirmRemoveAllChapters translation fr 2023-04-11 09:41:38 +02:00
advplyr 164937b454 Merge pull request #1659 from Dr-Blank/gujarati-translation
Kick off Gujarati translation.
2023-04-10 17:24:59 -05:00
Dr-Blank b0a8f3d207 Kick off Gujarati translation. 2023-04-09 23:58:51 -04:00
advplyr 77cc0934be Update:Episodes table sort by pub date treats episodes with no pub date as the oldest #1454 2023-04-09 17:20:56 -05:00
advplyr 718890cfad Add:Download button to download full library item #580 2023-04-09 17:05:35 -05:00
advplyr 418adcf891 Update:Only admin users can see full file path #1411 2023-04-09 16:10:03 -05:00
advplyr b96f878d69 Update:Sleep timer presets and add custom time input #1357 2023-04-09 15:37:49 -05:00
advplyr 22b8622c67 Fix:Crash for invalid payload to update cover endpoint #1644 2023-04-09 15:01:14 -05:00
advplyr 3dc9416da6 Add:Chapters to podcast episodes #1646 2023-04-09 14:32:51 -05:00
advplyr 5e5b674c17 Add:Remove all chapters button in chapter editor #1603 2023-04-09 12:47:36 -05:00
advplyr 3656eab8bf Update:Add audible_asin meta tag #1640 2023-04-09 11:23:02 -05:00
advplyr 25ca950dd0 Update listening sessions per device and show open sessions 2023-04-08 18:01:24 -05:00
advplyr 8fca84e4bd Fix:Chapter editor show save button when shifting times #1648 2023-04-08 14:32:12 -05:00
advplyr 56579f440b Update playback rate hotkey adjustment 2023-04-08 11:17:17 -05:00
advplyr a59311f795 Update:Adjust timestamps in player for playback speed #1647 2023-04-07 18:05:23 -05:00
advplyr 042c89039c Merge pull request #1654 from Dr-Blank/hindi-translation
Kicked off Hindi Translation.
2023-04-07 16:10:44 -05:00
Dr-Blank d94482827a Kicked of Hindi Translation. 2023-04-07 04:41:38 -04:00
advplyr a8dab5653b Merge pull request #1651 from Demian98/patch-1
Added german translation for abridged
2023-04-06 09:56:07 -05:00
Demian98 1d1200a3f2 Added german translation for abridged 2023-04-06 16:04:56 +02:00
advplyr 4d110ebe7e Fix:Podcast RSS feed parse when element has attributes #1650 2023-04-05 17:40:40 -05:00
advplyr b300f0d10c Merge pull request #1107 from ruoti/dev-documentation
Development documentation
2023-04-04 16:47:36 -05:00
Scott Ruoti 6dc4dc8f49 Updating devcontainer setup and related documentation 2023-04-04 12:10:45 -04:00
advplyr dfae6cf89f Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-03 17:50:47 -05:00
advplyr d7f18bdd8b Remove deprecated user settings 2023-04-03 17:41:03 -05:00
advplyr 05b102722b Remove unused ebook routes 2023-04-03 17:33:02 -05:00
advplyr ef954ee68f Remove downloads folder in metadata dir 2023-04-03 17:28:55 -05:00
advplyr dbaea9f87d Merge pull request #1645 from tomazed/translation-fr
update fr strings
2023-04-03 08:15:22 -05:00
Tomazed 64768ec2f9 update fr strings 2023-04-03 10:21:15 +02:00
advplyr 14ee17de47 Version bump 2.2.18 2023-04-02 17:40:55 -05:00
advplyr 034b8956a2 Add:Batch embed metadata and queue system for metadata embedding #700 2023-04-02 16:13:18 -05:00
advplyr 1a3f0e332e Fix download podcast episode that is not mp3 2023-04-01 16:31:04 -05:00
advplyr fc36e86db7 Update:Match tab show current cover and include resolutions #1605 2023-03-31 18:00:45 -05:00
advplyr 60b4bc1a7e Update:Show resolution under cover in book details modal #1547 2023-03-31 17:42:52 -05:00
advplyr 9fdc8df8bc Update:API endpoint for updating book media does not require an id for new series/authors #1540 2023-03-31 17:04:26 -05:00
advplyr 212b97fa20 Add:Parsing meta tags from podcast episode audio file #1488 2023-03-30 18:04:21 -05:00
advplyr 704fbaced8 Update:Download podcast episodes and embed meta tags #1488 2023-03-29 18:05:53 -05:00
advplyr 575a162f8b Update:API endpoint for get all users to use minimal payload 2023-03-29 14:56:50 -05:00
advplyr d2e0844493 Epub reader updates for mobile 2023-03-26 14:44:59 -05:00
advplyr f2baf3fafd Update epub media progress update 2023-03-26 13:50:44 -05:00
advplyr 916fd039ca Remove keydown event listener in epub reader 2023-03-26 13:40:47 -05:00
advplyr e248b6d8d8 Fix:Parsing id3 tags case insensitive 2023-03-25 16:09:41 -05:00
advplyr 936de68622 Update epub reader only store up to 3MB of locations cache 2023-03-25 15:53:19 -05:00
advplyr a99257e758 Fix getAllLibraryItemsInProgress route 2023-03-25 14:07:35 -05:00
advplyr c89d77dd06 Merge pull request #1627 from vincentscode/epub-reader
Save Progress for EPUBs
2023-03-24 18:01:13 -05:00
advplyr 3138865d69 Update toc menu and media progress display 2023-03-24 17:57:41 -05:00
Vincent Schmandt 4d29ebd647 Save Locations locally, add separate progress tracker 2023-03-23 08:45:00 +01:00
advplyr fd58df4729 Add:Abridged book detail, parse from audible, abridged book filter #1408 2023-03-22 18:05:43 -05:00
Vincent Schmandt 5078818295 Add MediaProgress fields
Add Table of Contents
2023-03-22 11:16:01 +01:00
advplyr 7181df0479 Fix:Patreon episodes with variable query strings #1622 2023-03-21 17:59:37 -05:00
Vincent Schmandt 6c618d7760 Adjust height to fit metadata 2023-03-21 13:36:06 +01:00
Vincent Schmandt 17b8cf19b7 Add Location Storage 2023-03-21 13:34:21 +01:00
Vincent Schmandt e018f8341e EPUB progress persistence 2023-03-21 13:27:21 +01:00
advplyr 59b5f8cbbe Merge pull request #1624 from maltejur/master
Truncate long title in stream container
2023-03-20 16:01:23 -05:00
advplyr d6108a0722 Merge pull request #1621 from burghy86/patch-9
Update it.json
2023-03-20 15:49:31 -05:00
advplyr 1af7e59d88 Merge pull request #1618 from fidoriel/transcode-continue-bug
Fix transcoded streams fail to continue listening
2023-03-20 15:49:11 -05:00
advplyr 7b425e9a9d Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:42 -05:00
advplyr 596a03900b Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:36 -05:00
advplyr b283644d95 Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:31 -05:00
Malte Jürgens 808690c137 truncate long title in stream container 2023-03-20 21:28:15 +01:00
burghy86 136c347586 Update it.json
fix new line
2023-03-20 12:20:02 +01:00
fidoriel e81238038e m3u8url 2023-03-19 22:26:36 +00:00
fidoriel fcf6964d7d hlsurl 2023-03-19 21:41:49 +00:00
advplyr bd75ad4576 Version bump 2.2.17 2023-03-18 17:44:45 -05:00
advplyr f970d8e539 Merge pull request #1517 from mfcar/addEpisodeFilter
Add episodes search
2023-03-18 17:25:53 -05:00
advplyr c49010b4e1 Merge master 2023-03-18 17:26:11 -05:00
advplyr 146093d81e Add:Support for .awb AMR-WB audio file #1565 2023-03-17 16:52:07 -05:00
advplyr 11ccbf1913 Merge pull request #1609 from Linden-Ryuujin/feature/semicolonSeperators
Support for scanning semicolon seperated author and narator lists.
2023-03-16 17:06:22 -05:00
Linden Ryuujin a4a334a18a Support for scanning semicolon seperated author and narator lists. 2023-03-16 21:44:03 +00:00
advplyr 387a37e4da Fix:Download podcast episodes that are not mp3 #1513 2023-03-15 18:04:31 -05:00
advplyr ebad304aa9 Remove filePerms log 2023-03-14 15:38:53 -05:00
advplyr 8b557a0cb9 Fix:Private Patreon feed URLs getting encoded twice #1600 2023-03-14 15:38:19 -05:00
advplyr 40b808e73d Update:Use title ID3 tag on tracks when setting chapters and prefer audio metadata setting is enabled #679 2023-03-13 17:56:16 -05:00
advplyr a8b57a1ce9 Cleanup rebuild tracks/set chapters 2023-03-13 17:45:44 -05:00
advplyr 35315843f2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-03-12 15:52:55 -05:00
advplyr 27b9d3b94f Update:Add support for MKA audio files #1597 2023-03-12 15:52:49 -05:00
advplyr 0010ac5a40 Merge pull request #1599 from Nab0y/master
Update Russian localization
2023-03-11 13:58:42 -06:00
Dmitry Naboychenko 884808f34e Update russian localization 2023-03-11 22:40:24 +03:00
advplyr f75ed07497 Update readme screenshot 2023-03-10 16:23:07 -06:00
advplyr b707d6f3c9 Update Dockerfile run command to use node index.js 2023-03-09 11:47:48 -06:00
advplyr a2d4a4a906 Update:Home page episodes dont show a number if no episode number 2023-03-08 08:30:54 -06:00
advplyr 434d743d99 Clean translation files alphabetized 2023-03-07 10:28:10 -06:00
advplyr 30f16b05fe Merge pull request #1586 from Machou/master
fr_FR update
2023-03-07 10:13:07 -06:00
Machou 92a88f4416 Update fr.json 2023-03-07 17:05:51 +01:00
advplyr 5c9c122af2 Update:Config side nav line height and padding for cleaner wrapping 2023-03-07 09:42:33 -06:00
advplyr 620d5ce578 Add:Spanish language option 2023-03-07 09:37:55 -06:00
advplyr 363e1cee4b Merge pull request #1587 from apineiro97/master
Spanish Translation
2023-03-07 09:30:16 -06:00
advplyr 93f576772a Merge pull request #1585 from springsunx/patch-1
Update zh-cn.json
2023-03-07 09:27:52 -06:00
Machou d4612bae92 Update fr.json 2023-03-07 06:48:24 +01:00
Arturo Pineiro e01af27008 LabelSettingsOverdriveMediaMarkersHelp 2023-03-06 21:41:02 -08:00
Arturo Pineiro 657fe0a650 Spanish translation 2023-03-06 21:36:38 -08:00
Arturo Pineiro 9a6ec5548e fix missing translations 2023-03-06 21:33:51 -08:00
Arturo Pineiro 0807509ea7 added "ButtonDownloadQueue": "Queue", 2023-03-06 21:28:09 -08:00
Arturo Pineiro d9d1c4e360 Added Spanish translation 2023-03-06 21:18:59 -08:00
blok 2135e5b066 fr_FR update 2023-03-07 05:54:20 +01:00
blok b69eb10ae0 fr_FR update 2023-03-07 05:36:53 +01:00
SunX e1512b6f54 Update zh-cn.json 2023-03-07 11:07:01 +08:00
Machou 1b8e8215d6 Update fr.json 2023-03-07 03:35:21 +01:00
advplyr 9b44e36e7b Version bump 2.2.16 2023-03-05 16:28:45 -06:00
advplyr db1ca08c2e Update scanner logs to show inode value on path changes and missing items #1447 2023-03-05 15:38:21 -06:00
advplyr 557d3243c3 Fix:Series & collection rss feeds repeating first book #1531 2023-03-05 15:26:18 -06:00
advplyr 785942b94f Update:Series books page fallback to sort by title/collapsed series name when no sequence #1503 2023-03-05 14:48:20 -06:00
advplyr 3df7caa838 Fix:OPF parser crash when no narrators #1578 2023-03-05 12:40:21 -06:00
advplyr aef2c52630 Merge pull request #1581 from mfcar/improvePodcastEditing
Improve podcast editing
2023-03-05 12:28:12 -06:00
advplyr dccad3055b Remove library item listener from edit episode modal 2023-03-05 12:28:20 -06:00
advplyr c629923a80 Merge pull request #1562 from mfcar/addNextScheduleInfo
Improve dates, times and schedule backup info
2023-03-05 11:44:59 -06:00
advplyr b4f1fd5b25 Remove currently from date/time setting 2023-03-05 11:38:07 -06:00
advplyr 267897ce74 Merge pull request #1559 from mfcar/addDownloadQueue
Add download queue page
2023-03-05 10:48:25 -06:00
advplyr 022bf9d0ef Show current episode download on init and download queue page updates 2023-03-05 10:35:34 -06:00
mfcar 61c759e0c4 Add tasks queue dropdown 2023-03-05 11:15:36 +00:00
mfcar cfb3ce0c60 Merge branch 'master' into addDownloadQueue 2023-03-04 22:00:18 +00:00
mfcar 72396c5a98 Add Prev/Next buttons on podcast editing 2023-03-04 19:04:55 +00:00
mfcar 12f231b886 Add save action without closing the modal 2023-03-04 16:44:52 +00:00
mfcar 6aeed24296 Update example label 2023-03-04 11:51:53 +00:00
mfcar d8b6e09bc0 Merge branch 'master' into addNextScheduleInfo 2023-03-04 11:09:35 +00:00
advplyr d95975cade Fix:Series page progress filter #1577 2023-03-03 17:35:14 -06:00
mfcar c4208a4690 package-lock.json lacking 2023-02-28 17:07:18 +00:00
mfcar 7c7a6df6e4 Using cron-parse lib to parse the cron expression. Cron-parse can handle with more scenarios. 2023-02-28 17:04:46 +00:00
advplyr 791c058ef8 Merge pull request #1563 from mfcar/improvePodcastSearch
Improve podcast search
2023-02-27 16:42:37 -06:00
advplyr c847aea0a4 Merge pull request #1556 from Weldawadyathink/public_rss_feeds
Fix incorrect tags when blocking public feeds
2023-02-27 16:40:18 -06:00
mfcar e56164aa5a Add a new date format 2023-02-27 20:31:38 +00:00
mfcar cfb5e909a9 Improve podcast search 2023-02-27 18:22:17 +00:00
mfcar 071444a9e7 Improve dates, times and schedule backup info 2023-02-27 18:04:26 +00:00
mfcar 34ac972130 Add download queue 2023-02-27 02:56:07 +00:00
advplyr 97b5cf04f5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-02-25 15:05:49 -06:00
advplyr 0d50d730d9 Update:Html sanitizer to allow br tag 2023-02-25 15:05:44 -06:00
Spenser Bushey 3a7fd0bcc9 Fix incorrect tags when blocking public feeds 2023-02-25 09:00:26 -08:00
advplyr f0edea5d52 Merge pull request #1553 from Smoukus/fix-german-typo
fix german typo
2023-02-25 08:59:05 -06:00
advplyr 9c6b07df99 Merge pull request #1554 from mfcar/blockRssFeed
Add rss feed configuration
2023-02-25 08:56:32 -06:00
advplyr caacf461ab Open rss feed metadataDetails optional 2023-02-25 08:53:09 -06:00
mfcar 5bdbc75522 Fix typo 2023-02-25 13:32:08 +00:00
mfcar 0d3e6b1d0a Add rss details configuration 2023-02-25 13:20:26 +00:00
Smoukus a122e25cba fix german typo 2023-02-25 11:57:07 +01:00
advplyr d7b287bfed Merge pull request #1551 from mfcar/mf/alreadyInYourLibraryIndicator
Improve explicit label and add a AlreadyInYourLibrary indicator
2023-02-24 17:57:44 -06:00
advplyr ba4f585318 Update client/pages/library/_library/podcast/search.vue 2023-02-24 17:57:25 -06:00
mfcar 3f859723a6 Typo 2023-02-24 23:45:06 +00:00
mfcar c820d0e62b Fix truncate hiding explicit icon 2023-02-24 23:36:15 +00:00
mfcar 7a47032a96 Improve explicit label and add a AlreadyInYourLibrary indicator 2023-02-24 23:31:16 +00:00
advplyr 2db4dd6a40 Merge pull request #1539 from Linden-Ryuujin/feature/coverImage
Prefer cover images called cover
2023-02-23 17:55:05 -06:00
advplyr f58e2b6dce Update cover image set on first scan 2023-02-23 17:55:11 -06:00
advplyr 859a53e79a Merge pull request #1536 from mfcar/addSeasonInfo
Adding podcast type, season and episode info to the feed
2023-02-23 17:39:46 -06:00
mfcar ad0edc6329 Fix merge conflicts and add language information on the feed rss 2023-02-23 00:33:04 +00:00
Linden Ryuujin 002fb7a35e When setting the cover image prefer images called "cover", otherwise fallback to original behaviour of first in the list. 2023-02-23 00:09:05 +00:00
mfcar cc62a20a5d Merge branch 'master' into addSeasonInfo
# Conflicts:
#	client/components/modals/podcast/NewModal.vue
2023-02-23 00:06:21 +00:00
advplyr ec7e965dfa Merge pull request #1534 from mfcar/fixExplicitInfo
Fixed explicit/language info import and added Explicit indicator
2023-02-22 17:36:59 -06:00
advplyr 9c3f5406a9 Update client/components/modals/podcast/NewModal.vue 2023-02-22 17:36:42 -06:00
mfcar f4ec6948d2 Add dropown 2023-02-22 19:18:42 +00:00
mfcar 9a51c3be0f Add dropdown to the episode type 2023-02-22 18:48:36 +00:00
mfcar b1ee54522a Add support to podcast type 2023-02-22 18:22:52 +00:00
mfcar c14d13440f Add explicit info 2023-02-22 12:48:12 +00:00
advplyr 8c84640484 Merge pull request #1530 from mfcar/fixingScheduleModal
Fixed schedule info when using Prev/Next button
2023-02-21 16:00:13 -06:00
advplyr 0d8917ced6 Update client/components/widgets/CronExpressionBuilder.vue 2023-02-21 16:00:01 -06:00
mfcar a006eb489d Fix schedule modal info 2023-02-21 21:40:15 +00:00
advplyr f2941e04d3 Merge pull request #1529 from tomazed/translation-fr
update fr locale
2023-02-21 14:51:38 -06:00
advplyr 2728546660 Merge pull request #1528 from Hallo951/master
Update de.json
2023-02-21 14:51:19 -06:00
mfcar eeb7c80518 Add translation strings and change the input type to search 2023-02-21 19:30:42 +00:00
Tomazed c8c40360ad update HeaderStatsLargestItems 2023-02-21 12:19:31 +01:00
Hallo951 79ab656217 Update de.json
Update german language
2023-02-21 10:14:49 +01:00
advplyr 5c250da388 Merge pull request #1518 from mfcar/addSizeStats
Add largest item stats
2023-02-20 17:41:20 -06:00
advplyr 505e0eb3a2 Update translations 2023-02-20 17:41:26 -06:00
advplyr 388444e51f Merge pull request #1515 from dwtong/encode-podcast-url
Encode podcast url when downloading episode
2023-02-20 17:26:33 -06:00
mfcar 08d7a9aa14 Add size stats 2023-02-19 21:39:28 +00:00
mfcar f650ae7f18 Add episode filter on the episodes list 2023-02-19 20:48:39 +00:00
mfcar 6d138ae905 Add episode filter 2023-02-19 20:07:32 +00:00
Dan Tong 956678c08c Encode podcast url when downloading episode 2023-02-18 14:21:45 +13:00
advplyr 911c854365 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-02-16 18:01:31 -06:00
advplyr 3c5dc17e3c Fix:Replace unicode x in playback speed control with regular x #1508 2023-02-16 18:01:25 -06:00
advplyr e709cc4cb1 Merge pull request #1468 from lkiesow/integration-test
Integration Test
2023-02-16 17:51:36 -06:00
advplyr da7825e3e3 Merge pull request #1505 from p-rintz/master
Add library tags variable to podcast notifications
2023-02-15 15:58:59 -06:00
advplyr 4039dc7968 Podcast episode download notification adding variables for mediaTags, podcastAuthor, podcastDescription, podcastGenres, episodeTitle, episodeSubtitle, episodeDescription 2023-02-15 15:57:04 -06:00
Philipp Rintz e345c4cc9e Correct the libraryTags variable 2023-02-15 00:00:34 +01:00
Philipp Rintz a08cfa436e Fix code formatting 2023-02-14 16:51:20 +01:00
Philipp Rintz 7207efb4da Add library tags variable to podcast notifications 2023-02-14 16:41:58 +01:00
advplyr 481611ff33 Merge pull request #1500 from Machou/patch-1
Update fr.json
2023-02-12 07:59:41 -06:00
Machou b67cd37a38 Update fr.json 2023-02-12 07:44:08 +01:00
Lars Kiesow d2512d324a Integration Test
This patch adds a minimal integration test building Audiobookshelf as a
binary, running it and checking if the server is available on each push
and pull request.

We can easily extend this with a Selenium or Playwright test later, but
it should already alert us about problems in the build pipeline without
the need for any developer to take a look at the new patches.
2023-02-02 00:48:09 +01:00
171 changed files with 7057 additions and 2539 deletions
+14 -3
View File
@@ -1,4 +1,15 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16 # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ ARG VARIANT=16
&& apt-get install ffmpeg gnupg2 -y FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
# Setup the node environment
ENV NODE_ENV=development ENV NODE_ENV=development
# Install additional OS packages.
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
curl tzdata ffmpeg && \
rm -rf /var/lib/apt/lists/*
# Move tone executable to appropriate directory
COPY --from=sandreas/tone:v0.1.2 /usr/local/bin/tone /usr/local/bin/
+9
View File
@@ -0,0 +1,9 @@
// Using port 3333 is important when running the client web app separately
const Path = require('path')
module.exports.config = {
Port: 3333,
ConfigPath: Path.resolve('config'),
MetadataPath: Path.resolve('metadata'),
FFmpegPath: '/usr/bin/ffmpeg',
FFProbePath: '/usr/bin/ffprobe'
}
+37 -9
View File
@@ -1,12 +1,40 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{ {
"build": { "dockerfile": "Dockerfile" }, "name": "Audiobookshelf",
"mounts": [ "build": {
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume" "dockerfile": "Dockerfile",
], // Update 'VARIANT' to pick a Node version: 18, 16, 14.
"features": { // Append -bullseye or -buster to pin to an OS version.
"fish": "latest" // Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "16"
}
}, },
"extensions": [ "mounts": [
"eamodio.gitlens" "source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
] "source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
],
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
3000,
3333
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"octref.vetur"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
} }
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Mark the working directory as safe for use with git
git config --global --add safe.directory $PWD
# If there is no dev.js file, create it
if [ ! -f dev.js ]; then
cp .devcontainer/dev.js .
fi
# Update permissions for node_modules folders
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
if [ -d node_modules ]; then
sudo chown $(id -u):$(id -g) node_modules
fi
if [ -d client/node_modules ]; then
sudo chown $(id -u):$(id -g) client/node_modules
fi
# Install packages for the server
if [ -f package.json ]; then
npm ci
fi
# Install packages and build the client
if [ -f client/package.json ]; then
(cd client; npm ci; npm run generate)
fi
+5
View File
@@ -0,0 +1,5 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
# Declare files that will always have CRLF line endings on checkout.
.devcontainer/post-create.sh text eol=lf
+44
View File
@@ -0,0 +1,44 @@
name: Integration Test
on:
pull_request:
push:
branches-ignore:
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
jobs:
build:
name: build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: setup nade
uses: actions/setup-node@v3
with:
node-version: 16
- name: install pkg
run: npm install -g pkg
- name: get client dependencies
working-directory: client
run: npm ci
- name: build client
working-directory: client
run: npm run generate
- name: get server dependencies
run: npm ci --only=production
- name: build binary
run: pkg -t node18-linux-x64 -o audiobookshelf .
- name: run audiobookshelf
run: |
./audiobookshelf &
sleep 5
- name: test if server is available
run: curl -sf http://127.0.0.1:3333 | grep Audiobookshelf
+2 -2
View File
@@ -1,6 +1,6 @@
.env .env
dev.js /dev.js
node_modules/ **/node_modules/
/config/ /config/
/audiobooks/ /audiobooks/
/audiobooks2/ /audiobooks2/
+44
View File
@@ -0,0 +1,44 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug server",
"runtimeExecutable": "npm",
"args": [
"run",
"dev"
],
"skipFiles": [
"<node_internals>/**"
]
},
{
"type": "node",
"request": "launch",
"name": "Debug client (nuxt)",
"runtimeExecutable": "npm",
"args": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/client",
"skipFiles": [
"${workspaceFolder}/<node_internals>/**"
]
}
],
"compounds": [
{
"name": "Debug server and client (nuxt)",
"configurations": [
"Debug server",
"Debug client (nuxt)"
]
}
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"version": "2.0.0",
"tasks": [
{
"path": "client",
"type": "npm",
"script": "generate",
"detail": "nuxt generate",
"label": "Build client",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"dependsOn": [
"Build client"
],
"type": "npm",
"script": "dev",
"detail": "nodemon --watch server index.js",
"label": "Run server",
"group": {
"kind": "test",
"isDefault": true
}
},
{
"path": "client",
"type": "npm",
"script": "dev",
"detail": "nuxt",
"label": "Run Live-reload client",
"group": {
"kind": "test",
"isDefault": false
}
}
]
}
+1 -1
View File
@@ -29,4 +29,4 @@ HEALTHCHECK \
--timeout=3s \ --timeout=3s \
--start-period=10s \ --start-period=10s \
CMD curl -f http://127.0.0.1/healthcheck || exit 1 CMD curl -f http://127.0.0.1/healthcheck || exit 1
CMD ["npm", "start"] CMD ["node", "index.js"]
+85 -24
View File
@@ -58,9 +58,6 @@
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span> <span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }} {{ $strings.ButtonPlay }}
</ui-btn> </ui-btn>
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
</ui-tooltip>
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom"> <ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" /> <ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
@@ -75,8 +72,11 @@
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom"> <ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" /> <ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span> <ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
<span class="material-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</ui-tooltip> </ui-tooltip>
</div> </div>
</div> </div>
@@ -160,9 +160,59 @@ export default {
}, },
isHttps() { isHttps() {
return location.protocol === 'https:' || process.env.NODE_ENV === 'development' return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
},
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
const options = [
{
text: this.$strings.ButtonQuickMatch,
action: 'quick-match'
}
]
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
options.push({
text: 'Quick Embed Metadata',
action: 'quick-embed'
})
}
return options
} }
}, },
methods: { methods: {
requestBatchQuickEmbed() {
const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/tools/batch/embed-metadata`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
console.log('Audio metadata embed started')
this.cancelSelectionMode()
})
.catch((error) => {
console.error('Audio metadata embed failed', error)
const errorMsg = error.response.data || 'Failed to embed metadata'
this.$toast.error(errorMsg)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction(action) {
if (action === 'quick-embed') {
this.requestBatchQuickEmbed()
} else if (action === 'quick-match') {
this.batchAutoMatchClick()
}
},
async playSelectedItems() { async playSelectedItems() {
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
@@ -237,26 +287,37 @@ export default {
}) })
}, },
batchDeleteClick() { batchDeleteClick() {
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item' const payload = {
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf` message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`,
if (confirm(confirmMsg)) { checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
this.$store.commit('setProcessingBatch', true) yesButtonText: this.$strings.ButtonDelete,
this.$axios yesButtonColor: 'error',
.$post(`/api/items/batch/delete`, { checkboxDefaultValue: true,
libraryItemIds: this.selectedMediaItems.map((i) => i.id) callback: (confirmed, hardDelete) => {
}) if (confirmed) {
.then(() => { this.$store.commit('setProcessingBatch', true)
this.$toast.success('Batch delete success!')
this.$store.commit('setProcessingBatch', false) this.$axios
this.$store.commit('globals/resetSelectedMediaItems', []) .$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
this.$eventBus.$emit('bookshelf_clear_selection') libraryItemIds: this.selectedMediaItems.map((i) => i.id)
}) })
.catch((error) => { .then(() => {
this.$toast.error('Batch delete failed') this.$toast.success('Batch delete success')
console.error('Failed to batch delete', error) this.$store.commit('globals/resetSelectedMediaItems', [])
this.$store.commit('setProcessingBatch', false) this.$eventBus.$emit('bookshelf_clear_selection')
}) })
.catch((error) => {
console.error('Batch delete failed', error)
this.$toast.error('Batch delete failed')
})
.finally(() => {
this.$store.commit('setProcessingBatch', false)
})
}
},
type: 'yesNo'
} }
this.$store.commit('globals/setConfirmPrompt', payload)
}, },
batchEditClick() { batchEditClick() {
this.$router.push('/batch') this.$router.push('/batch')
+26 -1
View File
@@ -64,12 +64,22 @@
<div class="flex-grow hidden sm:inline-block" /> <div class="flex-grow hidden sm:inline-block" />
<!-- collapse series checkbox -->
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" /> <ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<!-- library filter select -->
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" /> <controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
<!-- library sort select -->
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" /> <controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
<!-- series filter select -->
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" /> <controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
<!-- series sort select -->
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" /> <controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
<!-- issues page remove all button -->
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn> <ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
</template> </template>
<!-- search page --> <!-- search page -->
@@ -153,6 +163,14 @@ export default {
text: this.$strings.LabelAddedAt, text: this.$strings.LabelAddedAt,
value: 'addedAt' value: 'addedAt'
}, },
{
text: this.$strings.LabelLastBookAdded,
value: 'lastBookAdded'
},
{
text: this.$strings.LabelLastBookUpdated,
value: 'lastBookUpdated'
},
{ {
text: this.$strings.LabelTotalDuration, text: this.$strings.LabelTotalDuration,
value: 'totalDuration' value: 'totalDuration'
@@ -171,6 +189,9 @@ export default {
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
},
currentLibraryMediaType() { currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] return this.$store.getters['libraries/getCurrentLibraryMediaType']
}, },
@@ -305,7 +326,11 @@ export default {
const payload = {} const payload = {}
if (author.asin) payload.asin = author.asin if (author.asin) payload.asin = author.asin
else payload.q = author.name else payload.q = author.name
console.log('Payload', payload, 'author', author)
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
this.$eventBus.$emit(`searching-author-${author.id}`, true) this.$eventBus.$emit(`searching-author-${author.id}`, true)
+2 -2
View File
@@ -5,8 +5,8 @@
<span class="material-icons text-2xl">arrow_back</span> <span class="material-icons text-2xl">arrow_back</span>
</div> </div>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'"> <nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>{{ route.title }}</p> <p class="leading-4">{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
+12 -1
View File
@@ -86,6 +86,14 @@
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">file_download</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'"> <nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
<span class="material-icons text-2xl">warning</span> <span class="material-icons text-2xl">warning</span>
@@ -149,6 +157,9 @@ export default {
isMusicLibrary() { isMusicLibrary() {
return this.currentLibraryMediaType === 'music' return this.currentLibraryMediaType === 'music'
}, },
isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue'
},
isPodcastSearchPage() { isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search' return this.$route.name === 'library-library-podcast-search'
}, },
@@ -212,4 +223,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>
+39 -19
View File
@@ -1,22 +1,25 @@
<template> <template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2"> <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
<div id="videoDock" /> <div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer"> <nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> <covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</nuxt-link> </nuxt-link>
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'"> <div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div> <div class="min-w-0">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
{{ title }} {{ title }}
</nuxt-link> </nuxt-link>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center"> <div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span> <span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p> <div class="flex items-center">
<p v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</p> <div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base"> <div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link> <div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
</p> <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
<p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</p> </div>
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
</div>
</div> </div>
<div class="text-gray-400 flex items-center"> <div class="text-gray-400 flex items-center">
@@ -78,7 +81,7 @@ export default {
sleepTimerRemaining: 0, sleepTimerRemaining: 0,
sleepTimer: null, sleepTimer: null,
displayTitle: null, displayTitle: null,
initialPlaybackRate: 1, currentPlaybackRate: 1,
syncFailedToast: null syncFailedToast: null
} }
}, },
@@ -117,22 +120,31 @@ export default {
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
}, },
streamEpisode() {
if (!this.$store.state.streamEpisodeId) return null
const episodes = this.streamLibraryItem.media.episodes || []
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
},
libraryItemId() { libraryItemId() {
return this.streamLibraryItem ? this.streamLibraryItem.id : null return this.streamLibraryItem?.id || null
}, },
media() { media() {
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {} return this.streamLibraryItem?.media || {}
}, },
isPodcast() { isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false return this.streamLibraryItem?.mediaType === 'podcast'
}, },
isMusic() { isMusic() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false return this.streamLibraryItem?.mediaType === 'music'
},
isExplicit() {
return this.mediaMetadata.explicit || false
}, },
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
}, },
chapters() { chapters() {
if (this.streamEpisode) return this.streamEpisode.chapters || []
return this.media.chapters || [] return this.media.chapters || []
}, },
title() { title() {
@@ -146,7 +158,8 @@ export default {
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
}, },
totalDurationPretty() { totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration) // Adjusted by playback rate
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
}, },
podcastAuthor() { podcastAuthor() {
if (!this.isPodcast) return null if (!this.isPodcast) return null
@@ -249,7 +262,7 @@ export default {
this.playerHandler.setVolume(volume) this.playerHandler.setVolume(volume)
}, },
setPlaybackRate(playbackRate) { setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate this.currentPlaybackRate = playbackRate
this.playerHandler.setPlaybackRate(playbackRate) this.playerHandler.setPlaybackRate(playbackRate)
}, },
seek(time) { seek(time) {
@@ -378,7 +391,7 @@ export default {
libraryItem: session.libraryItem, libraryItem: session.libraryItem,
episodeId: session.episodeId episodeId: session.episodeId
}) })
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate) this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
}, },
streamOpen(session) { streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session) console.log(`[StreamContainer] Stream session open`, session)
@@ -445,7 +458,7 @@ export default {
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack() if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
}) })
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime) this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
}, },
pauseItem() { pauseItem() {
this.playerHandler.pause() this.playerHandler.pause()
@@ -453,6 +466,13 @@ export default {
showFailedProgressSyncs() { showFailedProgressSyncs() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast) if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' }) this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
},
sessionClosedEvent(sessionId) {
if (this.playerHandler.currentSessionId === sessionId) {
console.log('sessionClosedEvent closing current session', sessionId)
this.playerHandler.resetPlayer() // Closes player without reporting to server
this.$store.commit('setMediaPlaying', null)
}
} }
}, },
mounted() { mounted() {
@@ -474,4 +494,4 @@ export default {
#streamContainer { #streamContainer {
box-shadow: 0px -6px 8px #1111113f; box-shadow: 0px -6px 8px #1111113f;
} }
</style> </style>
+11
View File
@@ -77,6 +77,12 @@ export default {
}, },
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
} }
}, },
methods: { methods: {
@@ -92,6 +98,11 @@ export default {
if (this.asin) payload.asin = this.asin if (this.asin) payload.asin = this.asin
else payload.q = this.name else payload.q = this.name
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => { var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return null return null
+6 -2
View File
@@ -28,7 +28,11 @@
</div> </div>
</div> </div>
<div v-else class="px-4 flex-grow"> <div v-else class="px-4 flex-grow">
<h1>{{ book.title }}</h1> <h1>
<div class="flex items-center">
{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" />
</div>
</h1>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p> <p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p> <p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p> <p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
@@ -78,4 +82,4 @@ export default {
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
} }
} }
</script> </script>
@@ -0,0 +1,85 @@
<template>
<div class="flex items-center h-full px-1 overflow-hidden">
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
<widgets-loading-spinner v-else />
</div>
<div class="flex-grow px-2 taskRunningCardContent">
<p class="truncate text-sm">{{ title }}</p>
<p class="truncate text-xs text-gray-300">{{ description }}</p>
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
task: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
title() {
return this.task.title || 'No Title'
},
description() {
return this.task.description || ''
},
details() {
return this.task.details || 'Unknown'
},
isFinished() {
return this.task.isFinished || false
},
isFailed() {
return this.task.isFailed || false
},
failedMessage() {
return this.task.error || ''
},
action() {
return this.task.action || ''
},
actionIcon() {
switch (this.action) {
case 'download-podcast-episode':
return 'cloud_download'
case 'encode-m4b':
return 'sync'
default:
return 'settings'
}
},
taskIconStatus() {
if (this.isFinished && this.isFailed) {
return 'text-red-500'
}
if (this.isFinished && !this.isFailed) {
return 'text-green-500'
}
return ''
}
},
methods: {
},
mounted() {}
}
</script>
<style>
.taskRunningCardContent {
width: calc(100% - 80px);
height: 75px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
+59 -9
View File
@@ -7,9 +7,12 @@
<!-- Alternative bookshelf title/author/sort --> <!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }"> <div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }"> <div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
{{ displayTitle }} <div class="flex items-center">
</p> <span class="truncate">{{ displayTitle }}</span>
<widgets-explicit-indicator :explicit="isExplicit" />
</div>
</div>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div> </div>
@@ -102,8 +105,10 @@
</div> </div>
<!-- Podcast Episode # --> <!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
</p>
</div> </div>
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
@@ -193,6 +198,9 @@ export default {
isMusic() { isMusic() {
return this.mediaType === 'music' return this.mediaType === 'music'
}, },
isExplicit() {
return this.mediaMetadata.explicit || false
},
placeholderUrl() { placeholderUrl() {
const config = this.$config || this.$nuxt.$config const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg` return `${config.routerBasePath}/book_placeholder.jpg`
@@ -236,7 +244,7 @@ export default {
if (this.recentEpisode.episode) { if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '') return this.recentEpisode.episode.replace(/^#/, '')
} }
return this.recentEpisode.index return ''
}, },
collapsedSeries() { collapsedSeries() {
// Only added to item object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
@@ -317,8 +325,13 @@ export default {
if (this.episodeProgress) return this.episodeProgress if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
},
userProgressPercent() { userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0 if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
}, },
itemIsFinished() { itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false return this.userProgress ? !!this.userProgress.isFinished : false
@@ -513,6 +526,14 @@ export default {
} }
} }
} }
if (this.userCanDelete) {
items.push({
func: 'deleteLibraryItem',
text: this.$strings.ButtonDelete
})
}
return items return items
}, },
_socket() { _socket() {
@@ -734,7 +755,7 @@ export default {
episodeId: this.recentEpisode.id, episodeId: this.recentEpisode.id,
title: this.recentEpisode.title, title: this.recentEpisode.title,
subtitle: this.mediaMetadata.title, subtitle: this.mediaMetadata.title,
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date', caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: this.recentEpisode.audioFile.duration || null, duration: this.recentEpisode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
} }
@@ -764,6 +785,35 @@ export default {
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }]) this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
this.store.commit('globals/setShowPlaylistsModal', true) this.store.commit('globals/setShowPlaylistsModal', true)
}, },
deleteLibraryItem() {
const payload = {
message: 'This will delete the library item from the database and your file system. Are you sure?',
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error',
checkboxDefaultValue: true,
callback: (confirmed, hardDelete) => {
if (confirmed) {
this.processing = true
const axios = this.$axios || this.$nuxt.$axios
axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
this.$toast.success('Item deleted')
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.store.commit('globals/setConfirmPrompt', payload)
},
createMoreMenu() { createMoreMenu() {
if (!this.$refs.moreIcon) return if (!this.$refs.moreIcon) return
@@ -858,7 +908,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: this.mediaMetadata.title, subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date', caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: episode.audioFile.duration || null, duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
}) })
+13 -6
View File
@@ -81,13 +81,20 @@ export default {
return this.title return this.title
}, },
displaySortLine() { displaySortLine() {
if (this.orderBy === 'addedAt') { switch (this.orderBy) {
// return this.addedAt case 'addedAt':
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat) return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
} else if (this.orderBy === 'totalDuration') { case 'totalDuration':
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false) return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
case 'lastBookUpdated':
const lastUpdated = Math.max(...(this.books).map(x => x.updatedAt), 0)
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
case 'lastBookAdded':
const lastBookAdded = Math.max(...(this.books).map(x => x.addedAt), 0)
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
default:
return null
} }
return null
}, },
books() { books() {
return this.series ? this.series.books || [] : [] return this.series ? this.series.books || [] : []
@@ -185,6 +185,11 @@ export default {
value: 'tracks', value: 'tracks',
sublist: true sublist: true
}, },
{
text: this.$strings.LabelAbridged,
value: 'abridged',
sublist: false
},
{ {
text: this.$strings.ButtonIssues, text: this.$strings.ButtonIssues,
value: 'issues', value: 'issues',
@@ -1,7 +1,7 @@
<template> <template>
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside"> <div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)"> <div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg"></span></span> <span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
</div> </div>
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }"> <div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }"> <div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
@@ -11,7 +11,7 @@
<template v-for="rate in rates"> <template v-for="rate in rates">
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)"> <div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
<div class="w-full h-full flex justify-center items-center"> <div class="w-full h-full flex justify-center items-center">
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm"></span></p> <p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">x</span></p>
</div> </div>
</div> </div>
</template> </template>
@@ -19,7 +19,7 @@
<div class="w-full py-1 px-4"> <div class="w-full py-1 px-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" /> <ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl"></span></p> <p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" /> <ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
</div> </div>
</div> </div>
@@ -0,0 +1,118 @@
<template>
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
<p class="text-base text-gray-200">{{ metadata.filename }}</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
<div class="flex flex-col sm:flex-row text-sm">
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelSize }}
</p>
<p>{{ $bytesPretty(metadata.size) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelDuration }}
</p>
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
<p>{{ audioFile.format }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChapters }}
</p>
<p>{{ audioFile.chapters?.length || 0 }}</p>
</div>
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelEmbeddedCover }}
</p>
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
</div>
</div>
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelCodec }}
</p>
<p>{{ audioFile.codec }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChannels }}
</p>
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelBitrate }}
</p>
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
<p>{{ audioFile.timeBase }}</p>
</div>
<div v-if="audioFile.language" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelLanguage }}
</p>
<p>{{ audioFile.language || '' }}</p>
</div>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
<p class="w-32 min-w-32 text-black-50 mb-1">
{{ key.replace('tag', '') }}
</p>
<p>{{ value }}</p>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
audioFile: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
metadata() {
return this.audioFile?.metadata || {}
},
metaTags() {
return this.audioFile?.metaTags || {}
}
},
methods: {},
mounted() {}
}
</script>
+8 -2
View File
@@ -73,6 +73,12 @@ export default {
}, },
canCreateBookmark() { canCreateBookmark() {
return !this.bookmarks.find((bm) => bm.time === this.currentTime) return !this.bookmarks.find((bm) => bm.time === this.currentTime)
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {
@@ -111,7 +117,7 @@ export default {
}, },
submitCreateBookmark() { submitCreateBookmark() {
if (!this.newBookmarkTitle) { if (!this.newBookmarkTitle) {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm') this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
} }
var bookmark = { var bookmark = {
title: this.newBookmarkTitle, title: this.newBookmarkTitle,
@@ -134,4 +140,4 @@ export default {
} }
} }
} }
</script> </script>
+14 -11
View File
@@ -2,13 +2,13 @@
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'"> <modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh"> <div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters"> <template v-for="chap in chapters">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)"> <div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
<p class="chapter-title truncate text-sm md:text-base"> <p class="chapter-title truncate text-sm md:text-base">
{{ chap.title }} {{ chap.title }}
</p> </p>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span> <span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
<span class="flex-grow" /> <span class="flex-grow" />
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span> <span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" /> <div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div> </div>
@@ -28,7 +28,8 @@ export default {
currentChapter: { currentChapter: {
type: Object, type: Object,
default: () => null default: () => null
} },
playbackRate: Number
}, },
data() { data() {
return {} return {}
@@ -47,11 +48,15 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterId() { currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null return this.currentChapter ? this.currentChapter.id : null
}, },
currentChapterStart() { currentChapterStart() {
return this.currentChapter ? this.currentChapter.start : 0 return (this.currentChapter?.start || 0) / this._playbackRate
} }
}, },
methods: { methods: {
@@ -61,13 +66,11 @@ export default {
scrollToChapter() { scrollToChapter() {
if (!this.currentChapterId) return if (!this.currentChapterId) return
var container = this.$refs.container if (this.$refs.container) {
if (container) { const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) { if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop const containerHeight = this.$refs.container.clientHeight
var containerHeight = container.clientHeight this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
container.scrollTo({ top: offsetTop - containerHeight / 2 })
} }
} }
} }
@@ -19,13 +19,13 @@
<div class="flex items-center -mx-1 mb-1"> <div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div> <div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div>
<div class="px-1"> <div class="px-1">
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }} {{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}
</div> </div>
</div> </div>
<div class="flex items-center -mx-1 mb-1"> <div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div> <div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div>
<div class="px-1"> <div class="px-1">
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }} {{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}
</div> </div>
</div> </div>
<div class="flex items-center -mx-1 mb-1"> <div class="flex items-center -mx-1 mb-1">
@@ -98,7 +98,8 @@
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn> <ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
</div> </div>
</div> </div>
</modals-modal> </modals-modal>
@@ -151,6 +152,15 @@ export default {
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream' else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local' else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown' return 'Unknown'
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
},
isOpenSession() {
return !!this._session.open
} }
}, },
methods: { methods: {
@@ -182,8 +192,26 @@ export default {
var errMsg = error.response ? error.response.data || '' : '' var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed) this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
}) })
},
closeSessionClick() {
this.processing = true
this.$axios
.$post(`/api/session/${this._session.id}/close`)
.then(() => {
this.$toast.success('Session closed')
this.show = false
this.$emit('closedSession')
})
.catch((error) => {
console.error('Failed to close session', error)
const errMsg = error.response?.data || ''
this.$toast.error(errMsg || 'Failed to close open session')
})
.finally(() => {
this.processing = false
})
} }
}, },
mounted() {} mounted() {}
} }
</script> </script>
+29 -11
View File
@@ -9,10 +9,14 @@
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh"> <div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="!timerSet" class="w-full"> <div v-if="!timerSet" class="w-full">
<template v-for="time in sleepTimes"> <template v-for="time in sleepTimes">
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)"> <div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
<p class="text-xl text-center">{{ time.text }}</p> <p class="text-xl text-center">{{ time.text }}</p>
</div> </div>
</template> </template>
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
<ui-btn color="success" type="submit" padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
</form>
</div> </div>
<div v-else class="w-full p-4"> <div v-else class="w-full p-4">
<div class="mb-4 flex items-center justify-center"> <div class="mb-4 flex items-center justify-center">
@@ -48,19 +52,28 @@ export default {
}, },
data() { data() {
return { return {
customTime: null,
sleepTimes: [ sleepTimes: [
{
seconds: 10,
text: '10 seconds'
},
{ {
seconds: 60 * 5, seconds: 60 * 5,
text: '5 minutes' text: '5 minutes'
}, },
{
seconds: 60 * 15,
text: '15 minutes'
},
{
seconds: 60 * 20,
text: '20 minutes'
},
{ {
seconds: 60 * 30, seconds: 60 * 30,
text: '30 minutes' text: '30 minutes'
}, },
{
seconds: 60 * 45,
text: '45 minutes'
},
{ {
seconds: 60 * 60, seconds: 60 * 60,
text: '60 minutes' text: '60 minutes'
@@ -72,10 +85,6 @@ export default {
{ {
seconds: 60 * 120, seconds: 60 * 120,
text: '2 hours' text: '2 hours'
},
{
seconds: 60 * 180,
text: '3 hours'
} }
] ]
} }
@@ -97,8 +106,17 @@ export default {
} }
}, },
methods: { methods: {
setTime(time) { submitCustomTime() {
this.$emit('set', time.seconds) if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
this.customTime = null
return
}
const timeInSeconds = Math.round(Number(this.customTime) * 60)
this.setTime(timeInSeconds)
},
setTime(seconds) {
this.$emit('set', seconds)
}, },
increment(amount) { increment(amount) {
this.$emit('increment', amount) this.$emit('increment', amount)
@@ -85,6 +85,12 @@ export default {
}, },
title() { title() {
return this.$strings.HeaderUpdateAuthor return this.$strings.HeaderUpdateAuthor
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
} }
}, },
methods: { methods: {
@@ -151,6 +157,11 @@ export default {
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
else payload.q = this.authorCopy.name else payload.q = this.authorCopy.name
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => { var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return null return null
+4 -3
View File
@@ -2,7 +2,8 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative"> <div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<div class="relative"> <div class="relative">
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay --> <!-- book cover overlay -->
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100"> <div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" /> <div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
@@ -27,14 +28,14 @@
</form> </form>
</div> </div>
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary"> <div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
<div class="flex items-center justify-center py-2"> <div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p> <p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn> <ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
</div> </div>
<div v-if="showLocalCovers" class="flex items-center justify-center"> <div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
<template v-for="cover in localCovers"> <template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)"> <div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }"> <div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
+2 -25
View File
@@ -7,11 +7,6 @@
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'"> <div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
<div class="flex items-center px-4"> <div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">{{ $strings.ButtonRemove }}</ui-btn>
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
<div class="flex-grow" />
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4"> <ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn> <ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
</ui-tooltip> </ui-tooltip>
@@ -20,6 +15,8 @@
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn> <ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
</ui-tooltip> </ui-tooltip>
<div class="flex-grow" />
<!-- desktop --> <!-- desktop -->
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn> <ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn> <ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
@@ -77,9 +74,6 @@ export default {
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
}, },
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
libraryId() { libraryId() {
return this.libraryItem ? this.libraryItem.libraryId : null return this.libraryItem ? this.libraryItem.libraryId : null
}, },
@@ -184,23 +178,6 @@ export default {
} }
return false return false
}, },
removeItem() {
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}`)
.then(() => {
console.log('Item removed')
this.$toast.success('Item Removed')
this.$emit('close')
this.isProcessing = false
})
.catch((error) => {
console.error('Remove item failed', error)
this.isProcessing = false
})
}
},
checkIsScrollable() { checkIsScrollable() {
this.$nextTick(() => { this.$nextTick(() => {
var formWrapper = document.getElementById('formWrapper') var formWrapper = document.getElementById('formWrapper')
+1 -4
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" /> <tables-library-files-table expanded :library-item="libraryItem" :is-missing="isMissing" in-modal />
</div> </div>
</template> </template>
@@ -30,9 +30,6 @@ export default {
media() { media() {
return this.libraryItem.media || {} return this.libraryItem.media || {}
}, },
libraryFiles() {
return this.libraryItem.libraryFiles || []
},
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
+36 -8
View File
@@ -34,13 +34,25 @@
</div> </div>
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" /> <ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
<form @submit.prevent="submitMatchUpdate"> <form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2"> <div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" /> <div class="flex flex-grow items-center py-2">
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" /> <ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16"> <ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary"> </div>
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
</a> <div class="flex py-2">
<div>
<p class="text-center text-gray-200">New</p>
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</a>
</div>
<div v-if="media.coverPath">
<p class="text-center text-gray-200">Current</p>
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</a>
</div>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.title" class="flex items-center py-2"> <div v-if="selectedMatchOrig.title" class="flex items-center py-2">
@@ -164,6 +176,20 @@
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p> <p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
</div>
</div>
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? 'Abridged (checked)' : 'Unabridged (unchecked)' }}</p>
</div>
</div>
<div class="flex items-center justify-end py-2"> <div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn> <ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
@@ -209,6 +235,7 @@ export default {
explicit: true, explicit: true,
asin: true, asin: true,
isbn: true, isbn: true,
abridged: true,
// Podcast specific // Podcast specific
itunesPageUrl: true, itunesPageUrl: true,
itunesId: true, itunesId: true,
@@ -327,6 +354,7 @@ export default {
res.itunesPageUrl = res.pageUrl || null res.itunesPageUrl = res.pageUrl || null
res.itunesId = res.id || null res.itunesId = res.id || null
res.author = res.artistName || null res.author = res.artistName || null
res.explicit = res.explicit || false
return res return res
}) })
} }
@@ -352,6 +380,7 @@ export default {
explicit: true, explicit: true,
asin: true, asin: true,
isbn: true, isbn: true,
abridged: true,
// Podcast specific // Podcast specific
itunesPageUrl: true, itunesPageUrl: true,
itunesId: true, itunesId: true,
@@ -468,7 +497,6 @@ export default {
} else if (key === 'narrator') { } else if (key === 'narrator') {
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim()) updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'genres') { } else if (key === 'genres') {
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
updatePayload.metadata.genres = [...this.selectedMatch[key]] updatePayload.metadata.genres = [...this.selectedMatch[key]]
} else if (key === 'tags') { } else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim()) updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
@@ -59,6 +59,14 @@ export default {
newMaxNewEpisodesToDownload: 0 newMaxNewEpisodesToDownload: 0
} }
}, },
watch: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: { computed: {
isProcessing: { isProcessing: {
get() { get() {
@@ -176,4 +184,4 @@ export default {
height: calc(100% - 80px); height: calc(100% - 80px);
max-height: calc(100% - 80px); max-height: calc(100% - 80px);
} }
</style> </style>
+56 -4
View File
@@ -46,8 +46,20 @@
>{{ $strings.ButtonOpenManager }} >{{ $strings.ButtonOpenManager }}
<span class="material-icons text-lg ml-2">launch</span> <span class="material-icons text-lg ml-2">launch</span>
</ui-btn> </ui-btn>
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
</div> </div>
</div> </div>
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- processing alert -->
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
<p class="text-lg">Currently embedding metadata</p>
</widgets-alert>
</div> </div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p> <p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
@@ -71,10 +83,10 @@ export default {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
}, },
libraryItemId() { libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null return this.libraryItem?.id || null
}, },
media() { media() {
return this.libraryItem ? this.libraryItem.media || {} : {} return this.libraryItem?.media || {}
}, },
mediaTracks() { mediaTracks() {
return this.media.tracks || [] return this.media.tracks || []
@@ -92,9 +104,49 @@ export default {
showMp3Split() { showMp3Split() {
if (!this.mediaTracks.length) return false if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length return this.isSingleM4b && this.chapters.length
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []
},
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
},
tasks() {
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
},
embedTask() {
return this.tasks.find((t) => t.action === 'embed-metadata')
},
encodeTask() {
return this.tasks.find((t) => t.action === 'encode-m4b')
},
isEmbedTaskRunning() {
return this.embedTask && !this.embedTask?.isFinished
},
isEncodeTaskRunning() {
return this.encodeTask && !this.encodeTask?.isFinished
} }
}, },
methods: {}, methods: {
mounted() {} quickEmbed() {
const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)
.then(() => {
console.log('Audio metadata encode started')
})
.catch((error) => {
console.error('Audio metadata encode failed', error)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}
}
} }
</script> </script>
@@ -11,8 +11,15 @@
</template> </template>
</div> </div>
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevEpisode" @mousedown.prevent>arrow_back_ios</div>
</div>
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextEpisode" @mousedown.prevent>arrow_forward_ios</div>
</div>
<div ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh"> <div ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" /> <component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episodeItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div> </div>
</modals-modal> </modals-modal>
</template> </template>
@@ -21,8 +28,8 @@
export default { export default {
data() { data() {
return { return {
episodeItem: null,
processing: false, processing: false,
selectedTab: 'details',
tabs: [ tabs: [
{ {
id: 'details', id: 'details',
@@ -37,6 +44,29 @@ export default {
] ]
} }
}, },
watch: {
show: {
handler(newVal) {
if (newVal) {
const availableTabIds = this.tabs.map((tab) => tab.id)
if (!availableTabIds.length) {
this.show = false
return
}
if (!availableTabIds.includes(this.selectedTab)) {
this.selectedTab = availableTabIds[0]
}
this.episodeItem = null
this.init()
this.registerListeners()
} else {
this.unregisterListeners()
}
}
}
},
computed: { computed: {
show: { show: {
get() { get() {
@@ -46,27 +76,118 @@ export default {
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val) this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
} }
}, },
selectedTab: {
get() {
return this.$store.state.editPodcastModalTab
},
set(val) {
this.$store.commit('setEditPodcastModalTab', val)
}
},
libraryItem() { libraryItem() {
return this.$store.state.selectedLibraryItem return this.$store.state.selectedLibraryItem
}, },
episode() { episode() {
return this.$store.state.globals.selectedEpisode return this.$store.state.globals.selectedEpisode
}, },
selectedEpisodeId() {
return this.episode.id
},
title() { title() {
if (!this.libraryItem) return '' return this.libraryItem?.media.metadata.title || 'Unknown'
return this.libraryItem.media.metadata.title || 'Unknown'
}, },
tabComponentName() { tabComponentName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab) const _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : '' return _tab ? _tab.component : ''
},
episodeTableEpisodeIds() {
return this.$store.state.episodeTableEpisodeIds || []
},
currentEpisodeIndex() {
if (!this.episodeTableEpisodeIds.length) return 0
return this.episodeTableEpisodeIds.findIndex((bid) => bid === this.selectedEpisodeId)
},
canGoPrev() {
return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex > 0
},
canGoNext() {
return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex < this.episodeTableEpisodeIds.length - 1
} }
}, },
methods: { methods: {
async goPrevEpisode() {
if (this.currentEpisodeIndex - 1 < 0) return
const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1]
this.processing = true
const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => {
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode'
this.$toast.error(errorMsg)
return null
})
this.processing = false
if (prevEpisode) {
this.episodeItem = prevEpisode
this.$store.commit('globals/setSelectedEpisode', prevEpisode)
} else {
console.error('Episode not found', prevEpisodeId)
}
},
async goNextEpisode() {
if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return
this.processing = true
const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1]
const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => {
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
this.$toast.error(errorMsg)
return null
})
this.processing = false
if (nextEpisode) {
this.episodeItem = nextEpisode
this.$store.commit('globals/setSelectedEpisode', nextEpisode)
} else {
console.error('Episode not found', nextEpisodeId)
}
},
selectTab(tab) { selectTab(tab) {
this.selectedTab = tab if (this.selectedTab === tab) return
if (this.tabs.find((t) => t.id === tab)) {
this.selectedTab = tab
this.processing = false
}
},
init() {
this.fetchFull()
},
async fetchFull() {
try {
this.processing = true
this.episodeItem = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${this.selectedEpisodeId}`)
this.processing = false
} catch (error) {
console.error('Failed to fetch episode', this.selectedEpisodeId, error)
this.processing = false
this.show = false
}
},
hotkey(action) {
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
this.goNextEpisode()
} else if (action === this.$hotkeys.Modal.PREV_PAGE) {
this.goPrevEpisode()
}
},
registerListeners() {
this.$eventBus.$on('modal-hotkey', this.hotkey)
},
unregisterListeners() {
this.$eventBus.$off('modal-hotkey', this.hotkey)
} }
}, },
mounted() {} mounted() {},
beforeDestroy() {
this.unregisterListeners()
}
} }
</script> </script>
@@ -77,4 +198,4 @@ export default {
.tab.tab-selected { .tab.tab-selected {
height: 41px; height: 41px;
} }
</style> </style>
@@ -6,21 +6,33 @@
</div> </div>
</template> </template>
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden"> <div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form>
</div>
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto"> <div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
<div <div
v-for="(episode, index) in episodes" v-for="(episode, index) in episodesList"
:key="index" :key="index"
class="relative" class="relative"
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'" :class="itemEpisodeMap[episode.enclosure.url?.split('?')[0]] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(index, episode)" @click="toggleSelectEpisode(index, episode)"
> >
<div class="absolute top-0 left-0 h-full flex items-center p-2"> <div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span> <span v-if="itemEpisodeMap[episode.enclosure.url?.split('?')[0]]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" /> <ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
</div> </div>
<div class="px-8 py-2"> <div class="px-8 py-2">
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p> <div class="flex items-center font-semibold text-gray-200">
<p class="break-words mb-1">{{ episode.title }}</p> <div v-if="episode.season || episode.episode">#</div>
<div v-if="episode.season">{{ episode.season }}x</div>
<div v-if="episode.episode">{{ episode.episode }}</div>
</div>
<div class="flex items-center mb-1">
<div class="break-words">{{ episode.title }}</div>
<widgets-podcast-type-indicator :type="episode.episodeType" />
</div>
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p> <p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p> <p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
</div> </div>
@@ -52,7 +64,10 @@ export default {
return { return {
processing: false, processing: false,
selectedEpisodes: {}, selectedEpisodes: {},
selectAll: false selectAll: false,
search: null,
searchTimeout: null,
searchText: null
} }
}, },
watch: { watch: {
@@ -77,7 +92,7 @@ export default {
return this.libraryItem.media.metadata.title || 'Unknown' return this.libraryItem.media.metadata.title || 'Unknown'
}, },
allDownloaded() { allDownloaded() {
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url]) return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]])
}, },
episodesSelected() { episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key]) return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
@@ -93,23 +108,39 @@ export default {
itemEpisodeMap() { itemEpisodeMap() {
var map = {} var map = {}
this.itemEpisodes.forEach((item) => { this.itemEpisodes.forEach((item) => {
if (item.enclosure) map[item.enclosure.url] = true if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true
}) })
return map return map
},
episodesList() {
return this.episodes.filter((episode) => {
if (!this.searchText) return true
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
})
} }
}, },
methods: { methods: {
inputUpdate() {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
if (!this.search || !this.search.trim()) {
this.searchText = ''
return
}
this.searchText = this.search.toLowerCase().trim()
}, 500)
},
toggleSelectAll(val) { toggleSelectAll(val) {
for (let i = 0; i < this.episodes.length; i++) { for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i] const episode = this.episodes[i]
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) this.selectedEpisodes[String(i)] = false
else this.$set(this.selectedEpisodes, String(i), val) else this.$set(this.selectedEpisodes, String(i), val)
} }
}, },
checkSetIsSelectedAll() { checkSetIsSelectedAll() {
for (let i = 0; i < this.episodes.length; i++) { for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i] const episode = this.episodes[i]
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) { if (!this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]] && !this.selectedEpisodes[String(i)]) {
this.selectAll = false this.selectAll = false
return return
} }
@@ -117,7 +148,7 @@ export default {
this.selectAll = true this.selectAll = true
}, },
toggleSelectEpisode(index, episode) { toggleSelectEpisode(index, episode) {
if (this.itemEpisodeMap[episode.enclosure.url]) return if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)]) this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
this.checkSetIsSelectedAll() this.checkSetIsSelectedAll()
}, },
+25 -4
View File
@@ -28,6 +28,17 @@
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="$strings.LabelGenres" /> <ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="$strings.LabelGenres" />
</div> </div>
</div> </div>
<div class="flex flex-wrap">
<div class="md:w-1/4 p-2">
<ui-dropdown :label="$strings.LabelPodcastType" v-model="podcast.type" :items="podcastTypes" small />
</div>
<div class="md:w-1/4 p-2">
<ui-text-input-with-label v-model="podcast.language" :label="$strings.LabelLanguage" />
</div>
<div class="md:w-1/4 px-2 pt-7">
<ui-checkbox v-model="podcast.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
<div class="p-2 w-full"> <div class="p-2 w-full">
<ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" /> <ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" />
</div> </div>
@@ -82,7 +93,10 @@ export default {
itunesPageUrl: '', itunesPageUrl: '',
itunesId: '', itunesId: '',
itunesArtistId: '', itunesArtistId: '',
autoDownloadEpisodes: false autoDownloadEpisodes: false,
language: '',
explicit: false,
type: ''
} }
} }
}, },
@@ -140,6 +154,9 @@ export default {
selectedFolderPath() { selectedFolderPath() {
if (!this.selectedFolder) return '' if (!this.selectedFolder) return ''
return this.selectedFolder.fullPath return this.selectedFolder.fullPath
},
podcastTypes() {
return this.$store.state.globals.podcastTypes || []
} }
}, },
methods: { methods: {
@@ -170,7 +187,9 @@ export default {
itunesPageUrl: this.podcast.itunesPageUrl, itunesPageUrl: this.podcast.itunesPageUrl,
itunesId: this.podcast.itunesId, itunesId: this.podcast.itunesId,
itunesArtistId: this.podcast.itunesArtistId, itunesArtistId: this.podcast.itunesArtistId,
language: this.podcast.language language: this.podcast.language,
explicit: this.podcast.explicit,
type: this.podcast.type
}, },
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
} }
@@ -205,9 +224,11 @@ export default {
this.podcast.itunesPageUrl = this._podcastData.pageUrl || '' this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
this.podcast.itunesId = this._podcastData.id || '' this.podcast.itunesId = this._podcastData.id || ''
this.podcast.itunesArtistId = this._podcastData.artistId || '' this.podcast.itunesArtistId = this._podcastData.artistId || ''
this.podcast.language = this._podcastData.language || '' this.podcast.language = this._podcastData.language || this.feedMetadata.language || ''
this.podcast.autoDownloadEpisodes = false this.podcast.autoDownloadEpisodes = false
this.podcast.type = this._podcastData.type || this.feedMetadata.type || 'episodic'
this.podcast.explicit = this._podcastData.explicit || this.feedMetadata.explicit === 'yes' || this.feedMetadata.explicit == 'true'
if (this.folderItems[0]) { if (this.folderItems[0]) {
this.selectedFolderId = this.folderItems[0].value this.selectedFolderId = this.folderItems[0].value
this.folderUpdated() this.folderUpdated()
@@ -226,4 +247,4 @@ export default {
#episodes-scroll { #episodes-scroll {
max-height: calc(80vh - 200px); max-height: calc(80vh - 200px);
} }
</style> </style>
@@ -8,7 +8,7 @@
<ui-text-input-with-label v-model="newEpisode.episode" :label="$strings.LabelEpisode" /> <ui-text-input-with-label v-model="newEpisode.episode" :label="$strings.LabelEpisode" />
</div> </div>
<div class="w-1/5 p-1"> <div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" /> <ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
</div> </div>
<div class="w-2/5 p-1"> <div class="w-2/5 p-1">
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" /> <ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" />
@@ -24,7 +24,12 @@
</div> </div>
</div> </div>
<div class="flex items-center justify-end pt-4"> <div class="flex items-center justify-end pt-4">
<ui-btn @click="submit">{{ $strings.ButtonSubmit }}</ui-btn> <!-- desktop -->
<ui-btn @click="submit" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
<!-- mobile -->
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
</div> </div>
<div v-if="enclosureUrl" class="py-4"> <div v-if="enclosureUrl" class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p> <p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
@@ -89,6 +94,9 @@ export default {
}, },
enclosureUrl() { enclosureUrl() {
return this.enclosure.url return this.enclosure.url
},
episodeTypes() {
return this.$store.state.globals.episodeTypes || []
} }
}, },
methods: { methods: {
@@ -122,28 +130,43 @@ export default {
} }
return updatePayload return updatePayload
}, },
submit() { async saveAndClose() {
const payload = this.getUpdatePayload() const wasUpdated = await this.submit()
if (!Object.keys(payload).length) { if (wasUpdated !== null) this.$emit('close')
return this.$toast.info('No updates were made') },
async submit() {
if (this.isProcessing) {
return null
} }
const updatedDetails = this.getUpdatePayload()
if (!Object.keys(updatedDetails).length) {
this.$toast.info('No changes were made')
return false
}
return this.updateDetails(updatedDetails)
},
async updateDetails(updatedDetails) {
this.isProcessing = true this.isProcessing = true
this.$axios const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => {
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload) console.error('Failed update episode', error)
.then(() => { this.isProcessing = false
this.isProcessing = false this.$toast.error(error?.response?.data || 'Failed to update episode')
return false
})
this.isProcessing = false
if (updateResult) {
if (updateResult) {
this.$toast.success('Podcast episode updated') this.$toast.success('Podcast episode updated')
this.$emit('close') return true
}) } else {
.catch((error) => { this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode' }
console.error('Failed update episode', error) }
this.isProcessing = false return false
this.$toast.error(errorMsg)
})
} }
}, },
mounted() {} mounted() {}
} }
</script> </script>
@@ -14,6 +14,27 @@
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span> <span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
</div> </div>
<div v-if="currentFeed.meta" class="mt-5">
<div class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
</div>
<div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
</div>
<div v-if="currentFeed.meta.ownerName" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
</div>
<div>{{ currentFeed.meta.ownerName }}</div>
</div>
<div v-if="currentFeed.meta.ownerEmail" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
</div>
<div>{{ currentFeed.meta.ownerEmail }}</div>
</div>
</div>
</div> </div>
<div v-else class="w-full"> <div v-else class="w-full">
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderOpenRSSFeed }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderOpenRSSFeed }}</p>
@@ -22,6 +43,7 @@
<ui-text-input-with-label v-model="newFeedSlug" :label="$strings.LabelRSSFeedSlug" /> <ui-text-input-with-label v-model="newFeedSlug" :label="$strings.LabelRSSFeedSlug" />
<p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p> <p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
</div> </div>
<widgets-rss-feed-metadata-builder v-model="metadataDetails" />
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsHttps }}</p> <p v-if="isHttp" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsHttps }}</p>
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p> <p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p>
@@ -41,7 +63,12 @@ export default {
return { return {
processing: false, processing: false,
newFeedSlug: null, newFeedSlug: null,
currentFeed: null currentFeed: null,
metadataDetails: {
preventIndexing: true,
ownerName: '',
ownerEmail: ''
}
} }
}, },
watch: { watch: {
@@ -107,7 +134,8 @@ export default {
const payload = { const payload = {
serverAddress: window.origin, serverAddress: window.origin,
slug: this.newFeedSlug slug: this.newFeedSlug,
metadataDetails: this.metadataDetails
} }
if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}` if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
+13 -8
View File
@@ -38,7 +38,8 @@ export default {
currentChapter: { currentChapter: {
type: Object, type: Object,
default: () => {} default: () => {}
} },
playbackRate: Number
}, },
data() { data() {
return { return {
@@ -63,6 +64,10 @@ export default {
} }
}, },
computed: { computed: {
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterDuration() { currentChapterDuration() {
if (!this.currentChapter) return 0 if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentChapter.start return this.currentChapter.end - this.currentChapter.start
@@ -81,8 +86,8 @@ export default {
clickTrack(e) { clickTrack(e) {
if (this.loading) return if (this.loading) return
var offsetX = e.offsetX const offsetX = e.offsetX
var perc = offsetX / this.trackWidth const perc = offsetX / this.trackWidth
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0 const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const time = baseTime + perc * duration const time = baseTime + perc * duration
@@ -111,7 +116,7 @@ export default {
this.updateReadyTrack() this.updateReadyTrack()
}, },
updateReadyTrack() { updateReadyTrack() {
var widthReady = Math.round(this.trackWidth * this.percentReady) const widthReady = Math.round(this.trackWidth * this.percentReady)
if (this.readyTrackWidth === widthReady) return if (this.readyTrackWidth === widthReady) return
this.readyTrackWidth = widthReady this.readyTrackWidth = widthReady
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px' if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
@@ -124,7 +129,7 @@ export default {
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
var ptWidth = Math.round((time / duration) * this.trackWidth) const ptWidth = Math.round((time / duration) * this.trackWidth)
if (this.playedTrackWidth === ptWidth) { if (this.playedTrackWidth === ptWidth) {
return return
} }
@@ -133,7 +138,7 @@ export default {
}, },
setChapterTicks() { setChapterTicks() {
this.chapterTicks = this.chapters.map((chap) => { this.chapterTicks = this.chapters.map((chap) => {
var perc = chap.start / this.duration const perc = chap.start / this.duration
return { return {
title: chap.title, title: chap.title,
left: perc * this.trackWidth left: perc * this.trackWidth
@@ -141,7 +146,7 @@ export default {
}) })
}, },
mousemoveTrack(e) { mousemoveTrack(e) {
var offsetX = e.offsetX const offsetX = e.offsetX
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0 const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
@@ -167,7 +172,7 @@ export default {
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px' this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
} }
if (this.$refs.hoverTimestampText) { if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(progressTime) var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end) var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
if (chapter && chapter.title) { if (chapter && chapter.title) {
+16 -16
View File
@@ -46,7 +46,7 @@
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" /> <player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
</div> </div>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" /> <player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
<div class="flex"> <div class="flex">
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p> <p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
@@ -59,7 +59,7 @@
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p> <p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div> </div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
</div> </div>
</template> </template>
@@ -92,6 +92,11 @@ export default {
useChapterTrack: false useChapterTrack: false
} }
}, },
watch: {
playbackRate() {
this.updateTimestamp()
}
},
computed: { computed: {
sleepTimerRemainingString() { sleepTimerRemainingString() {
var rounded = Math.round(this.sleepTimerRemaining) var rounded = Math.round(this.sleepTimerRemaining)
@@ -213,18 +218,14 @@ export default {
} }
}, },
increasePlaybackRate() { increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3] if (this.playbackRate >= 10) return
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate) this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
if (currentRateIndex >= rates.length - 1) return this.setPlaybackRate(this.playbackRate)
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
}, },
decreasePlaybackRate() { decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3] if (this.playbackRate <= 0.5) return
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate) this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
if (currentRateIndex <= 0) return this.setPlaybackRate(this.playbackRate)
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
}, },
setPlaybackRate(playbackRate) { setPlaybackRate(playbackRate) {
this.$emit('setPlaybackRate', playbackRate) this.$emit('setPlaybackRate', playbackRate)
@@ -289,14 +290,13 @@ export default {
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady) if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
}, },
updateTimestamp() { updateTimestamp() {
var ts = this.$refs.currentTimestamp const ts = this.$refs.currentTimestamp
if (!ts) { if (!ts) {
console.error('No timestamp el') console.error('No timestamp el')
return return
} }
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
var currTimeClean = this.$secondsToTimestamp(time) ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
ts.innerText = currTimeClean
}, },
setBufferTime(bufferTime) { setBufferTime(bufferTime) {
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime) if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
@@ -312,7 +312,7 @@ export default {
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack) if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate) this.setPlaybackRate(this.playbackRate)
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) { if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
+21 -4
View File
@@ -3,11 +3,14 @@
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside"> <div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300"> <div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-lg mb-8 mt-2 px-1" v-html="message" /> <p class="text-lg mb-6 mt-2 px-1" v-html="message" />
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
<div class="flex px-1 items-center"> <div class="flex px-1 items-center">
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn> <ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="isYesNo" color="success" @click="confirm">{{ $strings.ButtonYes }}</ui-btn> <ui-btn v-if="isYesNo" :color="yesButtonColor" @click="confirm">{{ yesButtonText }}</ui-btn>
<ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn> <ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn>
</div> </div>
</div> </div>
@@ -21,7 +24,8 @@ export default {
data() { data() {
return { return {
el: null, el: null,
content: null content: null,
checkboxValue: false
} }
}, },
watch: { watch: {
@@ -57,6 +61,18 @@ export default {
persistent() { persistent() {
return !!this.confirmPromptOptions.persistent return !!this.confirmPromptOptions.persistent
}, },
checkboxLabel() {
return this.confirmPromptOptions.checkboxLabel
},
yesButtonText() {
return this.confirmPromptOptions.yesButtonText || this.$strings.ButtonYes
},
yesButtonColor() {
return this.confirmPromptOptions.yesButtonColor || 'success'
},
checkboxDefaultValue() {
return !!this.confirmPromptOptions.checkboxDefaultValue
},
isYesNo() { isYesNo() {
return this.type === 'yesNo' return this.type === 'yesNo'
}, },
@@ -84,10 +100,11 @@ export default {
this.show = false this.show = false
}, },
confirm() { confirm() {
if (this.callback) this.callback(true) if (this.callback) this.callback(true, this.checkboxValue)
this.show = false this.show = false
}, },
setShow() { setShow() {
this.checkboxValue = this.checkboxDefaultValue
this.$eventBus.$emit('showing-prompt', true) this.$eventBus.$emit('showing-prompt', true)
document.body.appendChild(this.el) document.body.appendChild(this.el)
setTimeout(() => { setTimeout(() => {
+227 -87
View File
@@ -1,18 +1,14 @@
<template> <template>
<div class="h-full w-full"> <div class="h-full w-full">
<div class="h-full flex items-center"> <div class="h-full flex items-center justify-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center"> <div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span> <span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</div> </div>
<div id="frame" class="w-full" style="height: 650px"> <div id="frame" class="w-full" style="height: 80%">
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div> <div id="viewer"></div>
<div class="py-4 flex justify-center" style="height: 50px">
<p>{{ progress }}%</p>
</div>
</div> </div>
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden"> <div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span> <span v-if="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</div> </div>
</div> </div>
</div> </div>
@@ -21,108 +17,252 @@
<script> <script>
import ePub from 'epubjs' import ePub from 'epubjs'
/**
* @typedef {object} EpubReader
* @property {ePub.Book} book
* @property {ePub.Rendition} rendition
*/
export default { export default {
props: { props: {
url: String url: String,
libraryItem: {
type: Object,
default: () => {}
}
}, },
data() { data() {
return { return {
windowWidth: 0,
/** @type {ePub.Book} */
book: null, book: null,
rendition: null, /** @type {ePub.Rendition} */
chapters: [], rendition: null
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
} }
}, },
computed: {}, computed: {
methods: { /** @returns {string} */
changedChapter() { libraryItemId() {
if (this.rendition) { return this.libraryItem?.id
this.rendition.display(this.selectedChapter)
}
}, },
hasPrev() {
return !this.rendition?.location?.atStart
},
hasNext() {
return !this.rendition?.location?.atEnd
},
/** @returns {Array<ePub.NavItem>} */
chapters() {
return this.book ? this.book.navigation.toc : []
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
readerWidth() {
if (this.windowWidth < 640) return this.windowWidth
return this.windowWidth - 200
}
},
methods: {
prev() { prev() {
if (this.rendition) { return this.rendition?.prev()
this.rendition.prev()
}
}, },
next() { next() {
if (this.rendition) { return this.rendition?.next()
this.rendition.next() },
goToChapter(href) {
return this.rendition?.display(href)
},
keyUp(e) {
const rtl = this.book.package.metadata.direction === 'rtl'
if ((e.keyCode || e.which) == 37) {
return rtl ? this.next() : this.prev()
} else if ((e.keyCode || e.which) == 39) {
return rtl ? this.prev() : this.next()
} }
}, },
keyUp() { /**
if ((e.keyCode || e.which) == 37) { * @param {object} payload
this.prev() * @param {string} payload.ebookLocation - CFI of the current location
} else if ((e.keyCode || e.which) == 39) { * @param {string} payload.ebookProgress - eBook Progress Percentage
this.next() */
updateProgress(payload) {
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
},
getAllEbookLocationData() {
const locations = []
let totalSize = 0 // Total in bytes
for (const key in localStorage) {
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
continue
}
try {
const ebookLocations = JSON.parse(localStorage[key])
if (!ebookLocations.locations) throw new Error('Invalid locations object')
ebookLocations.key = key
ebookLocations.size = (localStorage[key].length + key.length) * 2
locations.push(ebookLocations)
totalSize += ebookLocations.size
} catch (error) {
console.error('Failed to parse ebook locations', key, error)
localStorage.removeItem(key)
}
}
// Sort by oldest lastAccessed first
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
return {
locations,
totalSize
}
},
/** @param {string} locationString */
checkSaveLocations(locationString) {
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
// Too large overall
if (newLocationsSize > maxSizeInBytes) {
console.error('Epub locations are too large to store. Size =', newLocationsSize)
return
}
const ebookLocationsData = this.getAllEbookLocationData()
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
// Remove epub locations until there is room for locations
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
const oldestLocation = ebookLocationsData.locations.shift()
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
availableSpace += oldestLocation.size
localStorage.removeItem(oldestLocation.key)
}
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
this.saveLocations(locationString)
},
/** @param {string} locationString */
saveLocations(locationString) {
localStorage.setItem(
this.localStorageLocationsKey,
JSON.stringify({
lastAccessed: Date.now(),
locations: locationString
})
)
},
loadLocations() {
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
if (!locationsObjString) return null
const locationsObject = JSON.parse(locationsObjString)
// Remove invalid location objects
if (!locationsObject.locations) {
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
localStorage.removeItem(this.localStorageLocationsKey)
return null
}
// Update lastAccessed
this.saveLocations(locationsObject.locations)
return locationsObject.locations
},
/** @param {string} location - CFI of the new location */
relocated(location) {
if (this.userMediaProgress?.ebookLocation === location.start.cfi) {
return
}
if (location.end.percentage) {
this.updateProgress({
ebookLocation: location.start.cfi,
ebookProgress: location.end.percentage
})
} else {
this.updateProgress({
ebookLocation: location.start.cfi
})
} }
}, },
initEpub() { initEpub() {
// var book = ePub(this.url, { /** @type {EpubReader} */
// requestHeaders: { const reader = this
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
this.rendition = book.renderTo('viewer', { /** @type {ePub.Book} */
width: window.innerWidth - 200, reader.book = new ePub(reader.url, {
height: 600, width: this.readerWidth,
ignoreClass: 'annotator-hl', height: window.innerHeight - 50
manager: 'continuous',
spread: 'always'
}) })
var displayed = this.rendition.display()
book.ready /** @type {ePub.Rendition} */
.then(() => { reader.rendition = reader.book.renderTo('viewer', {
console.log('Book ready') width: this.readerWidth,
return book.locations.generate(1600) height: window.innerHeight * 0.8
})
// load saved progress
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
// load style
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
reader.book.ready.then(() => {
// set up event listeners
reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('keydown', reader.keyUp)
let touchStart = 0
let touchEnd = 0
reader.rendition.on('touchstart', (event) => {
touchStart = event.changedTouches[0].screenX
}) })
.then((locations) => {
// console.log('Loaded locations', locations) reader.rendition.on('touchend', (event) => {
// Wait for book to be rendered to get current page touchEnd = event.changedTouches[0].screenX
displayed.then(() => { const touchDistanceX = Math.abs(touchEnd - touchStart)
// Get the current CFI if (touchStart < touchEnd && touchDistanceX > 120) {
var currentLocation = this.rendition.currentLocation() this.next()
if (!currentLocation.start) { }
console.error('No Start', currentLocation) if (touchStart > touchEnd && touchDistanceX > 120) {
} else { this.prev()
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi) }
// console.log('current page', currentPage) })
}
// load ebook cfi locations
const savedLocations = this.loadLocations()
if (savedLocations) {
reader.book.locations.load(savedLocations)
} else {
reader.book.locations.generate().then(() => {
this.checkSaveLocations(reader.book.locations.save())
}) })
}) }
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
})
book.loaded.metadata.then((metadata) => {
this.author = metadata.creator
this.title = metadata.title
})
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
this.progress = Math.floor(percent * 100)
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
}) })
},
resize() {
this.windowWidth = window.innerWidth
this.rendition?.resize(this.readerWidth, window.innerHeight * 0.8)
} }
}, },
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.book?.destroy()
},
mounted() { mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', this.resize)
this.initEpub() this.initEpub()
} }
} }
-88
View File
@@ -1,88 +0,0 @@
<template>
<div class="h-full w-full">
<div id="viewer" class="border border-gray-100 bg-white text-black shadow-md h-screen overflow-y-auto p-4" v-html="pageHtml"></div>
</div>
</template>
<script>
export default {
props: {
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
bookInfo: {},
page: 0,
numPages: 0,
pageHtml: '',
progress: 0
}
},
computed: {
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
hasPrev() {
return this.page > 0
},
hasNext() {
return this.page < this.numPages - 1
}
},
methods: {
prev() {
if (!this.hasPrev) return
this.page--
this.loadPage()
},
next() {
if (!this.hasNext) return
this.page++
this.loadPage()
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
loadPage() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/page/${this.page}?dev=${this.$isDev ? 1 : 0}`)
.then((html) => {
this.pageHtml = html
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load page')
})
},
loadInfo() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/info?dev=${this.$isDev ? 1 : 0}`)
.then((bookInfo) => {
this.bookInfo = bookInfo
this.numPages = bookInfo.pages
this.page = 0
this.loadPage()
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load info')
})
},
initEpub() {
if (!this.libraryItemId) return
this.loadInfo()
}
},
mounted() {
this.initEpub()
}
}
</script>
+55 -11
View File
@@ -1,24 +1,48 @@
<template> <template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white"> <div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
<div class="absolute top-4 right-4 z-20"> <div class="absolute top-4 left-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span> <span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
</div> </div>
<div class="absolute top-4 left-4"> <div class="absolute top-4 left-1/2 transform -translate-x-1/2">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1> <h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
<p v-if="abAuthor">by {{ abAuthor }}</p> <span style="font-weight: 600">{{ abTitle }}</span>
<span v-if="abAuthor" style="display: inline"> </span>
<span v-if="abAuthor">{{ abAuthor }}</span>
</h1>
</div>
<div class="absolute top-4 right-4 z-20">
<span v-if="hasSettings" class="material-icons cursor-pointer text-2xl" @click="openSettings">settings</span>
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
</div> </div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" /> <component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div> <!-- TOC side nav -->
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
<div v-if="hasToC" class="w-72 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-72'" @click.stop.prevent="toggleToC">
<div class="p-4 h-full overflow-hidden">
<p class="text-lg font-semibold mb-2">Table of Contents</p>
<div class="tocContent">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
</li>
</ul>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
chapters: [],
tocOpen: false
}
}, },
watch: { watch: {
show(newVal) { show(newVal) {
@@ -37,13 +61,18 @@ export default {
} }
}, },
componentName() { componentName() {
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2' if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader' else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader' else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader' else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null return null
}, },
hasToC() {
return this.isEpub
},
hasSettings() {
return false
},
abTitle() { abTitle() {
return this.mediaMetadata.title return this.mediaMetadata.title
}, },
@@ -111,18 +140,29 @@ export default {
} }
}, },
methods: { methods: {
toggleToC() {
this.tocOpen = !this.tocOpen
this.chapters = this.$refs.readerComponent.chapters
},
openSettings() {},
hotkey(action) { hotkey(action) {
console.log('Reader hotkey', action) console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) { if (action === this.$hotkeys.EReader.NEXT_PAGE) {
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next() this.next()
} else if (action === this.$hotkeys.EReader.PREV_PAGE) { } else if (action === this.$hotkeys.EReader.PREV_PAGE) {
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev() this.prev()
} else if (action === this.$hotkeys.EReader.CLOSE) { } else if (action === this.$hotkeys.EReader.CLOSE) {
this.close() this.close()
} }
}, },
next() {
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
},
prev() {
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
},
registerListeners() { registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey) this.$eventBus.$on('reader-hotkey', this.hotkey)
}, },
@@ -151,4 +191,8 @@ export default {
.ebook-viewer { .ebook-viewer {
height: calc(100% - 96px); height: calc(100% - 96px);
} }
.tocContent {
height: calc(100% - 36px);
overflow-y: auto;
}
</style> </style>
@@ -0,0 +1,123 @@
<template>
<tr>
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
<td v-if="!showFullPath" class="hidden lg:table-cell">
{{ track.audioFile.codec || '' }}
</td>
<td v-if="!showFullPath" class="hidden xl:table-cell">
{{ $bytesPretty(track.audioFile.bitRate || 0, 0) }}
</td>
<td class="hidden md:table-cell">
{{ $bytesPretty(track.metadata.size) }}
</td>
<td class="hidden sm:table-cell">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
track: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
contextMenuItems() {
const items = []
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
if (this.userIsAdmin) {
items.push({
text: this.$strings.LabelMoreInfo,
action: 'more'
})
}
return items
},
downloadUrl() {
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.track.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
}
},
methods: {
contextMenuAction(action) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'more') {
this.$emit('showMore', this.track.audioFile)
}
},
deleteLibraryFile() {
const payload = {
message: 'This will delete the file from your file system. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.track.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
}
},
mounted() {}
}
</script>
+10 -4
View File
@@ -17,7 +17,7 @@
<td> <td>
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p> <p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
</td> </td>
<td class="hidden sm:table-cell font-sans text-sm">{{ backup.datePretty }}</td> <td class="hidden sm:table-cell font-sans text-sm">{{ $formatDatetime(backup.createdAt, dateFormat, timeFormat) }}</td>
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td> <td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
<td> <td>
<div class="w-full flex flex-row items-center justify-center"> <div class="w-full flex flex-row items-center justify-center">
@@ -46,7 +46,7 @@
<p class="text-error text-lg font-semibold">{{ $strings.MessageImportantNotice }}</p> <p class="text-error text-lg font-semibold">{{ $strings.MessageImportantNotice }}</p>
<p class="text-base py-1" v-html="$strings.MessageRestoreBackupWarning" /> <p class="text-base py-1" v-html="$strings.MessageRestoreBackupWarning" />
<p class="text-lg text-center my-8">{{ $strings.MessageRestoreBackupConfirm }} {{ selectedBackup.datePretty }}?</p> <p class="text-lg text-center my-8">{{ $strings.MessageRestoreBackupConfirm }} {{ $formatDatetime(selectedBackup.createdAt, dateFormat, timeFormat) }}?</p>
<div class="flex px-1 items-center"> <div class="flex px-1 items-center">
<ui-btn color="primary" @click="showConfirmApply = false">{{ $strings.ButtonNevermind }}</ui-btn> <ui-btn color="primary" @click="showConfirmApply = false">{{ $strings.ButtonNevermind }}</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -71,6 +71,12 @@ export default {
computed: { computed: {
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {
@@ -90,7 +96,7 @@ export default {
}) })
}, },
deleteBackupClick(backup) { deleteBackupClick(backup) {
if (confirm(this.$getString('MessageConfirmDeleteBackup', [backup.datePretty]))) { if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
this.processing = true this.processing = true
this.$axios this.$axios
.$delete(`/api/backups/${backup.id}`) .$delete(`/api/backups/${backup.id}`)
@@ -208,4 +214,4 @@ export default {
padding-bottom: 5px; padding-bottom: 5px;
background-color: #333; background-color: #333;
} }
</style> </style>
+41 -25
View File
@@ -6,7 +6,7 @@
<span class="text-sm font-mono">{{ files.length }}</span> <span class="text-sm font-mono">{{ files.length }}</span>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn> <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''"> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span> <span class="material-icons text-4xl">expand_more</span>
</div> </div>
@@ -18,60 +18,76 @@
<th class="text-left px-4">{{ $strings.LabelPath }}</th> <th class="text-left px-4">{{ $strings.LabelPath }}</th>
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th> <th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th> <th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
<th v-if="userCanDownload && !isMissing" class="text-center w-20">{{ $strings.LabelDownload }}</th> <th v-if="userCanDelete || userCanDownload || (userIsAdmin && audioFiles.length && !inModal)" class="text-center w-16"></th>
</tr> </tr>
<template v-for="file in files"> <template v-for="file in filesWithAudioFile">
<tr :key="file.path"> <tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" :inModal="inModal" @showMore="showMore" />
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<div class="flex items-center">
<p>{{ file.fileType }}</p>
</div>
</td>
<td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template> </template>
</table> </table>
</div> </div>
</transition> </transition>
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
files: { libraryItem: {
type: Array, type: Object,
default: () => [] default: () => {}
}, },
libraryItemId: String,
isMissing: Boolean, isMissing: Boolean,
expanded: Boolean // start expanded expanded: Boolean, // start expanded
inModal: Boolean
}, },
data() { data() {
return { return {
showFiles: false, showFiles: false,
showFullPath: false showFullPath: false,
showAudioFileDataModal: false,
selectedAudioFile: null
} }
}, },
computed: { computed: {
libraryItemId() {
return this.libraryItem.id
},
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
files() {
return this.libraryItem.libraryFiles || []
},
audioFiles() {
return this.libraryItem.media?.audioFiles || []
},
filesWithAudioFile() {
return this.files.map((file) => {
if (file.fileType === 'audio') {
file.audioFile = this.audioFiles.find((af) => af.ino === file.ino)
}
return file
})
} }
}, },
methods: { methods: {
clickBar() { clickBar() {
this.showFiles = !this.showFiles this.showFiles = !this.showFiles
},
showMore(audioFile) {
this.selectedAudioFile = audioFile
this.showAudioFileDataModal = true
} }
}, },
mounted() { mounted() {
@@ -0,0 +1,118 @@
<template>
<tr>
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td>
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<div class="flex items-center">
<p>{{ file.fileType }}</p>
</div>
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
},
inModal: Boolean
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.file.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
},
contextMenuItems() {
const items = []
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
// Currently not showing this option in the Files tab modal
if (this.userIsAdmin && this.file.audioFile && !this.inModal) {
items.push({
text: this.$strings.LabelMoreInfo,
action: 'more'
})
}
return items
}
},
methods: {
contextMenuAction(action) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'more') {
this.$emit('showMore', this.file.audioFile)
}
},
deleteLibraryFile() {
const payload = {
message: 'This will delete the file from your file system. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.file.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
}
},
mounted() {}
}
</script>
+19 -57
View File
@@ -5,9 +5,8 @@
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center"> <div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span> <span class="text-sm font-mono">{{ tracks.length }}</span>
</div> </div>
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn> <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent> <nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn> <ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
</nuxt-link> </nuxt-link>
@@ -21,41 +20,20 @@
<tr> <tr>
<th class="w-10">#</th> <th class="w-10">#</th>
<th class="text-left">{{ $strings.LabelFilename }}</th> <th class="text-left">{{ $strings.LabelFilename }}</th>
<th class="text-left w-20">{{ $strings.LabelSize }}</th> <th v-if="!showFullPath" class="text-left w-20 hidden lg:table-cell">{{ $strings.LabelCodec }}</th>
<th class="text-left w-20">{{ $strings.LabelDuration }}</th> <th v-if="!showFullPath" class="text-left w-20 hidden xl:table-cell">{{ $strings.LabelBitrate }}</th>
<th v-if="userCanDownload" class="text-center w-20">{{ $strings.LabelDownload }}</th> <th class="text-left w-20 hidden md:table-cell">{{ $strings.LabelSize }}</th>
<th v-if="showExperimentalFeatures" class="text-center w-20"> <th class="text-left w-20 hidden sm:table-cell">{{ $strings.LabelDuration }}</th>
<div class="flex items-center"> <th class="text-center w-16"></th>
<p>Tone</p>
<ui-tooltip text="Experimental feature for testing Tone library metadata scan results. Results logged in browser console." class="ml-2 w-2" direction="left">
<span class="material-icons-outlined text-sm">information</span>
</ui-tooltip>
</div>
</th>
</tr> </tr>
<template v-for="track in tracks"> <template v-for="track in tracks">
<tr :key="track.index"> <tables-audio-tracks-table-row :key="track.index" :track="track" :library-item-id="libraryItemId" :showFullPath="showFullPath" @showMore="showMore" />
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.metadata.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text pt-1">download</span></a>
</td>
<td v-if="showExperimentalFeatures" class="text-center">
<ui-icon-btn borderless :loading="toneProbing" icon="search" @click="toneProbe(track.index)" />
</td>
</tr>
</template> </template>
</table> </table>
</div> </div>
</transition> </transition>
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
</div> </div>
</template> </template>
@@ -77,47 +55,31 @@ export default {
return { return {
showTracks: false, showTracks: false,
showFullPath: false, showFullPath: false,
toneProbing: false selectedAudioFile: null,
showAudioFileDataModal: false
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },
showExperimentalFeatures() { userCanDelete() {
return this.$store.state.showExperimentalFeatures return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
} }
}, },
methods: { methods: {
clickBar() { clickBar() {
this.showTracks = !this.showTracks this.showTracks = !this.showTracks
}, },
toneProbe(index) { showMore(audioFile) {
this.toneProbing = true this.selectedAudioFile = audioFile
this.showAudioFileDataModal = true
this.$axios
.$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`)
.then((data) => {
console.log('Tone probe data', data)
if (data.error) {
this.$toast.error('Tone probe error: ' + data.error)
} else {
this.$toast.success('Tone probe successful! Check browser console')
}
})
.catch((error) => {
console.error('Failed to tone probe', error)
this.$toast.error('Tone probe failed')
})
.finally(() => {
this.toneProbing = false
})
} }
}, },
mounted() {} mounted() {}
+10 -4
View File
@@ -25,13 +25,13 @@
</div> </div>
</td> </td>
<td class="text-xs font-mono hidden sm:table-cell"> <td class="text-xs font-mono hidden sm:table-cell">
<ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDate(user.lastSeen, 'MMMM do, yyyy HH:mm')"> <ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDatetime(user.lastSeen, dateFormat, timeFormat)">
{{ $dateDistanceFromNow(user.lastSeen) }} {{ $dateDistanceFromNow(user.lastSeen) }}
</ui-tooltip> </ui-tooltip>
</td> </td>
<td class="text-xs font-mono hidden sm:table-cell"> <td class="text-xs font-mono hidden sm:table-cell">
<ui-tooltip direction="top" :text="$formatDate(user.createdAt, 'MMMM do, yyyy HH:mm')"> <ui-tooltip direction="top" :text="$formatDatetime(user.createdAt, dateFormat, timeFormat)">
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }} {{ $formatDate(user.createdAt, dateFormat) }}
</ui-tooltip> </ui-tooltip>
</td> </td>
<td class="py-0"> <td class="py-0">
@@ -74,6 +74,12 @@ export default {
var usermap = {} var usermap = {}
this.$store.state.users.usersOnline.forEach((u) => (usermap[u.id] = u)) this.$store.state.users.usersOnline.forEach((u) => (usermap[u.id] = u))
return usermap return usermap
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {
@@ -201,4 +207,4 @@ export default {
padding-bottom: 5px; padding-bottom: 5px;
background-color: #272727; background-color: #272727;
} }
</style> </style>
@@ -0,0 +1,65 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center">
<p class="pr-2 md:pr-4">{{ $strings.HeaderDownloadQueue }}</p>
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ queue.length }}</span>
</div>
</div>
<transition name="slide">
<div class="w-full">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4 min-w-48">{{ $strings.LabelPodcast }}</th>
<th class="text-left w-32 min-w-32">{{ $strings.LabelEpisode }}</th>
<th class="text-left px-4">{{ $strings.LabelEpisodeTitle }}</th>
<th class="text-left px-4 w-48">{{ $strings.LabelPubDate }}</th>
</tr>
<template v-for="downloadQueued in queue">
<tr :key="downloadQueued.id">
<td class="px-4">
<div class="flex items-center">
<nuxt-link :to="`/item/${downloadQueued.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ downloadQueued.podcastTitle }}</nuxt-link>
<widgets-explicit-indicator :explicit="downloadQueued.podcastExplicit" />
</div>
</td>
<td>
<div class="flex items-center">
<div v-if="downloadQueued.season">{{ downloadQueued.season }}x</div>
<div v-if="downloadQueued.episode">{{ downloadQueued.episode }}</div>
<widgets-podcast-type-indicator :type="downloadQueued.episodeType" />
</div>
</td>
<td class="px-4">
{{ downloadQueued.episodeDisplayTitle }}
</td>
<td class="text-xs">
<div class="flex items-center">
<p>{{ $dateDistanceFromNow(downloadQueued.publishedAt) }}</p>
</div>
</td>
</tr>
</template>
</table>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
queue: {
type: Array,
default: () => []
},
libraryItemId: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
@@ -2,16 +2,18 @@
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave"> <div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode"> <div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode">
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<p class="text-sm font-semibold"> <div class="flex items-center">
{{ title }} <span class="text-sm font-semibold">{{ title }}</span>
</p> <widgets-podcast-type-indicator :type="episode.episodeType" />
</div>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p> <p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
<div class="flex justify-between pt-2 max-w-xl"> <div class="flex justify-between pt-2 max-w-xl">
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p> <p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p> <p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p> <p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
</div> </div>
<div class="flex items-center pt-2"> <div class="flex items-center pt-2">
@@ -128,6 +130,9 @@ export default {
}, },
publishedAt() { publishedAt() {
return this.episode.publishedAt return this.episode.publishedAt
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
} }
}, },
methods: { methods: {
@@ -205,4 +210,4 @@ export default {
} }
} }
} }
</script> </script>
@@ -19,7 +19,12 @@
</template> </template>
</div> </div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
<template v-for="episode in episodesSorted"> <div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form>
</div>
<template v-for="episode in episodesList">
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" /> <tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" />
</template> </template>
@@ -46,7 +51,10 @@ export default {
selectedEpisodes: [], selectedEpisodes: [],
episodesToRemove: [], episodesToRemove: [],
processing: false, processing: false,
quickMatchingEpisodes: false quickMatchingEpisodes: false,
search: null,
searchTimeout: null,
searchText: null
} }
}, },
watch: { watch: {
@@ -131,21 +139,52 @@ export default {
return episodeProgress && !episodeProgress.isFinished return episodeProgress && !episodeProgress.isFinished
}) })
.sort((a, b) => { .sort((a, b) => {
if (this.sortDesc) { let aValue = a[this.sortKey]
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) let bValue = b[this.sortKey]
// Sort episodes with no pub date as the oldest
if (this.sortKey === 'publishedAt') {
if (!aValue) aValue = Number.MAX_VALUE
if (!bValue) bValue = Number.MAX_VALUE
} }
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
if (this.sortDesc) {
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
}
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
}) })
}, },
episodesList() {
return this.episodesSorted.filter((episode) => {
if (!this.searchText) return true
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
})
},
selectedIsFinished() { selectedIsFinished() {
// Find an item that is not finished, if none then all items finished // Find an item that is not finished, if none then all items finished
return !this.selectedEpisodes.find((episode) => { return !this.selectedEpisodes.find((episode) => {
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id) var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress || !itemProgress.isFinished return !itemProgress || !itemProgress.isFinished
}) })
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {
inputUpdate() {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
if (!this.search || !this.search.trim()) {
this.searchText = ''
return
}
this.searchText = this.search.toLowerCase().trim()
}, 500)
},
contextMenuAction(action) { contextMenuAction(action) {
if (action === 'quick-match-episodes') { if (action === 'quick-match-episodes') {
if (this.quickMatchingEpisodes) return if (this.quickMatchingEpisodes) return
@@ -195,7 +234,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: this.mediaMetadata.title, subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date', caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: episode.audioFile.duration || null, duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
} }
@@ -263,7 +302,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: this.mediaMetadata.title, subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date', caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: episode.audioFile.duration || null, duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
}) })
@@ -281,6 +320,8 @@ export default {
this.showPodcastRemoveModal = true this.showPodcastRemoveModal = true
}, },
editEpisode(episode) { editEpisode(episode) {
const episodeIds = this.episodesSorted.map((e) => e.id)
this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)
this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
@@ -314,4 +355,4 @@ export default {
.episode-leave-active { .episode-leave-active {
position: absolute; position: absolute;
} }
</style> </style>
+1 -1
View File
@@ -4,7 +4,7 @@
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" /> <input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg> <svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div> </div>
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div> <div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
</label> </label>
</template> </template>
+10 -4
View File
@@ -1,11 +1,13 @@
<template> <template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj"> <div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu"> <slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span> <button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
</button> <span class="material-icons" :class="iconClass">more_vert</span>
</button>
</slot>
<transition name="menu"> <transition name="menu">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm"> <div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm" :style="{ width: menuWidth }">
<template v-for="(item, index) in items"> <template v-for="(item, index) in items">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)"> <div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
@@ -27,6 +29,10 @@ export default {
iconClass: { iconClass: {
type: String, type: String,
default: '' default: ''
},
menuWidth: {
type: String,
default: '192px'
} }
}, },
data() { data() {
+1 -3
View File
@@ -68,8 +68,6 @@ export default {
} }
}, },
mounted() {}, mounted() {},
beforeDestroy() { beforeDestroy() {}
console.log('Before destroy')
}
} }
</script> </script>
+4 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" /> <input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center"> <div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span> <span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div> </div>
@@ -32,7 +32,9 @@ export default {
noSpinner: Boolean, noSpinner: Boolean,
textCenter: Boolean, textCenter: Boolean,
clearable: Boolean, clearable: Boolean,
inputId: String inputId: String,
step: [String, Number],
min: [String, Number]
}, },
data() { data() {
return { return {
+3 -3
View File
@@ -51,8 +51,8 @@ export default {
tooltip.style.zIndex = 100 tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)' tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.innerHTML = this.text tooltip.innerHTML = this.text
tooltip.addEventListener('mouseover', this.cancelHide); tooltip.addEventListener('mouseover', this.cancelHide)
tooltip.addEventListener('mouseleave', this.hideTooltip); tooltip.addEventListener('mouseleave', this.hideTooltip)
this.setTooltipPosition(tooltip) this.setTooltipPosition(tooltip)
@@ -107,7 +107,7 @@ export default {
this.isShowing = false this.isShowing = false
}, },
cancelHide() { cancelHide() {
if (this.hideTimeout) clearTimeout(this.hideTimeout); if (this.hideTimeout) clearTimeout(this.hideTimeout)
}, },
mouseover() { mouseover() {
if (!this.isShowing) this.showTooltip() if (!this.isShowing) this.showTooltip()
@@ -0,0 +1,70 @@
<template>
<ui-tooltip :text="$strings.LabelAbridged" direction="top">
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
<path
fill="white"
d="M 89.00,40.12
C 89.00,40.12 127.00,40.12 127.00,40.12
127.00,40.12 198.00,40.12 198.00,40.12
198.00,40.12 416.00,40.12 416.00,40.12
446.58,40.05 472.95,66.42 473.00,97.00
473.00,97.00 473.00,303.00 473.00,303.00
473.00,303.00 473.00,418.00 473.00,418.00
472.65,447.55 445.06,472.95 416.00,473.00
416.00,473.00 210.00,473.00 210.00,473.00
210.00,473.00 95.00,473.00 95.00,473.00
65.45,472.65 40.05,445.06 40.00,416.00
40.00,416.00 40.00,136.00 40.00,136.00
40.00,136.00 40.00,109.00 40.00,109.00
40.00,109.00 40.00,96.00 40.00,96.00
40.07,81.58 46.89,67.14 57.01,57.01
61.17,52.86 64.86,50.13 70.00,47.31
77.25,43.33 81.02,42.18 89.00,40.12 Z
M 372.00,392.00
C 372.00,392.00 364.02,364.00 364.02,364.00
364.02,364.00 350.72,319.00 350.72,319.00
350.72,319.00 310.42,183.00 310.42,183.00
310.42,183.00 296.86,137.00 296.86,137.00
296.86,137.00 291.30,121.99 291.30,121.99
291.30,121.99 284.00,121.00 284.00,121.00
284.00,121.00 230.00,121.00 230.00,121.00
230.00,121.00 222.51,122.02 222.51,122.02
222.51,122.02 216.86,137.00 216.86,137.00
216.86,137.00 203.28,183.00 203.28,183.00
203.28,183.00 163.28,318.00 163.28,318.00
163.28,318.00 148.71,367.00 148.71,367.00
148.71,367.00 142.00,392.00 142.00,392.00
142.00,392.00 183.00,392.00 183.00,392.00
183.00,392.00 190.86,390.43 190.86,390.43
190.86,390.43 195.86,375.00 195.86,375.00
195.86,375.00 206.00,338.00 206.00,338.00
206.00,338.00 293.00,338.00 293.00,338.00
295.64,338.01 299.26,337.65 301.30,339.60
303.23,341.43 304.80,348.22 305.58,351.00
305.58,351.00 313.00,378.00 313.00,378.00
316.91,391.63 315.20,391.98 325.00,392.00
325.00,392.00 372.00,392.00 372.00,392.00 Z
M 254.00,170.00
C 254.00,170.00 256.00,170.00 256.00,170.00
256.00,170.00 263.12,197.00 263.12,197.00
263.12,197.00 282.88,268.00 282.88,268.00
282.88,268.00 290.00,296.00 290.00,296.00
290.00,296.00 219.00,296.00 219.00,296.00
219.00,296.00 230.58,253.00 230.58,253.00
230.58,253.00 254.00,170.00 254.00,170.00 Z"
/>
</svg>
</ui-tooltip>
</template>
<script>
export default {
props: {},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
@@ -0,0 +1,19 @@
<template>
<ui-tooltip v-if="alreadyInLibrary" :text="$strings.LabelAlreadyInYourLibrary" direction="top">
<span class="material-icons ml-1 text-success" style="font-size: 0.8rem">check_circle</span>
</ui-tooltip>
</template>
<script>
export default {
props: {
alreadyInLibrary: Boolean
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+7 -1
View File
@@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" /> <tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
</div> </div>
</template> </template>
@@ -34,6 +34,12 @@ export default {
return {} return {}
}, },
computed: { computed: {
tracksWithAudioFile() {
return this.media.tracks.map((track) => {
track.audioFile = this.media.audioFiles.find((af) => af.metadata.path === track.metadata.path)
return track
})
},
missingPartChunks() { missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0] if (this.missingParts === 1) return this.missingParts[0]
var chunks = [] var chunks = []
@@ -50,7 +50,7 @@
</div> </div>
<div class="flex flex-wrap mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/2 px-1"> <div class="w-full md:w-1/4 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" /> <ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" />
</div> </div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
@@ -61,6 +61,11 @@
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" /> <ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div> </div>
</div> </div>
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center">
<ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
</div> </div>
</form> </form>
</div> </div>
@@ -89,7 +94,8 @@ export default {
isbn: null, isbn: null,
asin: null, asin: null,
genres: [], genres: [],
explicit: false explicit: false,
abridged: false
}, },
newTags: [] newTags: []
} }
@@ -271,6 +277,7 @@ export default {
this.details.isbn = this.mediaMetadata.isbn || null this.details.isbn = this.mediaMetadata.isbn || null
this.details.asin = this.mediaMetadata.asin || null this.details.asin = this.mediaMetadata.asin || null
this.details.explicit = !!this.mediaMetadata.explicit this.details.explicit = !!this.mediaMetadata.explicit
this.details.abridged = !!this.mediaMetadata.abridged
this.newTags = [...(this.media.tags || [])] this.newTags = [...(this.media.tags || [])]
}, },
submitForm() { submitForm() {
@@ -36,6 +36,10 @@
<p v-else class="text-success text-base md:text-lg text-center">{{ $strings.MessageValidCronExpression }}</p> <p v-else class="text-success text-base md:text-lg text-center">{{ $strings.MessageValidCronExpression }}</p>
</div> </div>
</template> </template>
<div v-if="cronExpression && isValid" class="flex items-center justify-center text-yellow-400 mt-2">
<span class="material-icons-outlined mr-2 text-xl">event</span>
<p>{{ $strings.LabelNextScheduledRun }}: {{ nextRun }}</p>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -63,6 +67,14 @@ export default {
isValid: true isValid: true
} }
}, },
watch: {
value: {
immediate: true,
handler(newVal) {
this.init()
}
}
},
computed: { computed: {
minuteIsValid() { minuteIsValid() {
return !(isNaN(this.selectedMinute) || this.selectedMinute === '' || this.selectedMinute < 0 || this.selectedMinute > 59) return !(isNaN(this.selectedMinute) || this.selectedMinute === '' || this.selectedMinute < 0 || this.selectedMinute > 59)
@@ -70,6 +82,11 @@ export default {
hourIsValid() { hourIsValid() {
return !(isNaN(this.selectedHour) || this.selectedHour === '' || this.selectedHour < 0 || this.selectedHour > 23) return !(isNaN(this.selectedHour) || this.selectedHour === '' || this.selectedHour < 0 || this.selectedHour > 23)
}, },
nextRun() {
if (!this.cronExpression) return ''
const parsed = this.$getNextScheduledDate(this.cronExpression)
return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || ''
},
description() { description() {
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return '' if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
@@ -271,6 +288,11 @@ export default {
}) })
}, },
init() { init() {
this.selectedInterval = 'custom'
this.selectedHour = 0
this.selectedMinute = 0
this.selectedWeekdays = []
if (!this.value) return if (!this.value) return
const pieces = this.value.split(' ') const pieces = this.value.split(' ')
if (pieces.length !== 5) { if (pieces.length !== 5) {
@@ -309,4 +331,4 @@ export default {
this.init() this.init()
} }
} }
</script> </script>
@@ -0,0 +1,53 @@
<template>
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
<path
fill="white"
d="M 89.00,40.12
C 89.00,40.12 127.00,40.12 127.00,40.12
127.00,40.12 198.00,40.12 198.00,40.12
198.00,40.12 416.00,40.12 416.00,40.12
446.58,40.05 472.95,66.42 473.00,97.00
473.00,97.00 473.00,303.00 473.00,303.00
473.00,303.00 473.00,418.00 473.00,418.00
472.65,447.55 445.06,472.95 416.00,473.00
416.00,473.00 210.00,473.00 210.00,473.00
210.00,473.00 95.00,473.00 95.00,473.00
65.45,472.65 40.05,445.06 40.00,416.00
40.00,416.00 40.00,136.00 40.00,136.00
40.00,136.00 40.00,109.00 40.00,109.00
40.00,109.00 40.00,96.00 40.00,96.00
40.07,81.58 46.89,67.14 57.01,57.01
61.17,52.86 64.86,50.13 70.00,47.31
77.25,43.33 81.02,42.18 89.00,40.12 Z
M 337.00,121.00
C 337.00,121.00 175.00,121.00 175.00,121.00
175.00,121.00 175.00,392.00 175.00,392.00
175.00,392.00 337.00,392.00 337.00,392.00
337.00,392.00 337.00,349.00 337.00,349.00
337.00,349.00 226.00,349.00 226.00,349.00
226.00,349.00 226.00,274.00 226.00,274.00
226.00,274.00 332.00,274.00 332.00,274.00
332.00,274.00 332.00,232.00 332.00,232.00
332.00,232.00 226.00,232.00 226.00,232.00
226.00,232.00 226.00,164.00 226.00,164.00
226.00,164.00 337.00,164.00 337.00,164.00
337.00,164.00 337.00,121.00 337.00,121.00 Z"
/>
</svg>
</ui-tooltip>
</template>
<script>
export default {
props: {
explicit: Boolean
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
@@ -1,15 +1,51 @@
<template> <template>
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative"> <div v-if="tasksRunning" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
<div class="flex h-full items-center justify-center"> <button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full cursor-pointer" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<widgets-loading-spinner /> <div class="flex h-full items-center justify-center">
</div> <ui-tooltip text="Tasks running" direction="bottom" class="flex items-center">
<widgets-loading-spinner />
</ui-tooltip>
</div>
</button>
<transition name="menu">
<div class="sm:w-80 w-full relative">
<div v-show="showMenu" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalTaskRunningMenu">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-if="tasksRunningOrFailed.length">
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelTasks }}</p>
<template v-for="task in tasksRunningOrFailed">
<nuxt-link :key="task.id" v-if="actionLink(task)" :to="actionLink(task)">
<li class="text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer">
<cards-item-task-running-card :task="task" />
</li>
</nuxt-link>
<li v-else :key="task.id" class="text-gray-50 select-none relative hover:bg-black-400 py-1">
<cards-item-task-running-card :task="task" />
</li>
</template>
</template>
<li v-else class="py-2 px-2">
<p>{{ $strings.MessageNoTasksRunning }}</p>
</li>
</ul>
</div>
</div>
</transition>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
clickOutsideObj: {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
},
showMenu: false,
disabled: false
}
}, },
computed: { computed: {
tasks() { tasks() {
@@ -17,9 +53,39 @@ export default {
}, },
tasksRunning() { tasksRunning() {
return this.tasks.some((t) => !t.isFinished) return this.tasks.some((t) => !t.isFinished)
},
tasksRunningOrFailed() {
// return just the tasks that are running or failed in the last 1 minute
return this.tasks.filter((t) => !t.isFinished || (t.isFailed && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
}
},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickedOutside() {
this.showMenu = false
},
actionLink(task) {
switch (task.action) {
case 'download-podcast-episode':
return `/library/${task.data.libraryId}/podcast/download-queue`
case 'encode-m4b':
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
case 'embed-metadata':
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
default:
return ''
}
} }
}, },
methods: {},
mounted() {} mounted() {}
} }
</script> </script>
<style>
.globalTaskRunningMenu {
max-height: 80vh;
}
</style>
@@ -39,6 +39,11 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex mt-2 -mx-1">
<div class="w-1/4 px-1">
<ui-dropdown :label="$strings.LabelPodcastType" v-model="details.type" :items="podcastTypes" small class="max-w-52" />
</div>
</div>
</form> </form>
</div> </div>
</template> </template>
@@ -65,7 +70,8 @@ export default {
itunesId: null, itunesId: null,
itunesArtistId: null, itunesArtistId: null,
explicit: false, explicit: false,
language: null language: null,
type: null
}, },
newTags: [] newTags: []
} }
@@ -93,6 +99,9 @@ export default {
}, },
filterData() { filterData() {
return this.$store.state.libraries.filterData || {} return this.$store.state.libraries.filterData || {}
},
podcastTypes() {
return this.$store.state.globals.podcastTypes || []
} }
}, },
methods: { methods: {
@@ -219,6 +228,7 @@ export default {
this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || '' this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || ''
this.details.language = this.mediaMetadata.language || '' this.details.language = this.mediaMetadata.language || ''
this.details.explicit = !!this.mediaMetadata.explicit this.details.explicit = !!this.mediaMetadata.explicit
this.details.type = this.mediaMetadata.type || 'episodic'
this.newTags = [...(this.media.tags || [])] this.newTags = [...(this.media.tags || [])]
}, },
@@ -228,4 +238,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>
@@ -0,0 +1,31 @@
<template>
<div>
<template v-if="type == 'bonus'">
<ui-tooltip text="Bonus" direction="top">
<span class="material-icons ml-1" style="font-size: 0.8rem">local_play</span>
</ui-tooltip>
</template>
<template v-if="type == 'trailer'">
<ui-tooltip text="Trailer" direction="top">
<span class="material-icons ml-1" style="font-size: 0.8rem">local_movies</span>
</ui-tooltip>
</template>
</div>
</template>
<script>
export default {
props: {
type: {
type: String,
default: 'full'
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
@@ -0,0 +1,90 @@
<template>
<div class="w-full py-2">
<div class="flex -mb-px">
<div class="w-1/2 h-6 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = false">
<p class="text-sm">{{ $strings.HeaderRSSFeedGeneral }}</p>
</div>
<div class="w-1/2 h-6 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = true">
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
</div>
</div>
<div class="px-2 py-4 md:p-4 border border-black-200 rounded-b-md mr-px" style="min-height: 200px">
<template v-if="!showAdvancedView">
<div class="flex-grow pt-2 mb-2">
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
</div>
</template>
<template v-else>
<div class="flex-grow pt-2 mb-2">
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
</div>
<div class="w-full relative mb-1">
<ui-text-input-with-label v-model="ownerName" :label="$strings.LabelRSSFeedCustomOwnerName" />
</div>
<div class="w-full relative mb-1">
<ui-text-input-with-label v-model="ownerEmail" :label="$strings.LabelRSSFeedCustomOwnerEmail" />
</div>
</template>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Object,
default: () => {
return {
preventIndexing: true,
ownerName: '',
ownerEmail: ''
}
}
}
},
data() {
return {
showAdvancedView: false
}
},
watch: {},
computed: {
preventIndexing: {
get() {
return this.value.preventIndexing
},
set(value) {
this.$emit('input', {
...this.value,
preventIndexing: value
})
}
},
ownerName: {
get() {
return this.value.ownerName
},
set(value) {
this.$emit('input', {
...this.value,
ownerName: value
})
}
},
ownerEmail: {
get() {
return this.value.ownerEmail
},
set(value) {
this.$emit('input', {
...this.value,
ownerEmail: value
})
}
}
},
methods: {},
mounted() {}
}
</script>
+25 -1
View File
@@ -278,6 +278,13 @@ export default {
console.log('Task finished', task) console.log('Task finished', task)
this.$store.commit('tasks/addUpdateTask', task) this.$store.commit('tasks/addUpdateTask', task)
}, },
metadataEmbedQueueUpdate(data) {
if (data.queued) {
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
} else {
this.$store.commit('tasks/removeQueuedEmbedLId', data.libraryItemId)
}
},
userUpdated(user) { userUpdated(user) {
if (this.$store.state.user.user.id === user.id) { if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
@@ -292,8 +299,17 @@ export default {
userStreamUpdate(user) { userStreamUpdate(user) {
this.$store.commit('users/updateUserOnline', user) this.$store.commit('users/updateUserOnline', user)
}, },
userSessionClosed(sessionId) {
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
},
userMediaProgressUpdate(payload) { userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload) this.$store.commit('user/updateMediaProgress', payload)
if (payload.data) {
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId)) {
// TODO: Update currently open session if being played from another device
}
}
}, },
collectionAdded(collection) { collectionAdded(collection) {
if (this.currentLibraryId !== collection.libraryId) return if (this.currentLibraryId !== collection.libraryId) return
@@ -398,6 +414,7 @@ export default {
this.socket.on('user_online', this.userOnline) this.socket.on('user_online', this.userOnline)
this.socket.on('user_offline', this.userOffline) this.socket.on('user_offline', this.userOffline)
this.socket.on('user_stream_update', this.userStreamUpdate) this.socket.on('user_stream_update', this.userStreamUpdate)
this.socket.on('user_session_closed', this.userSessionClosed)
this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate) this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
// Collection Listeners // Collection Listeners
@@ -418,6 +435,7 @@ export default {
// Task Listeners // Task Listeners
this.socket.on('task_started', this.taskStarted) this.socket.on('task_started', this.taskStarted)
this.socket.on('task_finished', this.taskFinished) this.socket.on('task_finished', this.taskFinished)
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
this.socket.on('backup_applied', this.backupApplied) this.socket.on('backup_applied', this.backupApplied)
@@ -531,12 +549,18 @@ export default {
}, },
loadTasks() { loadTasks() {
this.$axios this.$axios
.$get('/api/tasks') .$get('/api/tasks?include=queue')
.then((payload) => { .then((payload) => {
console.log('Fetched tasks', payload) console.log('Fetched tasks', payload)
if (payload.tasks) { if (payload.tasks) {
this.$store.commit('tasks/setTasks', payload.tasks) this.$store.commit('tasks/setTasks', payload.tasks)
} }
if (payload.queuedTaskData?.embedMetadata?.length) {
this.$store.commit(
'tasks/setQueuedEmbedLIds',
payload.queuedTaskData.embedMetadata.map((td) => td.libraryItemId)
)
}
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to load tasks', error) console.error('Failed to load tasks', error)
+35 -2
View File
@@ -1,17 +1,18 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.15", "version": "2.2.19",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.15", "version": "2.2.19",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0", "@nuxtjs/proxy": "^2.1.0",
"core-js": "^3.16.0", "core-js": "^3.16.0",
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
"epubjs": "^0.3.88", "epubjs": "^0.3.88",
"hls.js": "^1.0.7", "hls.js": "^1.0.7",
@@ -5464,6 +5465,17 @@
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
}, },
"node_modules/cron-parser": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.7.1.tgz",
"integrity": "sha512-WguFaoQ0hQ61SgsCZLHUcNbAvlK0lypKXu62ARguefYmjzaOXIVRNrAmyXzabTwUn4sQvQLkk6bjH+ipGfw8bA==",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -9134,6 +9146,14 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/luxon": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz",
"integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==",
"engines": {
"node": ">=12"
}
},
"node_modules/make-dir": { "node_modules/make-dir": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -21582,6 +21602,14 @@
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
}, },
"cron-parser": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.7.1.tgz",
"integrity": "sha512-WguFaoQ0hQ61SgsCZLHUcNbAvlK0lypKXu62ARguefYmjzaOXIVRNrAmyXzabTwUn4sQvQLkk6bjH+ipGfw8bA==",
"requires": {
"luxon": "^3.2.1"
}
},
"cross-spawn": { "cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -24397,6 +24425,11 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"luxon": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz",
"integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg=="
},
"make-dir": { "make-dir": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.15", "version": "2.2.19",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -16,6 +16,7 @@
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0", "@nuxtjs/proxy": "^2.1.0",
"core-js": "^3.16.0", "core-js": "^3.16.0",
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
"epubjs": "^0.3.88", "epubjs": "^0.3.88",
"hls.js": "^1.0.7", "hls.js": "^1.0.7",
+47 -6
View File
@@ -21,13 +21,14 @@
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" /> <ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
<div class="w-32 hidden lg:block" /> <div class="w-32 hidden lg:block" />
</div> </div>
<div class="flex items-center mb-3 py-1"> <div class="flex items-center mb-3 py-1 -mx-1">
<div class="w-12 hidden lg:block" /> <div class="w-12 hidden lg:block" />
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn> <ui-btn v-if="chapters.length" color="primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn> <ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" class="mx-1" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="hasChanges" small class="mx-2" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn> <ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="success" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn> <ui-btn v-if="hasChanges" color="success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-32 hidden lg:block" /> <div class="w-32 hidden lg:block" />
</div> </div>
@@ -41,7 +42,7 @@
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" /> <ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn> <ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">close</span> <span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">expand_less</span>
</div> </div>
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p> <p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
</div> </div>
@@ -329,6 +330,7 @@ export default {
chap.start = Math.max(0, chap.start + amount) chap.start = Math.max(0, chap.start + amount)
} }
} }
this.checkChapters()
}, },
editItem() { editItem() {
this.$store.commit('showEditModal', this.libraryItem) this.$store.commit('showEditModal', this.libraryItem)
@@ -587,6 +589,45 @@ export default {
] ]
} }
this.checkChapters() this.checkChapters()
},
removeAllChaptersClick() {
const payload = {
message: this.$strings.MessageConfirmRemoveAllChapters,
callback: (confirmed) => {
if (confirmed) {
this.removeAllChapters()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
removeAllChapters() {
this.saving = true
const payload = {
chapters: []
}
this.$axios
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
.then((data) => {
if (data.updated) {
this.$toast.success('Chapters removed')
if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
this.$router.push(`/item/${this.libraryItem.id}`)
}
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
})
.catch((error) => {
console.error('Failed to remove chapters', error)
this.$toast.error('Failed to remove chapters')
})
.finally(() => {
this.saving = false
})
} }
}, },
mounted() { mounted() {
+30 -28
View File
@@ -62,14 +62,20 @@
<div class="w-full h-px bg-white bg-opacity-10 my-8" /> <div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto"> <div class="w-full max-w-4xl mx-auto">
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4"> <!-- queued alert -->
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" /> <widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- metadata embed action buttons -->
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn> <ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div> </div>
<!-- m4b embed action buttons -->
<div v-else class="w-full flex items-center mb-4"> <div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions"> <button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span> <span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
@@ -83,6 +89,7 @@
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div> </div>
<!-- advanced encoding options -->
<div v-if="isM4BTool" class="overflow-hidden"> <div v-if="isM4BTool" class="overflow-hidden">
<transition name="slide"> <transition name="slide">
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10"> <div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
@@ -191,6 +198,7 @@ export default {
cnosole.error('No audio files') cnosole.error('No audio files')
return redirect('/?error=no audio files') return redirect('/?error=no audio files')
} }
return { return {
libraryItem libraryItem
} }
@@ -200,7 +208,6 @@ export default {
processing: false, processing: false,
audiofilesEncoding: {}, audiofilesEncoding: {},
audiofilesFinished: {}, audiofilesFinished: {},
isFinished: false,
toneObject: null, toneObject: null,
selectedTool: 'embed', selectedTool: 'embed',
isCancelingEncode: false, isCancelingEncode: false,
@@ -272,11 +279,28 @@ export default {
isTaskFinished() { isTaskFinished() {
return this.task && this.task.isFinished return this.task && this.task.isFinished
}, },
tasks() {
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
},
embedTask() {
return this.tasks.find((t) => t.action === 'embed-metadata')
},
encodeTask() {
return this.tasks.find((t) => t.action === 'encode-m4b')
},
task() { task() {
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId) if (this.isEmbedTool) return this.embedTask
else if (this.isM4BTool) return this.encodeTask
return null
}, },
taskRunning() { taskRunning() {
return this.task && !this.task.isFinished return this.task && !this.task.isFinished
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []
},
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
} }
}, },
methods: { methods: {
@@ -322,7 +346,7 @@ export default {
.catch((error) => { .catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
this.processing = true this.processing = false
}) })
}, },
embedClick() { embedClick() {
@@ -349,24 +373,6 @@ export default {
this.processing = false this.processing = false
}) })
}, },
audioMetadataStarted(data) {
console.log('audio metadata started', data)
if (data.libraryItemId !== this.libraryItemId) return
this.audiofilesFinished = {}
},
audioMetadataFinished(data) {
console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return
this.processing = false
this.audiofilesEncoding = {}
if (data.failed) {
this.$toast.error(data.error)
} else {
this.isFinished = true
this.$toast.success('Audio file metadata updated')
}
},
audiofileMetadataStarted(data) { audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, true) this.$set(this.audiofilesEncoding, data.ino, true)
@@ -412,14 +418,10 @@ export default {
}, },
mounted() { mounted() {
this.init() this.init()
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted) this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished) this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
}, },
beforeDestroy() { beforeDestroy() {
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted) this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished) this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
} }
+33 -15
View File
@@ -9,10 +9,17 @@
</div> </div>
<div v-if="enableBackups" class="mb-6"> <div v-if="enableBackups" class="mb-6">
<div class="flex items-center pl-6"> <div class="flex items-center pl-6 mb-2">
<span class="material-icons-outlined text-2xl text-black-50">schedule</span> <span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
<p class="text-gray-100 px-2">{{ scheduleDescription }}</p> <div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span></div>
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer" @click="showCronBuilder = !showCronBuilder">edit</span> <div class="text-gray-100">{{ scheduleDescription }}</div>
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
</div>
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span></div>
<div class="text-gray-100">{{ nextBackupDate }}</div>
</div> </div>
</div> </div>
@@ -64,10 +71,21 @@ export default {
serverSettings() { serverSettings() {
return this.$store.state.serverSettings return this.$store.state.serverSettings
}, },
dateFormat() {
return this.serverSettings.dateFormat
},
timeFormat() {
return this.serverSettings.timeFormat
},
scheduleDescription() { scheduleDescription() {
if (!this.cronExpression) return '' if (!this.cronExpression) return ''
const parsed = this.$parseCronExpression(this.cronExpression) const parsed = this.$parseCronExpression(this.cronExpression)
return parsed ? parsed.description : 'Custom cron expression ' + this.cronExpression return parsed ? parsed.description : `${this.$strings.LabelCustomCronExpression} ${this.cronExpression}`
},
nextBackupDate() {
if (!this.cronExpression) return ''
const parsed = this.$getNextScheduledDate(this.cronExpression)
return this.$formatJsDatetime(parsed, this.dateFormat, this.timeFormat) || ''
} }
}, },
methods: { methods: {
@@ -90,15 +108,15 @@ export default {
updateServerSettings(payload) { updateServerSettings(payload) {
this.updatingServerSettings = true this.updatingServerSettings = true
this.$store this.$store
.dispatch('updateServerSettings', payload) .dispatch('updateServerSettings', payload)
.then((success) => { .then((success) => {
console.log('Updated Server Settings', success) console.log('Updated Server Settings', success)
this.updatingServerSettings = false this.updatingServerSettings = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update server settings', error) console.error('Failed to update server settings', error)
this.updatingServerSettings = false this.updatingServerSettings = false
}) })
}, },
initServerSettings() { initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
@@ -113,4 +131,4 @@ export default {
this.initServerSettings() this.initServerSettings()
} }
} }
</script> </script>
+19 -2
View File
@@ -68,8 +68,14 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="py-2"> <div class="flex-grow py-2">
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" /> <ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ dateExample }}</p>
</div>
<div class="flex-grow py-2">
<ui-dropdown :label="$strings.LabelSettingsTimeFormat" v-model="newServerSettings.timeFormat" :items="timeFormats" small class="max-w-52" @input="(val) => updateSettingsKey('timeFormat', val)" />
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ timeExample }}</p>
</div> </div>
<div class="py-2"> <div class="py-2">
@@ -293,6 +299,17 @@ export default {
}, },
dateFormats() { dateFormats() {
return this.$store.state.globals.dateFormats return this.$store.state.globals.dateFormats
},
timeFormats() {
return this.$store.state.globals.timeFormats
},
dateExample() {
const date = new Date(2014, 2, 25)
return this.$formatJsDate(date, this.newServerSettings.dateFormat)
},
timeExample() {
const date = new Date(2014, 2, 25, 17, 30, 0)
return this.$formatJsTime(date, this.newServerSettings.timeFormat)
} }
}, },
methods: { methods: {
@@ -420,4 +437,4 @@ export default {
this.initServerSettings() this.initServerSettings()
} }
} }
</script> </script>
+27 -1
View File
@@ -60,6 +60,25 @@
</div> </div>
</template> </template>
</div> </div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLargestItems }}</h1>
<p v-if="!top10LargestItems.length">{{ $strings.MessageNoItems }}</p>
<template v-for="(ab, index) in top10LargestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ $bytesPretty(ab.size) }}</p>
</div>
</div>
</div>
</template>
</div>
</div> </div>
</app-settings-content> </app-settings-content>
</div> </div>
@@ -105,6 +124,13 @@ export default {
if (!this.top10LongestItems.length) return 0 if (!this.top10LongestItems.length) return 0
return this.top10LongestItems[0].duration return this.top10LongestItems[0].duration
}, },
top10LargestItems() {
return this.libraryStats ? this.libraryStats.largestItems || [] : []
},
largestItemSize() {
if (!this.top10LargestItems.length) return 0
return this.top10LargestItems[0].size
},
authorsWithCount() { authorsWithCount() {
return this.libraryStats ? this.libraryStats.authorsWithCount : [] return this.libraryStats ? this.libraryStats.authorsWithCount : []
}, },
@@ -135,4 +161,4 @@ export default {
this.init() this.init()
} }
} }
</script> </script>
+75 -5
View File
@@ -39,7 +39,7 @@
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td> </td>
<td class="text-center hidden sm:table-cell"> <td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')"> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p> <p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip> </ui-tooltip>
</td> </td>
@@ -52,9 +52,53 @@
</div> </div>
</div> </div>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p> <p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
<!-- open listening sessions table -->
<p v-if="openListeningSessions.length" class="text-lg mb-4 mt-8">Open Listening Sessions</p>
<div v-if="openListeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr>
<tr v-for="session in openListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
</div>
</app-settings-content> </app-settings-content>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" /> <modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
</div> </div>
</template> </template>
@@ -81,6 +125,7 @@ export default {
showSessionModal: false, showSessionModal: false,
selectedSession: null, selectedSession: null,
listeningSessions: [], listeningSessions: [],
openListeningSessions: [],
numPages: 0, numPages: 0,
total: 0, total: 0,
currentPage: 0, currentPage: 0,
@@ -105,9 +150,18 @@ export default {
if (!this.userFilter) return null if (!this.userFilter) return null
var user = this.users.find((u) => u.id === this.userFilter) var user = this.users.find((u) => u.id === this.userFilter)
return user ? user.username : null return user ? user.username : null
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {
closedSession() {
this.loadOpenSessions()
},
removedSession() { removedSession() {
// If on last page and this was the last session then load prev page // If on last page and this was the last session then load prev page
if (this.currentPage == this.numPages - 1) { if (this.currentPage == this.numPages - 1) {
@@ -149,7 +203,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: libraryItem.media.metadata.title, subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date', caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: episode.audioFile.duration || null, duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null coverPath: libraryItem.media.coverPath || null
} }
@@ -216,7 +270,7 @@ export default {
async loadSessions(page) { async loadSessions(page) {
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : '' var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => { const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
console.error('Failed to load listening sesions', err) console.error('Failed to load listening sessions', err)
return null return null
}) })
if (!data) { if (!data) {
@@ -230,8 +284,24 @@ export default {
this.listeningSessions = data.sessions this.listeningSessions = data.sessions
this.userFilter = data.userFilter this.userFilter = data.userFilter
}, },
async loadOpenSessions() {
const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
console.error('Failed to load open sessions', err)
return null
})
if (!data) {
this.$toast.error('Failed to load open sessions')
return
}
this.openListeningSessions = (data.sessions || []).map((s) => {
s.open = true
return s
})
},
init() { init() {
this.loadSessions(0) this.loadSessions(0)
this.loadOpenSessions()
} }
}, },
mounted() { mounted() {
@@ -266,4 +336,4 @@ export default {
padding: 4px 8px; padding: 4px 8px;
font-size: 0.75rem; font-size: 0.75rem;
} }
</style> </style>
+8 -2
View File
@@ -79,12 +79,12 @@
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p> <p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
</td> </td>
<td class="text-center hidden sm:table-cell"> <td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="item.startedAt" direction="top" :text="$formatDate(item.startedAt, 'MMMM do, yyyy HH:mm')"> <ui-tooltip v-if="item.startedAt" direction="top" :text="$formatDatetime(item.startedAt, dateFormat, timeFormat)">
<p class="text-sm">{{ $dateDistanceFromNow(item.startedAt) }}</p> <p class="text-sm">{{ $dateDistanceFromNow(item.startedAt) }}</p>
</ui-tooltip> </ui-tooltip>
</td> </td>
<td class="text-center hidden sm:table-cell"> <td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="item.lastUpdate" direction="top" :text="$formatDate(item.lastUpdate, 'MMMM do, yyyy HH:mm')"> <ui-tooltip v-if="item.lastUpdate" direction="top" :text="$formatDatetime(item.lastUpdate, dateFormat, timeFormat)">
<p class="text-sm">{{ $dateDistanceFromNow(item.lastUpdate) }}</p> <p class="text-sm">{{ $dateDistanceFromNow(item.lastUpdate) }}</p>
</ui-tooltip> </ui-tooltip>
</td> </td>
@@ -149,6 +149,12 @@ export default {
latestSession() { latestSession() {
if (!this.listeningSessions.sessions || !this.listeningSessions.sessions.length) return null if (!this.listeningSessions.sessions || !this.listeningSessions.sessions.length) return null
return this.listeningSessions.sessions[0] return this.listeningSessions.sessions[0]
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {
+9 -3
View File
@@ -46,7 +46,7 @@
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td> </td>
<td class="text-center hidden sm:table-cell"> <td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')"> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p> <p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip> </ui-tooltip>
</td> </td>
@@ -96,6 +96,12 @@ export default {
}, },
userOnline() { userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id) return this.$store.getters['users/getIsUserOnline'](this.user.id)
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {
@@ -140,7 +146,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: libraryItem.media.metadata.title, subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date', caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: episode.audioFile.duration || null, duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null coverPath: libraryItem.media.coverPath || null
} }
@@ -252,4 +258,4 @@ export default {
padding: 4px 8px; padding: 4px 8px;
font-size: 0.75rem; font-size: 0.75rem;
} }
</style> </style>
+140 -41
View File
@@ -1,8 +1,8 @@
<template> <template>
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8"> <div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
<div class="flex flex-col md:flex-row max-w-6xl mx-auto"> <div class="flex flex-col lg:flex-row max-w-6xl mx-auto">
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px"> <div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px">
<div class="relative" style="height: fit-content"> <div class="relative" style="height: fit-content">
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
@@ -21,11 +21,15 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10"> <div class="flex-grow px-2 py-6 lg:py-0 md:px-10">
<div class="flex justify-center"> <div class="flex justify-center">
<div class="mb-4"> <div class="mb-4">
<h1 class="text-2xl md:text-3xl font-semibold"> <h1 class="text-2xl md:text-3xl font-semibold">
{{ title }} <div class="flex items-center">
{{ title }}
<widgets-explicit-indicator :explicit="isExplicit" />
<widgets-abridged-indicator v-if="isAbridged" />
</div>
</h1> </h1>
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p> <p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
@@ -153,7 +157,7 @@
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''"> <div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p> <p v-if="progressPercent < 1" class="leading-6">{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
<p v-else class="text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p> <p v-else class="text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p> <p v-if="progressPercent < 1 && !useEBookProgress" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
<p class="text-gray-400 text-xs pt-1">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p> <p class="text-gray-400 text-xs pt-1">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick"> <div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
@@ -190,27 +194,18 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" /> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="showCollectionsButton" :text="$strings.LabelCollections" direction="top">
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast && tracks.length" :text="$strings.LabelYourPlaylists" direction="top">
<ui-icon-btn icon="playlist_add" class="mx-0.5" outlined @click="playlistsClick" />
</ui-tooltip>
<!-- Only admin or root user can download new episodes --> <!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top"> <ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" /> <ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="bookmarks.length" :text="$strings.LabelYourBookmarks" direction="top"> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction">
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" /> <template #default="{ showMenu, clickShowMenu, disabled }">
</ui-tooltip> <button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons">more_horiz</span>
<!-- RSS feed --> </button>
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top"> </template>
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" /> </ui-context-menu-dropdown>
</ui-tooltip>
</div> </div>
<div class="my-4 max-w-2xl"> <div class="my-4 max-w-2xl">
@@ -229,7 +224,7 @@
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" /> <tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" /> <tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item="libraryItem" class="mt-6" />
</div> </div>
</div> </div>
</div> </div>
@@ -273,6 +268,12 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
},
dateFormat() { dateFormat() {
return this.$store.state.serverSettings.dateFormat return this.$store.state.serverSettings.dateFormat
}, },
@@ -285,9 +286,6 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
isFile() {
return this.libraryItem.isFile
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
@@ -297,6 +295,9 @@ export default {
isDeveloperMode() { isDeveloperMode() {
return this.$store.state.developerMode return this.$store.state.developerMode
}, },
isFile() {
return this.libraryItem.isFile
},
isBook() { isBook() {
return this.libraryItem.mediaType === 'book' return this.libraryItem.mediaType === 'book'
}, },
@@ -315,6 +316,12 @@ export default {
isInvalid() { isInvalid() {
return this.libraryItem.isInvalid return this.libraryItem.isInvalid
}, },
isExplicit() {
return !!this.mediaMetadata.explicit
},
isAbridged() {
return !!this.mediaMetadata.abridged
},
invalidAudioFiles() { invalidAudioFiles() {
if (!this.isBook) return [] if (!this.isBook) return []
return this.libraryItem.media.audioFiles.filter((af) => af.invalid) return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
@@ -465,7 +472,12 @@ export default {
const duration = this.userMediaProgress.duration || this.duration const duration = this.userMediaProgress.duration || this.duration
return duration - this.userMediaProgress.currentTime return duration - this.userMediaProgress.currentTime
}, },
useEBookProgress() {
if (!this.userMediaProgress || this.userMediaProgress.progress) return false
return this.userMediaProgress.ebookProgress > 0
},
progressPercent() { progressPercent() {
if (this.useEBookProgress) return Math.max(Math.min(1, this.userMediaProgress.ebookProgress), 0)
return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0 return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0
}, },
userProgressStartedAt() { userProgressStartedAt() {
@@ -504,12 +516,56 @@ export default {
}, },
showCollectionsButton() { showCollectionsButton() {
return this.isBook && this.userCanUpdate return this.isBook && this.userCanUpdate
},
contextMenuItems() {
const items = []
if (this.showCollectionsButton) {
items.push({
text: this.$strings.LabelCollections,
action: 'collections'
})
}
if (!this.isPodcast && this.tracks.length) {
items.push({
text: this.$strings.LabelYourPlaylists,
action: 'playlists'
})
}
if (this.bookmarks.length) {
items.push({
text: this.$strings.LabelYourBookmarks,
action: 'bookmarks'
})
}
if (this.showRssFeedBtn) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
action: 'rss-feeds'
})
}
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
} }
}, },
methods: { methods: {
clickBookmarksBtn() {
this.showBookmarksModal = true
},
selectBookmark(bookmark) { selectBookmark(bookmark) {
if (!bookmark) return if (!bookmark) return
if (this.isStreaming) { if (this.isStreaming) {
@@ -632,7 +688,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: this.title, subtitle: this.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date', caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: episode.audioFile.duration || null, duration: episode.audioFile.duration || null,
coverPath: this.libraryItem.media.coverPath || null coverPath: this.libraryItem.media.coverPath || null
}) })
@@ -685,14 +741,6 @@ export default {
}) })
} }
}, },
collectionsClick() {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowCollectionsModal', true)
},
playlistsClick() {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
this.$store.commit('globals/setShowPlaylistsModal', true)
},
clickRSSFeed() { clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', { this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.libraryItemId, id: this.libraryItemId,
@@ -750,12 +798,63 @@ export default {
} }
this.$store.commit('addItemToQueue', queueItem) this.$store.commit('addItemToQueue', queueItem)
} }
},
downloadLibraryItem() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
},
deleteLibraryItem() {
const payload = {
message: 'This will delete the library item from the database and your file system. Are you sure?',
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error',
checkboxDefaultValue: true,
callback: (confirmed, hardDelete) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
this.$toast.success('Item deleted')
this.$router.replace(`/library/${this.libraryId}`)
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction(action) {
if (action === 'collections') {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowCollectionsModal', true)
} else if (action === 'playlists') {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
this.$store.commit('globals/setShowPlaylistsModal', true)
} else if (action === 'bookmarks') {
this.showBookmarksModal = true
} else if (action === 'rss-feeds') {
this.clickRSSFeed()
} else if (action === 'download') {
this.downloadLibraryItem()
} else if (action === 'delete') {
this.deleteLibraryItem()
}
} }
}, },
mounted() { mounted() {
if (this.libraryItem.episodesDownloading) { this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
this.episodeDownloadsQueued = this.libraryItem.episodesDownloading || [] this.episodesDownloading = this.libraryItem.episodesDownloading || []
}
// use this items library id as the current // use this items library id as the current
if (this.libraryId) { if (this.libraryId) {
@@ -0,0 +1,140 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<app-book-shelf-toolbar page="podcast-search" />
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-5xl mx-auto py-4">
<p class="text-xl mb-2 font-semibold px-4 md:px-0">{{ $strings.HeaderCurrentDownloads }}</p>
<p v-if="!episodesDownloading.length" class="text-lg py-4">{{ $strings.MessageNoDownloadsInProgress }}</p>
<template v-for="episode in episodesDownloading">
<div :key="episode.id" class="flex py-5 relative">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
<div class="flex-grow pl-4 max-w-2xl">
<!-- mobile -->
<div class="flex md:hidden mb-2">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
<div class="flex-grow px-2">
<div class="flex items-center">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>
</div>
<!-- desktop -->
<div class="hidden md:block">
<div class="flex items-center">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>
<div class="flex items-center font-semibold text-gray-200">
<div v-if="episode.season || episode.episode">#</div>
<div v-if="episode.season">{{ episode.season }}x</div>
<div v-if="episode.episode">{{ episode.episode }}</div>
</div>
<div class="flex items-center mb-2">
<span class="font-semibold text-sm md:text-base">{{ episode.episodeDisplayTitle }}</span>
<widgets-podcast-type-indicator :type="episode.episodeType" />
</div>
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
</div>
</div>
</template>
<tables-podcast-download-queue-table v-if="episodeDownloadsQueued.length" :queue="episodeDownloadsQueued"></tables-podcast-download-queue-table>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ params, redirect }) {
if (!params.library) {
console.error('No library...', params.library)
return redirect('/')
}
return {
libraryId: params.library
}
},
data() {
return {
episodesDownloading: [],
episodeDownloadsQueued: [],
processing: false
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {
episodeDownloadQueued(episodeDownload) {
if (episodeDownload.libraryId === this.libraryId) {
this.episodeDownloadsQueued.push(episodeDownload)
}
},
episodeDownloadStarted(episodeDownload) {
if (episodeDownload.libraryId === this.libraryId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading.push(episodeDownload)
}
},
episodeDownloadFinished(episodeDownload) {
if (episodeDownload.libraryId === this.libraryId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
}
},
episodeDownloadQueueUpdated(downloadQueueDetails) {
this.episodeDownloadsQueued = downloadQueueDetails.queue.filter((q) => q.libraryId == this.libraryId)
},
async loadInitialDownloadQueue() {
this.processing = true
const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => {
console.error('Failed to get download queue', error)
this.$toast.error('Failed to get download queue')
return null
})
this.processing = false
this.episodeDownloadsQueued = queuePayload?.queue || []
if (queuePayload?.currentDownload) {
this.episodesDownloading.push(queuePayload.currentDownload)
}
// Initialize listeners after load to prevent event race conditions
this.initListeners()
},
initListeners() {
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.on('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
}
},
mounted() {
if (this.libraryId) {
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
}
this.loadInitialDownloadQueue()
},
beforeDestroy() {
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.off('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
}
}
</script>
@@ -14,19 +14,36 @@
<div class="flex md:hidden mb-2"> <div class="flex md:hidden mb-2">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" /> <covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link> <div class="flex items-center">
<div class="flex" @click.stop>
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
</div>
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p> <p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div> </div>
</div> </div>
<!-- desktop --> <!-- desktop -->
<div class="hidden md:block"> <div class="hidden md:block">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link> <div class="flex items-center">
<div class="flex" @click.stop>
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
</div>
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p> <p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div> </div>
<p class="font-semibold mb-2 text-sm md:text-base">{{ episode.title }}</p> <div class="flex items-center font-semibold text-gray-200">
<div v-if="episode.season || episode.episode">#</div>
<div v-if="episode.season">{{ episode.season }}x</div>
<div v-if="episode.episode">{{ episode.episode }}</div>
</div>
<div class="flex items-center mb-2">
<div class="font-semibold text-sm md:text-base">{{ episode.title }}</div>
<widgets-podcast-type-indicator :type="episode.episodeType" />
</div>
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p> <p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
@@ -113,6 +130,9 @@ export default {
if (i.episodeId) episodeIds[i.episodeId] = true if (i.episodeId) episodeIds[i.episodeId] = true
}) })
return episodeIds return episodeIds
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
} }
}, },
methods: { methods: {
@@ -156,7 +176,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: episode.podcast.metadata.title, subtitle: episode.podcast.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date', caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: episode.duration || null, duration: episode.duration || null,
coverPath: episode.podcast.coverPath || null coverPath: episode.podcast.coverPath || null
}) })
@@ -194,7 +214,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: episode.podcast.metadata.title, subtitle: episode.podcast.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date', caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: episode.duration || null, duration: episode.duration || null,
coverPath: episode.podcast.coverPath || null coverPath: episode.podcast.coverPath || null
} }
@@ -206,4 +226,4 @@ export default {
this.loadRecentEpisodes() this.loadRecentEpisodes()
} }
} }
</script> </script>
@@ -5,13 +5,12 @@
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative"> <div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-4xl mx-auto flex"> <div class="w-full max-w-4xl mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow"> <form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" /> <ui-text-input v-model="searchInput" type="search" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
<ui-btn type="submit" :disabled="processing" class="hidden md:block">{{ $strings.ButtonSubmit }}</ui-btn> <ui-btn type="submit" :disabled="processing" class="hidden md:block">{{ $strings.ButtonSubmit }}</ui-btn>
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>{{ $strings.ButtonSubmit }}</ui-btn> <ui-btn type="submit" :disabled="processing" class="block md:hidden" small>{{ $strings.ButtonSubmit }}</ui-btn>
</form> </form>
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload">{{ $strings.ButtonUploadOPMLFile }}</ui-file-input> <ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload">{{ $strings.ButtonUploadOPMLFile }}</ui-file-input>
</div> </div>
<div class="w-full max-w-3xl mx-auto py-4"> <div class="w-full max-w-3xl mx-auto py-4">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">{{ $strings.MessageNoPodcastsFound }}</p> <p v-if="termSearched && !results.length && !processing" class="text-center text-xl">{{ $strings.MessageNoPodcastsFound }}</p>
<template v-for="podcast in results"> <template v-for="podcast in results">
@@ -20,7 +19,11 @@
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" /> <img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
</div> </div>
<div class="flex-grow pl-4 max-w-2xl"> <div class="flex-grow pl-4 max-w-2xl">
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a> <div class="flex items-center">
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<widgets-explicit-indicator :explicit="podcast.explicit" />
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary"/>
</div>
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p> <p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} {{ $strings.HeaderEpisodes }}</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} {{ $strings.HeaderEpisodes }}</p>
@@ -68,10 +71,14 @@ export default {
selectedPodcast: null, selectedPodcast: null,
selectedPodcastFeed: null, selectedPodcastFeed: null,
showOPMLFeedsModal: false, showOPMLFeedsModal: false,
opmlFeeds: [] opmlFeeds: [],
existentPodcasts: []
} }
}, },
computed: { computed: {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
} }
@@ -144,18 +151,29 @@ export default {
return [] return []
}) })
console.log('Got results', results) console.log('Got results', results)
for (let result of results) {
let podcast = this.existentPodcasts.find((p) => p.itunesId === result.id || p.title === result.title.toLowerCase())
if (podcast) {
result.alreadyInLibrary = true
result.existentId = podcast.id
}
}
this.results = results this.results = results
this.termSearched = term this.termSearched = term
this.processing = false this.processing = false
}, },
async selectPodcast(podcast) { async selectPodcast(podcast) {
console.log('Selected podcast', podcast) console.log('Selected podcast', podcast)
if(podcast.existentId){
this.$router.push(`/item/${podcast.existentId}`)
return
}
if (!podcast.feedUrl) { if (!podcast.feedUrl) {
this.$toast.error('Invalid podcast - no feed') this.$toast.error('Invalid podcast - no feed')
return return
} }
this.processing = true this.processing = true
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => { var payload = await this.$axios.$post(`/api/podcasts/feed`, {rssFeed: podcast.feedUrl}).catch((error) => {
console.error('Failed to get feed', error) console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed') this.$toast.error('Failed to get podcast feed')
return null return null
@@ -167,8 +185,26 @@ export default {
this.selectedPodcast = podcast this.selectedPodcast = podcast
this.showNewPodcastModal = true this.showNewPodcastModal = true
console.log('Got podcast feed', payload.podcast) console.log('Got podcast feed', payload.podcast)
},
async fetchExistentPodcastsInYourLibrary() {
this.processing = true
const podcasts = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/items?page=0&minified=1`).catch((error) => {
console.error('Failed to fetch podcasts', error)
return []
})
this.existentPodcasts = podcasts.results.map((p) => {
return {
title: p.media.metadata.title.toLowerCase(),
itunesId: p.media.metadata.itunesId,
id: p.id
}
})
this.processing = false
} }
}, },
mounted() {} mounted() {
this.fetchExistentPodcastsInYourLibrary()
}
} }
</script> </script>
+1
View File
@@ -127,6 +127,7 @@ export default class LocalAudioPlayer extends EventEmitter {
setHlsStream() { setHlsStream() {
this.trackStartTime = 0 this.trackStartTime = 0
this.currentTrackIndex = 0
// iOS does not support Media Elements but allows for HLS in the native audio player // iOS does not support Media Elements but allows for HLS in the native audio player
if (!Hls.isSupported()) { if (!Hls.isSupported()) {
+23 -4
View File
@@ -123,7 +123,7 @@ export default class PlayerHandler {
playerError() { playerError() {
// Switch to HLS stream on error // Switch to HLS stream on error
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) { if (!this.isCasting && (this.player instanceof LocalAudioPlayer)) {
console.log(`[PlayerHandler] Audio player error switching to HLS stream`) console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
this.prepare(true) this.prepare(true)
} }
@@ -173,16 +173,30 @@ export default class PlayerHandler {
this.ctx.setBufferTime(buffertime) this.ctx.setBufferTime(buffertime)
} }
getDeviceId() {
let deviceId = localStorage.getItem('absDeviceId')
if (!deviceId) {
deviceId = this.ctx.$randomId()
localStorage.setItem('absDeviceId', deviceId)
}
return deviceId
}
async prepare(forceTranscode = false) { async prepare(forceTranscode = false) {
var payload = { this.currentSessionId = null // Reset session
const payload = {
deviceInfo: {
deviceId: this.getDeviceId()
},
supportedMimeTypes: this.player.playableMimeTypes, supportedMimeTypes: this.player.playableMimeTypes,
mediaPlayer: this.isCasting ? 'chromecast' : 'html5', mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode, forceTranscode,
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
} }
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play` const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
var session = await this.ctx.$axios.$post(path, payload).catch((error) => { const session = await this.ctx.$axios.$post(path, payload).catch((error) => {
console.error('Failed to start stream', error) console.error('Failed to start stream', error)
}) })
this.prepareSession(session) this.prepareSession(session)
@@ -238,12 +252,17 @@ export default class PlayerHandler {
closePlayer() { closePlayer() {
console.log('[PlayerHandler] Close Player') console.log('[PlayerHandler] Close Player')
this.sendCloseSession() this.sendCloseSession()
this.resetPlayer()
}
resetPlayer() {
if (this.player) { if (this.player) {
this.player.destroy() this.player.destroy()
} }
this.player = null this.player = null
this.playerState = 'IDLE' this.playerState = 'IDLE'
this.libraryItem = null this.libraryItem = null
this.currentSessionId = null
this.startTime = 0 this.startTime = 0
this.stopPlayInterval() this.stopPlayInterval()
} }
+1 -1
View File
@@ -1,6 +1,6 @@
const SupportedFileTypes = { const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'], image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma'], audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'], info: ['nfo'],
text: ['txt'], text: ['txt'],
+1 -1
View File
@@ -7,7 +7,7 @@ const defaultCode = 'en-us'
const languageCodeMap = { const languageCodeMap = {
'de': { label: 'Deutsch', dateFnsLocale: 'de' }, 'de': { label: 'Deutsch', dateFnsLocale: 'de' },
'en-us': { label: 'English', dateFnsLocale: 'enUS' }, 'en-us': { label: 'English', dateFnsLocale: 'enUS' },
// 'es': { label: 'Español', dateFnsLocale: 'es' }, 'es': { label: 'Español', dateFnsLocale: 'es' },
'fr': { label: 'Français', dateFnsLocale: 'fr' }, 'fr': { label: 'Français', dateFnsLocale: 'fr' },
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' }, 'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
'it': { label: 'Italiano', dateFnsLocale: 'it' }, 'it': { label: 'Italiano', dateFnsLocale: 'it' },
+17 -1
View File
@@ -23,6 +23,22 @@ Vue.prototype.$formatJsDate = (jsdate, fnsFormat = 'MM/dd/yyyy HH:mm') => {
if (!jsdate || !isDate(jsdate)) return '' if (!jsdate || !isDate(jsdate)) return ''
return format(jsdate, fnsFormat) return format(jsdate, fnsFormat)
} }
Vue.prototype.$formatTime = (unixms, fnsFormat = 'HH:mm') => {
if (!unixms) return ''
return format(unixms, fnsFormat)
}
Vue.prototype.$formatJsTime = (jsdate, fnsFormat = 'HH:mm') => {
if (!jsdate || !isDate(jsdate)) return ''
return format(jsdate, fnsFormat)
}
Vue.prototype.$formatDatetime = (unixms, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
if (!unixms) return ''
return format(unixms, `${fnsDateFormart} ${fnsTimeFormat}`)
}
Vue.prototype.$formatJsDatetime = (jsdate, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
if (!jsdate || !isDate(jsdate)) return ''
return format(jsdate, `${fnsDateFormart} ${fnsTimeFormat}`)
}
Vue.prototype.$addDaysToToday = (daysToAdd) => { Vue.prototype.$addDaysToToday = (daysToAdd) => {
var date = addDays(new Date(), daysToAdd) var date = addDays(new Date(), daysToAdd)
if (!date || !isDate(date)) return null if (!date || !isDate(date)) return null
@@ -167,4 +183,4 @@ export default ({ app, store }, inject) => {
inject('isDev', process.env.NODE_ENV !== 'production') inject('isDev', process.env.NODE_ENV !== 'production')
store.commit('setRouterBasePath', app.$config.routerBasePath) store.commit('setRouterBasePath', app.$config.routerBasePath)
} }
+10 -1
View File
@@ -1,4 +1,8 @@
import Vue from 'vue' import Vue from 'vue'
import cronParser from 'cron-parser'
import { nanoid } from 'nanoid'
Vue.prototype.$randomId = () => nanoid()
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => { Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) { if (isNaN(bytes) || bytes == 0) {
@@ -136,6 +140,11 @@ Vue.prototype.$parseCronExpression = (expression) => {
} }
} }
Vue.prototype.$getNextScheduledDate = (expression) => {
const interval = cronParser.parseExpression(expression);
return interval.next().toDate()
}
export function supplant(str, subs) { export function supplant(str, subs) {
// source: http://crockford.com/javascript/remedial.html // source: http://crockford.com/javascript/remedial.html
return str.replace(/{([^{}]*)}/g, return str.replace(/{([^{}]*)}/g,
@@ -144,4 +153,4 @@ export function supplant(str, subs) {
return typeof r === 'string' || typeof r === 'number' ? r : a return typeof r === 'string' || typeof r === 'number' ? r : a
} }
) )
} }
+43 -4
View File
@@ -32,11 +32,50 @@ export const state = () => ({
text: 'DD/MM/YYYY', text: 'DD/MM/YYYY',
value: 'dd/MM/yyyy' value: 'dd/MM/yyyy'
}, },
{
text: 'DD.MM.YYYY',
value: 'dd.MM.yyyy'
},
{ {
text: 'YYYY-MM-DD', text: 'YYYY-MM-DD',
value: 'yyyy-MM-dd' value: 'yyyy-MM-dd'
},
{
text: 'MMM do, yyyy',
value: 'MMM do, yyyy'
},
{
text: 'MMMM do, yyyy',
value: 'MMMM do, yyyy'
},
{
text: 'dd MMM yyyy',
value: 'dd MMM yyyy'
},
{
text: 'dd MMMM yyyy',
value: 'dd MMMM yyyy'
} }
], ],
timeFormats: [
{
text: 'h:mma (am/pm)',
value: 'h:mma'
},
{
text: 'HH:mm (24-hour)',
value: 'HH:mm'
}
],
podcastTypes: [
{ text: 'Episodic', value: 'episodic' },
{ text: 'Serial', value: 'serial' }
],
episodeTypes: [
{ text: 'Full', value: 'full' },
{ text: 'Trailer', value: 'trailer' },
{ text: 'Bonus', value: 'bonus' }
],
libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart'] libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart']
}) })
@@ -60,14 +99,14 @@ export const getters = {
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}` return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
}, },
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null) => { getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null, raw = false) => {
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg` if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
if (!libraryItemId) return placeholder if (!libraryItemId) return placeholder
var userToken = rootGetters['user/getToken'] var userToken = rootGetters['user/getToken']
if (process.env.NODE_ENV !== 'production') { // Testing if (process.env.NODE_ENV !== 'production') { // Testing
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}` return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
} }
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}` return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
}, },
getIsBatchSelectingMediaItems: (state) => { getIsBatchSelectingMediaItems: (state) => {
return state.selectedMediaItems.length return state.selectedMediaItems.length
@@ -169,4 +208,4 @@ export const mutations = {
state.selectedMediaItems.push(item) state.selectedMediaItems.push(item)
} }
} }
} }
+9 -1
View File
@@ -13,6 +13,7 @@ export const state = () => ({
playerQueueAutoPlay: true, playerQueueAutoPlay: true,
playerIsFullscreen: false, playerIsFullscreen: false,
editModalTab: 'details', editModalTab: 'details',
editPodcastModalTab: 'details',
showEditModal: false, showEditModal: false,
showEReader: false, showEReader: false,
selectedLibraryItem: null, selectedLibraryItem: null,
@@ -21,6 +22,7 @@ export const state = () => ({
previousPath: '/', previousPath: '/',
showExperimentalFeatures: false, showExperimentalFeatures: false,
bookshelfBookIds: [], bookshelfBookIds: [],
episodeTableEpisodeIds: [],
openModal: null, openModal: null,
innerModalOpen: false, innerModalOpen: false,
lastBookshelfScrollData: {}, lastBookshelfScrollData: {},
@@ -135,6 +137,9 @@ export const mutations = {
setBookshelfBookIds(state, val) { setBookshelfBookIds(state, val) {
state.bookshelfBookIds = val || [] state.bookshelfBookIds = val || []
}, },
setEpisodeTableEpisodeIds(state, val) {
state.episodeTableEpisodeIds = val || []
},
setPreviousPath(state, val) { setPreviousPath(state, val) {
state.previousPath = val state.previousPath = val
}, },
@@ -198,6 +203,9 @@ export const mutations = {
setShowEditModal(state, val) { setShowEditModal(state, val) {
state.showEditModal = val state.showEditModal = val
}, },
setEditPodcastModalTab(state, tab) {
state.editPodcastModalTab = tab
},
showEReader(state, libraryItem) { showEReader(state, libraryItem) {
state.selectedLibraryItem = libraryItem state.selectedLibraryItem = libraryItem
@@ -225,4 +233,4 @@ export const mutations = {
setInnerModalOpen(state, val) { setInnerModalOpen(state, val) {
state.innerModalOpen = val state.innerModalOpen = val
} }
} }
+22 -4
View File
@@ -1,11 +1,12 @@
export const state = () => ({ export const state = () => ({
tasks: [] tasks: [],
queuedEmbedLIds: []
}) })
export const getters = { export const getters = {
getTaskByLibraryItemId: (state) => (libraryItemId) => { getTasksByLibraryItemId: (state) => (libraryItemId) => {
return state.tasks.find(t => t.data && t.data.libraryItemId === libraryItemId) return state.tasks.filter(t => t.data && t.data.libraryItemId === libraryItemId)
} }
} }
@@ -18,14 +19,31 @@ export const mutations = {
state.tasks = tasks state.tasks = tasks
}, },
addUpdateTask(state, task) { addUpdateTask(state, task) {
var index = state.tasks.findIndex(d => d.id === task.id) const index = state.tasks.findIndex(d => d.id === task.id)
if (index >= 0) { if (index >= 0) {
state.tasks.splice(index, 1, task) state.tasks.splice(index, 1, task)
} else { } else {
// Remove duplicate (only have one library item per action)
state.tasks = state.tasks.filter(_task => {
if (!_task.data?.libraryItemId || _task.action !== task.action) return true
return _task.data.libraryItemId !== task.data.libraryItemId
})
state.tasks.push(task) state.tasks.push(task)
} }
}, },
removeTask(state, task) { removeTask(state, task) {
state.tasks = state.tasks.filter(d => d.id !== task.id) state.tasks = state.tasks.filter(d => d.id !== task.id)
},
setQueuedEmbedLIds(state, libraryItemIds) {
state.queuedEmbedLIds = libraryItemIds
},
addQueuedEmbedLId(state, libraryItemId) {
if (!state.queuedEmbedLIds.some(lid => lid === libraryItemId)) {
state.queuedEmbedLIds.push(libraryItemId)
}
},
removeQueuedEmbedLId(state, libraryItemId) {
state.queuedEmbedLIds = state.queuedEmbedLIds.filter(lid => lid !== libraryItemId)
} }
} }
+38 -2
View File
@@ -17,9 +17,10 @@
"ButtonCloseFeed": "Feed schließen", "ButtonCloseFeed": "Feed schließen",
"ButtonCollections": "Sammlungen", "ButtonCollections": "Sammlungen",
"ButtonConfigureScanner": "Scannereinstellungen", "ButtonConfigureScanner": "Scannereinstellungen",
"ButtonCreate": "Ertsellen", "ButtonCreate": "Erstellen",
"ButtonCreateBackup": "Sicherung erstellen", "ButtonCreateBackup": "Sicherung erstellen",
"ButtonDelete": "Löschen", "ButtonDelete": "Löschen",
"ButtonDownloadQueue": "Queue",
"ButtonEdit": "Bearbeiten", "ButtonEdit": "Bearbeiten",
"ButtonEditChapters": "Kapitel bearbeiten", "ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten", "ButtonEditPodcast": "Podcast bearbeiten",
@@ -92,7 +93,9 @@
"HeaderCollection": "Sammlungen", "HeaderCollection": "Sammlungen",
"HeaderCollectionItems": "Sammlungseinträge", "HeaderCollectionItems": "Sammlungseinträge",
"HeaderCover": "Titelbild", "HeaderCover": "Titelbild",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderEpisodes": "Episoden", "HeaderEpisodes": "Episoden",
"HeaderFiles": "Dateien", "HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen", "HeaderFindChapters": "Kapitel suchen",
@@ -126,6 +129,7 @@
"HeaderPreviewCover": "Vorschau Titelbild", "HeaderPreviewCover": "Vorschau Titelbild",
"HeaderRemoveEpisode": "Episode löschen", "HeaderRemoveEpisode": "Episode löschen",
"HeaderRemoveEpisodes": "Lösche {0} Episoden", "HeaderRemoveEpisodes": "Lösche {0} Episoden",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet", "HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte", "HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
"HeaderSchedule": "Zeitplan", "HeaderSchedule": "Zeitplan",
@@ -138,6 +142,7 @@
"HeaderSettingsGeneral": "Allgemein", "HeaderSettingsGeneral": "Allgemein",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Einschlaf-Timer", "HeaderSleepTimer": "Einschlaf-Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Längste Einträge (h)", "HeaderStatsLongestItems": "Längste Einträge (h)",
"HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)", "HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)",
"HeaderStatsRecentSessions": "Neueste Ereignisse", "HeaderStatsRecentSessions": "Neueste Ereignisse",
@@ -150,11 +155,13 @@
"HeaderUpdateLibrary": "Bibliothek aktualisieren", "HeaderUpdateLibrary": "Bibliothek aktualisieren",
"HeaderUsers": "Benutzer", "HeaderUsers": "Benutzer",
"HeaderYourStats": "Eigene Statistiken", "HeaderYourStats": "Eigene Statistiken",
"LabelAbridged": "Gekürzt",
"LabelAccountType": "Kontoart", "LabelAccountType": "Kontoart",
"LabelAccountTypeAdmin": "Admin", "LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gast", "LabelAccountTypeGuest": "Gast",
"LabelAccountTypeUser": "Benutzer", "LabelAccountTypeUser": "Benutzer",
"LabelActivity": "Aktivitäten", "LabelActivity": "Aktivitäten",
"LabelAdded": "Added",
"LabelAddedAt": "Hinzugefügt am", "LabelAddedAt": "Hinzugefügt am",
"LabelAddToCollection": "Zur Sammlung hinzufügen", "LabelAddToCollection": "Zur Sammlung hinzufügen",
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu", "LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
@@ -162,6 +169,7 @@
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
"LabelAll": "Alle", "LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer", "LabelAllUsers": "Alle Benutzer",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAppend": "Anhängen", "LabelAppend": "Anhängen",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Vorname Nachname)", "LabelAuthorFirstLast": "Autor (Vorname Nachname)",
@@ -175,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Zum Schutz vor Fehlkonfigurationen schlagen Sicherungen fehl, wenn sie die konfigurierte Größe überschreiten.", "LabelBackupsMaxBackupSizeHelp": "Zum Schutz vor Fehlkonfigurationen schlagen Sicherungen fehl, wenn sie die konfigurierte Größe überschreiten.",
"LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen", "LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen",
"LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.", "LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Bücher", "LabelBooks": "Bücher",
"LabelChangePassword": "Passwort ändern", "LabelChangePassword": "Passwort ändern",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "gefundene Kapitel", "LabelChaptersFound": "gefundene Kapitel",
"LabelChapterTitle": "Kapitelüberschrift", "LabelChapterTitle": "Kapitelüberschrift",
"LabelClosePlayer": "Player schließen", "LabelClosePlayer": "Player schließen",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Serien zusammenfassen", "LabelCollapseSeries": "Serien zusammenfassen",
"LabelCollections": "Sammlungen", "LabelCollections": "Sammlungen",
"LabelComplete": "Vollständig", "LabelComplete": "Vollständig",
@@ -192,6 +204,7 @@
"LabelCronExpression": "Cron Ausdruck", "LabelCronExpression": "Cron Ausdruck",
"LabelCurrent": "Aktuell", "LabelCurrent": "Aktuell",
"LabelCurrently": "Aktuell:", "LabelCurrently": "Aktuell:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelDatetime": "Datum & Uhrzeit", "LabelDatetime": "Datum & Uhrzeit",
"LabelDescription": "Beschreibung", "LabelDescription": "Beschreibung",
"LabelDeselectAll": "Alles abwählen", "LabelDeselectAll": "Alles abwählen",
@@ -204,11 +217,13 @@
"LabelDuration": "Laufzeit", "LabelDuration": "Laufzeit",
"LabelDurationFound": "Gefundene Laufzeit:", "LabelDurationFound": "Gefundene Laufzeit:",
"LabelEdit": "Bearbeiten", "LabelEdit": "Bearbeiten",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Aktivieren", "LabelEnable": "Aktivieren",
"LabelEnd": "Ende", "LabelEnd": "Ende",
"LabelEpisode": "Episode", "LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episodentitel", "LabelEpisodeTitle": "Episodentitel",
"LabelEpisodeType": "Episodentyp", "LabelEpisodeType": "Episodentyp",
"LabelExample": "Example",
"LabelExplicit": "Explizit (Altersbeschränkung)", "LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFile": "Datei", "LabelFile": "Datei",
@@ -220,6 +235,7 @@
"LabelFinished": "beendet", "LabelFinished": "beendet",
"LabelFolder": "Ordner", "LabelFolder": "Ordner",
"LabelFolders": "Verzeichnisse", "LabelFolders": "Verzeichnisse",
"LabelFormat": "Format",
"LabelGenre": "Kategorie", "LabelGenre": "Kategorie",
"LabelGenres": "Kategorien", "LabelGenres": "Kategorien",
"LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHardDeleteFile": "Datei dauerhaft löschen",
@@ -241,6 +257,8 @@
"LabelItem": "Medium", "LabelItem": "Medium",
"LabelLanguage": "Sprache", "LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache", "LabelLanguageDefaultServer": "Standard-Server-Sprache",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Zuletzt angesehen", "LabelLastSeen": "Zuletzt angesehen",
"LabelLastTime": "Letztes Mal", "LabelLastTime": "Letztes Mal",
"LabelLastUpdate": "Letzte Aktualisierung", "LabelLastUpdate": "Letzte Aktualisierung",
@@ -259,10 +277,12 @@
"LabelMediaType": "Medientyp", "LabelMediaType": "Medientyp",
"LabelMetadataProvider": "Metadatenanbieter", "LabelMetadataProvider": "Metadatenanbieter",
"LabelMetaTag": "Meta Schlagwort", "LabelMetaTag": "Meta Schlagwort",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMissing": "Fehlend", "LabelMissing": "Fehlend",
"LabelMissingParts": "Fehlende Teile", "LabelMissingParts": "Fehlende Teile",
"LabelMore": "Mehr", "LabelMore": "Mehr",
"LabelMoreInfo": "More Info",
"LabelName": "Name", "LabelName": "Name",
"LabelNarrator": "Erzähler", "LabelNarrator": "Erzähler",
"LabelNarrators": "Erzähler", "LabelNarrators": "Erzähler",
@@ -270,6 +290,8 @@
"LabelNewestAuthors": "Neuste Autoren", "LabelNewestAuthors": "Neuste Autoren",
"LabelNewestEpisodes": "Neueste Episoden", "LabelNewestEpisodes": "Neueste Episoden",
"LabelNewPassword": "Neues Passwort", "LabelNewPassword": "Neues Passwort",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNotes": "Hinweise", "LabelNotes": "Hinweise",
"LabelNotFinished": "nicht beendet", "LabelNotFinished": "nicht beendet",
"LabelNotificationAppriseURL": "Apprise URL(s)", "LabelNotificationAppriseURL": "Apprise URL(s)",
@@ -300,7 +322,9 @@
"LabelPlayMethod": "Abspielmethode", "LabelPlayMethod": "Abspielmethode",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)", "LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Fortschritt", "LabelProgress": "Fortschritt",
"LabelProvider": "Anbieter", "LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum", "LabelPubDate": "Veröffentlichungsdatum",
@@ -312,7 +336,10 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum", "LabelReleaseDate": "Veröffentlichungsdatum",
"LabelRemoveCover": "Lösche Titelbild", "LabelRemoveCover": "Lösche Titelbild",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Offen", "LabelRSSFeedOpen": "RSS Feed Offen",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Schlagwort", "LabelRSSFeedSlug": "RSS Feed Schlagwort",
"LabelRSSFeedURL": "RSS Feed URL", "LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Begriff suchen", "LabelSearchTerm": "Begriff suchen",
@@ -357,6 +384,7 @@
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.", "LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern", "LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern",
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.", "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Alles anzeigen", "LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe", "LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer", "LabelSleepTimer": "Einschlaf-Timer",
@@ -381,9 +409,11 @@
"LabelStatsWeekListening": "Gehörte Wochen", "LabelStatsWeekListening": "Gehörte Wochen",
"LabelSubtitle": "Untertitel", "LabelSubtitle": "Untertitel",
"LabelSupportedFileTypes": "Unterstützte Dateitypen", "LabelSupportedFileTypes": "Unterstützte Dateitypen",
"LabelTag": "Tag", "LabelTag": "Schlagwort",
"LabelTags": "Schlagwörter", "LabelTags": "Schlagwörter",
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter", "LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Gehörte Zeit", "LabelTimeListened": "Gehörte Zeit",
"LabelTimeListenedToday": "Heute gehörte Zeit", "LabelTimeListenedToday": "Heute gehörte Zeit",
"LabelTimeRemaining": "{0} verbleibend", "LabelTimeRemaining": "{0} verbleibend",
@@ -403,6 +433,7 @@
"LabelTracksMultiTrack": "Mehrfachdatei", "LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksSingleTrack": "Einzeldatei", "LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnabridged": "Ungekürzt",
"LabelUnknown": "Unbekannt", "LabelUnknown": "Unbekannt",
"LabelUpdateCover": "Titelbild aktualisieren", "LabelUpdateCover": "Titelbild aktualisieren",
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird", "LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
@@ -446,6 +477,7 @@
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", "MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?", "MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?", "MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?", "MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?", "MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?", "MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
@@ -485,6 +517,8 @@
"MessageNoCollections": "Keine Sammlungen", "MessageNoCollections": "Keine Sammlungen",
"MessageNoCoversFound": "Keine Titelbilder gefunden", "MessageNoCoversFound": "Keine Titelbilder gefunden",
"MessageNoDescription": "Keine Beschreibung", "MessageNoDescription": "Keine Beschreibung",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden", "MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden",
"MessageNoEpisodes": "Keine Episoden", "MessageNoEpisodes": "Keine Episoden",
"MessageNoFoldersAvailable": "Keine Ordner verfügbar", "MessageNoFoldersAvailable": "Keine Ordner verfügbar",
@@ -501,6 +535,7 @@
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"", "MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
"MessageNoSeries": "Keine Serien", "MessageNoSeries": "Keine Serien",
"MessageNoTags": "Keine Tags", "MessageNoTags": "Keine Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNotYetImplemented": "Noch nicht implementiert", "MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich", "MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
@@ -546,6 +581,7 @@
"PlaceholderNewFolderPath": "Neuer Ordnerpfad", "PlaceholderNewFolderPath": "Neuer Ordnerpfad",
"PlaceholderNewPlaylist": "Neuer Wiedergabelistenname", "PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
"PlaceholderSearch": "Suche...", "PlaceholderSearch": "Suche...",
"PlaceholderSearchEpisode": "Search episode...",
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen", "ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
"ToastAccountUpdateSuccess": "Konto aktualisiert", "ToastAccountUpdateSuccess": "Konto aktualisiert",
"ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden", "ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden",
+36
View File
@@ -20,6 +20,7 @@
"ButtonCreate": "Create", "ButtonCreate": "Create",
"ButtonCreateBackup": "Create Backup", "ButtonCreateBackup": "Create Backup",
"ButtonDelete": "Delete", "ButtonDelete": "Delete",
"ButtonDownloadQueue": "Queue",
"ButtonEdit": "Edit", "ButtonEdit": "Edit",
"ButtonEditChapters": "Edit Chapters", "ButtonEditChapters": "Edit Chapters",
"ButtonEditPodcast": "Edit Podcast", "ButtonEditPodcast": "Edit Podcast",
@@ -92,7 +93,9 @@
"HeaderCollection": "Collection", "HeaderCollection": "Collection",
"HeaderCollectionItems": "Collection Items", "HeaderCollectionItems": "Collection Items",
"HeaderCover": "Cover", "HeaderCover": "Cover",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderEpisodes": "Episodes", "HeaderEpisodes": "Episodes",
"HeaderFiles": "Files", "HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "Find Chapters",
@@ -126,6 +129,7 @@
"HeaderPreviewCover": "Preview Cover", "HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode", "HeaderRemoveEpisode": "Remove Episode",
"HeaderRemoveEpisodes": "Remove {0} Episodes", "HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open", "HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderSavedMediaProgress": "Saved Media Progress", "HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule", "HeaderSchedule": "Schedule",
@@ -138,6 +142,7 @@
"HeaderSettingsGeneral": "General", "HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Sleep Timer", "HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)", "HeaderStatsLongestItems": "Longest Items (hrs)",
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)", "HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
"HeaderStatsRecentSessions": "Recent Sessions", "HeaderStatsRecentSessions": "Recent Sessions",
@@ -150,11 +155,13 @@
"HeaderUpdateLibrary": "Update Library", "HeaderUpdateLibrary": "Update Library",
"HeaderUsers": "Users", "HeaderUsers": "Users",
"HeaderYourStats": "Your Stats", "HeaderYourStats": "Your Stats",
"LabelAbridged": "Abridged",
"LabelAccountType": "Account Type", "LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin", "LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest", "LabelAccountTypeGuest": "Guest",
"LabelAccountTypeUser": "User", "LabelAccountTypeUser": "User",
"LabelActivity": "Activity", "LabelActivity": "Activity",
"LabelAdded": "Added",
"LabelAddedAt": "Added At", "LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection", "LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToCollectionBatch": "Add {0} Books to Collection",
@@ -162,6 +169,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "All Users", "LabelAllUsers": "All Users",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAppend": "Append", "LabelAppend": "Append",
"LabelAuthor": "Author", "LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Author (First Last)",
@@ -175,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.", "LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Number of backups to keep", "LabelBackupsNumberToKeep": "Number of backups to keep",
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.", "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Books", "LabelBooks": "Books",
"LabelChangePassword": "Change Password", "LabelChangePassword": "Change Password",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "chapters found", "LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title", "LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player", "LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series", "LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Collections", "LabelCollections": "Collections",
"LabelComplete": "Complete", "LabelComplete": "Complete",
@@ -192,6 +204,7 @@
"LabelCronExpression": "Cron Expression", "LabelCronExpression": "Cron Expression",
"LabelCurrent": "Current", "LabelCurrent": "Current",
"LabelCurrently": "Currently:", "LabelCurrently": "Currently:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelDatetime": "Datetime", "LabelDatetime": "Datetime",
"LabelDescription": "Description", "LabelDescription": "Description",
"LabelDeselectAll": "Deselect All", "LabelDeselectAll": "Deselect All",
@@ -204,11 +217,13 @@
"LabelDuration": "Duration", "LabelDuration": "Duration",
"LabelDurationFound": "Duration found:", "LabelDurationFound": "Duration found:",
"LabelEdit": "Edit", "LabelEdit": "Edit",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable", "LabelEnable": "Enable",
"LabelEnd": "End", "LabelEnd": "End",
"LabelEpisode": "Episode", "LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episode Title", "LabelEpisodeTitle": "Episode Title",
"LabelEpisodeType": "Episode Type", "LabelEpisodeType": "Episode Type",
"LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFile": "File", "LabelFile": "File",
@@ -220,6 +235,7 @@
"LabelFinished": "Finished", "LabelFinished": "Finished",
"LabelFolder": "Folder", "LabelFolder": "Folder",
"LabelFolders": "Folders", "LabelFolders": "Folders",
"LabelFormat": "Format",
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
@@ -241,6 +257,8 @@
"LabelItem": "Item", "LabelItem": "Item",
"LabelLanguage": "Language", "LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language", "LabelLanguageDefaultServer": "Default Server Language",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Last Seen", "LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time", "LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update", "LabelLastUpdate": "Last Update",
@@ -259,10 +277,12 @@
"LabelMediaType": "Media Type", "LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMissing": "Missing", "LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts", "LabelMissingParts": "Missing Parts",
"LabelMore": "More", "LabelMore": "More",
"LabelMoreInfo": "More Info",
"LabelName": "Name", "LabelName": "Name",
"LabelNarrator": "Narrator", "LabelNarrator": "Narrator",
"LabelNarrators": "Narrators", "LabelNarrators": "Narrators",
@@ -270,6 +290,8 @@
"LabelNewestAuthors": "Newest Authors", "LabelNewestAuthors": "Newest Authors",
"LabelNewestEpisodes": "Newest Episodes", "LabelNewestEpisodes": "Newest Episodes",
"LabelNewPassword": "New Password", "LabelNewPassword": "New Password",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNotes": "Notes", "LabelNotes": "Notes",
"LabelNotFinished": "Not Finished", "LabelNotFinished": "Not Finished",
"LabelNotificationAppriseURL": "Apprise URL(s)", "LabelNotificationAppriseURL": "Apprise URL(s)",
@@ -300,7 +322,9 @@
"LabelPlayMethod": "Play Method", "LabelPlayMethod": "Play Method",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)", "LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progress", "LabelProgress": "Progress",
"LabelProvider": "Provider", "LabelProvider": "Provider",
"LabelPubDate": "Pub Date", "LabelPubDate": "Pub Date",
@@ -312,7 +336,10 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Release Date", "LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover", "LabelRemoveCover": "Remove cover",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL", "LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Search Term", "LabelSearchTerm": "Search Term",
@@ -357,6 +384,7 @@
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
"LabelSettingsStoreMetadataWithItem": "Store metadata with item", "LabelSettingsStoreMetadataWithItem": "Store metadata with item",
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Show All", "LabelShowAll": "Show All",
"LabelSize": "Size", "LabelSize": "Size",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
@@ -384,6 +412,8 @@
"LabelTag": "Tag", "LabelTag": "Tag",
"LabelTags": "Tags", "LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User", "LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened", "LabelTimeListened": "Time Listened",
"LabelTimeListenedToday": "Time Listened Today", "LabelTimeListenedToday": "Time Listened Today",
"LabelTimeRemaining": "{0} remaining", "LabelTimeRemaining": "{0} remaining",
@@ -403,6 +433,7 @@
"LabelTracksMultiTrack": "Multi-track", "LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track", "LabelTracksSingleTrack": "Single-track",
"LabelType": "Type", "LabelType": "Type",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Unknown", "LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover", "LabelUpdateCover": "Update Cover",
"LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located", "LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
@@ -446,6 +477,7 @@
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
@@ -485,6 +517,8 @@
"MessageNoCollections": "No Collections", "MessageNoCollections": "No Collections",
"MessageNoCoversFound": "No Covers Found", "MessageNoCoversFound": "No Covers Found",
"MessageNoDescription": "No description", "MessageNoDescription": "No description",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "No episode matches found", "MessageNoEpisodeMatchesFound": "No episode matches found",
"MessageNoEpisodes": "No Episodes", "MessageNoEpisodes": "No Episodes",
"MessageNoFoldersAvailable": "No Folders Available", "MessageNoFoldersAvailable": "No Folders Available",
@@ -501,6 +535,7 @@
"MessageNoSearchResultsFor": "No search results for \"{0}\"", "MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags", "MessageNoTags": "No Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNotYetImplemented": "Not yet implemented", "MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary", "MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary",
@@ -546,6 +581,7 @@
"PlaceholderNewFolderPath": "New folder path", "PlaceholderNewFolderPath": "New folder path",
"PlaceholderNewPlaylist": "New playlist name", "PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..", "PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
"ToastAccountUpdateFailed": "Failed to update account", "ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated", "ToastAccountUpdateSuccess": "Account updated",
"ToastAuthorImageRemoveFailed": "Failed to remove image", "ToastAuthorImageRemoveFailed": "Failed to remove image",
+620 -584
View File
File diff suppressed because it is too large Load Diff
+226 -190
View File
@@ -8,18 +8,19 @@
"ButtonAuthors": "Auteurs", "ButtonAuthors": "Auteurs",
"ButtonBrowseForFolder": "Naviguer vers le répertoire", "ButtonBrowseForFolder": "Naviguer vers le répertoire",
"ButtonCancel": "Annuler", "ButtonCancel": "Annuler",
"ButtonCancelEncode": "Annuler l'encodage", "ButtonCancelEncode": "Annuler lencodage",
"ButtonChangeRootPassword": "Changer le mot de passe Administrateur", "ButtonChangeRootPassword": "Modifier le mot de passe Administrateur",
"ButtonCheckAndDownloadNewEpisodes": "Vérifier & télécharger de nouveaux épisodes", "ButtonCheckAndDownloadNewEpisodes": "Vérifier et télécharger de nouveaux épisodes",
"ButtonChooseAFolder": "Choisir un dossier", "ButtonChooseAFolder": "Choisir un dossier",
"ButtonChooseFiles": "Choisir les fichiers", "ButtonChooseFiles": "Choisir les fichiers",
"ButtonClearFilter": "Effacer le filtre", "ButtonClearFilter": "Effacer le filtre",
"ButtonCloseFeed": "Fermer le flux", "ButtonCloseFeed": "Fermer le flux",
"ButtonCollections": "Collections", "ButtonCollections": "Collections",
"ButtonConfigureScanner": "Configurer l'analyse", "ButtonConfigureScanner": "Configurer lanalyse",
"ButtonCreate": "Créer", "ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une sauvegarde", "ButtonCreateBackup": "Créer une sauvegarde",
"ButtonDelete": "Effacer", "ButtonDelete": "Effacer",
"ButtonDownloadQueue": "Queue de téléchargement",
"ButtonEdit": "Modifier", "ButtonEdit": "Modifier",
"ButtonEditChapters": "Modifier les chapitres", "ButtonEditChapters": "Modifier les chapitres",
"ButtonEditPodcast": "Modifier les podcasts", "ButtonEditPodcast": "Modifier les podcasts",
@@ -30,16 +31,16 @@
"ButtonIssues": "Parutions", "ButtonIssues": "Parutions",
"ButtonLatest": "Dernière version", "ButtonLatest": "Dernière version",
"ButtonLibrary": "Bibliothèque", "ButtonLibrary": "Bibliothèque",
"ButtonLogout": "Se Déconnecter", "ButtonLogout": "Me déconnecter",
"ButtonLookup": "Rechercher", "ButtonLookup": "Chercher",
"ButtonManageTracks": "Gérer les pistes", "ButtonManageTracks": "Gérer les pistes",
"ButtonMapChapterTitles": "Correspondance des titres de chapitres", "ButtonMapChapterTitles": "Correspondance des titres de chapitres",
"ButtonMatchAllAuthors": "Rechercher tous les auteurs", "ButtonMatchAllAuthors": "Chercher tous les auteurs",
"ButtonMatchBooks": "Rechercher les Livres", "ButtonMatchBooks": "Chercher les livres",
"ButtonNevermind": "Oubliez cela", "ButtonNevermind": "Non merci",
"ButtonOk": "Ok", "ButtonOk": "Ok",
"ButtonOpenFeed": "Ouvrir le Flux", "ButtonOpenFeed": "Ouvrir le flux",
"ButtonOpenManager": "Ouvrir le Gestionnaire", "ButtonOpenManager": "Ouvrir le gestionnaire",
"ButtonPlay": "Écouter", "ButtonPlay": "Écouter",
"ButtonPlaying": "En lecture", "ButtonPlaying": "En lecture",
"ButtonPlaylists": "Listes de lecture", "ButtonPlaylists": "Listes de lecture",
@@ -59,25 +60,25 @@
"ButtonReset": "Réinitialiser", "ButtonReset": "Réinitialiser",
"ButtonRestore": "Rétablir", "ButtonRestore": "Rétablir",
"ButtonSave": "Sauvegarder", "ButtonSave": "Sauvegarder",
"ButtonSaveAndClose": "Sauvegarder & Fermer", "ButtonSaveAndClose": "Sauvegarder et Fermer",
"ButtonSaveTracklist": "Sauvegarder la liste de lecture", "ButtonSaveTracklist": "Sauvegarder la liste de lecture",
"ButtonScan": "Analyser", "ButtonScan": "Analyser",
"ButtonScanLibrary": "Analyser la bibliothèque", "ButtonScanLibrary": "Analyser la bibliothèque",
"ButtonSearch": "Rechercher", "ButtonSearch": "Chercher",
"ButtonSelectFolderPath": "Sélectionner le Chemin du dossier", "ButtonSelectFolderPath": "Sélectionner le chemin du dossier",
"ButtonSeries": "Séries", "ButtonSeries": "Séries",
"ButtonSetChaptersFromTracks": "Positionner les Chapitre par rapports aux Pistes", "ButtonSetChaptersFromTracks": "Positionner les chapitres par rapports aux pistes",
"ButtonShiftTimes": "Décaler le Temps", "ButtonShiftTimes": "Décaler lhorodatage du livre",
"ButtonShow": "Afficher", "ButtonShow": "Afficher",
"ButtonStartM4BEncode": "Démarrer l'encodage M4B", "ButtonStartM4BEncode": "Démarrer lencodage M4B",
"ButtonStartMetadataEmbed": "Démarrer les Métadonnées Intégrées", "ButtonStartMetadataEmbed": "Démarrer les Métadonnées intégrées",
"ButtonSubmit": "Soumettre", "ButtonSubmit": "Soumettre",
"ButtonUpload": "Téléverser", "ButtonUpload": "Téléverser",
"ButtonUploadBackup": "Téléverser une Sauvegarde", "ButtonUploadBackup": "Téléverser une sauvegarde",
"ButtonUploadCover": "Téléverser une Couverture", "ButtonUploadCover": "Téléverser une couverture",
"ButtonUploadOPMLFile": "Téléverser un Fichier OPML", "ButtonUploadOPMLFile": "Téléverser un fichier OPML",
"ButtonUserDelete": "Effacer l'utilisateur {0}", "ButtonUserDelete": "Effacer lutilisateur {0}",
"ButtonUserEdit": "Modifier l'utilisateur {0}", "ButtonUserEdit": "Modifier lutilisateur {0}",
"ButtonViewAll": "Afficher tout", "ButtonViewAll": "Afficher tout",
"ButtonYes": "Oui", "ButtonYes": "Oui",
"HeaderAccount": "Compte", "HeaderAccount": "Compte",
@@ -86,47 +87,50 @@
"HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook", "HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook",
"HeaderAudioTracks": "Pistes zudio", "HeaderAudioTracks": "Pistes zudio",
"HeaderBackups": "Sauvegardes", "HeaderBackups": "Sauvegardes",
"HeaderChangePassword": "Chager le mot de passe", "HeaderChangePassword": "Modifier le mot de passe",
"HeaderChapters": "Chapitres", "HeaderChapters": "Chapitres",
"HeaderChooseAFolder": "Choisir un dossier", "HeaderChooseAFolder": "Choisir un dossier",
"HeaderCollection": "Collection", "HeaderCollection": "Collection",
"HeaderCollectionItems": "Entrées de la Collection", "HeaderCollectionItems": "Entrées de la Collection",
"HeaderCover": "Couverture", "HeaderCover": "Couverture",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Détails", "HeaderDetails": "Détails",
"HeaderDownloadQueue": "Queue de téléchargement",
"HeaderEpisodes": "Épisodes", "HeaderEpisodes": "Épisodes",
"HeaderFiles": "Fichiers", "HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les chapitres", "HeaderFindChapters": "Trouver les chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés", "HeaderIgnoredFiles": "Fichiers Ignorés",
"HeaderItemFiles": "Fichiers des Articles", "HeaderItemFiles": "Fichiers des Articles",
"HeaderItemMetadataUtils": "Outils de gestion des métadonnées", "HeaderItemMetadataUtils": "Outils de gestion des métadonnées",
"HeaderLastListeningSession": "Dernière Session d'écoute", "HeaderLastListeningSession": "Dernière Session découte",
"HeaderLatestEpisodes": "Dernier épisodes", "HeaderLatestEpisodes": "Dernier épisodes",
"HeaderLibraries": "Bibliothèque", "HeaderLibraries": "Bibliothèque",
"HeaderLibraryFiles": "Fichier de bibliothèque", "HeaderLibraryFiles": "Fichier de bibliothèque",
"HeaderLibraryStats": "Statistiques de bibliothèque", "HeaderLibraryStats": "Statistiques de bibliothèque",
"HeaderListeningSessions": "Sessions d'écoute", "HeaderListeningSessions": "Sessions découte",
"HeaderListeningStats": "Statistiques d'écoute", "HeaderListeningStats": "Statistiques découte",
"HeaderLogin": "Connexion", "HeaderLogin": "Connexion",
"HeaderLogs": "Fichiers Journaux", "HeaderLogs": "Journaux",
"HeaderManageGenres": "Gérer les genres", "HeaderManageGenres": "Gérer les genres",
"HeaderManageTags": "Gérer les étiquettes", "HeaderManageTags": "Gérer les étiquettes",
"HeaderMapDetails": "Édition en Masse", "HeaderMapDetails": "Édition en masse",
"HeaderMatch": "Rechercher", "HeaderMatch": "Chercher",
"HeaderMetadataToEmbed": "Métadonnée à Intégrer", "HeaderMetadataToEmbed": "Métadonnée à intégrer",
"HeaderNewAccount": "Nouveau Compte", "HeaderNewAccount": "Nouveau compte",
"HeaderNewLibrary": "Nouvelle Bibliothèque", "HeaderNewLibrary": "Nouvelle bibliothèque",
"HeaderNotifications": "Notifications", "HeaderNotifications": "Notifications",
"HeaderOpenRSSFeed": "Ouvrir Flux RSS", "HeaderOpenRSSFeed": "Ouvrir Flux RSS",
"HeaderOtherFiles": "Autres fichiers", "HeaderOtherFiles": "Autres fichiers",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Liste d'écoute", "HeaderPlayerQueue": "Liste découte",
"HeaderPlaylist": "Liste de lecture", "HeaderPlaylist": "Liste de lecture",
"HeaderPlaylistItems": "Éléments de la liste de lecture", "HeaderPlaylistItems": "Éléments de la liste de lecture",
"HeaderPodcastsToAdd": "Podcasts à ajouter", "HeaderPodcastsToAdd": "Podcasts à ajouter",
"HeaderPreviewCover": "Prévisualiser la couverture", "HeaderPreviewCover": "Prévisualiser la couverture",
"HeaderRemoveEpisode": "Supprimer l'épisode", "HeaderRemoveEpisode": "Supprimer lépisode",
"HeaderRemoveEpisodes": "Suppression de {0} épisodes", "HeaderRemoveEpisodes": "Suppression de {0} épisodes",
"HeaderRSSFeedIsOpen": "Le Flux RSS et Ouvert", "HeaderRSSFeedGeneral": "Détails de flux RSS",
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias", "HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
"HeaderSchedule": "Programmation", "HeaderSchedule": "Programmation",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque", "HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
@@ -138,48 +142,56 @@
"HeaderSettingsGeneral": "Général", "HeaderSettingsGeneral": "Général",
"HeaderSettingsScanner": "Scanneur", "HeaderSettingsScanner": "Scanneur",
"HeaderSleepTimer": "Minuterie", "HeaderSleepTimer": "Minuterie",
"HeaderStatsLargestItems": "Articles les plus lourd",
"HeaderStatsLongestItems": "Articles les plus long (heures)", "HeaderStatsLongestItems": "Articles les plus long (heures)",
"HeaderStatsMinutesListeningChart": "Minutes d'écoute (7 derniers jours)", "HeaderStatsMinutesListeningChart": "Minutes découte (7 derniers jours)",
"HeaderStatsRecentSessions": "Sessions récentes", "HeaderStatsRecentSessions": "Sessions récentes",
"HeaderStatsTop10Authors": "Top 10 Auteurs", "HeaderStatsTop10Authors": "Top 10 Auteurs",
"HeaderStatsTop5Genres": "Top 5 Genres", "HeaderStatsTop5Genres": "Top 5 Genres",
"HeaderTools": "Outils", "HeaderTools": "Outils",
"HeaderUpdateAccount": "Mettre à jour le compte", "HeaderUpdateAccount": "Mettre à jour le compte",
"HeaderUpdateAuthor": "Mettre à jour l'auteur", "HeaderUpdateAuthor": "Mettre à jour lauteur",
"HeaderUpdateDetails": "Mettre à jour les détails", "HeaderUpdateDetails": "Mettre à jour les détails",
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque", "HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
"HeaderUsers": "Utilisateurs", "HeaderUsers": "Utilisateurs",
"HeaderYourStats": "Vos statistiques", "HeaderYourStats": "Vos statistiques",
"LabelAbridged": "Version courte",
"LabelAccountType": "Type de compte", "LabelAccountType": "Type de compte",
"LabelAccountTypeAdmin": "Admin", "LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Invité", "LabelAccountTypeGuest": "Invité",
"LabelAccountTypeUser": "Utilisateur", "LabelAccountTypeUser": "Utilisateur",
"LabelActivity": "Activité", "LabelActivity": "Activité",
"LabelAddedAt": "Date d'ajout", "LabelAdded": "Added",
"LabelAddedAt": "Date dajout",
"LabelAddToCollection": "Ajouter à la collection", "LabelAddToCollection": "Ajouter à la collection",
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection", "LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
"LabelAddToPlaylist": "Ajouter à la liste de lecture", "LabelAddToPlaylist": "Ajouter à la liste de lecture",
"LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture", "LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture",
"LabelAll": "Tout", "LabelAll": "Tout",
"LabelAllUsers": "Tous les Utilisateurs", "LabelAllUsers": "Tous les utilisateurs",
"LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque",
"LabelAppend": "Ajouter", "LabelAppend": "Ajouter",
"LabelAuthor": "Auteur", "LabelAuthor": "Auteur",
"LabelAuthorFirstLast": "Auteur (Prénom Nom)", "LabelAuthorFirstLast": "Auteur (Prénom Nom)",
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)", "LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
"LabelAuthors": "Auteurs", "LabelAuthors": "Auteurs",
"LabelAutoDownloadEpisodes": "Téléchargement automatique d'épisode", "LabelAutoDownloadEpisodes": "Téléchargement automatique dépisode",
"LabelBackToUser": "Revenir à l'Utilisateur", "LabelBackToUser": "Revenir à lUtilisateur",
"LabelBackupsEnableAutomaticBackups": "Activer les Sauvegardes Automatiques", "LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques",
"LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups", "LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups",
"LabelBackupsMaxBackupSize": "Taille de Sauvegarde Maximale (en GB)", "LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)",
"LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.", "LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.",
"LabelBackupsNumberToKeep": "Nombre de Sauvegardes à maintenir", "LabelBackupsNumberToKeep": "Nombre de sauvegardes à maintenir",
"LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.", "LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Livres", "LabelBooks": "Livres",
"LabelChangePassword": "Changer le mot de passe", "LabelChangePassword": "Modifier le mot de passe",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "Chapitres trouvés", "LabelChaptersFound": "Chapitres trouvés",
"LabelChapterTitle": "Titres du chapitre", "LabelChapterTitle": "Titres du chapitre",
"LabelClosePlayer": "Fermer le lecteur", "LabelClosePlayer": "Fermer le lecteur",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Réduire les séries", "LabelCollapseSeries": "Réduire les séries",
"LabelCollections": "Collections", "LabelCollections": "Collections",
"LabelComplete": "Complet", "LabelComplete": "Complet",
@@ -187,16 +199,17 @@
"LabelContinueListening": "Continuer la lecture", "LabelContinueListening": "Continuer la lecture",
"LabelContinueSeries": "Continuer la série", "LabelContinueSeries": "Continuer la série",
"LabelCover": "Couverture", "LabelCover": "Couverture",
"LabelCoverImageURL": "URL vers l'image de couverture", "LabelCoverImageURL": "URL vers limage de couverture",
"LabelCreatedAt": "Créé le", "LabelCreatedAt": "Créé le",
"LabelCronExpression": "Expression Cron", "LabelCronExpression": "Expression Cron",
"LabelCurrent": "Courrant", "LabelCurrent": "Courrant",
"LabelCurrently": "En ce moment :", "LabelCurrently": "En ce moment :",
"LabelCustomCronExpression": "Expression cron personnalisée:",
"LabelDatetime": "Datetime", "LabelDatetime": "Datetime",
"LabelDescription": "Description", "LabelDescription": "Description",
"LabelDeselectAll": "Tout Déselectionner", "LabelDeselectAll": "Tout déselectionner",
"LabelDevice": "Appareil", "LabelDevice": "Appareil",
"LabelDeviceInfo": "Détail de l'appareil", "LabelDeviceInfo": "Détail de lappareil",
"LabelDirectory": "Répertoire", "LabelDirectory": "Répertoire",
"LabelDiscFromFilename": "Disque depuis le fichier", "LabelDiscFromFilename": "Disque depuis le fichier",
"LabelDiscFromMetadata": "Disque depuis les métadonnées", "LabelDiscFromMetadata": "Disque depuis les métadonnées",
@@ -204,22 +217,25 @@
"LabelDuration": "Durée", "LabelDuration": "Durée",
"LabelDurationFound": "Durée trouvée :", "LabelDurationFound": "Durée trouvée :",
"LabelEdit": "Modifier", "LabelEdit": "Modifier",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Activer", "LabelEnable": "Activer",
"LabelEnd": "Fin", "LabelEnd": "Fin",
"LabelEpisode": "Épisode", "LabelEpisode": "Épisode",
"LabelEpisodeTitle": "Titre de l'épisode", "LabelEpisodeTitle": "Titre de lépisode",
"LabelEpisodeType": "Type de l'épisode", "LabelEpisodeType": "Type de lépisode",
"LabelExample": "Exemple",
"LabelExplicit": "Restriction", "LabelExplicit": "Restriction",
"LabelFeedURL": "URL deu flux", "LabelFeedURL": "URL deu flux",
"LabelFile": "Fichier", "LabelFile": "Fichier",
"LabelFileBirthtime": "Creation du fichier", "LabelFileBirthtime": "Creation du fichier",
"LabelFileModified": "Modification du fichier", "LabelFileModified": "Modification du fichier",
"LabelFilename": "Nom de Fichier", "LabelFilename": "Nom de fichier",
"LabelFilterByUser": "Filtrer par l'utilisateur", "LabelFilterByUser": "Filtrer par lutilisateur",
"LabelFindEpisodes": "Trouver des épisodes", "LabelFindEpisodes": "Trouver des épisodes",
"LabelFinished": "Fini(e)", "LabelFinished": "Fini(e)",
"LabelFolder": "Dossier", "LabelFolder": "Dossier",
"LabelFolders": "Dossiers", "LabelFolders": "Dossiers",
"LabelFormat": "Format",
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Suppression du fichier", "LabelHardDeleteFile": "Suppression du fichier",
@@ -241,28 +257,32 @@
"LabelItem": "Article", "LabelItem": "Article",
"LabelLanguage": "Langue", "LabelLanguage": "Langue",
"LabelLanguageDefaultServer": "Langue par défaut", "LabelLanguageDefaultServer": "Langue par défaut",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Vu dernièrement", "LabelLastSeen": "Vu dernièrement",
"LabelLastTime": "Progression", "LabelLastTime": "Progression",
"LabelLastUpdate": "Dernière mise à jour", "LabelLastUpdate": "Dernière mise à jour",
"LabelLess": "Moins", "LabelLess": "Moins",
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l'utilisateur", "LabelLibrariesAccessibleToUser": "Bibliothèque accessible à lutilisateur",
"LabelLibrary": "Bibliothèque", "LabelLibrary": "Bibliothèque",
"LabelLibraryItem": "Article de bibliothèque", "LabelLibraryItem": "Article de bibliothèque",
"LabelLibraryName": "Nom de bibliothèque", "LabelLibraryName": "Nom de la bibliothèque",
"LabelLimit": "Limite", "LabelLimit": "Limite",
"LabelListenAgain": "Écouter à nouveau", "LabelListenAgain": "Écouter à nouveau",
"LabelLogLevelDebug": "Debug", "LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Rechercher de nouveaux épisode après cette date", "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date",
"LabelMediaPlayer": "Lecteur multimédia", "LabelMediaPlayer": "Lecteur multimédia",
"LabelMediaType": "Type de média", "LabelMediaType": "Type de média",
"LabelMetadataProvider": "Fournisseur de métadonnées", "LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMetaTag": "Etiquette de métadonnée", "LabelMetaTag": "Etiquette de métadonnée",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMissing": "Manquant", "LabelMissing": "Manquant",
"LabelMissingParts": "Parties manquantes", "LabelMissingParts": "Parties manquantes",
"LabelMore": "Plus", "LabelMore": "Plus",
"LabelMoreInfo": "More Info",
"LabelName": "Nom", "LabelName": "Nom",
"LabelNarrator": "Narrateur", "LabelNarrator": "Narrateur",
"LabelNarrators": "Narrateurs", "LabelNarrators": "Narrateurs",
@@ -270,50 +290,57 @@
"LabelNewestAuthors": "Nouveaux auteurs", "LabelNewestAuthors": "Nouveaux auteurs",
"LabelNewestEpisodes": "Derniers épisodes", "LabelNewestEpisodes": "Derniers épisodes",
"LabelNewPassword": "Nouveau mot de passe", "LabelNewPassword": "Nouveau mot de passe",
"LabelNextBackupDate": "Prochaine date de sauvegarde",
"LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNotes": "Notes", "LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)", "LabelNotFinished": "Non terminé(e)",
"LabelNotificationAppriseURL": "URL(s) d'apprise", "LabelNotificationAppriseURL": "URL(s) dapprise",
"LabelNotificationAvailableVariables": "Variables disponibles", "LabelNotificationAvailableVariables": "Variables disponibles",
"LabelNotificationBodyTemplate": "Modèle de Message", "LabelNotificationBodyTemplate": "Modèle de Message",
"LabelNotificationEvent": "Evènement de Notification", "LabelNotificationEvent": "Evènement de Notification",
"LabelNotificationsMaxFailedAttempts": "Nombres de tentatives d'envoi", "LabelNotificationsMaxFailedAttempts": "Nombres de tentatives denvoi",
"LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint", "LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint",
"LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Le notification seront ignorées si la file d'attente est à son maximum. Cela empêche un flot trop important.", "LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Le notification seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.",
"LabelNotificationTitleTemplate": "Modèle de Titre", "LabelNotificationTitleTemplate": "Modèle de Titre",
"LabelNotStarted": "Non Démarré(e)", "LabelNotStarted": "Non Démarré(e)",
"LabelNumberOfBooks": "Nombre de Livres", "LabelNumberOfBooks": "Nombre de Livres",
"LabelNumberOfEpisodes": "Nombre d'Episodes", "LabelNumberOfEpisodes": "Nombre dEpisodes",
"LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelOverwrite": "Ecraser", "LabelOverwrite": "Écraser",
"LabelPassword": "Mot de Passe", "LabelPassword": "Mot de passe",
"LabelPath": "Chemin", "LabelPath": "Chemin",
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque", "LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes", "LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
"LabelPermissionsAccessExplicitContent": "Peut acceter au contenu restreint", "LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
"LabelPermissionsDelete": "Peut supprimer", "LabelPermissionsDelete": "Peut supprimer",
"LabelPermissionsDownload": "Peut télécharger", "LabelPermissionsDownload": "Peut télécharger",
"LabelPermissionsUpdate": "Peut mettre à Jour", "LabelPermissionsUpdate": "Peut mettre à jour",
"LabelPermissionsUpload": "Peut téléverser", "LabelPermissionsUpload": "Peut téléverser",
"LabelPhotoPathURL": "Chemin / URL des photos", "LabelPhotoPathURL": "Chemin / URL des photos",
"LabelPlaylists": "Listes de lecture", "LabelPlaylists": "Listes de lecture",
"LabelPlayMethod": "Méthode d'écoute", "LabelPlayMethod": "Méthode découte",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Type de Podcast",
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)", "LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progression", "LabelProgress": "Progression",
"LabelProvider": "Fournisseur", "LabelProvider": "Fournisseur",
"LabelPubDate": "Date de publication", "LabelPubDate": "Date de publication",
"LabelPublisher": "Éditeur", "LabelPublisher": "Éditeur",
"LabelPublishYear": "Année d'édition", "LabelPublishYear": "Année dédition",
"LabelRecentlyAdded": "Derniers ajouts", "LabelRecentlyAdded": "Derniers ajouts",
"LabelRecentSeries": "Séries récentes", "LabelRecentSeries": "Séries récentes",
"LabelRecommended": "Recommandé", "LabelRecommended": "Recommandé",
"LabelRegion": "Région", "LabelRegion": "Région",
"LabelReleaseDate": "Date de parution", "LabelReleaseDate": "Date de parution",
"LabelRemoveCover": "Supprimer la couverture", "LabelRemoveCover": "Supprimer la couverture",
"LabelRSSFeedCustomOwnerEmail": "Email propriétaire personnalisé",
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
"LabelRSSFeedOpen": "Flux RSS ouvert", "LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedSlug": "Identificateur d'adresse du Flux RSS ", "LabelRSSFeedPreventIndexing": "Empêcher l'indexation",
"LabelRSSFeedSlug": "Identificateur dadresse du Flux RSS ",
"LabelRSSFeedURL": "Adresse du flux RSS", "LabelRSSFeedURL": "Adresse du flux RSS",
"LabelSearchTerm": "Terme de recherche", "LabelSearchTerm": "Terme de recherche",
"LabelSearchTitle": "Titre de recherche", "LabelSearchTitle": "Titre de recherche",
@@ -323,40 +350,41 @@
"LabelSeries": "Séries", "LabelSeries": "Séries",
"LabelSeriesName": "Nom de la série", "LabelSeriesName": "Nom de la série",
"LabelSeriesProgress": "Progression de séries", "LabelSeriesProgress": "Progression de séries",
"LabelSettingsBookshelfViewHelp": "Design Skeuomorphic avec une étagère en bois", "LabelSettingsBookshelfViewHelp": "Interface Skeuomorphic avec une étagère en bois",
"LabelSettingsChromecastSupport": "Support Chromecast", "LabelSettingsChromecastSupport": "Support du Chromecast",
"LabelSettingsDateFormat": "Format de date", "LabelSettingsDateFormat": "Format de date",
"LabelSettingsDisableWatcher": "Désactiver la surveillance", "LabelSettingsDisableWatcher": "Désactiver la surveillance",
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance du dossier pour la bibliothèque", "LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*", "LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*",
"LabelSettingsEnableEReader": "Active E-reader pour tous les utilisateurs", "LabelSettingsEnableEReader": "Active E-reader pour tous les utilisateurs",
"LabelSettingsEnableEReaderHelp": "E-reader est toujours en cours de développement, mais ce paramètre l'active pour tous les utilisateurs (ou utiliser l'interrupteur \"Fonctionnalités Expérimentales\" pour l'activer seulement pour vous)", "LabelSettingsEnableEReaderHelp": "E-reader est toujours en cours de développement, mais ce paramètre lactive pour tous les utilisateurs (ou utiliser linterrupteur « Fonctionnalités expérimentales » pour lactiver seulement pour vous)",
"LabelSettingsExperimentalFeatures": "Fonctionnalités Expérimentales", "LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquels nous attendons votre retour et expérience. Cliquer pour ouvrir la discussion Github.", "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquels nous attendons votre retour et expérience. Cliquer pour ouvrir la discussion Github.",
"LabelSettingsFindCovers": "Rechercher des Couvertures", "LabelSettingsFindCovers": "Chercher des couvertures de livre",
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l'analyser tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d'analyse.", "LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyser tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps danalyse.",
"LabelSettingsHomePageBookshelfView": "La page d'accueil utilise la vue étagère", "LabelSettingsHomePageBookshelfView": "La page daccueil utilise la vue étagère",
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère", "LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres", "LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
"LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 d'Overdrive viennent avec les minutages des chapitres intégrés en métadonnées. Activer ce paramètre utilisera ces minutages pour les chapitres automatiquement.", "LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 dOverdrive viennent avec les minutages des chapitres intégrés en métadonnées. Activer ce paramètre utilisera ces minutages pour les chapitres automatiquement.",
"LabelSettingsParseSubtitles": "Analyse des sous-titres", "LabelSettingsParseSubtitles": "Analyse des sous-titres",
"LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du Livre Audio.<br>Les sous-titres doivent être séparés par \" - \"<br>i.e. \"Titre du Livre - Ceci est un sous-titre\" aura le sous-titre \"Ceci est un sous-titre\"", "LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du Livre Audio.<br>Les sous-titres doivent être séparés par « - »<br>i.e. « Titre du Livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »",
"LabelSettingsPreferAudioMetadata": "Préférer les Métadonnées Audio", "LabelSettingsPreferAudioMetadata": "Préférer les Métadonnées audio",
"LabelSettingsPreferAudioMetadataHelp": "Les méta étiquettes ID3 des fichiers audios seront utilisés à la place des noms de dossier pour les détails du livre audio", "LabelSettingsPreferAudioMetadataHelp": "Les méta étiquettes ID3 des fichiers audios seront utilisés à la place des noms de dossier pour les détails du livre audio",
"LabelSettingsPreferMatchedMetadata": "Préférer les Métadonnées par correspondance", "LabelSettingsPreferMatchedMetadata": "Préférer les Métadonnées par correspondance",
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de l'article lors d'une Recherche par Correspondance Rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.", "LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de larticle lors dune recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
"LabelSettingsPreferOPFMetadata": "Préférer les Métadonnées OPF", "LabelSettingsPreferOPFMetadata": "Préférer les Métadonnées OPF",
"LabelSettingsPreferOPFMetadataHelp": "Les fichiers de métadonnées OPF seront utilisés à la place des noms de dossier pour les détails du Livre Audio", "LabelSettingsPreferOPFMetadataHelp": "Les fichiers de métadonnées OPF seront utilisés à la place des noms de dossier pour les détails du Livre Audio",
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN", "LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri", "LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe \"le\", le livre avec pour titre \"Le Titre du Livre\" sera trié en tant que \"Titre du Livre, Le\"", "LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »",
"LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées", "LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées",
"LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standardes de 1.6:1.", "LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standardes de 1.6:1.",
"LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles", "LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles",
"LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiersde l'article. Seul un fichier nommé \"cover\" sera gardé.", "LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de larticle. Seul un fichier nommé « cover » sera conservé.",
"LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles", "LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles",
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de l'article avec une extension \".abs\".", "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de larticle avec une extension « .abs ».",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Afficher Tout", "LabelShowAll": "Afficher Tout",
"LabelSize": "Taille", "LabelSize": "Taille",
"LabelSleepTimer": "Minuterie", "LabelSleepTimer": "Minuterie",
@@ -369,23 +397,25 @@
"LabelStatsBestDay": "Meilleur Jour", "LabelStatsBestDay": "Meilleur Jour",
"LabelStatsDailyAverage": "Moyenne Journalière", "LabelStatsDailyAverage": "Moyenne Journalière",
"LabelStatsDays": "Jours", "LabelStatsDays": "Jours",
"LabelStatsDaysListened": "Jours d'écoute", "LabelStatsDaysListened": "Jours découte",
"LabelStatsHours": "Heures", "LabelStatsHours": "Heures",
"LabelStatsInARow": "d'affilé(s)", "LabelStatsInARow": "daffilé(s)",
"LabelStatsItemsFinished": "Articles Terminés", "LabelStatsItemsFinished": "Articles terminés",
"LabelStatsItemsInLibrary": "Articles dans la Bibliothèque", "LabelStatsItemsInLibrary": "Articles dans la Bibliothèque",
"LabelStatsMinutes": "minutes", "LabelStatsMinutes": "minutes",
"LabelStatsMinutesListening": "Minutes d'écoute", "LabelStatsMinutesListening": "Minutes découte",
"LabelStatsOverallDays": "Jours au total", "LabelStatsOverallDays": "Jours au total",
"LabelStatsOverallHours": "Heures au total", "LabelStatsOverallHours": "Heures au total",
"LabelStatsWeekListening": "Écoute de la semaine", "LabelStatsWeekListening": "Écoute de la semaine",
"LabelSubtitle": "Sous-Titre", "LabelSubtitle": "Sous-Titre",
"LabelSupportedFileTypes": "Types de fichiers Supportés", "LabelSupportedFileTypes": "Types de fichiers supportés",
"LabelTag": "Étiquette", "LabelTag": "Étiquette",
"LabelTags": "Étiquettes", "LabelTags": "Étiquettes",
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l'utilisateur", "LabelTagsAccessibleToUser": "Étiquettes accessibles à lutilisateur",
"LabelTimeListened": "Temps d'écoute", "LabelTasks": "Tasks Running",
"LabelTimeListenedToday": "Nombres d'écoutes Aujourd'hui", "LabelTimeBase": "Time Base",
"LabelTimeListened": "Temps d’écoute",
"LabelTimeListenedToday": "Nombres d’écoutes Aujourdhui",
"LabelTimeRemaining": "{0} restantes", "LabelTimeRemaining": "{0} restantes",
"LabelTimeToShift": "Temps de décalage en secondes", "LabelTimeToShift": "Temps de décalage en secondes",
"LabelTitle": "Titre", "LabelTitle": "Titre",
@@ -394,166 +424,172 @@
"LabelToolsMakeM4b": "Créer un fichier Livre Audio M4B", "LabelToolsMakeM4b": "Créer un fichier Livre Audio M4B",
"LabelToolsMakeM4bDescription": "Génère un fichier Livre Audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", "LabelToolsMakeM4bDescription": "Génère un fichier Livre Audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.",
"LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3", "LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3",
"LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l'image de couverture et les chapitres.", "LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, limage de couverture et les chapitres.",
"LabelTotalDuration": "Durée Totale", "LabelTotalDuration": "Durée Totale",
"LabelTotalTimeListened": "Temps d'écoute total", "LabelTotalTimeListened": "Temps découte total",
"LabelTrackFromFilename": "Piste depuis le fichier", "LabelTrackFromFilename": "Piste depuis le fichier",
"LabelTrackFromMetadata": "Piste depuis les métadonnées", "LabelTrackFromMetadata": "Piste depuis les métadonnées",
"LabelTracks": "Pistes", "LabelTracks": "Pistes",
"LabelTracksMultiTrack": "Piste Multiple", "LabelTracksMultiTrack": "Piste multiple",
"LabelTracksSingleTrack": "Piste Simple", "LabelTracksSingleTrack": "Piste simple",
"LabelType": "Type", "LabelType": "Type",
"LabelUnabridged": "Version intégrale",
"LabelUnknown": "Inconnu", "LabelUnknown": "Inconnu",
"LabelUpdateCover": "Mettre à jour la Couverture", "LabelUpdateCover": "Mettre à jour la couverture",
"LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsqu'une correspondance est trouvée", "LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsquune correspondance est trouvée",
"LabelUpdatedAt": "Mis à jour à", "LabelUpdatedAt": "Mis à jour à",
"LabelUpdateDetails": "Mettre à jours les Détails", "LabelUpdateDetails": "Mettre à jours les détails",
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu'une correspondance est trouvée", "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUploaderDragAndDrop": "Glisser & Déposer des fichiers ou dossiers", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUseChapterTrack": "Utiliser la Piste du Chapitre", "LabelUseChapterTrack": "Utiliser la piste du chapitre",
"LabelUseFullTrack": "Utiliser la Piste Complète", "LabelUseFullTrack": "Utiliser la piste Complète",
"LabelUser": "Utilisateur", "LabelUser": "Utilisateur",
"LabelUsername": "Nom d'Utilisateur", "LabelUsername": "Nom dutilisateur",
"LabelValue": "Valeur", "LabelValue": "Valeur",
"LabelVersion": "Version", "LabelVersion": "Version",
"LabelViewBookmarks": "Afficher les Signets", "LabelViewBookmarks": "Afficher les signets",
"LabelViewChapters": "Afficher les Chapitres", "LabelViewChapters": "Afficher les chapitres",
"LabelViewQueue": "Afficher la liste de lecture", "LabelViewQueue": "Afficher la liste de lecture",
"LabelVolume": "Volume", "LabelVolume": "Volume",
"LabelWeekdaysToRun": "Jours de la semaine à exécuter", "LabelWeekdaysToRun": "Jours de la semaine à exécuter",
"LabelYourAudiobookDuration": "Durée de vos Livres Audios", "LabelYourAudiobookDuration": "Durée de vos livres audios",
"LabelYourBookmarks": "Vos Signets", "LabelYourBookmarks": "Vos signets",
"LabelYourPlaylists": "Vos listes de lecture", "LabelYourPlaylists": "Vos listes de lecture",
"LabelYourProgress": "Votre progression", "LabelYourProgress": "Votre progression",
"MessageAddToPlayerQueue": "Ajouter en file d'attente", "MessageAddToPlayerQueue": "Ajouter en file dattente",
"MessageAppriseDescription": "Nécessite une instance d'<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />L'URL de l'API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Nécessite une instance d<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />lURL de lAPI Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Les Sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les Sauvegardes n'incluent pas les fichiers de votre bibliothèque.", "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les sauvegardes nincluent pas les fichiers de votre bibliothèque.",
"MessageBatchQuickMatchDescription": "La Recherche par Correspondance Rapide tentera d'ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l'option suivante pour autoriser la Recherche par Correspondance à écraser les données existantes.", "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera dajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer loption suivante pour autoriser la recherche par correspondance à écraser les données existantes.",
"MessageBookshelfNoCollections": "Vous n'avez pas encore de collections", "MessageBookshelfNoCollections": "Vous navez pas encore de collections",
"MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0}: {1} »",
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS n'est ouvert", "MessageBookshelfNoRSSFeeds": "Aucun flux RSS nest ouvert",
"MessageBookshelfNoSeries": "Vous n'avez aucune séries", "MessageBookshelfNoSeries": "Vous navez aucune séries",
"MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio", "MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio",
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0", "MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre", "MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre", "MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
"MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio", "MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio",
"MessageCheckingCron": "Vérification du cron...", "MessageCheckingCron": "Vérification du cron",
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?", "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?",
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque \"{0}\" ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?", "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?",
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer comme terminé tous les livres de cette série ?", "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer comme terminé tous les livres de cette série ?",
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer comme non terminé tous les livres de cette série ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer comme non terminé tous les livres de cette série ?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection \"{0}\" ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l'épisode \"{0}\" ?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture \"{0}\" ?", "MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?",
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre \"{0}\" vers \"{1}\" pour tous les articles ?", "MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » vers « {1} » pour tous les articles ?",
"MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.", "MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
"MessageConfirmRenameGenreWarning": "Attention ! Un genre similaire avec une casse différente existe déjà \"{0}\".", "MessageConfirmRenameGenreWarning": "Attention ! Un genre similaire avec une casse différente existe déjà « {0} ».",
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l'étiquette \"{0}\" vers \"{1}\" pour tous les articles ?", "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer létiquette « {0} » vers « {1} » pour tous les articles ?",
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
"MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà \"{0}\".", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».",
"MessageDownloadingEpisode": "Téléchargement de l'épisode", "MessageDownloadingEpisode": "Téléchargement de lépisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct", "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans lordre correct",
"MessageEmbedFinished": "Intégration Terminée !", "MessageEmbedFinished": "Intégration Terminée !",
"MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement", "MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement",
"MessageFeedURLWillBe": "L'URL du Flux sera {0}", "MessageFeedURLWillBe": "lURL du Flux sera {0}",
"MessageFetching": "Récupération...", "MessageFetching": "Récupération",
"MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme s'ils étaient nouveaux.", "MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme sils étaient nouveaux.",
"MessageImportantNotice": "Information Importante !", "MessageImportantNotice": "Information Importante !",
"MessageInsertChapterBelow": "Insérer le chapitre ci-dessous", "MessageInsertChapterBelow": "Insérer le chapitre ci-dessous",
"MessageItemsSelected": "{0} articles sélectionnés", "MessageItemsSelected": "{0} articles sélectionnés",
"MessageItemsUpdated": "{0} articles mis à jour", "MessageItemsUpdated": "{0} articles mis à jour",
"MessageJoinUsOn": "Rejoignez-nous sur", "MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier", "MessageListeningSessionsInTheLastYear": "{0} sessions découte lan dernier",
"MessageLoading": "Chargement...", "MessageLoading": "Chargement",
"MessageLoadingFolders": "Chargement des dossiers...", "MessageLoadingFolders": "Chargement des dossiers",
"MessageM4BFailed": "M4B en échec !", "MessageM4BFailed": "M4B en échec !",
"MessageM4BFinished": "M4B terminé !", "MessageM4BFinished": "M4B terminé !",
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster l'horodatage.", "MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster lhorodatage.",
"MessageMarkAsFinished": "Marquer comme terminé", "MessageMarkAsFinished": "Marquer comme terminé",
"MessageMarkAsNotFinished": "Marquer comme non Terminé", "MessageMarkAsNotFinished": "Marquer comme non Terminé",
"MessageMatchBooksDescription": "tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. N'écrase pas les données existantes.", "MessageMatchBooksDescription": "tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. Nécrase pas les données existantes.",
"MessageNoAudioTracks": "Pas de pistes audio", "MessageNoAudioTracks": "Aucune piste audio",
"MessageNoAuthors": "Pas d'Auteurs", "MessageNoAuthors": "Aucun auteur",
"MessageNoBackups": "Pas de Sauvegardes", "MessageNoBackups": "Aucune sauvegarde",
"MessageNoBookmarks": "Pas de signets", "MessageNoBookmarks": "Aucun signet",
"MessageNoChapters": "Pas de chapitres", "MessageNoChapters": "Aucun chapitre",
"MessageNoCollections": "Pas de collections", "MessageNoCollections": "Aucune collection",
"MessageNoCoversFound": "Aucune couverture trouvée", "MessageNoCoversFound": "Aucune couverture trouvée",
"MessageNoDescription": "Pas de description", "MessageNoDescription": "Aucune description",
"MessageNoEpisodeMatchesFound": "Pas de correspondance d'épisode trouvée", "MessageNoDownloadsInProgress": "Aucun téléchargement en cours",
"MessageNoDownloadsQueued": "Aucun téléchargement en file dattente",
"MessageNoEpisodeMatchesFound": "Aucune correspondance d’épisode trouvée",
"MessageNoEpisodes": "Aucun épisode", "MessageNoEpisodes": "Aucun épisode",
"MessageNoFoldersAvailable": "Aucun dossier disponible", "MessageNoFoldersAvailable": "Aucun dossier disponible",
"MessageNoGenres": "Pas de genres", "MessageNoGenres": "Aucun genre",
"MessageNoIssues": "Pas de parution", "MessageNoIssues": "Aucune parution",
"MessageNoItems": "Pas d'Articles", "MessageNoItems": "Aucun article",
"MessageNoItemsFound": "Pas d'Articles Trouvés", "MessageNoItemsFound": "Aucun article trouvé",
"MessageNoListeningSessions": "Pas de sessions d'écoutes", "MessageNoListeningSessions": "Aucune session découte en cours",
"MessageNoLogs": "Pas de journaux", "MessageNoLogs": "Aucun journaux",
"MessageNoMediaProgress": "Pas de Média en cours", "MessageNoMediaProgress": "Aucun média en cours",
"MessageNoNotifications": "Pas de Notifications", "MessageNoNotifications": "Aucune notification",
"MessageNoPodcastsFound": "Pas de podcasts trouvés", "MessageNoPodcastsFound": "Aucun podcast trouvé",
"MessageNoResults": "Pas de résultats", "MessageNoResults": "Aucun résultat",
"MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"", "MessageNoSearchResultsFor": "Aucun résultat pour la recherche « {0} »",
"MessageNoSeries": "Pas de séries", "MessageNoSeries": "Aucune série",
"MessageNoTags": "Pas d'étiquettes", "MessageNoTags": "Aucune détiquettes",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNotYetImplemented": "Non implémenté", "MessageNotYetImplemented": "Non implémenté",
"MessageNoUpdateNecessary": "Pas de mise à jour nécessaire", "MessageNoUpdateNecessary": "Aucune mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire", "MessageNoUpdatesWereNecessary": "Aucune mise à jour nétait nécessaire",
"MessageNoUserPlaylists": "Vous n'avez aucune liste de lecture", "MessageNoUserPlaylists": "Vous navez aucune liste de lecture",
"MessageOr": "ou", "MessageOr": "ou",
"MessagePauseChapter": "Suspendre la lecture du chapitre", "MessagePauseChapter": "Suspendre la lecture du chapitre",
"MessagePlayChapter": "Écouter depuis le début du chapitre", "MessagePlayChapter": "Écouter depuis le début du chapitre",
"MessagePlaylistCreateFromCollection": "Créer une liste de lecture depuis la collection", "MessagePlaylistCreateFromCollection": "Créer une liste de lecture depuis la collection",
"MessagePodcastHasNoRSSFeedForMatching": "Le Podcast n'a pas d'URL de flux RSS à utiliser pour la correspondance", "MessagePodcastHasNoRSSFeedForMatching": "Le Podcast na pas dURL de flux RSS à utiliser pour la correspondance",
"MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de '{0}'. N'écrase pas les données présentes à moins que le paramètre 'Préférer les Métadonnées par correspondance' soit activé.", "MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de « {0} ». Nécrase pas les données présentes à moins que le paramètre « Préférer les Métadonnées par correspondance » soit activé.",
"MessageRemoveAllItemsWarning": "ATTENTION ! Cette action supprimera toute la base de données de la bibliothèque ainsi que les mises à jour ou correspondances qui auraient été effectuées. Cela n'a aucune incidence sur les fichiers de la bibliothèque. Souhaitez-vous continuer ?", "MessageRemoveAllItemsWarning": "ATTENTION ! Cette action supprimera toute la base de données de la bibliothèque ainsi que les mises à jour ou correspondances qui auraient été effectuées. Cela na aucune incidence sur les fichiers de la bibliothèque. Souhaitez-vous continuer ?",
"MessageRemoveChapter": "Supprimer le chapitre", "MessageRemoveChapter": "Supprimer le chapitre",
"MessageRemoveEpisodes": "Suppression de {0} épisode(s)", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)",
"MessageRemoveFromPlayerQueue": "Supprimer de la liste d'écoute", "MessageRemoveFromPlayerQueue": "Supprimer de la liste découte",
"MessageRemoveUserWarning": "Êtes-vous certain de vouloir supprimer définitivement l'utilisateur \"{0}\" ?", "MessageRemoveUserWarning": "Êtes-vous certain de vouloir supprimer définitivement lutilisateur « {0} » ?",
"MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur", "MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur",
"MessageResetChaptersConfirm": "Êtes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués ?", "MessageResetChaptersConfirm": "Êtes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués ?",
"MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le", "MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le",
"MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items & /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.",
"MessageSearchResultsFor": "Résultats de recherche pour", "MessageSearchResultsFor": "Résultats de recherche pour",
"MessageServerCouldNotBeReached": "Serveur inaccessible", "MessageServerCouldNotBeReached": "Serveur inaccessible",
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre", "MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour \"{0}\" à {1} ?", "MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
"MessageThinking": "On réfléchit...", "MessageThinking": "Je cherche…",
"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éversement",
"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 !",
"MessageYourAudiobookDurationIsLonger": "La durée de votre Livre Audio est plus longue que la durée trouvée", "MessageYourAudiobookDurationIsLonger": "La durée de votre Livre Audio est plus longue que la durée trouvée",
"MessageYourAudiobookDurationIsShorter": "La durée de votre Livre Audio est plus courte que la durée trouvée", "MessageYourAudiobookDurationIsShorter": "La durée de votre Livre Audio est plus courte que la durée trouvée",
"NoteChangeRootPassword": "L'utilisateur Root est le seul a pouvoir utiliser un mote de passe vide", "NoteChangeRootPassword": "seul lutilisateur « root » peut utiliser un mot de passe vide",
"NoteChapterEditorTimes": "Information: L'horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du Livre Audio.", "NoteChapterEditorTimes": "Information : lhorodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du Livre Audio.",
"NoteFolderPicker": "Information: Les dossiers déjà surveillés ne sont pas affichés", "NoteFolderPicker": "Information : Les dossiers déjà surveillés ne sont pas affichés",
"NoteFolderPickerDebian": "Information: La sélection de dossier sur une installation debian n'est pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.", "NoteFolderPickerDebian": "Information : La sélection de dossier sur une installation debian nest pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.",
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.", "NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.", "NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
"NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.", "NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
"NoteUploaderOnlyAudioFiles": "Si vous téléverser uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.", "NoteUploaderOnlyAudioFiles": "Si vous téléverser uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.",
"NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier d'élément sont ignorés.", "NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier délément sont ignorés.",
"PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier", "PlaceholderNewFolderPath": "Nouveau chemin de dossier",
"PlaceholderNewPlaylist": "Nouveau nom de liste de lecture", "PlaceholderNewPlaylist": "Nouveau nom de liste de lecture",
"PlaceholderSearch": "Recherche...", "PlaceholderSearch": "Recherche...",
"PlaceholderSearchEpisode": "Search episode...",
"ToastAccountUpdateFailed": "Échec de la mise à jour du compte", "ToastAccountUpdateFailed": "Échec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAccountUpdateSuccess": "Compte mis à jour",
"ToastAuthorImageRemoveFailed": "Échec de la suppression de l'image", "ToastAuthorImageRemoveFailed": "Échec de la suppression de limage",
"ToastAuthorImageRemoveSuccess": "Image de l'auteur supprimée", "ToastAuthorImageRemoveSuccess": "Image de lauteur supprimée",
"ToastAuthorUpdateFailed": "Échec de la mise à jour de l'auteur", "ToastAuthorUpdateFailed": "Échec de la mise à jour de lauteur",
"ToastAuthorUpdateMerged": "Auteur fusionné", "ToastAuthorUpdateMerged": "Auteur fusionné",
"ToastAuthorUpdateSuccess": "Auteur mis à jour", "ToastAuthorUpdateSuccess": "Auteur mis à jour",
"ToastAuthorUpdateSuccessNoImageFound": "Auteur mis à jour (pas d'image trouvée)", "ToastAuthorUpdateSuccessNoImageFound": "Auteur mis à jour (aucune image trouvée)",
"ToastBackupCreateFailed": "Échec de la création de sauvegarde", "ToastBackupCreateFailed": "Échec de la création de sauvegarde",
"ToastBackupCreateSuccess": "Sauvegarde créée", "ToastBackupCreateSuccess": "Sauvegarde créée",
"ToastBackupDeleteFailed": "Échec de la suppression de sauvegarde", "ToastBackupDeleteFailed": "Échec de la suppression de sauvegarde",
@@ -577,23 +613,23 @@
"ToastCollectionRemoveSuccess": "Collection supprimée", "ToastCollectionRemoveSuccess": "Collection supprimée",
"ToastCollectionUpdateFailed": "Échec de la mise à jour de la collection", "ToastCollectionUpdateFailed": "Échec de la mise à jour de la collection",
"ToastCollectionUpdateSuccess": "Collection mise à jour", "ToastCollectionUpdateSuccess": "Collection mise à jour",
"ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de l'article", "ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de larticle",
"ToastItemCoverUpdateSuccess": "Couverture de l'article mise à jour", "ToastItemCoverUpdateSuccess": "Couverture de larticle mise à jour",
"ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de l'article", "ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de larticle",
"ToastItemDetailsUpdateSuccess": "Détails de l'article mis à jour", "ToastItemDetailsUpdateSuccess": "Détails de larticle mis à jour",
"ToastItemDetailsUpdateUnneeded": "Pas de mise à jour nécessaire pour les détails de l'article", "ToastItemDetailsUpdateUnneeded": "Pas de mise à jour nécessaire sur les détails de larticle",
"ToastItemMarkedAsFinishedFailed": "Échec de l'annotation terminée", "ToastItemMarkedAsFinishedFailed": "Échec de lannotation terminée",
"ToastItemMarkedAsFinishedSuccess": "Article marqué comme terminé", "ToastItemMarkedAsFinishedSuccess": "Article marqué comme terminé",
"ToastItemMarkedAsNotFinishedFailed": "Échec de l'annotation non-terminée", "ToastItemMarkedAsNotFinishedFailed": "Échec de lannotation non-terminée",
"ToastItemMarkedAsNotFinishedSuccess": "Article marqué comme non-terminé", "ToastItemMarkedAsNotFinishedSuccess": "Article marqué comme non-terminé",
"ToastLibraryCreateFailed": "Échec de la création de bibliothèque", "ToastLibraryCreateFailed": "Échec de la création de bibliothèque",
"ToastLibraryCreateSuccess": "Bibliothèque \"{0}\" créée", "ToastLibraryCreateSuccess": "Bibliothèque « {0} » créée",
"ToastLibraryDeleteFailed": "Échec de la suppression de la bibliothèque", "ToastLibraryDeleteFailed": "Échec de la suppression de la bibliothèque",
"ToastLibraryDeleteSuccess": "Bibliothèque supprimée", "ToastLibraryDeleteSuccess": "Bibliothèque supprimée",
"ToastLibraryScanFailedToStart": "Échec du démarrage de l'analyse", "ToastLibraryScanFailedToStart": "Échec du démarrage de lanalyse",
"ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée", "ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée",
"ToastLibraryUpdateFailed": "Échec de la mise à jour de la bibliothèque", "ToastLibraryUpdateFailed": "Échec de la mise à jour de la bibliothèque",
"ToastLibraryUpdateSuccess": "Bibliothèque \"{0}\" mise à jour", "ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour",
"ToastPlaylistCreateFailed": "Échec de la création de la liste de lecture", "ToastPlaylistCreateFailed": "Échec de la création de la liste de lecture",
"ToastPlaylistCreateSuccess": "Liste de lecture créée", "ToastPlaylistCreateSuccess": "Liste de lecture créée",
"ToastPlaylistRemoveFailed": "Échec de la suppression de la liste de lecture", "ToastPlaylistRemoveFailed": "Échec de la suppression de la liste de lecture",
@@ -602,17 +638,17 @@
"ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour", "ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour",
"ToastPodcastCreateFailed": "Échec de la création du Podcast", "ToastPodcastCreateFailed": "Échec de la création du Podcast",
"ToastPodcastCreateSuccess": "Podcast créé", "ToastPodcastCreateSuccess": "Podcast créé",
"ToastRemoveItemFromCollectionFailed": "Échec de la suppression de l'article de la collection", "ToastRemoveItemFromCollectionFailed": "Échec de la suppression de larticle de la collection",
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection", "ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS", "ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
"ToastRSSFeedCloseSuccess": "Flux RSS fermé", "ToastRSSFeedCloseSuccess": "Flux RSS fermé",
"ToastSeriesUpdateFailed": "Echec de la mise à jour de la série", "ToastSeriesUpdateFailed": "Échec de la mise à jour de la série",
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie", "ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
"ToastSessionDeleteFailed": "Échec de la suppression de session", "ToastSessionDeleteFailed": "Échec de la suppression de session",
"ToastSessionDeleteSuccess": "Session supprimée", "ToastSessionDeleteSuccess": "Session supprimée",
"ToastSocketConnected": "WebSocket connecté", "ToastSocketConnected": "WebSocket connecté",
"ToastSocketDisconnected": "WebSocket déconnecté", "ToastSocketDisconnected": "WebSocket déconnecté",
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket", "ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Échec de la suppression de l'utilisateur", "ToastUserDeleteFailed": "Échec de la suppression de lutilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé" "ToastUserDeleteSuccess": "Utilisateur supprimé"
} }
+654
View File
@@ -0,0 +1,654 @@
{
"ButtonAdd": "ઉમેરો",
"ButtonAddChapters": "પ્રકરણો ઉમેરો",
"ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો",
"ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો",
"ButtonApply": "લાગુ કરો",
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
"ButtonAuthors": "લેખકો",
"ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ",
"ButtonCancel": "રદ કરો",
"ButtonCancelEncode": "એન્કોડ રદ કરો",
"ButtonChangeRootPassword": "રૂટ પાસવર્ડ બદલો",
"ButtonCheckAndDownloadNewEpisodes": "નવા એપિસોડ્સ ચેક કરો અને ડાઉનલોડ કરો",
"ButtonChooseAFolder": "ફોલ્ડર પસંદ કરો",
"ButtonChooseFiles": "ફાઇલો પસંદ કરો",
"ButtonClearFilter": "ફિલ્ટર જતુ કરો ",
"ButtonCloseFeed": "ફીડ બંધ કરો",
"ButtonCollections": "સંગ્રહ",
"ButtonConfigureScanner": "સ્કેનર સેટિંગ બદલો",
"ButtonCreate": "બનાવો",
"ButtonCreateBackup": "બેકઅપ બનાવો",
"ButtonDelete": "કાઢી નાખો",
"ButtonDownloadQueue": "કતાર ડાઉનલોડ કરો",
"ButtonEdit": "સંપાદિત કરો",
"ButtonEditChapters": "પ્રકરણો સંપાદિત કરો",
"ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો",
"ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો",
"ButtonFullPath": "સંપૂર્ણ પથ",
"ButtonHide": "છુપાવો",
"ButtonHome": "ઘર",
"ButtonIssues": "સમસ્યાઓ",
"ButtonLatest": "નવીનતમ",
"ButtonLibrary": "પુસ્તકાલય",
"ButtonLogout": "લૉગ આઉટ",
"ButtonLookup": "શોધો",
"ButtonManageTracks": "ટ્રેક્સ મેનેજ કરો",
"ButtonMapChapterTitles": "પ્રકરણ શીર્ષકો મેપ કરો",
"ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
"ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
"ButtonNevermind": "કંઈ વાંધો નહીં",
"ButtonOk": "ઓકે",
"ButtonOpenFeed": "ફીડ ખોલો",
"ButtonOpenManager": "મેનેજર ખોલો",
"ButtonPlay": "ચલાવો",
"ButtonPlaying": "ચલાવી રહ્યું છે",
"ButtonPlaylists": "પ્લેલિસ્ટ",
"ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
"ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
"ButtonPurgeMediaProgress": "બધું સાંભળ્યું કાઢી નાખો",
"ButtonQueueAddItem": "કતારમાં ઉમેરો",
"ButtonQueueRemoveItem": "કતારથી કાઢી નાખો",
"ButtonQuickMatch": "ઝડપી મેળ ખવડાવો",
"ButtonRead": "વાંચો",
"ButtonRemove": "કાઢી નાખો",
"ButtonRemoveAll": "બધું કાઢી નાખો",
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
"ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
"ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો",
"ButtonReScan": "ફરીથી સ્કેન કરો",
"ButtonReset": "રીસેટ કરો",
"ButtonRestore": "પુનઃસ્થાપિત કરો",
"ButtonSave": "સાચવો",
"ButtonSaveAndClose": "સાચવો અને બંધ કરો",
"ButtonSaveTracklist": "ટ્રેક યાદી સાચવો",
"ButtonScan": "સ્કેન કરો",
"ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો",
"ButtonSearch": "શોધો",
"ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો",
"ButtonSeries": "સિરીઝ",
"ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો",
"ButtonShiftTimes": "સમય શિફ્ટ કરો",
"ButtonShow": "બતાવો",
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
"ButtonSubmit": "સબમિટ કરો",
"ButtonUpload": "અપલોડ કરો",
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
"ButtonUploadCover": "કવર અપલોડ કરો",
"ButtonUploadOPMLFile": "OPML ફાઇલ અપલોડ કરો",
"ButtonUserDelete": "વપરાશકર્તા {0} કાઢી નાખો",
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
"ButtonViewAll": "બધું જુઓ",
"ButtonYes": "હા",
"HeaderAccount": "એકાઉન્ટ",
"HeaderAdvanced": "અડ્વાન્સડ",
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
"HeaderAudiobookTools": "Audiobook File Management Tools",
"HeaderAudioTracks": "Audio Tracks",
"HeaderBackups": "Backups",
"HeaderChangePassword": "Change Password",
"HeaderChapters": "Chapters",
"HeaderChooseAFolder": "Choose a Folder",
"HeaderCollection": "Collection",
"HeaderCollectionItems": "Collection Items",
"HeaderCover": "Cover",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderEpisodes": "Episodes",
"HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries",
"HeaderLibraryFiles": "Library Files",
"HeaderLibraryStats": "Library Stats",
"HeaderListeningSessions": "Listening Sessions",
"HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account",
"HeaderNewLibrary": "New Library",
"HeaderNotifications": "Notifications",
"HeaderOpenRSSFeed": "Open RSS Feed",
"HeaderOtherFiles": "Other Files",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add",
"HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode",
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Set Backup Schedule",
"HeaderSettings": "Settings",
"HeaderSettingsDisplay": "Display",
"HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)",
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
"HeaderStatsRecentSessions": "Recent Sessions",
"HeaderStatsTop10Authors": "Top 10 Authors",
"HeaderStatsTop5Genres": "Top 5 Genres",
"HeaderTools": "Tools",
"HeaderUpdateAccount": "Update Account",
"HeaderUpdateAuthor": "Update Author",
"HeaderUpdateDetails": "Update Details",
"HeaderUpdateLibrary": "Update Library",
"HeaderUsers": "Users",
"HeaderYourStats": "Your Stats",
"LabelAbridged": "Abridged",
"LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
"LabelAccountTypeUser": "User",
"LabelActivity": "Activity",
"LabelAdded": "Added",
"LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All",
"LabelAllUsers": "All Users",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAppend": "Append",
"LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelBackToUser": "Back to User",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
"LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Number of backups to keep",
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Books",
"LabelChangePassword": "Change Password",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening",
"LabelContinueSeries": "Continue Series",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
"LabelCreatedAt": "Created At",
"LabelCronExpression": "Cron Expression",
"LabelCurrent": "Current",
"LabelCurrently": "Currently:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelDatetime": "Datetime",
"LabelDescription": "Description",
"LabelDeselectAll": "Deselect All",
"LabelDevice": "Device",
"LabelDeviceInfo": "Device Info",
"LabelDirectory": "Directory",
"LabelDiscFromFilename": "Disc from Filename",
"LabelDiscFromMetadata": "Disc from Metadata",
"LabelDownload": "Download",
"LabelDuration": "Duration",
"LabelDurationFound": "Duration found:",
"LabelEdit": "Edit",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episode Title",
"LabelEpisodeType": "Episode Type",
"LabelExample": "Example",
"LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL",
"LabelFile": "File",
"LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified",
"LabelFilename": "Filename",
"LabelFilterByUser": "Filter by User",
"LabelFindEpisodes": "Find Episodes",
"LabelFinished": "Finished",
"LabelFolder": "Folder",
"LabelFolders": "Folders",
"LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
"LabelHour": "Hour",
"LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist",
"LabelIncomplete": "Incomplete",
"LabelInProgress": "In Progress",
"LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
"LabelIntervalEvery12Hours": "Every 12 hours",
"LabelIntervalEvery15Minutes": "Every 15 minutes",
"LabelIntervalEvery2Hours": "Every 2 hours",
"LabelIntervalEvery30Minutes": "Every 30 minutes",
"LabelIntervalEvery6Hours": "Every 6 hours",
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts",
"LabelItem": "Item",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update",
"LabelLess": "Less",
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
"LabelLibrary": "Library",
"LabelLibraryItem": "Library Item",
"LabelLibraryName": "Library Name",
"LabelLimit": "Limit",
"LabelListenAgain": "Listen Again",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute",
"LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts",
"LabelMore": "More",
"LabelMoreInfo": "More Info",
"LabelName": "Name",
"LabelNarrator": "Narrator",
"LabelNarrators": "Narrators",
"LabelNew": "New",
"LabelNewestAuthors": "Newest Authors",
"LabelNewestEpisodes": "Newest Episodes",
"LabelNewPassword": "New Password",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNotes": "Notes",
"LabelNotFinished": "Not Finished",
"LabelNotificationAppriseURL": "Apprise URL(s)",
"LabelNotificationAvailableVariables": "Available variables",
"LabelNotificationBodyTemplate": "Body Template",
"LabelNotificationEvent": "Notification Event",
"LabelNotificationsMaxFailedAttempts": "Max failed attempts",
"LabelNotificationsMaxFailedAttemptsHelp": "Notifications are disabled once they fail to send this many times",
"LabelNotificationsMaxQueueSize": "Max queue size for notification events",
"LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
"LabelNotificationTitleTemplate": "Title Template",
"LabelNotStarted": "Not Started",
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password",
"LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
"LabelPermissionsAccessAllTags": "Can Access All Tags",
"LabelPermissionsAccessExplicitContent": "Can Access Explicit Content",
"LabelPermissionsDelete": "Can Delete",
"LabelPermissionsDownload": "Can Download",
"LabelPermissionsUpdate": "Can Update",
"LabelPermissionsUpload": "Can Upload",
"LabelPhotoPathURL": "Photo Path/URL",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Play Method",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progress",
"LabelProvider": "Provider",
"LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year",
"LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended",
"LabelRegion": "Region",
"LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Search Term",
"LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season",
"LabelSequence": "Sequence",
"LabelSeries": "Series",
"LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format",
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
"LabelSettingsParseSubtitles": "Parse subtitles",
"LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by \" - \"<br>i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
"LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
"LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Use square book covers",
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
"LabelSettingsStoreCoversWithItem": "Store covers with item",
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
"LabelSettingsStoreMetadataWithItem": "Store metadata with item",
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
"LabelStartTime": "Start Time",
"LabelStatsAudioTracks": "Audio Tracks",
"LabelStatsAuthors": "Authors",
"LabelStatsBestDay": "Best Day",
"LabelStatsDailyAverage": "Daily Average",
"LabelStatsDays": "Days",
"LabelStatsDaysListened": "Days Listened",
"LabelStatsHours": "Hours",
"LabelStatsInARow": "in a row",
"LabelStatsItemsFinished": "Items Finished",
"LabelStatsItemsInLibrary": "Items in Library",
"LabelStatsMinutes": "minutes",
"LabelStatsMinutesListening": "Minutes Listening",
"LabelStatsOverallDays": "Overall Days",
"LabelStatsOverallHours": "Overall Hours",
"LabelStatsWeekListening": "Week Listening",
"LabelSubtitle": "Subtitle",
"LabelSupportedFileTypes": "Supported File Types",
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened",
"LabelTimeListenedToday": "Time Listened Today",
"LabelTimeRemaining": "{0} remaining",
"LabelTimeToShift": "Time to shift in seconds",
"LabelTitle": "Title",
"LabelToolsEmbedMetadata": "Embed Metadata",
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
"LabelToolsMakeM4b": "Make M4B Audiobook File",
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsSplitM4b": "Split M4B to MP3's",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
"LabelTotalDuration": "Total Duration",
"LabelTotalTimeListened": "Total Time Listened",
"LabelTrackFromFilename": "Track from Filename",
"LabelTrackFromMetadata": "Track from Metadata",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover",
"LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
"LabelUpdatedAt": "Updated At",
"LabelUpdateDetails": "Update Details",
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files",
"LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track",
"LabelUser": "User",
"LabelUsername": "Username",
"LabelValue": "Value",
"LabelVersion": "Version",
"LabelViewBookmarks": "View bookmarks",
"LabelViewChapters": "View chapters",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdays to run",
"LabelYourAudiobookDuration": "Your audiobook duration",
"LabelYourBookmarks": "Your Bookmarks",
"LabelYourPlaylists": "Your Playlists",
"LabelYourProgress": "Your Progress",
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
"MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
"MessageBookshelfNoCollections": "You haven't made any collections yet",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
"MessageFeedURLWillBe": "Feed URL will be {0}",
"MessageFetching": "Fetching...",
"MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",
"MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...",
"MessageLoadingFolders": "Loading folders...",
"MessageM4BFailed": "M4B Failed!",
"MessageM4BFinished": "M4B Finished!",
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
"MessageMarkAsFinished": "Mark as Finished",
"MessageMarkAsNotFinished": "Mark as Not Finished",
"MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
"MessageNoAudioTracks": "No audio tracks",
"MessageNoAuthors": "No Authors",
"MessageNoBackups": "No Backups",
"MessageNoBookmarks": "No Bookmarks",
"MessageNoChapters": "No Chapters",
"MessageNoCollections": "No Collections",
"MessageNoCoversFound": "No Covers Found",
"MessageNoDescription": "No description",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "No episode matches found",
"MessageNoEpisodes": "No Episodes",
"MessageNoFoldersAvailable": "No Folders Available",
"MessageNoGenres": "No Genres",
"MessageNoIssues": "No Issues",
"MessageNoItems": "No Items",
"MessageNoItemsFound": "No items found",
"MessageNoListeningSessions": "No Listening Sessions",
"MessageNoLogs": "No Logs",
"MessageNoMediaProgress": "No Media Progress",
"MessageNoNotifications": "No Notifications",
"MessageNoPodcastsFound": "No podcasts found",
"MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary",
"MessageNoUserPlaylists": "You have no playlists",
"MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
"MessageRemoveChapter": "Remove chapter",
"MessageRemoveEpisodes": "Remove {0} episode(s)",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for",
"MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
"MessageThinking": "Thinking...",
"MessageUploaderItemFailed": "Failed to upload",
"MessageUploaderItemSuccess": "Successfully Uploaded!",
"MessageUploading": "Uploading...",
"MessageValidCronExpression": "Valid cron expression",
"MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
"MessageXLibraryIsEmpty": "{0} Library is empty!",
"MessageYourAudiobookDurationIsLonger": "Your audiobook duration is longer than the duration found",
"MessageYourAudiobookDurationIsShorter": "Your audiobook duration is shorter than duration found",
"NoteChangeRootPassword": "Root user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
"NoteUploaderOnlyAudioFiles": "If uploading only audio files then each audio file will be handled as a separate audiobook.",
"NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.",
"PlaceholderNewCollection": "New collection name",
"PlaceholderNewFolderPath": "New folder path",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
"ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated",
"ToastAuthorImageRemoveFailed": "Failed to remove image",
"ToastAuthorImageRemoveSuccess": "Author image removed",
"ToastAuthorUpdateFailed": "Failed to update author",
"ToastAuthorUpdateMerged": "Author merged",
"ToastAuthorUpdateSuccess": "Author updated",
"ToastAuthorUpdateSuccessNoImageFound": "Author updated (no image found)",
"ToastBackupCreateFailed": "Failed to create backup",
"ToastBackupCreateSuccess": "Backup created",
"ToastBackupDeleteFailed": "Failed to delete backup",
"ToastBackupDeleteSuccess": "Backup deleted",
"ToastBackupRestoreFailed": "Failed to restore backup",
"ToastBackupUploadFailed": "Failed to upload backup",
"ToastBackupUploadSuccess": "Backup uploaded",
"ToastBatchUpdateFailed": "Batch update failed",
"ToastBatchUpdateSuccess": "Batch update success",
"ToastBookmarkCreateFailed": "Failed to create bookmark",
"ToastBookmarkCreateSuccess": "Bookmark added",
"ToastBookmarkRemoveFailed": "Failed to remove bookmark",
"ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateFailed": "Failed to update bookmark",
"ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveFailed": "Failed to remove collection",
"ToastCollectionRemoveSuccess": "Collection removed",
"ToastCollectionUpdateFailed": "Failed to update collection",
"ToastCollectionUpdateSuccess": "Collection updated",
"ToastItemCoverUpdateFailed": "Failed to update item cover",
"ToastItemCoverUpdateSuccess": "Item cover updated",
"ToastItemDetailsUpdateFailed": "Failed to update item details",
"ToastItemDetailsUpdateSuccess": "Item details updated",
"ToastItemDetailsUpdateUnneeded": "No updates needed for item details",
"ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
"ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
"ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished",
"ToastItemMarkedAsNotFinishedSuccess": "Item marked as Not Finished",
"ToastLibraryCreateFailed": "Failed to create library",
"ToastLibraryCreateSuccess": "Library \"{0}\" created",
"ToastLibraryDeleteFailed": "Failed to delete library",
"ToastLibraryDeleteSuccess": "Library deleted",
"ToastLibraryScanFailedToStart": "Failed to start scan",
"ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPodcastCreateFailed": "Failed to create podcast",
"ToastPodcastCreateSuccess": "Podcast created successfully",
"ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session",
"ToastSessionDeleteSuccess": "Session deleted",
"ToastSocketConnected": "Socket connected",
"ToastSocketDisconnected": "Socket disconnected",
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Failed to delete user",
"ToastUserDeleteSuccess": "User deleted"
}
+654
View File
@@ -0,0 +1,654 @@
{
"ButtonAdd": "जोड़ें",
"ButtonAddChapters": "अध्याय जोड़ें",
"ButtonAddPodcasts": "पॉडकास्ट जोड़ें",
"ButtonAddYourFirstLibrary": "अपनी पहली पुस्तकालय जोड़ें",
"ButtonApply": "लागू करें",
"ButtonApplyChapters": "अध्यायों में परिवर्तन लागू करें",
"ButtonAuthors": "लेखक",
"ButtonBrowseForFolder": "फ़ोल्डर खोजें",
"ButtonCancel": "रद्द करें",
"ButtonCancelEncode": "एनकोड रद्द करें",
"ButtonChangeRootPassword": "रूट का पासवर्ड बदलें",
"ButtonCheckAndDownloadNewEpisodes": "नए एपिसोड खोजें और डाउनलोड करें",
"ButtonChooseAFolder": "एक फ़ोल्डर चुनें",
"ButtonChooseFiles": "फ़ाइलें चुनें",
"ButtonClearFilter": "लागू फ़िल्टर साफ़ करें",
"ButtonCloseFeed": "फ़ीड बंद करें",
"ButtonCollections": "संग्रह",
"ButtonConfigureScanner": "स्कैनर सेटिंग्स बदलें",
"ButtonCreate": "बनाएं",
"ButtonCreateBackup": "बैकअप लें",
"ButtonDelete": "हटाएं",
"ButtonDownloadQueue": "कतार डाउनलोड करें",
"ButtonEdit": "संपादित करें",
"ButtonEditChapters": "अध्याय संपादित करें",
"ButtonEditPodcast": "पॉडकास्ट संपादित करें",
"ButtonForceReScan": "बलपूर्वक पुन: स्कैन करें",
"ButtonFullPath": "पूर्ण पथ",
"ButtonHide": "छुपाएं",
"ButtonHome": "घर",
"ButtonIssues": "समस्याएं",
"ButtonLatest": "नवीनतम",
"ButtonLibrary": "पुस्तकालय",
"ButtonLogout": "लॉग आउट",
"ButtonLookup": "तलाश करें",
"ButtonManageTracks": "ट्रैक्स मैनेज करें",
"ButtonMapChapterTitles": "अध्यायों का मिलान करें",
"ButtonMatchAllAuthors": "सभी लेखकों को तलाश करें",
"ButtonMatchBooks": "संबंधित पुस्तकों का मिलान करें",
"ButtonNevermind": "कोई बात नहीं",
"ButtonOk": "ठीक है",
"ButtonOpenFeed": "फ़ीड खोलें",
"ButtonOpenManager": "मैनेजर खोलें",
"ButtonPlay": "चलाएँ",
"ButtonPlaying": "चल रही है",
"ButtonPlaylists": "प्लेलिस्ट्स",
"ButtonPurgeAllCache": "सभी Cache मिटाएं",
"ButtonPurgeItemsCache": "आइटम Cache मिटाएं",
"ButtonPurgeMediaProgress": "अभी तक सुना हुआ सब हटा दे",
"ButtonQueueAddItem": "क़तार में जोड़ें",
"ButtonQueueRemoveItem": "कतार से हटाएं",
"ButtonQuickMatch": "जल्दी से समानता की तलाश करें",
"ButtonRead": "पढ़ लिया",
"ButtonRemove": "हटाएं",
"ButtonRemoveAll": "सभी हटाएं",
"ButtonRemoveAllLibraryItems": "पुस्तकालय की सभी आइटम हटाएं",
"ButtonRemoveFromContinueListening": "सुनना जारी रखें से हटाएं",
"ButtonRemoveSeriesFromContinueSeries": "इस सीरीज को कंटिन्यू सीरीज से हटा दें",
"ButtonReScan": "पुन: स्कैन करें",
"ButtonReset": "रीसेट करें",
"ButtonRestore": "पुनर्स्थापित करें",
"ButtonSave": "सहेजें",
"ButtonSaveAndClose": "सहेजें और बंद करें",
"ButtonSaveTracklist": "ट्रैक सूची सहेजें",
"ButtonScan": "स्कैन करें",
"ButtonScanLibrary": "पुस्तकालय स्कैन करें",
"ButtonSearch": "खोजें",
"ButtonSelectFolderPath": "फ़ोल्डर का पथ चुनें",
"ButtonSeries": "सीरीज",
"ButtonSetChaptersFromTracks": "ट्रैक्स से अध्याय बनाएं",
"ButtonShiftTimes": "समय खिसकाए",
"ButtonShow": "दिखाएं",
"ButtonStartM4BEncode": "M4B एन्कोडिंग शुरू करें",
"ButtonStartMetadataEmbed": "मेटाडेटा एम्बेडिंग शुरू करें",
"ButtonSubmit": "जमा करें",
"ButtonUpload": "अपलोड करें",
"ButtonUploadBackup": "बैकअप अपलोड करें",
"ButtonUploadCover": "कवर अपलोड करें",
"ButtonUploadOPMLFile": "OPML फ़ाइल अपलोड करें",
"ButtonUserDelete": "उपयोगकर्ता {0} को हटाएं",
"ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें",
"ButtonViewAll": "सभी को देखें",
"ButtonYes": "हाँ",
"HeaderAccount": "खाता",
"HeaderAdvanced": "विकसित",
"HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
"HeaderAudiobookTools": "Audiobook File Management Tools",
"HeaderAudioTracks": "Audio Tracks",
"HeaderBackups": "Backups",
"HeaderChangePassword": "Change Password",
"HeaderChapters": "Chapters",
"HeaderChooseAFolder": "Choose a Folder",
"HeaderCollection": "Collection",
"HeaderCollectionItems": "Collection Items",
"HeaderCover": "Cover",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderEpisodes": "Episodes",
"HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries",
"HeaderLibraryFiles": "Library Files",
"HeaderLibraryStats": "Library Stats",
"HeaderListeningSessions": "Listening Sessions",
"HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account",
"HeaderNewLibrary": "New Library",
"HeaderNotifications": "Notifications",
"HeaderOpenRSSFeed": "Open RSS Feed",
"HeaderOtherFiles": "Other Files",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add",
"HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode",
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Set Backup Schedule",
"HeaderSettings": "Settings",
"HeaderSettingsDisplay": "Display",
"HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)",
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
"HeaderStatsRecentSessions": "Recent Sessions",
"HeaderStatsTop10Authors": "Top 10 Authors",
"HeaderStatsTop5Genres": "Top 5 Genres",
"HeaderTools": "Tools",
"HeaderUpdateAccount": "Update Account",
"HeaderUpdateAuthor": "Update Author",
"HeaderUpdateDetails": "Update Details",
"HeaderUpdateLibrary": "Update Library",
"HeaderUsers": "Users",
"HeaderYourStats": "Your Stats",
"LabelAbridged": "Abridged",
"LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
"LabelAccountTypeUser": "User",
"LabelActivity": "Activity",
"LabelAdded": "Added",
"LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All",
"LabelAllUsers": "All Users",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAppend": "Append",
"LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelBackToUser": "Back to User",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
"LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Number of backups to keep",
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Books",
"LabelChangePassword": "Change Password",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening",
"LabelContinueSeries": "Continue Series",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
"LabelCreatedAt": "Created At",
"LabelCronExpression": "Cron Expression",
"LabelCurrent": "Current",
"LabelCurrently": "Currently:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelDatetime": "Datetime",
"LabelDescription": "Description",
"LabelDeselectAll": "Deselect All",
"LabelDevice": "Device",
"LabelDeviceInfo": "Device Info",
"LabelDirectory": "Directory",
"LabelDiscFromFilename": "Disc from Filename",
"LabelDiscFromMetadata": "Disc from Metadata",
"LabelDownload": "Download",
"LabelDuration": "Duration",
"LabelDurationFound": "Duration found:",
"LabelEdit": "Edit",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episode Title",
"LabelEpisodeType": "Episode Type",
"LabelExample": "Example",
"LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL",
"LabelFile": "File",
"LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified",
"LabelFilename": "Filename",
"LabelFilterByUser": "Filter by User",
"LabelFindEpisodes": "Find Episodes",
"LabelFinished": "Finished",
"LabelFolder": "Folder",
"LabelFolders": "Folders",
"LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
"LabelHour": "Hour",
"LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist",
"LabelIncomplete": "Incomplete",
"LabelInProgress": "In Progress",
"LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
"LabelIntervalEvery12Hours": "Every 12 hours",
"LabelIntervalEvery15Minutes": "Every 15 minutes",
"LabelIntervalEvery2Hours": "Every 2 hours",
"LabelIntervalEvery30Minutes": "Every 30 minutes",
"LabelIntervalEvery6Hours": "Every 6 hours",
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts",
"LabelItem": "Item",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update",
"LabelLess": "Less",
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
"LabelLibrary": "Library",
"LabelLibraryItem": "Library Item",
"LabelLibraryName": "Library Name",
"LabelLimit": "Limit",
"LabelListenAgain": "Listen Again",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute",
"LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts",
"LabelMore": "More",
"LabelMoreInfo": "More Info",
"LabelName": "Name",
"LabelNarrator": "Narrator",
"LabelNarrators": "Narrators",
"LabelNew": "New",
"LabelNewestAuthors": "Newest Authors",
"LabelNewestEpisodes": "Newest Episodes",
"LabelNewPassword": "New Password",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNotes": "Notes",
"LabelNotFinished": "Not Finished",
"LabelNotificationAppriseURL": "Apprise URL(s)",
"LabelNotificationAvailableVariables": "Available variables",
"LabelNotificationBodyTemplate": "Body Template",
"LabelNotificationEvent": "Notification Event",
"LabelNotificationsMaxFailedAttempts": "Max failed attempts",
"LabelNotificationsMaxFailedAttemptsHelp": "Notifications are disabled once they fail to send this many times",
"LabelNotificationsMaxQueueSize": "Max queue size for notification events",
"LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
"LabelNotificationTitleTemplate": "Title Template",
"LabelNotStarted": "Not Started",
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password",
"LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
"LabelPermissionsAccessAllTags": "Can Access All Tags",
"LabelPermissionsAccessExplicitContent": "Can Access Explicit Content",
"LabelPermissionsDelete": "Can Delete",
"LabelPermissionsDownload": "Can Download",
"LabelPermissionsUpdate": "Can Update",
"LabelPermissionsUpload": "Can Upload",
"LabelPhotoPathURL": "Photo Path/URL",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Play Method",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progress",
"LabelProvider": "Provider",
"LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year",
"LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended",
"LabelRegion": "Region",
"LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Search Term",
"LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season",
"LabelSequence": "Sequence",
"LabelSeries": "Series",
"LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format",
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
"LabelSettingsParseSubtitles": "Parse subtitles",
"LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by \" - \"<br>i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
"LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
"LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Use square book covers",
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
"LabelSettingsStoreCoversWithItem": "Store covers with item",
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
"LabelSettingsStoreMetadataWithItem": "Store metadata with item",
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
"LabelStartTime": "Start Time",
"LabelStatsAudioTracks": "Audio Tracks",
"LabelStatsAuthors": "Authors",
"LabelStatsBestDay": "Best Day",
"LabelStatsDailyAverage": "Daily Average",
"LabelStatsDays": "Days",
"LabelStatsDaysListened": "Days Listened",
"LabelStatsHours": "Hours",
"LabelStatsInARow": "in a row",
"LabelStatsItemsFinished": "Items Finished",
"LabelStatsItemsInLibrary": "Items in Library",
"LabelStatsMinutes": "minutes",
"LabelStatsMinutesListening": "Minutes Listening",
"LabelStatsOverallDays": "Overall Days",
"LabelStatsOverallHours": "Overall Hours",
"LabelStatsWeekListening": "Week Listening",
"LabelSubtitle": "Subtitle",
"LabelSupportedFileTypes": "Supported File Types",
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened",
"LabelTimeListenedToday": "Time Listened Today",
"LabelTimeRemaining": "{0} remaining",
"LabelTimeToShift": "Time to shift in seconds",
"LabelTitle": "Title",
"LabelToolsEmbedMetadata": "Embed Metadata",
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
"LabelToolsMakeM4b": "Make M4B Audiobook File",
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsSplitM4b": "Split M4B to MP3's",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
"LabelTotalDuration": "Total Duration",
"LabelTotalTimeListened": "Total Time Listened",
"LabelTrackFromFilename": "Track from Filename",
"LabelTrackFromMetadata": "Track from Metadata",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover",
"LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
"LabelUpdatedAt": "Updated At",
"LabelUpdateDetails": "Update Details",
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files",
"LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track",
"LabelUser": "User",
"LabelUsername": "Username",
"LabelValue": "Value",
"LabelVersion": "Version",
"LabelViewBookmarks": "View bookmarks",
"LabelViewChapters": "View chapters",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdays to run",
"LabelYourAudiobookDuration": "Your audiobook duration",
"LabelYourBookmarks": "Your Bookmarks",
"LabelYourPlaylists": "Your Playlists",
"LabelYourProgress": "Your Progress",
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
"MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
"MessageBookshelfNoCollections": "You haven't made any collections yet",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
"MessageFeedURLWillBe": "Feed URL will be {0}",
"MessageFetching": "Fetching...",
"MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",
"MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...",
"MessageLoadingFolders": "Loading folders...",
"MessageM4BFailed": "M4B Failed!",
"MessageM4BFinished": "M4B Finished!",
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
"MessageMarkAsFinished": "Mark as Finished",
"MessageMarkAsNotFinished": "Mark as Not Finished",
"MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
"MessageNoAudioTracks": "No audio tracks",
"MessageNoAuthors": "No Authors",
"MessageNoBackups": "No Backups",
"MessageNoBookmarks": "No Bookmarks",
"MessageNoChapters": "No Chapters",
"MessageNoCollections": "No Collections",
"MessageNoCoversFound": "No Covers Found",
"MessageNoDescription": "No description",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "No episode matches found",
"MessageNoEpisodes": "No Episodes",
"MessageNoFoldersAvailable": "No Folders Available",
"MessageNoGenres": "No Genres",
"MessageNoIssues": "No Issues",
"MessageNoItems": "No Items",
"MessageNoItemsFound": "No items found",
"MessageNoListeningSessions": "No Listening Sessions",
"MessageNoLogs": "No Logs",
"MessageNoMediaProgress": "No Media Progress",
"MessageNoNotifications": "No Notifications",
"MessageNoPodcastsFound": "No podcasts found",
"MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary",
"MessageNoUserPlaylists": "You have no playlists",
"MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
"MessageRemoveChapter": "Remove chapter",
"MessageRemoveEpisodes": "Remove {0} episode(s)",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for",
"MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
"MessageThinking": "Thinking...",
"MessageUploaderItemFailed": "Failed to upload",
"MessageUploaderItemSuccess": "Successfully Uploaded!",
"MessageUploading": "Uploading...",
"MessageValidCronExpression": "Valid cron expression",
"MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
"MessageXLibraryIsEmpty": "{0} Library is empty!",
"MessageYourAudiobookDurationIsLonger": "Your audiobook duration is longer than the duration found",
"MessageYourAudiobookDurationIsShorter": "Your audiobook duration is shorter than duration found",
"NoteChangeRootPassword": "रूट user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
"NoteUploaderOnlyAudioFiles": "If uploading only audio files then each audio file will be handled as a separate audiobook.",
"NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.",
"PlaceholderNewCollection": "New collection name",
"PlaceholderNewFolderPath": "New folder path",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
"ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated",
"ToastAuthorImageRemoveFailed": "Failed to remove image",
"ToastAuthorImageRemoveSuccess": "Author image removed",
"ToastAuthorUpdateFailed": "Failed to update author",
"ToastAuthorUpdateMerged": "Author merged",
"ToastAuthorUpdateSuccess": "Author updated",
"ToastAuthorUpdateSuccessNoImageFound": "Author updated (no image found)",
"ToastBackupCreateFailed": "Failed to create backup",
"ToastBackupCreateSuccess": "Backup created",
"ToastBackupDeleteFailed": "Failed to delete backup",
"ToastBackupDeleteSuccess": "Backup deleted",
"ToastBackupRestoreFailed": "Failed to restore backup",
"ToastBackupUploadFailed": "Failed to upload backup",
"ToastBackupUploadSuccess": "Backup uploaded",
"ToastBatchUpdateFailed": "Batch update failed",
"ToastBatchUpdateSuccess": "Batch update success",
"ToastBookmarkCreateFailed": "Failed to create bookmark",
"ToastBookmarkCreateSuccess": "Bookmark added",
"ToastBookmarkRemoveFailed": "Failed to remove bookmark",
"ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateFailed": "Failed to update bookmark",
"ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveFailed": "Failed to remove collection",
"ToastCollectionRemoveSuccess": "Collection removed",
"ToastCollectionUpdateFailed": "Failed to update collection",
"ToastCollectionUpdateSuccess": "Collection updated",
"ToastItemCoverUpdateFailed": "Failed to update item cover",
"ToastItemCoverUpdateSuccess": "Item cover updated",
"ToastItemDetailsUpdateFailed": "Failed to update item details",
"ToastItemDetailsUpdateSuccess": "Item details updated",
"ToastItemDetailsUpdateUnneeded": "No updates needed for item details",
"ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
"ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
"ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished",
"ToastItemMarkedAsNotFinishedSuccess": "Item marked as Not Finished",
"ToastLibraryCreateFailed": "Failed to create library",
"ToastLibraryCreateSuccess": "Library \"{0}\" created",
"ToastLibraryDeleteFailed": "Failed to delete library",
"ToastLibraryDeleteSuccess": "Library deleted",
"ToastLibraryScanFailedToStart": "Failed to start scan",
"ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPodcastCreateFailed": "Failed to create podcast",
"ToastPodcastCreateSuccess": "Podcast created successfully",
"ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session",
"ToastSessionDeleteSuccess": "Session deleted",
"ToastSocketConnected": "Socket connected",
"ToastSocketDisconnected": "Socket disconnected",
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Failed to delete user",
"ToastUserDeleteSuccess": "User deleted"
}
+36
View File
@@ -20,6 +20,7 @@
"ButtonCreate": "Napravi", "ButtonCreate": "Napravi",
"ButtonCreateBackup": "Napravi backup", "ButtonCreateBackup": "Napravi backup",
"ButtonDelete": "Obriši", "ButtonDelete": "Obriši",
"ButtonDownloadQueue": "Queue",
"ButtonEdit": "Edit", "ButtonEdit": "Edit",
"ButtonEditChapters": "Uredi poglavlja", "ButtonEditChapters": "Uredi poglavlja",
"ButtonEditPodcast": "Uredi podcast", "ButtonEditPodcast": "Uredi podcast",
@@ -92,7 +93,9 @@
"HeaderCollection": "Kolekcija", "HeaderCollection": "Kolekcija",
"HeaderCollectionItems": "Stvari u kolekciji", "HeaderCollectionItems": "Stvari u kolekciji",
"HeaderCover": "Cover", "HeaderCover": "Cover",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Detalji", "HeaderDetails": "Detalji",
"HeaderDownloadQueue": "Download Queue",
"HeaderEpisodes": "Epizode", "HeaderEpisodes": "Epizode",
"HeaderFiles": "Datoteke", "HeaderFiles": "Datoteke",
"HeaderFindChapters": "Pronađi poglavlja", "HeaderFindChapters": "Pronađi poglavlja",
@@ -126,6 +129,7 @@
"HeaderPreviewCover": "Pregledaj Cover", "HeaderPreviewCover": "Pregledaj Cover",
"HeaderRemoveEpisode": "Ukloni epizodu", "HeaderRemoveEpisode": "Ukloni epizodu",
"HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e", "HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren", "HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
"HeaderSavedMediaProgress": "Spremljen Media Progress", "HeaderSavedMediaProgress": "Spremljen Media Progress",
"HeaderSchedule": "Schedule", "HeaderSchedule": "Schedule",
@@ -138,6 +142,7 @@
"HeaderSettingsGeneral": "Opčenito", "HeaderSettingsGeneral": "Opčenito",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Sleep Timer", "HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Najduže stavke (sati)", "HeaderStatsLongestItems": "Najduže stavke (sati)",
"HeaderStatsMinutesListeningChart": "Minuta odslušanih (posljednjih 7 dana)", "HeaderStatsMinutesListeningChart": "Minuta odslušanih (posljednjih 7 dana)",
"HeaderStatsRecentSessions": "Nedavne sesije", "HeaderStatsRecentSessions": "Nedavne sesije",
@@ -150,11 +155,13 @@
"HeaderUpdateLibrary": "Aktualiziraj biblioteku", "HeaderUpdateLibrary": "Aktualiziraj biblioteku",
"HeaderUsers": "Korinici", "HeaderUsers": "Korinici",
"HeaderYourStats": "Tvoja statistika", "HeaderYourStats": "Tvoja statistika",
"LabelAbridged": "Abridged",
"LabelAccountType": "Vrsta korisničkog računa", "LabelAccountType": "Vrsta korisničkog računa",
"LabelAccountTypeAdmin": "Administrator", "LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gost", "LabelAccountTypeGuest": "Gost",
"LabelAccountTypeUser": "Korisnik", "LabelAccountTypeUser": "Korisnik",
"LabelActivity": "Aktivnost", "LabelActivity": "Aktivnost",
"LabelAdded": "Added",
"LabelAddedAt": "Added At", "LabelAddedAt": "Added At",
"LabelAddToCollection": "Dodaj u kolekciju", "LabelAddToCollection": "Dodaj u kolekciju",
"LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToCollectionBatch": "Add {0} Books to Collection",
@@ -162,6 +169,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "Svi korisnici", "LabelAllUsers": "Svi korisnici",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAppend": "Append", "LabelAppend": "Append",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Author (First Last)",
@@ -175,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.", "LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Broj backupa zadržati", "LabelBackupsNumberToKeep": "Broj backupa zadržati",
"LabelBackupsNumberToKeepHelp": "Samo 1 backup će biti odjednom obrisan. Ako koristite više njih, morati ćete ih ručno ukloniti.", "LabelBackupsNumberToKeepHelp": "Samo 1 backup će biti odjednom obrisan. Ako koristite više njih, morati ćete ih ručno ukloniti.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Knjige", "LabelBooks": "Knjige",
"LabelChangePassword": "Promijeni lozinku", "LabelChangePassword": "Promijeni lozinku",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "poglavlja pronađena", "LabelChaptersFound": "poglavlja pronađena",
"LabelChapterTitle": "Ime poglavlja", "LabelChapterTitle": "Ime poglavlja",
"LabelClosePlayer": "Close player", "LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series", "LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Kolekcije", "LabelCollections": "Kolekcije",
"LabelComplete": "Complete", "LabelComplete": "Complete",
@@ -192,6 +204,7 @@
"LabelCronExpression": "Cron Expression", "LabelCronExpression": "Cron Expression",
"LabelCurrent": "Trenutan", "LabelCurrent": "Trenutan",
"LabelCurrently": "Trenutno:", "LabelCurrently": "Trenutno:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelDatetime": "Datetime", "LabelDatetime": "Datetime",
"LabelDescription": "Opis", "LabelDescription": "Opis",
"LabelDeselectAll": "Odznači sve", "LabelDeselectAll": "Odznači sve",
@@ -204,11 +217,13 @@
"LabelDuration": "Trajanje", "LabelDuration": "Trajanje",
"LabelDurationFound": "Pronađeno trajanje:", "LabelDurationFound": "Pronađeno trajanje:",
"LabelEdit": "Uredi", "LabelEdit": "Uredi",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Uključi", "LabelEnable": "Uključi",
"LabelEnd": "Kraj", "LabelEnd": "Kraj",
"LabelEpisode": "Epizoda", "LabelEpisode": "Epizoda",
"LabelEpisodeTitle": "Naslov epizode", "LabelEpisodeTitle": "Naslov epizode",
"LabelEpisodeType": "Vrsta epizode", "LabelEpisodeType": "Vrsta epizode",
"LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFile": "Datoteka", "LabelFile": "Datoteka",
@@ -220,6 +235,7 @@
"LabelFinished": "Finished", "LabelFinished": "Finished",
"LabelFolder": "Folder", "LabelFolder": "Folder",
"LabelFolders": "Folderi", "LabelFolders": "Folderi",
"LabelFormat": "Format",
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Žanrovi", "LabelGenres": "Žanrovi",
"LabelHardDeleteFile": "Obriši datoteku zauvijek", "LabelHardDeleteFile": "Obriši datoteku zauvijek",
@@ -241,6 +257,8 @@
"LabelItem": "Stavka", "LabelItem": "Stavka",
"LabelLanguage": "Jezik", "LabelLanguage": "Jezik",
"LabelLanguageDefaultServer": "Default jezik servera", "LabelLanguageDefaultServer": "Default jezik servera",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Zadnje pogledano", "LabelLastSeen": "Zadnje pogledano",
"LabelLastTime": "Prošli put", "LabelLastTime": "Prošli put",
"LabelLastUpdate": "Zadnja aktualizacija", "LabelLastUpdate": "Zadnja aktualizacija",
@@ -259,10 +277,12 @@
"LabelMediaType": "Media Type", "LabelMediaType": "Media Type",
"LabelMetadataProvider": "Poslužitelj metapodataka ", "LabelMetadataProvider": "Poslužitelj metapodataka ",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minuta", "LabelMinute": "Minuta",
"LabelMissing": "Nedostaje", "LabelMissing": "Nedostaje",
"LabelMissingParts": "Nedostajali dijelovi", "LabelMissingParts": "Nedostajali dijelovi",
"LabelMore": "Više", "LabelMore": "Više",
"LabelMoreInfo": "More Info",
"LabelName": "Ime", "LabelName": "Ime",
"LabelNarrator": "Narrator", "LabelNarrator": "Narrator",
"LabelNarrators": "Naratori", "LabelNarrators": "Naratori",
@@ -270,6 +290,8 @@
"LabelNewestAuthors": "Najnoviji autori", "LabelNewestAuthors": "Najnoviji autori",
"LabelNewestEpisodes": "Najnovije epizode", "LabelNewestEpisodes": "Najnovije epizode",
"LabelNewPassword": "Nova lozinka", "LabelNewPassword": "Nova lozinka",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNotes": "Bilješke", "LabelNotes": "Bilješke",
"LabelNotFinished": "Nedovršeno", "LabelNotFinished": "Nedovršeno",
"LabelNotificationAppriseURL": "Apprise URL(s)", "LabelNotificationAppriseURL": "Apprise URL(s)",
@@ -300,7 +322,9 @@
"LabelPlayMethod": "Vrsta reprodukcije", "LabelPlayMethod": "Vrsta reprodukcije",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)", "LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Napredak", "LabelProgress": "Napredak",
"LabelProvider": "Dobavljač", "LabelProvider": "Dobavljač",
"LabelPubDate": "Datam izdavanja", "LabelPubDate": "Datam izdavanja",
@@ -312,7 +336,10 @@
"LabelRegion": "Regija", "LabelRegion": "Regija",
"LabelReleaseDate": "Datum izlaska", "LabelReleaseDate": "Datum izlaska",
"LabelRemoveCover": "Remove cover", "LabelRemoveCover": "Remove cover",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL", "LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Traži pojam", "LabelSearchTerm": "Traži pojam",
@@ -357,6 +384,7 @@
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
"LabelSettingsStoreMetadataWithItem": "Spremi metapodatke uz stavku", "LabelSettingsStoreMetadataWithItem": "Spremi metapodatke uz stavku",
"LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke. Koristi .abs ekstenziju.", "LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke. Koristi .abs ekstenziju.",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Prikaži sve", "LabelShowAll": "Prikaži sve",
"LabelSize": "Veličina", "LabelSize": "Veličina",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
@@ -384,6 +412,8 @@
"LabelTag": "Tag", "LabelTag": "Tag",
"LabelTags": "Tags", "LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags dostupni korisniku", "LabelTagsAccessibleToUser": "Tags dostupni korisniku",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Vremena odslušano", "LabelTimeListened": "Vremena odslušano",
"LabelTimeListenedToday": "Vremena odslušano danas", "LabelTimeListenedToday": "Vremena odslušano danas",
"LabelTimeRemaining": "{0} preostalo", "LabelTimeRemaining": "{0} preostalo",
@@ -403,6 +433,7 @@
"LabelTracksMultiTrack": "Multi-track", "LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track", "LabelTracksSingleTrack": "Single-track",
"LabelType": "Tip", "LabelType": "Tip",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Nepoznato", "LabelUnknown": "Nepoznato",
"LabelUpdateCover": "Aktualiziraj Cover", "LabelUpdateCover": "Aktualiziraj Cover",
"LabelUpdateCoverHelp": "Dozvoli postavljanje novog covera za odabrane knjige nakon što je match pronađen.", "LabelUpdateCoverHelp": "Dozvoli postavljanje novog covera za odabrane knjige nakon što je match pronađen.",
@@ -446,6 +477,7 @@
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?", "MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?", "MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?", "MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?", "MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
@@ -485,6 +517,8 @@
"MessageNoCollections": "Nema kolekcija", "MessageNoCollections": "Nema kolekcija",
"MessageNoCoversFound": "Covers nisu pronađeni", "MessageNoCoversFound": "Covers nisu pronađeni",
"MessageNoDescription": "Nema opisa", "MessageNoDescription": "Nema opisa",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "Nijedna epizoda pronađena", "MessageNoEpisodeMatchesFound": "Nijedna epizoda pronađena",
"MessageNoEpisodes": "Nema epizoda", "MessageNoEpisodes": "Nema epizoda",
"MessageNoFoldersAvailable": "Nema dostupnih foldera", "MessageNoFoldersAvailable": "Nema dostupnih foldera",
@@ -501,6 +535,7 @@
"MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"", "MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags", "MessageNoTags": "No Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNotYetImplemented": "Not yet implemented", "MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "Aktualiziranje nije potrebno", "MessageNoUpdateNecessary": "Aktualiziranje nije potrebno",
"MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno", "MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno",
@@ -546,6 +581,7 @@
"PlaceholderNewFolderPath": "Nova folder putanja", "PlaceholderNewFolderPath": "Nova folder putanja",
"PlaceholderNewPlaylist": "New playlist name", "PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Traži...", "PlaceholderSearch": "Traži...",
"PlaceholderSearchEpisode": "Search episode...",
"ToastAccountUpdateFailed": "Neuspješno aktualiziranje korisničkog računa", "ToastAccountUpdateFailed": "Neuspješno aktualiziranje korisničkog računa",
"ToastAccountUpdateSuccess": "Korisnički račun aktualiziran", "ToastAccountUpdateSuccess": "Korisnički račun aktualiziran",
"ToastAuthorImageRemoveFailed": "Neuspješno uklanjanje slike", "ToastAuthorImageRemoveFailed": "Neuspješno uklanjanje slike",

Some files were not shown because too many files have changed in this diff Show More