Compare commits

...

770 Commits

Author SHA1 Message Date
advplyr f2aed08d51 Version bump v2.4.4 2023-09-30 16:04:06 -05:00
advplyr c2c8cf919e Fix:Bad backup causing other backup files to not be displayed #1961 2023-09-30 16:01:10 -05:00
advplyr 9ebe23e91b Merge pull request #2162 from 92Kev/master
Update nl.json
2023-09-30 15:25:36 -05:00
advplyr 3d96749d38 Fix:Downloading podcasts with watcher causing duplicate episodes #2122 2023-09-30 15:12:37 -05:00
advplyr 1dc369180c Fix:Home page recent series shelf respect hide single book series library setting #2134 2023-09-30 14:32:40 -05:00
advplyr 8d3a326216 Fix:Newest episodes home page shelf #2119 2023-09-30 14:19:10 -05:00
advplyr 1b22205f74 Update:Add libraryItems table index to improve performance #2073 2023-09-30 12:39:16 -05:00
92Kev 826fee4590 Update nl.json
Added Dutch translations where they were missing
2023-09-30 16:42:28 +02:00
advplyr f0929729a3 Fix:Adding new podcast with auto download episodes not setting the schedule #2160 2023-09-29 14:52:04 -05:00
advplyr 98ed2e01cc Fix:Scanner overwriting metadata when metadata file is not stored with items #2155 2023-09-28 17:23:52 -05:00
advplyr ed82a5aa19 Update:Library folder path editable in library edit modal until submit #2150 2023-09-27 17:50:32 -05:00
advplyr d7b2476473 Merge pull request #2146 from JBlond/master
Update German translation strings.
2023-09-26 17:18:18 -05:00
JBlond ee162f468a Adjust Message according to https://de.wikipedia.org/wiki/Durchkopplung#Fremdsprachliche_Begriffe
as suggested by https://github.com/vanto
2023-09-26 09:51:34 +02:00
JBlond cb6678fa71 Update German translation strings. 2023-09-25 11:16:57 +02:00
advplyr 10011d3886 Add:Remove option for authors & show authors with 0 books on authors page #2124 2023-09-24 17:06:32 -05:00
advplyr 0367d9ec2a Fix:OPF files creating empty tags and genres #2142 2023-09-24 16:15:42 -05:00
advplyr 26f520ca4a Fix:Listening sessions page showing filtered user for open listening sessions #2136 2023-09-24 15:59:29 -05:00
advplyr 8683fc9fe4 Fix:Show series name when collapsing sub-series #2140 2023-09-23 14:38:30 -05:00
advplyr fd0920c808 Fix:Updating RSS feeds with new episodes #2139 2023-09-23 14:27:13 -05:00
advplyr a446fc0f20 Merge pull request #2138 from husjon/add-norwegian_translation
Added Norwegian translation
2023-09-23 09:37:55 -05:00
Jon Erling Hustadnes 202c26acf5 translation progress 2023-09-23 16:31:23 +02:00
Jon Erling Hustadnes f0b2acb4c7 added 'no' to languageCodeMap i18n.js 2023-09-23 16:29:54 +02:00
advplyr 102c90c4e8 Merge pull request #2133 from mfcar/mf/backup
Add more information to the backup page
2023-09-22 16:56:12 -05:00
advplyr 7c484d8e96 Remove commented out backup location 2023-09-22 16:53:59 -05:00
advplyr e9f0f7d1bc Add LabelBackupLocation translation strings 2023-09-22 16:51:35 -05:00
advplyr f37ab53eff Update get all backups api endpoint to return backupLocation, display location above backup settings 2023-09-22 16:49:01 -05:00
advplyr 97b0b98605 Merge pull request #2102 from selfhost-alt/sqlite-query-logging
Add ability to enable DEV logs of Sqlite queries
2023-09-22 16:17:32 -05:00
advplyr 1ab34fa77f Update server/Database.js 2023-09-22 16:14:12 -05:00
advplyr b64ecc7c6f Update server/Database.js 2023-09-22 16:14:00 -05:00
advplyr a11fc214e9 Merge pull request #2099 from mikiher/Fuzzy-Matching
Fuzzy Matching V1
2023-09-22 16:07:17 -05:00
advplyr 61c48602e8 Add jsdocs to BookFinder search functions 2023-09-22 16:03:41 -05:00
Jon Erling Hustadnes 452d59dcf6 copy of en-us.json to no.json 2023-09-22 12:31:38 +02:00
advplyr 5e976c08af Update cover API endpoint to only load necessary data from DB #2073 2023-09-21 16:57:48 -05:00
advplyr f1cce76e2c Merge pull request #2121 from Hallo951/master
Update de.json
2023-09-20 17:32:03 -05:00
advplyr 872fba1103 Merge pull request #2129 from mikiher/URI-encoding-2
Use encodeURIComponent for text inputs in Match.vue
2023-09-20 16:49:18 -05:00
mfcar 944f5950ca File missing 2023-09-20 22:36:30 +01:00
mfcar bfa87a2131 Add a way to see the backup location 2023-09-20 22:33:58 +01:00
mikiher 6eab985b1e Use encodeURIComponent for text inputs 2023-09-20 11:25:21 +00:00
mikiher 81a9b8d158 Merge branch 'advplyr:master' into Fuzzy-Matching 2023-09-20 13:12:18 +03:00
mfcar 9519f6418d Now, whenever someone requests a backup file, it will automatically suggest a default file name for the downloaded file. 2023-09-19 22:37:57 +01:00
advplyr 9967a5dc66 Fix:Set ebookFormat on scans #2126 2023-09-19 15:42:38 -05:00
Hallo951 9382055bf2 Update de.json 2023-09-19 09:39:46 +02:00
advplyr 604f52762b Merge pull request #2120 from itzexor/x-accel-encode
[server] x-accel: encode all paths to URIs
2023-09-18 17:51:53 -05:00
advplyr ae88a4d20a Fix:Matching a library with no items not removing library scan #2118 2023-09-18 17:38:45 -05:00
advplyr b5a27226cc Fix:Misleading log on cover manager 2023-09-18 16:45:30 -05:00
advplyr 2c71324381 Fix:Book re-scan properly checking if existing coverPath exists #2110 2023-09-18 16:43:43 -05:00
James Ross 207ba7ec8e x-accel: encode all paths to URIs
updates util function  encodeUriPath to use node:url with a file:// path
prefix, and updates all instances x-accel redirection to use this helper
util instead of sending unencoded paths into the header.
2023-09-18 13:08:19 -07:00
advplyr e56b8edc0a Version bump 2.4.3 2023-09-17 16:13:19 -05:00
advplyr 8ab0a0a14d Update personalized shelves logs to dev logs 2023-09-17 16:09:21 -05:00
advplyr 4e01722ba6 Merge pull request #2103 from selfhost-alt/faster-scan-for-empty-series
Scan for empty book series more efficiently
2023-09-17 15:54:33 -05:00
advplyr 87eaacea22 Fix empty podcast and empty book queries when cleaning db on init 2023-09-17 15:53:25 -05:00
advplyr 3ad4f05449 Merge branch 'master' into faster-scan-for-empty-series 2023-09-17 15:47:06 -05:00
advplyr 817be40959 Merge pull request #2101 from selfhost-alt/fix-parse-full-name-typo
Fix typo in fixParsedNameCase
2023-09-17 15:43:25 -05:00
advplyr d18592eaeb Fix:Duplicate series and authors being added on matches and scans #2106 2023-09-17 15:29:39 -05:00
advplyr 0aae672e19 Fix:Scanner purge cover cache when extracting from audio file 2023-09-17 14:53:25 -05:00
advplyr cfd9a01da7 Fix:Server crash when removing item from playlist #2115 2023-09-17 12:40:13 -05:00
Selfhost Alt 19cf3bfb9f Fix query to actually return empty series 2023-09-15 13:32:21 -07:00
mikiher 67bbe21513 Make quick-match more conservative 2023-09-15 09:24:19 +00:00
Selfhost Alt b668c6e37a Remove stray quote 2023-09-14 23:04:47 -07:00
Selfhost Alt 71762ef837 Newline before printing query 2023-09-14 23:01:40 -07:00
Selfhost Alt b1524d245e Add ability to enable DEV logs of Sqlite queries 2023-09-14 22:52:43 -07:00
Selfhost Alt 8b39b01269 Scan for empty book series more efficiently 2023-09-14 22:35:33 -07:00
Selfhost Alt f7849d2956 Fix typo in fixParsedNameCase 2023-09-14 22:12:22 -07:00
mikiher ac746f199b Fuzzy Matching V1 2023-09-14 21:32:20 +00:00
advplyr fea28351f9 Version bump 2.4.2 2023-09-13 16:48:28 -05:00
advplyr bb124d3274 Merge pull request #2089 from Lionfox2/patch-2
Update de.json
2023-09-13 16:46:39 -05:00
advplyr 6cd1b82ada Merge pull request #2083 from Machou/patch-1
Update fr.json
2023-09-13 16:46:23 -05:00
advplyr c701617fbb Merge pull request #2079 from Nab0y/master
Update Russian localization
2023-09-13 16:46:02 -05:00
Lionfox2 5d84c426fe Update de.json
The more suitable translation for "discover" is "Entdecken", same wording as in other media apps like Spotify
2023-09-12 23:30:58 +02:00
advplyr 083ba2fe19 Fix:Podcast download queue page available on refresh #2088 2023-09-12 15:35:14 -05:00
advplyr 1024bc5a75 Fix:Podcast library stat for total size #2072 2023-09-12 13:43:28 -05:00
advplyr 9553c19b33 Fix:Authors dropdown to use filter data instead of API endpoint #2077 2023-09-12 12:33:41 -05:00
Machou 2cbc9a07cb Update fr.json 2023-09-12 18:11:38 +02:00
advplyr ab97a9d613 Fix:Crash when updating book author or series that includes an apostrophe #2070 2023-09-12 10:41:39 -05:00
advplyr f1a7fd0d50 Fix:Podcast library include number of incomplete episodes in home page shelf api request #2081 2023-09-11 17:51:39 -05:00
Dmitry Naboychenko e9d7efbc5c Update russian localization 2023-09-11 21:35:36 +03:00
advplyr 6e5d334874 Version bump 2.4.1 2023-09-09 15:47:17 -05:00
advplyr 6822628994 Fix:Missing narrators library filter 2023-09-09 15:46:33 -05:00
advplyr 98d9fd8c32 Fix:Get all items api endpoint support providing no limit #2067 2023-09-09 15:01:58 -05:00
advplyr e2cca60853 Fix:Crash on podcast library page sort by title #2069 2023-09-09 14:56:36 -05:00
advplyr e80b313a7b Fix:Server crash when quick match with find covers setting enabled #2068 2023-09-09 08:57:59 -05:00
advplyr b09b95ef24 Fix:Browse folders when adding new library folder crashing server #2065 2023-09-09 07:47:17 -05:00
advplyr aec45d04f7 Version bump 2.4.0 2023-09-08 17:26:21 -05:00
advplyr 87d037cb0a Fix clean database method and version bump 2.3.5 2023-09-08 17:20:39 -05:00
advplyr f6baf06164 Version bump 2.3.4 2023-09-08 16:20:03 -05:00
advplyr 7e75845851 Ignore podcast directory from watcher for additional 5s after downloading new episode 2023-09-08 15:12:39 -05:00
advplyr 2a11932822 Scanner ignore .part files #2063 2023-09-08 14:50:59 -05:00
advplyr 80fee92037 Update "disable watcher" server setting to display as "enable watcher" #2055 2023-09-08 14:28:21 -05:00
advplyr d0c02a801a Update open rss feed prevent indexing - dont include block tags when not preventing indexing 2023-09-08 14:03:12 -05:00
advplyr 9e13c64408 Handle sorting when collapsing by series and filtering by series on library page 2023-09-08 13:42:19 -05:00
advplyr 826963bf00 Add api route for changing sorting prefixes, update default sorting prefixes to include a 2023-09-08 12:32:30 -05:00
advplyr 39b6ede1e9 Add support for hide from continue listening 2023-09-08 11:20:22 -05:00
advplyr 066d853156 Add support for hide from continue listening on new home page shelves route 2023-09-07 17:49:35 -05:00
advplyr efae529fac Add cover finder to new book scanner 2023-09-06 17:48:50 -05:00
advplyr 934c0b9093 Fix watcher scanner detecting existing items 2023-09-06 15:43:59 -05:00
advplyr f02992dd4d Remove the setting of file permissions #2057 2023-09-06 07:12:11 -05:00
advplyr 10011bd6a3 Add startup function to remove invalid records from DB 2023-09-05 17:58:13 -05:00
advplyr a44ee913c4 Fix crash on get recent series home page shelf endpoint 2023-09-05 16:10:46 -05:00
advplyr adccccbd7a Remove index creation from migration file 2023-09-05 15:36:19 -05:00
advplyr 05b1b2be36 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-09-05 15:07:49 -05:00
advplyr 7cc35a2cbe Remove indexes for columns that didnt exist in 2.3.3 2023-09-05 15:07:41 -05:00
advplyr 8d479b6e34 Merge pull request #2054 from JBlond/master
Follow-up update for German strings
2023-09-05 08:23:04 -05:00
JBlond 74d300f048 Follow up update for German string to 469167df66.
Plus addtional string translation
2023-09-05 11:36:41 +02:00
advplyr 1dd1fe8994 Update match all books to load items from DB, remove library items loading to memory on init 2023-09-04 16:33:55 -05:00
advplyr 03115e5e53 Replace old items filter/sort api endpoint with new, handle collapse sub-series 2023-09-04 15:26:07 -05:00
advplyr b1c07834be Remove force re-scan and old scanner logic 2023-09-04 13:59:37 -05:00
advplyr b9da3fa30e Add new podcast scanner and remove old scanner 2023-09-04 11:50:55 -05:00
advplyr 42ff3d8314 Add new library item scanner 2023-09-03 17:51:58 -05:00
advplyr e63aab95d8 Update new library scanner to handle metadata file changes 2023-09-03 15:14:58 -05:00
advplyr 9123dcb365 Remove series search api endpoint, update authors and series to load from db 2023-09-03 10:49:02 -05:00
advplyr 7567e91878 Update get library item api endpoint to remove unnecessary authors include query param 2023-09-03 10:04:14 -05:00
advplyr 1b1bdea3c8 Remove authors search api endpoint 2023-09-03 09:54:23 -05:00
advplyr 2df95c1712 Updates for new book scanner 2023-09-02 17:49:28 -05:00
advplyr 4ad1cd2968 Fix:Batch API endpoints crash on reset library filter data 2023-09-02 10:46:47 -05:00
advplyr 0ecfdab463 Update new library scanner for scanning in new books 2023-09-01 18:01:17 -05:00
advplyr 75276f5a44 Fix:Server crash when updating cover to a directory #2007 2023-08-30 18:05:52 -05:00
advplyr 4585d2816b Fix:Comic reader not detecting file sort order when number is more than 5-digit #2036 2023-08-29 15:47:34 -05:00
advplyr f8f94f2a6d Update new library scanner to check for cover images and ebooks 2023-08-28 17:50:21 -05:00
advplyr 2c8448d147 Updates to new library scanner and adding jsdoc types 2023-08-27 17:19:57 -05:00
advplyr ea1d051cfb Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-08-26 16:33:34 -05:00
advplyr a38e43213d Fix:Server crash when deleting library item #2031 2023-08-26 16:33:27 -05:00
advplyr 6cac8fcd6e Merge pull request #2030 from bluecmd/debian-tar-owner
Fix system file ownership for Debian package
2023-08-25 16:19:26 -05:00
Christian Svensson 8e65c78869 Fix system file ownership for Debian package
Extract ffmpeg and tone but ignore the ownership information from the tar archive. This will ensure that those files are owned by root, not the UIDs/GIDs the tar files happen to contain.
2023-08-25 18:14:17 +02:00
advplyr a3899b68e1 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-08-24 17:55:35 -05:00
advplyr 1187f91063 Update jsdoc defs for models 2023-08-24 17:55:29 -05:00
advplyr 7c288a5ff9 Merge pull request #2027 from realmain/docker-compose
Added podcasts volume to docker compose
2023-08-24 15:22:24 -05:00
advplyr e0dae44c7d Update:Show published year on library page when sorting books by published year #2017 2023-08-23 18:01:58 -05:00
realmain 754498958d Added podcasts volume 2023-08-23 15:55:07 -07:00
advplyr ec15978e26 Merge pull request #2026 from shawnphoffman/shawn/rss-feeds
Add config page for all RSS feeds
2023-08-22 16:42:31 -05:00
advplyr 469167df66 Update get all feeds route to be admin-only, map translation strings 2023-08-22 16:37:22 -05:00
advplyr e7c43a3f32 Merge pull request #2009 from JBlond/master
Update German strings
2023-08-22 16:08:47 -05:00
Shawn Hoffman 24989e73ae Merge branch 'master' into shawn/rss-feeds 2023-08-22 10:30:16 -07:00
Shawn Hoffman 13427b9f70 Add RSS feeds config page 2023-08-22 10:11:10 -07:00
Mario adafefecd4 Merge branch 'advplyr:master' into master 2023-08-22 11:39:13 +02:00
advplyr 6f96b069b5 Fix search query 2023-08-21 16:33:16 -05:00
advplyr 6c1b4e3a36 Update db model references 2023-08-20 13:34:03 -05:00
advplyr 21343ffbd1 Update numIssues on filter data, fix watcher scanning in new items 2023-08-20 13:16:53 -05:00
advplyr 4f94deefa0 Fix remove items with issues API route & remove old endpoints 2023-08-19 17:12:24 -05:00
advplyr 332078e6c1 Update library stats API route to load from db 2023-08-19 16:53:33 -05:00
advplyr ff0d6326d3 Update OPML api route to load podcasts from db 2023-08-19 15:19:27 -05:00
advplyr 8d451217a3 Update recent-episodes API route to load from db 2023-08-19 14:49:06 -05:00
advplyr f21d69339f Update search query to use user permissions 2023-08-19 14:11:34 -05:00
advplyr c77cead9ae Update search endpoints to search db directly 2023-08-19 13:59:22 -05:00
advplyr b334d40998 Update library routes to middlewareNew 2023-08-18 17:12:15 -05:00
advplyr 4e4a976050 Update get library series api endpoint to load from db 2023-08-18 17:08:34 -05:00
advplyr 9d7d4c6902 Update filterData for authors/series when added/removed 2023-08-18 14:40:36 -05:00
advplyr 7222171c5b Update checking empty series to load from Db 2023-08-17 17:58:57 -05:00
advplyr 361732a463 Update get User API endpoint to load media progress from db 2023-08-17 17:26:12 -05:00
advplyr 1ebe8a6f4c Update scanner to load library items from db 2023-08-16 18:08:00 -05:00
advplyr a98942a361 Add jsdoc types to remaining models 2023-08-16 16:38:48 -05:00
advplyr 0bc89cd40f Fix collapse series and sort by title without ignore prefix 2023-08-16 15:24:56 -05:00
advplyr 2ae86ab5bb Fix Library undefined sequelize 2023-08-16 14:49:06 -05:00
advplyr c707bcf0f6 Add jsdoc types for models 2023-08-15 18:03:43 -05:00
JBlond 10040ba9fa Update German strings as follow up of 09eefae808 2023-08-15 15:38:27 +02:00
advplyr 7afda1295b Update Author model to define types 2023-08-14 18:22:38 -05:00
advplyr 6d6e8613cf Update library API endpoints to load library items from db 2023-08-13 17:45:53 -05:00
advplyr 3651fffbee Update library filter data to load from db and cache, update rss feed routes to load library items from db 2023-08-13 15:10:26 -05:00
advplyr 8d03b23f46 Update MiscController api routes to load library items from db 2023-08-13 13:10:34 -05:00
advplyr fc44c801f2 Update playlist API endpoints to load library items from DB 2023-08-13 11:22:38 -05:00
advplyr 6056c14926 Update podcast controller to load library items from db 2023-08-12 17:29:08 -05:00
advplyr f465193b9c Update User.toJSONForPublic to remove mostRecent key and session key only includes the PlaybackSession 2023-08-12 16:11:58 -05:00
advplyr 09c9c28028 Remove test API endpoint for albums 2023-08-12 15:54:59 -05:00
advplyr f1130eb63a Update MeController api endpoints to load library items from DB 2023-08-12 15:52:09 -05:00
advplyr db80cec168 Update collection API routes to load libraryItems from DB 2023-08-12 15:01:27 -05:00
advplyr 38029d1202 Update library collections api endpoint to use libraryItems from db 2023-08-11 17:49:06 -05:00
advplyr aac2879652 Fix library query sort by title, add indexes for books and libraryItems 2023-08-10 17:46:27 -05:00
advplyr 8c9fc3ddb5 Fix discover home page shelf query, add indexes for libraryItems and mediaProgresses table 2023-08-09 17:48:31 -05:00
advplyr 33e04d0cbb Update home page queries merging listen/read shelves, limit recent shelves to 60 days out 2023-08-07 17:59:04 -05:00
advplyr fbb5fd41fb Merge pull request #1994 from NiclasHaderer/fix-backup-server-crash
Fix: server crash when uploading invalid backup file
2023-08-07 17:17:57 -05:00
advplyr 43a5296dd7 Update server/managers/BackupManager.js 2023-08-07 17:14:47 -05:00
advplyr 345ff1aa66 Update author API endpoints to load library items from DB 2023-08-06 15:06:45 -05:00
advplyr 56e3449db6 Remove media progress for podcast episodes when episode is removed 2023-08-06 14:18:51 -05:00
advplyr 1372c24535 Update queries to account for user permissions 2023-08-06 13:36:58 -05:00
Niclas Haderer 409c5f7b75 fix: the server does not crash any more when an invalid backup file is uploaded 2023-08-06 10:05:53 +02:00
advplyr 83d0db0607 Fix getting default library id for user 2023-08-05 16:37:26 -05:00
advplyr 91b6c4412d Add remaining personalized shelf queries for podcasts 2023-08-05 15:28:16 -05:00
advplyr 09eefae808 Add remaining personalized shelf queries, update book libraries home page to use new API endpoint 2023-08-05 14:01:16 -05:00
advplyr 80b3bfea51 Add recent series home page shelf query 2023-08-04 18:07:55 -05:00
advplyr 516298b5b2 Update continue series shelf to include rss feed 2023-08-04 17:26:43 -05:00
advplyr 8edab98163 Update continue series shelf queries 2023-08-04 17:24:06 -05:00
advplyr 58da095bcf Update query for continue series shelf 2023-08-03 18:14:25 -05:00
advplyr b9633691f4 Add new personalized home page shelves API endpoint 2023-08-02 18:29:28 -05:00
advplyr 7ec1d8ee5f Fix socket authority check for valid client 2023-08-01 16:34:01 -05:00
advplyr 83a1374e79 Merge pull request #1985 from tomazed/translation-fr
fr translation update
2023-08-01 16:23:28 -05:00
Tomazed 5ef00bac92 fr translation update 2023-08-01 11:51:15 +02:00
advplyr 95c4b3862b Include library item podcast queries 2023-07-31 17:59:51 -05:00
advplyr eeaf012cdc Update new library item API endpoint to handle collapse series 2023-07-30 17:51:44 -05:00
advplyr 11120a3765 Update version specific db migration check 2023-07-30 11:09:49 -05:00
advplyr 4d0acb30ba Update bookSeries & bookAuthors table to include createdAt timestamp 2023-07-29 17:25:11 -05:00
advplyr 4dbe8d29d9 Update db migration for duration, size, lastFirst, and ignore prefix columns 2023-07-28 18:03:31 -05:00
advplyr 0ca4ff4fca Update readme 2023-07-27 18:17:29 -05:00
advplyr 8be1651c6b Fix:Sync local media progress when library item not found #1971 2023-07-26 18:08:55 -05:00
advplyr af2db86d1a Merge pull request #1965 from petras-sukys/l10n/lt
Added Lithuanian translation
2023-07-25 15:03:07 -05:00
Petras Šukys 57c834f88d Added EOL at EOF 2023-07-25 22:20:13 +03:00
Petras Šukys 65fdebde20 Added language entry for Lithuanian (Lietuvių) 2023-07-25 22:16:13 +03:00
Petras Šukys b58e42ebf3 Lithuanian translation, part 5 2023-07-25 22:11:12 +03:00
Petras Šukys b2d45f598b Merge branch 'master' into l10n/lt 2023-07-25 08:03:19 +03:00
Petras Šukys 09c4e690c6 Lithuanian translation, part 4 2023-07-25 07:57:51 +03:00
Petras Šukys 67ba481dca Lithuanian translation, part 3 2023-07-24 22:49:44 +03:00
advplyr 710a62c2af Update:Load playlists only when needed & remove podcast episode from playlist when deleted 2023-07-23 09:42:57 -05:00
advplyr 5a9eed0a5a Update:Only load collections when needed 2023-07-22 16:18:55 -05:00
advplyr 354e16e462 Update:Only load Users when needed 2023-07-22 15:32:20 -05:00
advplyr 1d974375a0 Update:Only load libraries from db when needed 2023-07-22 14:25:20 -05:00
advplyr 1c40af3eef Update:Sequelize transactionType to IMMEDIATE to fix SQLITE_BUSY #1910 2023-07-22 11:30:29 -05:00
advplyr daa8c4cd67 Update:Remove sort index from podcast episodes 2023-07-22 09:24:46 -05:00
advplyr d5da4441cd Fix:Set podcast episode audio file index to 1 on scans 2023-07-22 09:05:43 -05:00
advplyr 80aea0c82d Fix:Save metadata files when updating library items #1952 2023-07-22 07:50:47 -05:00
Petras Šukys 14836eeb0d Lithuanian translation, part 2 2023-07-22 12:00:29 +03:00
Petras Šukys 85e9883d3e Lithuanian translation, part 1 2023-07-22 11:53:37 +03:00
Petras Šukys 80ca73e491 Copied en-us language file to lt 2023-07-22 10:52:06 +03:00
advplyr 22323f606d Fix:RSS feed covers #1948 2023-07-21 16:59:00 -05:00
advplyr 01b65eb678 Fix:Initialize Feed entityUpdatedAt to prevent updated RSS feed every request 2023-07-21 16:18:58 -05:00
advplyr d1d94c37a7 Update:Remove --max-old-space-size from Dockerfile 2023-07-20 17:00:08 -05:00
advplyr 838a24c8a5 Update:Remove healthcheck from Dockerfile 2023-07-20 16:59:46 -05:00
advplyr 3f380b0839 Fix:Parsing authors from meta tags removes duplicates #1932 2023-07-20 16:55:49 -05:00
advplyr 7fdf1a1d7f Merge pull request #1946 from rasmuslos/master
Fix byte conversion
2023-07-20 15:55:16 -05:00
advplyr c2793fe29b Fix:Crash when author is set without a name #1934 2023-07-19 17:13:57 -05:00
Rasmus Krämer 38596d017f Fix byte conversion 2023-07-19 23:59:00 +02:00
advplyr 24b9ac6a68 Version bump 2.3.3 2023-07-19 16:32:41 -05:00
advplyr 9a5ed64fae Update database loading library items incrementally to reduce mem usage 2023-07-19 15:36:18 -05:00
advplyr c2af96e7cd Fix:New user setting tags array #1933 2023-07-18 17:43:33 -05:00
advplyr 104cadb0b3 Version bump 2.3.2 2023-07-17 17:49:12 -05:00
advplyr 6814adffcc Update:Only load feeds when needed 2023-07-17 16:48:46 -05:00
advplyr 20c11e381e Update docker file heap size 2023-07-17 14:41:21 -05:00
advplyr b5952f16eb Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-07-17 13:58:25 -05:00
advplyr 5b6878e5de Fix:Crash on local playback sessions #1912 2023-07-17 13:58:19 -05:00
advplyr 89a25bcf39 Merge pull request #1917 from burghy86/patch-10
Update it.json
2023-07-17 08:09:28 -05:00
advplyr d0cd512be8 Fix:Crash when updating sequence on series #1919 2023-07-17 08:09:08 -05:00
advplyr 3543dea0fb Merge pull request #1923 from JBlond/master
Update German language file
2023-07-17 08:09:07 -05:00
advplyr 1949e25ccb Merge pull request #1925 from Machou/patch-1
Update fr.json
2023-07-17 08:08:43 -05:00
advplyr b715ef3bfc Increase heap size to 4gb in Dockerfile 2023-07-17 07:48:23 -05:00
Machou 954050df81 Update fr.json 2023-07-17 14:28:56 +02:00
JBlond e4aa7f10fa Update German language file 2023-07-17 10:02:54 +02:00
advplyr 2afd0e2acd Update dbMigration for old main library ids 2023-07-16 16:39:59 -05:00
advplyr 0829237166 Fix:Libraries out of order #1911 2023-07-16 15:43:46 -05:00
advplyr 541975f038 Version bump 2.3.1 2023-07-16 15:34:35 -05:00
advplyr 01bf58ab97 Fix createAuthor 2023-07-16 15:29:43 -05:00
advplyr d99b2c25e8 Fixes for db migration & local playback sessions 2023-07-16 15:05:51 -05:00
burghy86 a31df5ff81 Update it.json
traslate new string and fix error
2023-07-16 21:50:15 +02:00
advplyr 63e5cf2e60 Fix:Accessing series page for some users #787 2023-07-16 08:39:08 -05:00
advplyr 7beca048e7 Version bump v2.3.0 2023-07-15 15:29:25 -05:00
advplyr ec998dc1ac Update:Podcast library item covers show number of episodes incomplete #782 2023-07-15 14:45:08 -05:00
advplyr ddc54c8811 Update:Downloading library item shows log on the server with username #1461 2023-07-15 13:39:12 -05:00
advplyr 72e306935f Update:Support and as separator between multiple authors #1790 2023-07-15 13:28:31 -05:00
advplyr 96a7c7f4d1 Fix:Embedded chapters with invalid IDs, update chapter ids to always be the index #1783 2023-07-15 12:46:51 -05:00
advplyr 9c65d655b8 Fix:Realtime update cover on cover tab in item edit modal 2023-07-15 12:37:33 -05:00
advplyr b108f2241b Add:Library filter for publishers & link to publisher filter on book page #1813 2023-07-15 12:22:13 -05:00
advplyr 9439acf300 Merge pull request #1906 from warnwar/master
stop opf importer from adding duplicate info
2023-07-15 11:44:41 -05:00
advplyr d181e66d83 Update server/utils/parsers/parseOpfMetadata.js 2023-07-15 11:41:44 -05:00
advplyr a87c3f2c77 Update server/utils/parsers/parseOpfMetadata.js 2023-07-15 11:41:40 -05:00
advplyr 2834f6077e Update server/utils/parsers/parseOpfMetadata.js 2023-07-15 11:41:35 -05:00
advplyr 918013ccb3 Add:Option on podcast page to mark all episodes as finished/unfinished #1862 2023-07-15 11:27:06 -05:00
advplyr 4c4672c6c1 Update:Item page UI for details that take up multiple lines 2023-07-15 11:00:07 -05:00
advplyr b3991574c7 Merge pull request #1907 from advplyr/sqlite_2
Migration to use sqlite3
2023-07-14 15:11:23 -05:00
advplyr c881bcbe59 Update logs for cache purge 2023-07-14 15:04:27 -05:00
advplyr 89aa4a8bdc Update logger to support dev only log, remove old model docs 2023-07-14 14:50:37 -05:00
advplyr c5a4f63670 Update Backup to use key to check for old backups no longer supported 2023-07-14 14:20:35 -05:00
advplyr 1b97582975 Update dbMigration mappings 2023-07-14 14:04:47 -05:00
advplyr 9b7aacf3ea Update dbMigration mappings 2023-07-14 14:04:28 -05:00
WarWar 47b9ee557e stop opf importer from adding duplicate info 2023-07-14 05:15:29 +00:00
advplyr e40e0bfa25 Update:Listening session modal UI 2023-07-13 17:44:20 -05:00
advplyr d56e3a3617 Merge branch 'master' into sqlite_2 2023-07-11 17:07:13 -05:00
advplyr 78fe6d47ba Fix:Library settings context menu actions for mobile view #1886 2023-07-11 17:06:14 -05:00
advplyr 995cf51ae3 Update:Default m4b encoding bitrate to 128k #1892 2023-07-11 16:57:30 -05:00
advplyr d838ff2f2e Merge branch 'master' into sqlite_2 2023-07-10 17:37:47 -05:00
advplyr f2f07ff534 Update:Show num episodes on podcast item page #1891 2023-07-10 17:37:35 -05:00
advplyr 8cff68ca64 Fix purge metadata/items paths 2023-07-10 17:00:31 -05:00
advplyr eb5331d34a Update playlist & collection models to use sort order 2023-07-10 16:07:22 -05:00
advplyr f425185575 Merge branch 'master' into sqlite_2 2023-07-09 15:50:50 -05:00
advplyr 9fc352a5a4 Fix:Download episode from rss feed with very long description #1893 2023-07-09 15:50:40 -05:00
advplyr e85ddc1aa1 Update package.json pkg assets, remove njodb and dependencies 2023-07-09 14:22:30 -05:00
advplyr b9be7510f8 Remove purge-media-progress api route 2023-07-09 14:08:14 -05:00
advplyr f4497acd48 Remove API routes for removing all items and purging media progress 2023-07-09 14:07:30 -05:00
advplyr f73a0cce72 Update Dockerfile for sqlite3, update models for cascade delete, fix backup schedule 2023-07-09 11:39:15 -05:00
advplyr 254ba1f089 Migrate backups manager 2023-07-08 14:40:49 -05:00
advplyr 0a179e4eed Update author and series to include libraryId 2023-07-08 10:07:57 -05:00
advplyr 0ac63b2678 Update Series and Author model to be library specific 2023-07-08 09:57:32 -05:00
advplyr 1d13d0a553 Merge master 2023-07-08 08:25:33 -05:00
advplyr fc6ff016a7 Update:Playback sync request timeout to 9s and show sync alerts after 4 failed syncs #1884 2023-07-08 08:08:14 -05:00
advplyr e378b79fbc Fix:Access series that are in multiple libraries and user does not have access to all #1899, new libraries/series endpoint 2023-07-07 17:59:17 -05:00
advplyr 7e377297d7 Update:Remove toast notifications for marking items as finished #1900 2023-07-07 17:22:38 -05:00
advplyr 00a02921dd Fix:RSS feeds that include an id as a query string #1896 2023-07-06 18:06:26 -05:00
advplyr b5d4c11f6f Fix RSS feeds to use slug instead of id 2023-07-06 17:07:10 -05:00
advplyr a0bc959850 Add feed migration and cleanup 2023-07-05 18:18:37 -05:00
advplyr a4b0f6c202 Merge branch 'master' into sqlite_2 2023-07-04 18:15:52 -05:00
advplyr 65cf928afe Fix:Delete ereader device 2023-07-04 18:15:43 -05:00
advplyr cf7fd315b6 Init sqlite take 2 2023-07-04 18:14:44 -05:00
advplyr d86a3b3dc2 Update:Filter out podcasts from search that dont have an RSS feed url #1514 2023-07-01 09:00:40 -05:00
advplyr e07e2cd359 Update:Select all episodes showing option #1878 & add translations to episodes modal 2023-06-30 17:30:15 -05:00
advplyr 8140d7021a Update:Increase timeout for progress sync to 6s and sync interval to 10s #1884 2023-06-30 16:28:02 -05:00
advplyr bdbc5e3161 Add:Library setting to hide single book series #1433 2023-06-29 17:55:17 -05:00
advplyr bb9013541b Update:Get all users api endpoint to include latest session, display device info on users table #724 2023-06-28 17:57:46 -05:00
advplyr 1668153acd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-06-27 17:13:38 -05:00
advplyr aeba7674f8 Add new api route for downloading backup, remove static metadata route 2023-06-27 16:41:32 -05:00
advplyr 5b0d105e21 Remove deprecated /s/ and /ebook/ api routes 2023-06-27 15:56:33 -05:00
advplyr feb54d0629 Merge pull request #1874 from springsunx/master
update zh-cn.json
2023-06-27 08:53:23 -05:00
SunX 3284fe8f31 update zh-cn.json
update zh-cn.json
2023-06-27 21:23:20 +08:00
advplyr 18cb394884 Update:Remove episodes from newest shelf when finished #1871 2023-06-26 17:32:45 -05:00
advplyr d0bce2949e Add:FFProbe api endpoint 2023-06-25 16:16:11 -05:00
advplyr a0e80772cd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-06-23 17:32:09 -05:00
advplyr e44595521d Update:Cleanup collections edit modal ui for mobile 2023-06-23 17:32:03 -05:00
advplyr fdf647eb32 Update:Cleanup chapters page ui on mobile 2023-06-23 17:28:35 -05:00
advplyr 71369bd2a0 Update:Podcast rss feed fetch timeout to 12s #1856 2023-06-22 17:27:09 -05:00
advplyr 36b1f43f4c Fix:epub ereader on mobile #1854 2023-06-18 14:10:01 -05:00
advplyr a8bc1df3e7 Fix epub ereader theme sticking for other ebook formats 2023-06-18 12:56:32 -05:00
advplyr a96869f547 Add ereader translations 2023-06-16 17:00:40 -05:00
advplyr 77b030199e Fix:Non-admin access to config pages #1848 and dev proxy #1848 2023-06-15 17:41:27 -05:00
advplyr 0e1c6c0ba7 Merge pull request #1849 from Nab0y/master
Update Russian localization
2023-06-15 16:10:24 -05:00
Dmitry Naboychenko c397422d3b Update russian localization 2023-06-15 23:28:14 +03:00
advplyr 15313826bf Add:Epub ereader settings for font scale, line spacing, theme and spread 2023-06-14 17:30:08 -05:00
advplyr c6405b9013 Merge pull request #1838 from daVinci2793/master
Updates to Email settings/manager to include test email
2023-06-12 17:16:18 -05:00
advplyr d748d43efc Fallback to using from address if test address is not set, add reset button when form has changes 2023-06-12 17:12:52 -05:00
daVinci2793 d54edb93d6 Updates to Email settings/manager to include test email 2023-06-12 04:53:51 +00:00
advplyr b8ca6671fc Minor cleanup 2023-06-11 13:22:58 -05:00
advplyr cb7fb646ba Fix:Comic reader next/prev buttons 2023-06-11 11:37:28 -05:00
advplyr aa82c8a253 Version bump 2.2.23 2023-06-10 16:00:48 -05:00
advplyr aae92649b1 Add:Ebook and supplementary ebook library filters 2023-06-10 15:59:44 -05:00
advplyr a9f5c64204 Update:Cleanup UI/UX for filter and sort dropdowns 2023-06-10 15:46:12 -05:00
advplyr 1392baf1eb Fix:Remove experimental features 2023-06-10 15:28:21 -05:00
advplyr 0ec50bb570 Remove experimental features and experimental ereader setting 2023-06-10 14:11:51 -05:00
advplyr b60473d7ae Update:Setting new other ebook files as supplementary #1809 2023-06-10 13:20:38 -05:00
advplyr 014fc45c15 Add:Audiobooks only library settings, supplementary ebooks #1664 2023-06-10 12:46:57 -05:00
advplyr 4b4fb33d8f Fix:Pressing edit on a podcast episode from a playlist #1833 2023-06-09 17:12:38 -05:00
advplyr 35e3458fb4 Add:Download button in comic reader to download current page image #1822 2023-06-07 17:03:23 -05:00
advplyr 8f42153bee Add:Save progress for comics #1829 2023-06-07 16:14:48 -05:00
advplyr 2f04d34bce Fix:Submenu overflowing page #1828 2023-06-07 15:48:23 -05:00
advplyr 09566c02ea Fix:Series page In Progress filter showing completed series #1827 2023-06-07 14:01:03 -05:00
advplyr d714ef37d9 Fix:Using arrow keys when editing podcast description #1826 2023-06-07 11:01:11 -05:00
advplyr fde07d26e5 Update:Prefer epub ebook file when setting ebook #1825, validate ebookLocation 2023-06-06 16:53:11 -05:00
advplyr 9547824aaa Merge pull request #1819 from mayli/usetemp
Fix: useTempFiles=true, upload use tmp instead of ram
2023-06-06 15:41:28 -05:00
advplyr 5a01be1ee3 Add tempFileDir for uploads 2023-06-06 15:40:52 -05:00
advplyr 5dc4606657 Add:Support for CAF audio files 2023-06-05 16:23:40 -05:00
Coda 2fd3238576 Fix: useTempFiles=true, upload use tmp instead of ram 2023-06-04 15:56:41 -07:00
advplyr c1bcfe8304 Merge pull request #1816 from mayli/utf8-filename
Fix: decode filename as utf8 on upload
2023-06-04 08:39:38 -05:00
Coda a3642b204d Fix: decode filename as utf8 on upload 2023-06-03 21:44:13 -07:00
advplyr 8243da69f6 Merge pull request #1814 from depwl9992/patch-1
Update readme.md - Expand required Apache modules for reverse proxy and detail how to handle acme challenges
2023-06-03 17:30:16 -05:00
Daniel Powell 6d5987b2e0 Update readme.md
After setting up the reverse proxy for Apache per the current instructions, I received the error: "AH01144: No protocol handler was valid for the URL / (scheme 'http')". Installing proxy_http and proxy_balancer modules in addition to those listed fixed this issue.

a2enmod command does not include the redundant '_module' suffix.

Let's Encrypt was not able to validate certificates either automatically or manually as ABS does not respond to ACME challenges directly. Editing the virtual environment configuration to ignore proxy requests to .well-known and serving that directly from Apache allowed LE to validate the challenge instead.
2023-06-03 11:46:42 -06:00
advplyr a2fdc3e876 Update:Increase max height of libraries dropdown 2023-06-01 17:09:04 -05:00
advplyr f92b66a469 Merge pull request #1810 from glacasa/master
Update french translations
2023-06-01 16:29:48 -05:00
Guillaume Lacasa c3d256c42b Update french translations 2023-06-01 09:38:00 +02:00
advplyr fdc792cb82 Version bump v2.2.22 2023-05-30 16:59:04 -05:00
advplyr a16fb31e6e Update:Library filter max height #1673 2023-05-30 16:55:52 -05:00
advplyr 4d8a1b5b6d Add:Ebook library filter, and update e-book to ebook 2023-05-30 16:37:24 -05:00
advplyr c382f07b05 Fix:Close player resetting progress #1807 2023-05-30 16:08:30 -05:00
advplyr 9f6a7d065c Merge pull request #1808 from Lionfox2/patch-1
Update de.json
2023-05-30 04:14:33 -05:00
Lionfox2 11aa75ecbe Update de.json
I corrected a spelling mistake (starseite --> startseite)
2023-05-30 00:56:27 +02:00
advplyr 05ce9c6eda Add:Email smtp config & send ebooks to devices #1474 2023-05-29 17:38:38 -05:00
advplyr 15aaf2863c Add:OPML Export #1260 2023-05-28 15:10:34 -05:00
advplyr 019063e6f4 Update:New API routes for library files and downloads 2023-05-28 12:34:22 -05:00
advplyr ea79948122 Fix:Podcast episode downloads where RSS feed uses the same title #1802 2023-05-28 11:24:51 -05:00
advplyr 7a0f27e3cc Fix:Epub3 background color #1804 2023-05-28 10:55:37 -05:00
advplyr 4f75a89633 Update:New EBook API endpoint 2023-05-28 10:47:28 -05:00
advplyr b3f19ef628 Fix:Static file route check authorization 2023-05-28 09:34:03 -05:00
advplyr f16e312319 Fix:Series api check user has access to library 2023-05-28 08:51:34 -05:00
advplyr 056da0ef70 Fix:Static ebook route 2023-05-28 08:39:41 -05:00
advplyr ca5f781531 Version bump v2.2.21 2023-05-27 17:54:20 -05:00
advplyr 53c96b2540 Update:Handle multiple sessions open, sync when paused, show alert of multiple sessions open when both are playing #1660 2023-05-27 17:21:43 -05:00
advplyr 9712bdf5f0 Update:Check if directory already exists before upload #1497 2023-05-27 16:00:34 -05:00
advplyr 0678c26627 Fix:Catch exception when podcast episode download request fails #1759 2023-05-27 15:24:08 -05:00
advplyr b52e240025 Add:Batch re-scan #1754 2023-05-27 14:51:03 -05:00
advplyr 2fa73f7a8d Update:Audio player visible when ereader is open #1800 and adding zoom to PDF reader 2023-05-27 11:58:01 -05:00
advplyr 2cc23b6d6b Update:Auto update home page shelves when new episode is added #716 2023-05-27 09:13:44 -05:00
advplyr 9a617226b3 Update:Continue Reading shelf Remove from Continue Reading menu item 2023-05-27 09:00:54 -05:00
advplyr fbfc015d92 Fix:Hide Chapters tab for ebook only items #1795 2023-05-27 08:31:47 -05:00
advplyr 3e4c94e2b4 Update:Continue Reading and Read Again home page shelves for ebook only items #1782 2023-05-27 08:20:09 -05:00
advplyr 1da471e136 Fix:Embed metadata tool embed ASIN, SERIES and SERIESPART #1794 2023-05-26 17:57:56 -05:00
advplyr 4dba95c000 Update:Show series on collection items #1449 2023-05-24 17:37:51 -05:00
advplyr 36477a832c Add:Saving progress for PDF ebooks #1791 2023-05-24 16:41:16 -05:00
advplyr b4aa8f0c9a Merge pull request #1786 from 92Kev/master
Update Dutch translation
2023-05-23 17:27:15 -05:00
advplyr 6a974d5ef0 Update:Epub TOC shows subitems #1787 2023-05-23 15:38:49 -05:00
92Kev 304eda9f8c 'Audio tracks' to 'Audiotracks'. 2023-05-21 18:30:55 +02:00
92Kev 581f2e3d15 Fixes some errors and changes some translations to more intuitive terms. 2023-05-21 18:16:00 +02:00
advplyr be2d317325 Fix initialize currentTime and ebookProgress for MediaProgress 2023-05-20 15:37:44 -05:00
advplyr 9f6bfeb839 Fix:Removing media progress that was started local 2023-05-20 15:19:09 -05:00
advplyr f4f5f79af7 Update:Add chapters to playback session so they can be used for podcast episodes in mobile apps 2023-05-20 13:30:07 -05:00
advplyr 92bb2fb23d Fix:Levenshtein distance crash when passed non-strings 2023-05-18 17:07:58 -05:00
advplyr 3c406c12b4 Updates to metadata file format changing, use chapters from metadata file 2023-05-16 18:58:01 -05:00
advplyr 81d4ac3ed2 Add:metadata.json format #1775 #916 2023-05-15 18:23:31 -05:00
advplyr 32bdae31a8 Add:All providers option for searching covers #1774 2023-05-14 13:43:20 -05:00
advplyr 84c16c4a39 Fix:Audible india provider 2023-05-14 13:42:29 -05:00
advplyr b8b3d05f5e Fix:Authors page includes library id in url #1767 2023-05-13 16:47:38 -05:00
advplyr bac09de23d Fix:getNarrators API endpoint check narrators are strings #1770 2023-05-12 18:22:09 -05:00
advplyr b0bf9604bb Update:First sync after 20 seconds listening #1546 2023-05-11 15:31:47 -04:00
advplyr 688531f0a7 Update:Podcast episodes fallback to description when subtitle is null #1752 2023-05-10 20:18:29 -04:00
advplyr dfc7877f69 Fix:Persist book cover provider separate from match provider #1764 2023-05-09 19:26:37 -04:00
advplyr e00116a0e3 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-05-08 16:21:02 -04:00
advplyr 2ab287e2a9 Fix:Podcast cron filter out failed library item 2023-05-08 16:20:09 -04:00
advplyr 1e0da09b2f Merge pull request #1760 from Alistair1231/seek-instead-of-skip
seek instead of skip with action handlers
2023-05-07 15:16:04 -05:00
Alistair1231 0e7a5649cc seek instead of skip with action handlers 2023-05-07 19:23:07 +00:00
advplyr 30009e45da Update:Book subtitle searchable #1755 2023-05-06 09:43:13 -05:00
advplyr f9a668cb41 Version bump 2.2.20 2023-05-05 17:03:42 -05:00
advplyr c848f366de Update:Audio file disc meta tag support for TPA #1749 2023-05-03 17:33:01 -05:00
advplyr 25daab2f34 Update:Show publisher on book page #1751 2023-05-03 17:29:54 -05:00
advplyr 7170ab7239 Add:Dutch language option 2023-05-03 07:52:32 -05:00
advplyr 063b3bb8db Merge pull request #1747 from 92Kev/Add-Dutch
Add Dutch translation
2023-05-03 07:43:55 -05:00
advplyr 6eb6a7b115 Merge pull request #1750 from pilabor/master
Added `part` to supported tags for `file_tag_seriespart`
2023-05-03 07:43:16 -05:00
Andreas d0972348b9 Added part to supported tags for file_tag_seriespart
Since `part` is a supported tag for `m4b` files, it's now added as another fallback option.
2023-05-03 10:40:11 +02:00
92Kev 0e70af77c6 Update nl.json
Translated some remaining terms, changed others to more appropriate Dutch translations
2023-05-03 09:40:33 +02:00
92Kev 4efca78602 Merge branch 'advplyr:master' into Add-Dutch 2023-05-03 07:52:51 +02:00
advplyr 87d10bd6f5 Merge pull request #1748 from jkuehnemundt/de-lang
Update de lang file
2023-05-02 15:45:32 -05:00
Jannik Kühnemundt 0f82aed4ce Update de.json 2023-05-02 21:49:03 +02:00
Jannik Kühnemundt 58f10ad7af Update de.json 2023-05-02 21:45:13 +02:00
92Kev 68dcf87aea Update nl.json
Add Dutch translations
2023-05-02 08:55:14 +02:00
92Kev c2f85deb11 Added Dutch language string file
Initial translation to Dutch from English string file
2023-05-02 08:51:57 +02:00
advplyr 0dd3a52cc8 Add narrator confirm translations 2023-05-01 16:25:01 -05:00
advplyr c07c73c649 Remove sortablejs 2023-05-01 16:24:31 -05:00
advplyr dbde5f773c Merge pull request #1745 from springsunx/patch-1
update zh-cn translation
2023-05-01 14:45:08 -05:00
SunX 68bf038205 update zh-cn translation 2023-05-01 11:29:33 +08:00
advplyr eb7f66c89e Add:Narrators page #860 #1139 2023-04-30 14:11:54 -05:00
advplyr 58ebde2982 Update:Podcast episode audio files More Info option 2023-04-30 09:45:28 -05:00
advplyr 604a671549 Update:Show tags and podcast type on library item page 2023-04-30 09:41:49 -05:00
advplyr 5286b53334 Add:Progress bar on series covers #1734 2023-04-29 16:26:56 -05:00
advplyr 4db26f9f79 Add:Log user and ip on successful login #1740 2023-04-28 16:16:47 -05:00
advplyr ff8a58c7bc Remove log about not modifying permissions 2023-04-28 16:08:57 -05:00
advplyr 6f67c7bfa2 Merge pull request #1686 from divyangbw/feat-user-access-by-tag-enhancement
Invert Tag Selection
2023-04-27 17:20:16 -05:00
advplyr e9f5bd9bfe Merge branch 'master' into feat-user-access-by-tag-enhancement 2023-04-27 17:18:56 -05:00
advplyr 56e213d654 Update itemTagsSelected migration 2023-04-27 17:18:54 -05:00
advplyr 98323de64c Merge pull request #1736 from divyangbw/fix-fr-json-corrupted
Remove what seems to be a paste error in the french translations
2023-04-27 16:44:13 -05:00
Divyang Joshi 4a13712b1c fix: Remove what seems to be a paste error in the french translations 2023-04-27 17:07:09 -04:00
Divyang Joshi 0387436111 feat: add support for inverting the selection on libraries and tags 2023-04-27 17:02:15 -04:00
advplyr 7685ead000 Merge pull request #1729 from apineiro97/master
update es translation
2023-04-27 04:46:39 -05:00
advplyr 8665d66923 Merge pull request #1730 from Hallo951/master
Update german strings
2023-04-27 04:46:15 -05:00
Hallo951 9a808602c4 Update german strings 2023-04-27 10:41:06 +02:00
Arturo Pineiro 813e553dbb update es translation 2023-04-26 18:20:46 -07:00
advplyr be050a7d57 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-26 18:15:55 -05:00
advplyr 065675697d Fix:Catch exception when failing to download podcast episodes 2023-04-26 18:15:50 -05:00
advplyr 8f6832fc2e Merge pull request #1727 from tomazed/translation-fr
update on pending translation
2023-04-26 10:50:54 -05:00
Tomazed bdb154a6e5 update on pending translation 2023-04-26 17:39:15 +02:00
advplyr f557289274 Update:Set lang in HTML tag #1399 2023-04-25 19:00:57 -05:00
advplyr a5627a1b52 Add:Search for narrators #1495 2023-04-24 18:25:30 -05:00
advplyr 33f20d54cc Updated docker template xml 2023-04-23 17:08:36 -05:00
advplyr dadd41cb5c Fix:Podcast episode quick match crash #1711 2023-04-21 17:49:25 -05:00
advplyr 35e27e4f61 Merge pull request #1710 from Weldawadyathink/audiobook-covers-2
Add AudiobookCovers.com metadata provider
2023-04-21 16:17:48 -05:00
advplyr 84839bea44 Cleanup audiobookcovers.com addition 2023-04-21 16:17:52 -05:00
Spenser Bushey 1342897858 Removed useless comments 2023-04-20 16:39:04 -07:00
advplyr c32efb8db8 Fix:Podcast episode search modal search filter #1699 2023-04-20 17:51:06 -05:00
Spenser Bushey f9ed412e4e Add AudiobookCovers.com metadata provider
AudiobookCovers.com acts as a cover-only metadata provider, therefore will only show up in the covers selector.
2023-04-19 22:13:52 -07:00
advplyr 6ae3ad508e Readme update 2023-04-19 18:03:43 -05:00
advplyr 24af702b41 Merge pull request #1707 from coldshouldermedia/master
Updated SWAG Reverse Proxy guide
2023-04-19 17:59:59 -05:00
advplyr a57ff20f35 Merge pull request #1692 from divyangbw/fix-show-all-genres-on-match-tab
fix: Make sure all existing genres also show up on the match tab
2023-04-19 17:39:04 -05:00
advplyr 39e710deb1 Merge pull request #1706 from ajaxbits/master
Update:Fix filename issue in tone, bump to v0.1.5
2023-04-19 17:22:24 -05:00
advplyr 3b6fa73ac0 Update tone in debian package to v0.1.5 2023-04-19 17:22:25 -05:00
coldshouldermedia e2dd66d450 Updated SWAG Reverse Proxy guide 2023-04-19 16:19:29 -06:00
Alex Jackson b1b53a1eae Update:Bump tone version in devcontainer to v0.1.5 2023-04-19 16:40:46 -05:00
Alex Jackson 6f73345f39 Merge branch 'advplyr:master' into master 2023-04-19 16:24:21 -05:00
Alex Jackson c7b4b3bd3e Update:Bump tone version
Addresses #1703. Paths with quotations were not handled by tone<v0.1.3.
2023-04-19 16:22:15 -05:00
advplyr 98d543e3e5 Merge pull request #1695 from jkuehnemundt/de-lang-fix
Improve German (de.json) translation
2023-04-18 18:07:43 -05:00
advplyr 4de4e958a0 Merge pull request #1684 from ThinkSalat/bugfix/notification-url-blur
blur the url input when clicking submit to add info currently in input
2023-04-18 18:06:28 -05:00
advplyr cc5e92ec8e Update client/components/modals/notification/NotificationEditModal.vue 2023-04-18 18:06:22 -05:00
Jannik Kühnemundt 6cb9dfaa85 Update de.json 2023-04-18 17:55:59 +02:00
Jannik Kühnemundt 8790166ac1 Fix further translation 2023-04-18 16:37:34 +02:00
Divyang Joshi 3b97e2146d fix: Make sure all existing genres also show up on the match tab 2023-04-17 20:09:59 -05:00
advplyr 0bb1cf002d Fix:Crash when podcasts put empty spaces with episode file path in RSS feed #1650 2023-04-17 17:03:58 -05:00
advplyr 307c7ebc9d Merge pull request #1688 from Machou/patch-1
Update fr.json
2023-04-17 16:35:31 -05:00
Jannik Kühnemundt cc1b41995d Fixed german translation 2023-04-17 23:17:40 +02:00
Machou 730d60575e Update fr.json 2023-04-17 22:42:48 +02:00
Shawn Salat 1b96297cc7 blur the url input when clicking submit to add info currently in input 2023-04-17 08:25:20 -06:00
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
advplyr 6e8547f0c8 Version bump 2.2.15 2023-02-11 16:30:06 -06:00
advplyr 96930d7ecc Fix:Scrollable config side nav and mobile ui 2023-02-11 16:19:04 -06:00
advplyr 23f2c8a251 Fix:Replacing item cover remove old covers case insensitive #1391 2023-02-11 15:56:18 -06:00
advplyr c5372d1405 Fix:Series,Collection,Playlist title scaling #1440 2023-02-11 15:51:23 -06:00
advplyr dcfbed5f30 Update:Add inode value to log #1447 2023-02-11 15:39:34 -06:00
advplyr 895ab8d18a Fix:Audio track hover timestamp bubble z-index 2023-02-11 15:29:39 -06:00
advplyr 8b5d05739f Fix:Adding new podcast when folder already exists #1462 2023-02-11 15:25:54 -06:00
advplyr a8f6202302 Remove Gentium Book font, reduce appbar icon and title font size 2023-02-11 15:02:56 -06:00
advplyr 5d40fdf277 Merge pull request #1487 from Nab0y/master
FantLab.ru BookFinder
2023-02-11 14:29:38 -06:00
advplyr f35c96e118 FantLab minor refactor 2023-02-11 14:25:25 -06:00
advplyr 8f8d6f81ab Fix:Upload API endpoint crashing on invalid request with no files #1473 2023-02-10 17:25:19 -06:00
advplyr e195eec1c5 Fix:OPF parser supporting attributes on tags #1478 2023-02-10 17:22:23 -06:00
advplyr 33846e46fa Fix:Handle podcast RSS feeds with iso-8859-1 encoding #1489 2023-02-10 17:07:25 -06:00
advplyr 2ad03bcb9a Fix:Bad backup files and backing up playlists, feeds #1485 2023-02-10 15:33:42 -06:00
Dmitry 371cd3b2e5 Update server/providers/FantLab.js
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2023-02-09 23:09:44 +03:00
Dmitry Naboychenko b9307143bd FantLab match provider fixes after code review 2023-02-08 22:32:27 +03:00
Dmitry 36e44e902a Merge branch 'advplyr:master' into master 2023-02-08 17:31:19 +03:00
advplyr 4529fc0124 Merge pull request #1484 from magnww/fix_oom_crash
Reduce memory usage when scanning large folders
2023-02-07 16:59:33 -06:00
gefan ba07761de3 Revert "kill zombie processes to reduce memory usage"
This reverts commit 19e39f6321.
2023-02-07 12:33:33 +08:00
Dmitry 3b7ce69327 Merge branch 'advplyr:master' into master 2023-02-07 00:25:45 +03:00
Dmitry Naboychenko cf927f61a0 Add FantLab.ru Book Finder 2023-02-07 00:25:18 +03:00
gefan 61c32d99e7 scan media files in batches 2023-02-07 00:18:57 +08:00
gefan 19e39f6321 kill zombie processes to reduce memory usage 2023-02-07 00:18:48 +08:00
advplyr f9e6655359 Update:API endpoint for syncing multiple local sessions. New API endpoint to get current user. Deprecate /me/sync-local-progress endpoint 2023-02-05 16:52:17 -06:00
advplyr debf0f495d Fix:OPML upload path separator #1476 2023-02-04 13:34:50 -06:00
advplyr 3383ec2046 Add logs to playback session manager 2023-02-04 13:23:13 -06:00
advplyr b957e1a36b Update:API endpoints for library and library item scan updated to POST requests 2023-02-03 17:50:42 -06:00
advplyr c93f17051a Fix:Event sent when changing languages to rehydrate page 2023-02-03 14:50:48 -06:00
advplyr 5983f0262f Merge pull request #1472 from Nab0y/master
Russian localization
2023-02-03 14:49:14 -06:00
Dmitry 96a8e74d38 Merge branch 'advplyr:master' into master 2023-02-03 23:09:09 +03:00
Dmitry Naboychenko 6f3d488c3d Add russian localization 2023-02-03 23:08:38 +03:00
advplyr 74dcc4f9e4 Merge pull request #1470 from tomazed/patch-localization
Patch localization Item Metadata Utils Header
2023-02-03 04:27:41 -06:00
Tomazed 8c4d3b93c8 revert formatting 2023-02-03 11:25:02 +01:00
Tomazed c411cf04cc ItemMetaDataUtils Header localized 2023-02-03 11:16:45 +01:00
advplyr d1b25da408 Merge pull request #1469 from yuuzhan/adding-tags-to-metadata.abs
Adding tags to metadata.abs
2023-02-02 17:19:30 -06:00
advplyr 08f765fa51 Update parsing and using tags from abmetadata file 2023-02-02 17:13:22 -06:00
advplyr 337cf90c4b Add debug logs to playback sessions 2023-02-02 16:24:34 -06:00
advplyr 17b930e13d Merge pull request #1463 from Machou/patch-1
Update fr.json
2023-02-02 16:17:23 -06:00
yuuzhan 639b600570 Updated parseAndCheckForUpdates to pass in LibraryItem instead of Metadata Object 2023-02-02 12:47:12 -05:00
yuuzhan 7a751b8f91 Updated function parseAndCheckForUpdates to pass Library Item rather then just the metadata object 2023-02-02 12:46:22 -05:00
yuuzhan 68621e0c07 Update abmetadataGenerator.js 2023-02-02 12:43:48 -05: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
advplyr 5abb02e93a Version bump 2.2.14 2023-02-01 16:17:36 -06:00
advplyr 573079c5a1 Fix:Downgrade to axios 0.27.2 for pkg #1466 2023-02-01 15:58:58 -06:00
advplyr 5bde320ac7 Update:Remove X-Powered-By express response headers 2023-02-01 14:34:01 -06:00
Machou 5f63d97e59 Update fr.json 2023-02-01 03:40:41 +01:00
Machou bf2fe3faea Update fr.json
fr_FR update
fr_FR reworked
fr_FR cleaned
2023-02-01 03:37:53 +01:00
advplyr ea60f19e7a Version bump 2.2.13 2023-01-31 16:35:49 -06:00
advplyr cefc75a4ed Fix:Edit library modal blur inputs on submit #1427 2023-01-31 16:04:30 -06:00
advplyr d8753aafb9 Fix:Series collapse series filter out empty sequences #1450 2023-01-31 15:53:04 -06:00
advplyr ba5ad228cc Merge pull request #1456 from jramer/master
Fixes m4b chapters only taken from first file.
2023-01-30 17:53:48 -06:00
advplyr 0203f9cc1b Update server/objects/mediaTypes/Book.js 2023-01-30 17:50:50 -06:00
advplyr 4770be5a39 Update server/objects/mediaTypes/Book.js 2023-01-30 17:50:45 -06:00
advplyr 1bac395bed Merge pull request #1453 from lkiesow/bookshelf-view
One Default Bookshelf View
2023-01-30 17:29:44 -06:00
advplyr e818f270cd Merge pull request #1457 from tomazed/LocalizedDateFns
Localized date fns
2023-01-30 08:44:06 -06:00
advplyr c4e2726622 Update client/plugins/i18n.js 2023-01-30 08:42:56 -06:00
Tomazed 74d8a09f31 Merge branch 'master' into LocalizedDateFns 2023-01-30 15:22:59 +01:00
Tomazed 618338165e Removed unused strings from translations 2023-01-30 15:17:42 +01:00
Tomazed db494001a2 Import all locales from date-fns/locale 2023-01-30 15:17:04 +01:00
Tomazed a67213fb7b Simplified languageCodeMap maintainability 2023-01-30 15:09:11 +01:00
Joakim Ramer 5d96b2cc6e Logs correctly and simplifies for single audio file. 2023-01-30 12:56:22 +01:00
Joakim Ramer 72d0b097ab Reverts change of file ending 2023-01-30 12:48:50 +01:00
Joakim Ramer 36d2957fb4 Adds check for duplicated chapter data 2023-01-30 12:46:41 +01:00
Tomazed b5de517aad Generate days from date-fns locale 2023-01-30 01:49:30 +01:00
Tomazed 41db0e3bb1 Use Date-Fns locale to generate month and day labels 2023-01-30 01:25:35 +01:00
Tomazed e8d582269b localized date-fns library 2023-01-30 00:52:28 +01:00
Joakim Ramer 80ef8ee890 Fixes m4b chapters only taken from first file. 2023-01-30 00:42:02 +01:00
Lars Kiesow a65859f575 One Default Bookshelf View
This patch updates the default value of the home page bookshelf view so
that it is identical to the default library view. Having different
styles by default seems odd. I picked the non-wooden view as default
based on the recent discussion on Matrx/Discord that that one looks more
modern.
2023-01-28 23:17:31 +01:00
advplyr 5724887785 Merge pull request #1451 from springsunx/patch-1
Update zh-cn.json
2023-01-28 15:04:21 -06:00
advplyr 8908aa7a82 Fix:Podcast RSS feeds update on new/updated episodes #1435 2023-01-28 14:58:10 -06:00
advplyr f83dd29213 Update:syncLocalMediaProgress API response payload 2023-01-28 14:46:01 -06:00
SunX 99d90778f4 Update zh-cn.json 2023-01-28 20:36:16 +08:00
advplyr 49279430fc Add:Recommended book home page shelf 2023-01-27 17:59:06 -06:00
advplyr 030c20b12e Merge pull request #1438 from tomazed/LocalizedStatsPage
Localized stats page
2023-01-24 17:30:32 -06:00
Tomazed 5e943ef152 Fix Localized Days in Your Stats page 2023-01-24 19:19:42 +01:00
Tomazed 4ae057f626 Fix Localized Months displaying in heatmap 2023-01-24 18:03:32 +01:00
advplyr 9ebe4b55dd Merge pull request #1431 from lkiesow/x-accel
Implement X-Accel Redirect
2023-01-23 17:27:23 -06:00
advplyr 2f7403adec Merge pull request #1432 from jmt-gh/issue_1410
Align "Series Name" and "Sequence" inputs on Series Editor modal
2023-01-23 17:19:50 -06:00
jmt-gh 2777b496ad change the label to be a label instead of a p 2023-01-22 20:44:39 -08:00
advplyr f7a3dbf209 Fix:Embed metadata tool embeds cover image 2023-01-22 18:02:57 -06:00
advplyr d900093976 Fix:Make m4b embed cover image 2023-01-22 18:00:35 -06:00
Lars Kiesow 08250e266e Implement X-Accel Redirect
This patch implements [X-Accel](https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/)
redirect headers as an optional way for offloading static file delivery
from Express to Nginx, which is far better optimized for static file
delivery.

This provides a really easy to configure way for getting a huge
performance boost over delivering all files through Audiobookshelf.

How it works
------------

The way this works is basically that Audiobookshelf gets an HTTP request
for delivering a static file (let's say an audiobook). It will first
check the user is authorized and then convert the API path to a local
file path.

Now, instead of reading and delivering the file, Audiobookshelf will
return just the HTTP header with an additional `X-Accel-Redirect`
pointing to the file location on the file syste.

This header is picked up by Nginx which will then deliver the file.

Configuration
-------------

The configuration for this is very simple. You need to run Nginx as
reverse proxy and it must have access to your Audiobookshelf data
folder.

You then configure Audiobookshelf to use X-Accel by setting
`USE_X_ACCEL=/protected`. The path is the internal redirect path used by
Nginx.

In the Nginx configuration you then configure this location and map it
to the storage area to serve like this:

```
location /protected/ {
  internal;
  alias /;
}
```

That's all.

Impact
------

I just did a very simple performance test, downloading a 1170620819
bytes large audiobook file from another machine on the same network
like this, using `time -p` to measure how log the process took:

```sh
URL='https://url to audiobook…'

for i in `seq 1 50`
do
  echo "$i"
  curl -s -o /dev/null "${URL}"
done
```

This sequential test with 50 iterations and without x-accel resulted in:

```
real 413.42
user 197.11
sys 82.04
```

That is an average download speed of about 1080 MBit/s.

With X-Accel enabled, serving the files through Nginx, the same test
yielded the following results:

```
real 200.37
user 86.95
sys 29.79
```

That is an average download speed of about 2229 MBit/s, more than
doubling the previous speed.

I have also run the same test with 4 parallel processes and 25 downloads
each. Without x-accel, that test resulted in:

```
real 364.89
user 273.09
sys 112.75
```

That is an average speed of about 2448 MBit/s.

With X-Accel enabled, the parallel test also shows a significant
speedup:

```
real 167.19
user 195.62
sys 78.61
```

That is an average speed of about 5342 MBit/s.

While doing that, I also peaked at the system load which was a bit lower
when using X-Accel. Even though the system was delivering far more data.
But I just looked at the `load1` values and did not build a proper test
for that. That means, I cant provide any definitive data.

Supported Media
---------------

The current implementation works for audio files and book covers. There
are other media files which would benefit from this mechanism like feed
covers or author pictures.

But that's something for a future developer ;-)
2023-01-23 00:02:27 +01:00
advplyr da2d1455d7 Merge pull request #1420 from Bostrolicious/master
Fix HTTP links not working in podcast show notes.
2023-01-22 08:07:55 -06:00
advplyr b6c6c4c939 Merge pull request #1423 from lkiesow/prod-opts
Ensure default are similar in different environments
2023-01-22 08:05:30 -06:00
advplyr 22179d82b8 Merge branch 'master' into prod-opts 2023-01-22 08:05:25 -06:00
advplyr 343ce312f1 Merge pull request #1422 from tomazed/translation-fr
Update fr.json Months and Days
2023-01-22 08:03:44 -06:00
advplyr 10677d6fb0 Merge pull request #1421 from lkiesow/fingerprinting
Reduce Fingerprinting
2023-01-22 08:03:17 -06:00
advplyr 49a8aead9b Merge pull request #1425 from lkiesow/undefined-uid-gid
Skip AUDIOBOOKSHELF_UID/GID if undefined
2023-01-22 08:02:53 -06:00
Lars Kiesow 274b0e48be Skip AUDIOBOOKSHELF_UID/GID if undefined
This patch slightly changes the behavior of the `AUDIOBOOKSHELF_UID` and
`AUDIOBOOKSHELF_GID` options. Instead of defining a default user and
group, trying to modify files and silently failing if the filesystem
mode cannot be changed, this patch will just skip the entire process in
the first place.

If these options are defined, Audiobookshelf should behave exactly as
before. If they are not defined, Audiobookshelf will now cause fewer
file modifications (or less failures when trying to modify files).

If this patch gets applied, it should probably be highlighted in the
release notes. This usually shouldn't cause problems for migrations
since the Docker guides explicitly configure the options and the
package installations do not seem to use this at all, but there is still
a change that it will and users should be aware of that.

If a problem arises, users can easily fix the problem by either setting
the permissions once manually to the audiobookshelf user or by simply
defining the `AUDIOBOOKSHELF_UID/GID` options.
2023-01-22 12:30:36 +01:00
Lars Kiesow 4d8ffc5d99 Ensure default are similar in different environments
This patch tries to make sure that defaults and options work in the same
way regardless of users using Docker or a package deployment.

- Commit 10295b0 updated `Host` to be empty by default to better support
  IPv6. This fixes a missed occurrence of the old default.
- This makes sure the `SOURCE` environment variable is honored when
  installing the packages. The `--source` option will still take
  precedence though.
2023-01-22 00:53:10 +01:00
Tomazed 4f3029e5b2 Update fr.json Months and Days 2023-01-22 00:39:23 +01:00
Lars Kiesow a1b49f5fcf Reduce Fingerprinting
As DieselTech#6997 pointed out in Matrix, it is a good idea to reduce
fingerprinting by removing the `X-Powered-By` response header as pointed
out by the Express security best practices:

See http://expressjs.com/en/advanced/best-practice-security.html#reduce-fingerprinting
2023-01-21 23:18:06 +01:00
Martin Boström 89d497a305 Fix mailto links not working in podcast show notes. 2023-01-21 22:46:38 +01:00
Martin Boström 9e095a4bc1 Fix HTTP links not working in podcast show notes. 2023-01-21 21:51:05 +01:00
advplyr 024d052a7b Update:Show total duration of episodes on podcast page 2023-01-19 17:55:40 -06:00
advplyr c312979aec Update:Heatmap day of week translations 2023-01-18 18:04:03 -06:00
advplyr 773e621944 Merge pull request #1404 from burghy86/patch-8
update it
2023-01-18 17:50:42 -06:00
advplyr ed4f33b565 Merge pull request #1364 from lkiesow/update-server-libs
Update Server Libraries
2023-01-18 17:36:20 -06:00
advplyr f8a0852dfc Merge pull request #1403 from k9withabone/fix-banner
Fix banner view box
2023-01-18 17:35:28 -06:00
advplyr 6dec750d3e Fix:Close open playback session on server when local playback session syncing from mobile 2023-01-15 15:00:18 -06:00
burghy86 3c98a5fb24 update
and add a month and day a abbreviated for annual table
2023-01-14 23:37:07 +01:00
advplyr 702ee3d350 Merge pull request #1398 from lkiesow/dont-list-book-twice
Don't list book twice in continue series
2023-01-13 14:02:53 -06:00
advplyr fcc2f3650b Merge pull request #1400 from springsunx/master
Update zh-cn.json
2023-01-13 13:54:46 -06:00
Paul Nettleton e4ad622c01 Fix banner view box 2023-01-13 02:51:20 -06:00
SunX 458403eec9 Update zh-cn.json 2023-01-13 10:53:05 +08:00
Lars Kiesow aaede2752c Don't list book twice in continue series
Sometimes, a book belongs to more than one series. If you listen to and
finish such a book, Audiobookshelf will list the next book in “Continue
Series” twice, right next to each other. That is not helpful.

This patch fixes the problem by not adding books to the list if they are
already in the list.
2023-01-13 00:50:04 +01:00
advplyr 39d8c2cf04 Merge pull request #1385 from Hallo951/master
Update de.json
2023-01-11 04:51:52 -06:00
advplyr dd5c940d36 Merge pull request #1388 from lkiesow/continue-listening
Show next book only if previous book is finished
2023-01-10 16:39:59 -06:00
advplyr 277f024bbc Merge pull request #1390 from lkiesow/button-no-submit
Toggle switch shouldn't submit form
2023-01-10 16:32:14 -06:00
Lars Kiesow 59ad1e5e36 Toggle switch shouldn't submit form
This patch fixes the problem that toggling one of the options in the
user account dialog will automatically submit the form.

The problem got introduced as a combination of the recent accessibility
fixes where some elements got turned into HTML button elements to make
them keyboard accessible. Doing that, I did not realize that the default
type of a button is `submit` [1]. This causes no problems at most places,
but will cause problem within a form (e.g. the user account settings)
where toggling an option is now identical to clicking submit.

This patch fixes the issue by setting the `type` attribute to `button`.
Not only for the toggle switch, but also for a few other elements which
have been recently converted to buttons.

[1] https://www.w3.org/TR/2011/WD-html5-20110525/the-button-element.html#attr-button-type
2023-01-10 22:58:20 +01:00
Lars Kiesow 02c4b21d3f Show next book only if previous book is finished
This patch changes the books displayed in “Continue Series”, avoiding
books if another book from the series is played back right now. This
prevents Audiobookshelf suggesting books to which users will not listen
to because they are still listening to the last one.

Once a book is finished, the next book in the series will pop still be
suggested to the user.

This fixes  #1382
2023-01-10 21:50:33 +01:00
Hallo951 33ae5445be Update 2023-01-10 10:06:57 +01:00
advplyr 5ed06871b6 Merge pull request #1381 from tomazed/translation-fr
Update fr.json => ButtonEdit
2023-01-09 18:18:41 -06:00
Tomazed e98eb8f1eb Update fr.json => ButtonEdit 2023-01-09 22:31:24 +01:00
Lars Kiesow f095d89980 Update Server Libraries
This patch updates the server libraries to their latest state.
2023-01-05 00:46:23 +01:00
391 changed files with 35621 additions and 12620 deletions
+14 -3
View File
@@ -1,4 +1,15 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get install ffmpeg gnupg2 -y
# [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
ARG VARIANT=16
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
# Setup the node environment
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.5 /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" },
"mounts": [
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
],
"features": {
"fish": "latest"
"name": "Audiobookshelf",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "16"
}
},
"extensions": [
"eamodio.gitlens"
]
"mounts": [
"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
+3 -2
View File
@@ -1,6 +1,6 @@
.env
dev.js
node_modules/
/dev.js
**/node_modules/
/config/
/audiobooks/
/audiobooks2/
@@ -15,3 +15,4 @@ test/
sw.*
.DS_STORE
.idea/*
+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
}
}
]
}
+10 -8
View File
@@ -6,15 +6,19 @@ RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###
FROM sandreas/tone:v0.1.2 AS tone
FROM sandreas/tone:v0.1.5 AS tone
FROM node:16-alpine
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
curl \
tzdata \
ffmpeg
ffmpeg \
make \
python3 \
g++
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist
@@ -23,10 +27,8 @@ COPY server server
RUN npm ci --only=production
RUN apk del make python3 g++
EXPOSE 80
HEALTHCHECK \
--interval=30s \
--timeout=3s \
--start-period=10s \
CMD curl -f http://127.0.0.1/healthcheck || exit 1
CMD ["npm", "start"]
CMD ["node", "index.js"]
+4 -4
View File
@@ -50,7 +50,7 @@ install_ffmpeg() {
echo "Starting FFMPEG Install"
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.2/tone-0.1.2-linux-x64.tar.gz --output-document=tone-0.1.2-linux-x64.tar.gz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
if ! cd "$FFMPEG_INSTALL_DIR"; then
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
@@ -60,14 +60,14 @@ install_ffmpeg() {
fi
$WGET
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
rm ffmpeg-git-amd64-static.tar.xz
# Temp downloading tone library to the ffmpeg dir
echo "Getting tone.."
$WGET_TONE
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
rm tone-0.1.2-linux-x64.tar.gz
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 --no-same-owner
rm tone-0.1.5-linux-x64.tar.gz
echo "Good to go on Ffmpeg (& tone)... hopefully"
}
+15 -1
View File
@@ -112,7 +112,7 @@ input[type=number] {
background-color: #373838;
}
.tracksTable tr:hover {
.tracksTable tr:hover:not(:has(th)) {
background-color: #474747;
}
@@ -232,6 +232,20 @@ Bookshelf Label
-webkit-box-orient: vertical;
}
.episode-subtitle-long {
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-height: 16px;
/* fallback */
max-height: 72px;
/* fallback */
-webkit-line-clamp: 6;
/* number of lines to show */
-webkit-box-orient: vertical;
}
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
-8
View File
@@ -48,14 +48,6 @@
font-size: 1.5rem;
}
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
+122 -33
View File
@@ -3,11 +3,11 @@
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center">
<nuxt-link to="/">
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
</nuxt-link>
<nuxt-link to="/">
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
</nuxt-link>
<ui-libraries-dropdown class="mr-2" />
@@ -15,8 +15,6 @@
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
<div class="flex-grow" />
<widgets-notification-widget class="hidden md:block" />
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
</ui-tooltip>
@@ -24,6 +22,8 @@
<google-cast-launcher></google-cast-launcher>
</div>
<widgets-notification-widget class="hidden md:block" />
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
@@ -58,9 +58,6 @@
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }}
</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-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip>
@@ -75,8 +72,11 @@
<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-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>
</div>
</div>
@@ -149,9 +149,6 @@ export default {
processingBatch() {
return this.$store.state.processingBatch
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isChromecastEnabled() {
return this.$store.getters['getServerSetting']('chromecastEnabled')
},
@@ -160,9 +157,90 @@ export default {
},
isHttps() {
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'
})
}
options.push({
text: 'Re-Scan',
action: 'rescan'
})
return options
}
},
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()
} else if (action === 'rescan') {
this.batchRescan()
}
},
async batchRescan() {
const payload = {
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/items/batch/scan`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
console.log('Batch Re-Scan started')
this.cancelSelectionMode()
})
.catch((error) => {
console.error('Batch Re-Scan failed', error)
const errorMsg = error.response.data || 'Failed to batch re-scan'
this.$toast.error(errorMsg)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)
@@ -225,38 +303,49 @@ export default {
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Batch update success!')
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
this.$store.commit('setProcessingBatch', false)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error('Batch update failed')
this.$toast.error(this.$strings.ToastBatchUpdateFailed)
console.error('Failed to batch update read/not read', error)
this.$store.commit('setProcessingBatch', false)
})
},
batchDeleteClick() {
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
if (confirm(confirmMsg)) {
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/delete`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error('Batch delete failed')
console.error('Failed to batch delete', error)
this.$store.commit('setProcessingBatch', false)
})
const payload = {
message: `This will delete ${this.numMediaItemsSelected} library items 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.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success')
this.$store.commit('globals/resetSelectedMediaItems', [])
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() {
this.$router.push('/batch')
+46 -30
View File
@@ -4,19 +4,19 @@
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div>
</div>
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
<p class="text-center text-xl font-book py-4">No results for query</p>
<p class="text-center text-xl py-4">No results for query</p>
</div>
<!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
<template v-for="(shelf, index) in shelves">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-item-slider>
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
@@ -28,12 +28,15 @@
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-authors-slider>
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-narrators-slider>
</template>
</div>
<!-- Regular bookshelf view -->
<div v-else class="w-full">
<template v-for="(shelf, index) in shelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
</template>
</div>
</div>
@@ -62,12 +65,12 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
@@ -168,7 +171,7 @@ export default {
},
async fetchCategories() {
const categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
.then((data) => {
return data
})
@@ -185,8 +188,8 @@ export default {
this.shelves = categories
},
async setShelvesFromSearch() {
var shelves = []
if (this.results.books && this.results.books.length) {
const shelves = []
if (this.results.books?.length) {
shelves.push({
id: 'books',
label: 'Books',
@@ -196,7 +199,7 @@ export default {
})
}
if (this.results.podcasts && this.results.podcasts.length) {
if (this.results.podcasts?.length) {
shelves.push({
id: 'podcasts',
label: 'Podcasts',
@@ -206,7 +209,7 @@ export default {
})
}
if (this.results.series && this.results.series.length) {
if (this.results.series?.length) {
shelves.push({
id: 'series',
label: 'Series',
@@ -221,7 +224,7 @@ export default {
})
})
}
if (this.results.tags && this.results.tags.length) {
if (this.results.tags?.length) {
shelves.push({
id: 'tags',
label: 'Tags',
@@ -236,7 +239,7 @@ export default {
})
})
}
if (this.results.authors && this.results.authors.length) {
if (this.results.authors?.length) {
shelves.push({
id: 'authors',
label: 'Authors',
@@ -250,6 +253,20 @@ export default {
})
})
}
if (this.results.narrators?.length) {
shelves.push({
id: 'narrators',
label: 'Narrators',
labelStringKey: 'LabelNarrators',
type: 'narrators',
entities: this.results.narrators.map((n) => {
return {
...n,
type: 'narrator'
}
})
})
}
this.shelves = shelves
},
scan() {
@@ -269,7 +286,8 @@ export default {
}
if (user.mediaProgress.length) {
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
this.removeItemsFromContinueListening(mediaProgressToHide)
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
}
},
libraryItemAdded(libraryItem) {
@@ -319,8 +337,9 @@ export default {
},
libraryItemsAdded(libraryItems) {
console.log('libraryItems added', libraryItems)
// TODO: Check if audiobook would be on this shelf
if (!this.search) {
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
if (!this.search && isThisLibrary) {
this.fetchCategories()
}
},
@@ -329,6 +348,12 @@ export default {
this.libraryItemUpdated(li)
})
},
episodeAdded(episodeWithLibraryItem) {
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
if (!this.search && isThisLibrary) {
this.fetchCategories()
}
},
removeAllSeriesFromContinueSeries(seriesIds) {
this.shelves.forEach((shelf) => {
if (shelf.type == 'book' && shelf.id == 'continue-series') {
@@ -340,8 +365,8 @@ export default {
}
})
},
removeItemsFromContinueListening(mediaProgressItems) {
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
if (continueListeningShelf) {
if (continueListeningShelf.type === 'book') {
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
@@ -356,17 +381,6 @@ export default {
})
}
}
// this.shelves.forEach((shelf) => {
// if (shelf.id == 'continue-listening') {
// if (shelf.type == 'book') {
// // Filter out books from continue listening shelf
// shelf.entities = shelf.entities.filter((ent) => {
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
// return true
// })
// }
// }
// })
},
authorUpdated(author) {
this.shelves.forEach((shelf) => {
@@ -400,6 +414,7 @@ export default {
this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('items_added', this.libraryItemsAdded)
this.$root.socket.on('episode_added', this.episodeAdded)
} else {
console.error('Error socket not initialized')
}
@@ -414,6 +429,7 @@ export default {
this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('items_added', this.libraryItemsAdded)
this.$root.socket.off('episode_added', this.episodeAdded)
} else {
console.error('Error socket not initialized')
}
+7 -1
View File
@@ -41,10 +41,15 @@
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
</template>
</div>
<div v-if="shelf.type === 'narrators'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
</template>
</div>
</div>
</div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
<div class="absolute text-center categoryPlacard transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<p class="transform text-sm">{{ $strings[shelf.labelStringKey] }}</p>
</div>
@@ -88,6 +93,7 @@ export default {
return this.bookCoverWidth * this.bookCoverAspectRatio
},
shelfHeight() {
if (this.shelf.type === 'narrators') return 148
return this.bookCoverHeight + 48
},
paddingLeft() {
+54 -4
View File
@@ -42,7 +42,7 @@
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page -->
<template v-if="selectedSeries">
<p class="pl-2 font-book text-base md:text-lg">
<p class="pl-2 text-base md:text-lg">
{{ seriesName }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
@@ -60,17 +60,29 @@
</template>
<!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
<p class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
<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" />
<!-- 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" />
<!-- 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" />
<!-- 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" />
<!-- 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" />
<!-- 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-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
</template>
<!-- search page -->
<template v-else-if="page === 'search'">
@@ -153,6 +165,14 @@ export default {
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelLastBookAdded,
value: 'lastBookAdded'
},
{
text: this.$strings.LabelLastBookUpdated,
value: 'lastBookUpdated'
},
{
text: this.$strings.LabelTotalDuration,
value: 'totalDuration'
@@ -168,9 +188,15 @@ export default {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
@@ -255,10 +281,30 @@ export default {
},
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
contextMenuItems() {
const items = []
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
items.push({
text: 'Export OPML',
action: 'export-opml'
})
}
return items
}
},
methods: {
seriesContextMenuAction(action) {
contextMenuAction({ action }) {
if (action === 'export-opml') {
this.exportOPML()
}
},
exportOPML() {
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
},
seriesContextMenuAction({ action }) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
} else if (action === 're-add-to-continue-listening') {
@@ -305,7 +351,11 @@ export default {
const payload = {}
if (author.asin) payload.asin = author.asin
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)
+23 -11
View File
@@ -1,15 +1,19 @@
<template>
<div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span>
<div>
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<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 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" />
</nuxt-link>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
</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'">
<p>{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
<div class="flex justify-between">
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
@@ -17,8 +21,6 @@
</div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
</div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
</div>
</template>
@@ -88,10 +90,20 @@ export default {
title: this.$strings.HeaderNotifications,
path: '/config/notifications'
},
{
id: 'config-email',
title: this.$strings.HeaderEmail,
path: '/config/email'
},
{
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,
path: '/config/item-metadata-utils'
},
{
id: 'config-rss-feeds',
title: this.$strings.HeaderRSSFeeds,
path: '/config/rss-feeds'
}
]
+9 -7
View File
@@ -7,7 +7,7 @@
</template>
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
<div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
@@ -78,9 +78,6 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
@@ -316,12 +313,12 @@ export default {
this.currentSFQueryString = this.buildSearchParams()
}
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error)
console.error('failed to fetch items', error)
return null
})
@@ -626,6 +623,11 @@ export default {
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
},
async init(bookshelf) {
if (this.entityName === 'series') {
this.booksPerFetch = 50
} else {
this.booksPerFetch = 100
}
this.checkUpdateSearchParams()
this.initSizeData(bookshelf)
+1 -1
View File
@@ -4,7 +4,7 @@
<h1 class="text-xl">{{ headerText }}</h1>
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
<button class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
<button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
</div>
</div>
+36 -14
View File
@@ -8,7 +8,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -16,7 +16,7 @@
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">format_list_bulleted</span>
<p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -26,7 +26,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -36,7 +36,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -44,11 +44,19 @@
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" 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="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" 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="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" 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="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path
@@ -57,15 +65,23 @@
/>
</svg>
<p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" 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="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">record_voice_over</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" 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="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="abs-icons icon-podcast text-xl"></span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -73,23 +89,23 @@
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-xl">album</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" 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="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<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="font-book pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<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'">
<span class="material-icons text-2xl">warning</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
@@ -149,6 +165,9 @@ export default {
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
},
@@ -167,6 +186,9 @@ export default {
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
},
isNarratorsPage() {
return this.$route.name === 'library-library-narrators'
},
isPlaylistsPage() {
return this.paramId === 'playlists'
},
@@ -212,4 +234,4 @@ export default {
},
mounted() {}
}
</script>
</script>
+56 -29
View File
@@ -1,22 +1,25 @@
<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" />
<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" />
</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>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
<div class="min-w-0">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
{{ title }}
</nuxt-link>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<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>
<p v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</p>
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<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>
<p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</p>
<div class="flex items-center">
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</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 class="text-gray-400 flex items-center">
@@ -78,7 +81,7 @@ export default {
sleepTimerRemaining: 0,
sleepTimer: null,
displayTitle: null,
initialPlaybackRate: 1,
currentPlaybackRate: 1,
syncFailedToast: null
}
},
@@ -117,22 +120,31 @@ export default {
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() {
return this.streamLibraryItem ? this.streamLibraryItem.id : null
return this.streamLibraryItem?.id || null
},
media() {
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
return this.streamLibraryItem?.media || {}
},
isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
return this.streamLibraryItem?.mediaType === 'podcast'
},
isMusic() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
return this.streamLibraryItem?.mediaType === 'music'
},
isExplicit() {
return this.mediaMetadata.explicit || false
},
mediaMetadata() {
return this.media.metadata || {}
},
chapters() {
if (this.streamEpisode) return this.streamEpisode.chapters || []
return this.media.chapters || []
},
title() {
@@ -146,7 +158,8 @@ export default {
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
// Adjusted by playback rate
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
},
podcastAuthor() {
if (!this.isPodcast) return null
@@ -249,12 +262,16 @@ export default {
this.playerHandler.setVolume(volume)
},
setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate
this.currentPlaybackRate = playbackRate
this.playerHandler.setPlaybackRate(playbackRate)
},
seek(time) {
this.playerHandler.seek(time)
},
playbackTimeUpdate(time) {
// When updating progress from another session
this.playerHandler.seek(time, false)
},
setCurrentTime(time) {
this.currentTime = time
if (this.$refs.audioPlayer) {
@@ -353,21 +370,22 @@ export default {
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
} else {
console.warn('Media session not available')
}
},
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
console.error('No Audio Ref')
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
console.error('No Audio Ref')
}
}
},
sessionOpen(session) {
@@ -376,7 +394,7 @@ export default {
libraryItem: session.libraryItem,
episodeId: session.episodeId
})
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
},
streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session)
@@ -443,7 +461,7 @@ export default {
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() {
this.playerHandler.pause()
@@ -451,17 +469,26 @@ export default {
showFailedProgressSyncs() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
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() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('playback-seek', this.seek)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
},
beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('playback-seek', this.seek)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
}
@@ -472,4 +499,4 @@ export default {
#streamContainer {
box-shadow: 0px -6px 8px #1111113f;
}
</style>
</style>
+12 -1
View File
@@ -1,5 +1,5 @@
<template>
<nuxt-link :to="`/author/${author.id}`">
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
<div @mouseover="mouseover" @mouseleave="mouseleave">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder -->
@@ -77,6 +77,12 @@ export default {
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
}
},
methods: {
@@ -92,6 +98,11 @@ export default {
if (this.asin) payload.asin = this.asin
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) => {
console.error('Failed', error)
return null
+6 -2
View File
@@ -28,7 +28,11 @@
</div>
</div>
<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 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>
@@ -78,4 +82,4 @@ export default {
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
}
}
</script>
</script>
+1 -1
View File
@@ -6,7 +6,7 @@
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
</div>
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
+7 -6
View File
@@ -5,12 +5,12 @@
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div>
</div>
</template>
@@ -61,18 +61,19 @@ export default {
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
// This used to highlight the part of the search found
// but with removing commas periods etc this is no longer plausible
const html = this.matchText
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
return `${html}`
}
},
@@ -0,0 +1,94 @@
<template>
<div class="flex items-center px-1 overflow-hidden">
<div class="w-8 flex items-center justify-center">
<!-- <div class="text-lg"> -->
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
<widgets-loading-spinner v-else />
<!-- </div> -->
</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
},
isFailed() {
return !!this.task.isFailed
},
isSuccess() {
return this.isFinished && !this.isFailed
},
failedMessage() {
return this.task.error || ''
},
action() {
return this.task.action || ''
},
actionIcon() {
if (this.isFailed) {
return 'error'
} else if (this.isSuccess) {
return 'done'
}
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% - 84px);
height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
@@ -114,6 +114,7 @@ export default {
var files = this.item.itemFiles.concat(this.item.otherFiles)
return {
index: this.item.index,
directory: this.directory,
...this.itemData,
files
}
+1 -1
View File
@@ -5,7 +5,7 @@
<covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
+143 -37
View File
@@ -7,9 +7,12 @@
<!-- Alternative bookshelf title/author/sort -->
<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' }">
{{ displayTitle }}
</p>
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<div class="flex items-center">
<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 v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
@@ -23,7 +26,7 @@
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
</div>
<!-- Cover Image -->
@@ -32,13 +35,13 @@
<!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
{{ titleCleaned }}
</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
@@ -73,6 +76,10 @@
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
</div>
</div>
<!-- Processing/loading spinner overlay -->
@@ -82,7 +89,7 @@
<!-- Series name overlay -->
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ seriesName }}</p>
</div>
<!-- Error widget -->
@@ -102,14 +109,21 @@
</div>
<!-- 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` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
<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<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
</p>
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<div v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
</div>
</div>
</template>
@@ -165,12 +179,6 @@ export default {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
enableEReader() {
return this.store.getters['getServerSetting']('enableEReader')
},
_libraryItem() {
return this.libraryItem || {}
},
@@ -193,6 +201,9 @@ export default {
isMusic() {
return this.mediaType === 'music'
},
isExplicit() {
return this.mediaMetadata.explicit || false
},
placeholderUrl() {
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
@@ -207,13 +218,16 @@ export default {
// Only included when filtering by series or collapse series or Continue Series shelf on home page
return this.mediaMetadata.series
},
seriesName() {
return this.series?.name || null
},
seriesSequence() {
return this.series ? this.series.sequence : null
return this.series?.sequence || null
},
libraryId() {
return this._libraryItem.libraryId
},
hasEbook() {
ebookFormat() {
return this.media.ebookFormat
},
numTracks() {
@@ -221,9 +235,11 @@ export default {
return this.media.numTracks || 0 // toJSONMinified
},
numEpisodes() {
if (!this.isPodcast) return 0
return this.media.numEpisodes || 0
},
numEpisodesIncomplete() {
return this._libraryItem.numEpisodesIncomplete || 0
},
processingBatch() {
return this.store.state.processingBatch
},
@@ -236,7 +252,7 @@ export default {
if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '')
}
return this.recentEpisode.index
return ''
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
@@ -244,14 +260,14 @@ export default {
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
return this.collapsedSeries?.numBooks || 0
},
seriesSequenceList() {
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
return this.collapsedSeries?.seriesSequenceList || null
},
libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
return this.collapsedSeries?.libraryItemIds || []
},
hasCover() {
return !!this.media.coverPath
@@ -305,6 +321,7 @@ export default {
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
if (this.orderBy === 'media.metadata.publishedYear' && this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
return null
},
episodeProgress() {
@@ -317,8 +334,16 @@ export default {
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
isEBookOnly() {
return !this.numTracks && this.ebookFormat
},
useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
},
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() {
return this.userProgress ? !!this.userProgress.isFinished : false
@@ -347,13 +372,13 @@ export default {
return this.store.getters['getIsStreamingFromDifferentLibrary']
},
showReadButton() {
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
return !this.isSelectionMode && this.ebookFormat
},
isMissing() {
return this._libraryItem.isMissing
@@ -469,6 +494,18 @@ export default {
text: this.$strings.LabelAddToPlaylist
})
}
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
items.push({
text: this.$strings.LabelSendEbookToDevice,
subitems: this.store.state.libraries.ereaderDevices.map((d) => {
return {
text: d.name,
func: 'sendToDevice',
data: d.name
}
})
})
}
}
if (this.userCanUpdate) {
items.push({
@@ -495,7 +532,7 @@ export default {
if (this.continueListeningShelf) {
items.push({
func: 'removeFromContinueListening',
text: this.$strings.ButtonRemoveFromContinueListening
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
})
}
if (!this.isPodcast) {
@@ -513,6 +550,14 @@ export default {
}
}
}
if (this.userCanDelete) {
items.push({
func: 'deleteLibraryItem',
text: this.$strings.ButtonDelete
})
}
return items
},
_socket() {
@@ -646,7 +691,6 @@ export default {
.$patch(apiEndpoint, updatePayload)
.then(() => {
this.processing = false
toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
@@ -662,7 +706,7 @@ export default {
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.$post(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
var result = data.result
if (!result) {
@@ -691,7 +735,40 @@ export default {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
sendToDevice(deviceName) {
// More menu func
const payload = {
// message: `Are you sure you want to send ${this.ebookFormat} ebook "${this.title}" to device "${deviceName}"?`,
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFormat, this.title, deviceName]),
callback: (confirmed) => {
if (confirmed) {
const payload = {
libraryItemId: this.libraryItemId,
deviceName
}
this.processing = true
const axios = this.$axios || this.$nuxt.$axios
axios
.$post(`/api/emails/send-ebook-to-device`, payload)
.then(() => {
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
})
.catch((error) => {
console.error('Failed to send ebook to device', error)
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.store.commit('globals/setConfirmPrompt', payload)
},
removeSeriesFromContinueListening() {
if (!this.series) return
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
@@ -734,7 +811,7 @@ export default {
episodeId: this.recentEpisode.id,
title: this.recentEpisode.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,
coverPath: this.media.coverPath || null
}
@@ -764,6 +841,35 @@ export default {
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
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() {
if (!this.$refs.moreIcon) return
@@ -775,8 +881,8 @@ export default {
items: this.moreMenuItems
},
created() {
this.$on('action', (func) => {
if (_this[func]) _this[func]()
this.$on('action', (action) => {
if (action.func && _this[action.func]) _this[action.func](action.data)
})
this.$on('close', () => {
_this.isMoreMenuOpen = false
@@ -788,7 +894,7 @@ export default {
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
var el = instance.$el
var elHeight = this.moreMenuItems.length * 28 + 2
var elHeight = this.moreMenuItems.length * 28 + 10
var elWidth = 130
var bottomOfIcon = wrapperBox.top + wrapperBox.height
@@ -815,13 +921,13 @@ export default {
this.createMoreMenu()
},
async clickReadEBook() {
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
const axios = this.$axios || this.$nuxt.$axios
var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to get lirbary item', this.libraryItemId)
return null
})
if (!libraryItem) return
console.log('Got library itemn', libraryItem)
this.store.commit('showEReader', libraryItem)
this.store.commit('showEReader', { libraryItem, keepProgress: true })
},
selectBtnClick(evt) {
if (this.processingBatch) return
@@ -859,7 +965,7 @@ export default {
episodeId: episode.id,
title: episode.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,
coverPath: this.media.coverPath || null
})
@@ -12,13 +12,13 @@
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
</div>
</div>
</template>
+4 -4
View File
@@ -9,13 +9,13 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
</div>
</div>
</template>
@@ -50,8 +50,8 @@ export default {
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6)
return this.width / 120
},
title() {
return this.playlist ? this.playlist.name : ''
+25 -10
View File
@@ -7,21 +7,21 @@
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</div>
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
</div>
@@ -81,13 +81,20 @@ export default {
return this.title
},
displaySortLine() {
if (this.orderBy === 'addedAt') {
// return this.addedAt
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
} else if (this.orderBy === 'totalDuration') {
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
switch (this.orderBy) {
case 'addedAt':
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
case 'totalDuration':
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() {
return this.series ? this.series.books || [] : []
@@ -108,6 +115,14 @@ export default {
seriesBooksFinished() {
return this.seriesBookProgress.filter((p) => p.isFinished)
},
hasSeriesBookInProgress() {
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
},
seriesPercentInProgress() {
let totalFinishedAndInProgress = this.seriesBooksFinished.length
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
},
isSeriesFinished() {
return this.books.length === this.seriesBooksFinished.length
},
+50
View File
@@ -0,0 +1,50 @@
<template>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
<span class="material-icons-outlined text-[10rem]">record_voice_over</span>
</div>
<!-- Narrator name & num books overlay -->
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
</div>
</nuxt-link>
</template>
<script>
export default {
props: {
narrator: {
type: Object,
default: () => {}
},
width: Number,
height: Number,
sizeMultiplier: {
type: Number,
default: 1
}
},
data() {
return {}
},
computed: {
name() {
return this.narrator?.name || ''
},
numBooks() {
return this.narrator?.numBooks || this.narrator?.books?.length || 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {}
}
</script>
@@ -0,0 +1,34 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
</div>
<div class="flex-grow px-2 narratorSearchCardContent h-full">
<p class="truncate text-sm">{{ narrator }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
narrator: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style scoped>
.narratorSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
@@ -54,7 +54,7 @@ export default {
},
folderPath() {
if (!this.libraryFolderPath) return ''
return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
return `${this.libraryFolderPath}/${this.$sanitizeFilename(this.title)}`
},
detailsWidth() {
return this.width - 85
@@ -0,0 +1,212 @@
<template>
<div>
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
><span :key="index" v-if="index < narrators.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="publishedYear" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
</div>
<div>
{{ publishedYear }}
</div>
</div>
<div v-if="publisher" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
</div>
<div>
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
</div>
</div>
<div v-if="musicAlbum" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
</div>
<div>
{{ musicAlbum }}
</div>
</div>
<div v-if="musicAlbumArtist" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
</div>
<div>
{{ musicAlbumArtist }}
</div>
</div>
<div v-if="musicTrackPretty" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
</div>
<div>
{{ musicTrackPretty }}
</div>
</div>
<div v-if="musicDiscPretty" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
</div>
<div>
{{ musicDiscPretty }}
</div>
</div>
<div v-if="podcastType" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
</div>
<div class="capitalize">
{{ podcastType }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div class="flex py-0.5" v-if="tags.length">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(tag, index) in tags">
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
><span :key="index" v-if="index < tags.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div>
<div>
{{ durationPretty }}
</div>
</div>
<div class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
</div>
<div>
{{ sizePretty }}
</div>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
libraryId() {
return this.libraryItem.libraryId
},
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
audioFile() {
// Music track
return this.media.audioFile
},
media() {
return this.libraryItem.media || {}
},
tracks() {
return this.media.tracks || []
},
podcastEpisodes() {
return this.media.episodes || []
},
mediaMetadata() {
return this.media.metadata || {}
},
publishedYear() {
return this.mediaMetadata.publishedYear
},
genres() {
return this.mediaMetadata.genres || []
},
tags() {
return this.media.tags || []
},
podcastAuthor() {
return this.mediaMetadata.author || ''
},
authors() {
return this.mediaMetadata.authors || []
},
publisher() {
return this.mediaMetadata.publisher || ''
},
musicArtists() {
return this.mediaMetadata.artists || []
},
musicAlbum() {
return this.mediaMetadata.album || ''
},
musicAlbumArtist() {
return this.mediaMetadata.albumArtist || ''
},
musicTrackPretty() {
if (!this.mediaMetadata.trackNumber) return null
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
},
musicDiscPretty() {
if (!this.mediaMetadata.discNumber) return null
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
},
narrators() {
return this.mediaMetadata.narrators || []
},
durationPretty() {
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
if (!this.tracks.length && !this.audioFile) return 'N/A'
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
return this.$elapsedPretty(this.duration)
},
duration() {
if (!this.tracks.length && !this.audioFile) return 0
return this.media.duration
},
totalPodcastDuration() {
if (!this.podcastEpisodes.length) return 0
let totalDuration = 0
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
return totalDuration
},
sizePretty() {
return this.$bytesPretty(this.media.size)
},
podcastType() {
return this.mediaMetadata.type
}
},
methods: {},
mounted() {}
}
</script>
+9 -4
View File
@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs">{{ selectedText }}</span>
</span>
@@ -14,12 +14,17 @@
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>
+16 -4
View File
@@ -63,6 +63,15 @@
</nuxt-link>
</li>
</template>
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
<template v-for="narrator in narratorResults">
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<cards-narrator-search-card :narrator="narrator.name" />
</nuxt-link>
</li>
</template>
</template>
</ul>
</div>
@@ -84,6 +93,7 @@ export default {
authorResults: [],
seriesResults: [],
tagResults: [],
narratorResults: [],
searchTimeout: null,
lastSearch: null
}
@@ -93,7 +103,7 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
totalResults() {
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length
}
},
methods: {
@@ -114,6 +124,7 @@ export default {
this.authorResults = []
this.seriesResults = []
this.tagResults = []
this.narratorResults = []
this.showMenu = false
this.isFetching = false
this.isTyping = false
@@ -142,7 +153,7 @@ export default {
}
this.isFetching = true
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
console.error('Search error', error)
return []
})
@@ -155,6 +166,7 @@ export default {
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || []
this.narratorResults = searchResults.narrators || []
this.isFetching = false
if (!this.showMenu) {
@@ -185,8 +197,8 @@ export default {
}
</script>
<style>
<style scoped>
.globalSearchMenu {
max-height: 80vh;
max-height: calc(100vh - 75px);
}
</style>
@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
@@ -9,31 +9,35 @@
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-icons" style="font-size: 1.1rem">close</span>
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_right</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>
</ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_left</span>
</div>
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate">Back</span>
<span class="font-normal block truncate">Back</span>
</div>
</li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
@@ -41,16 +45,15 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div>
</li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>
<!-- selected checkmark icon -->
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>
</ul>
@@ -72,9 +75,8 @@ export default {
},
watch: {
showMenu(newVal) {
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
if (newVal) {
this.sublist = this.selectedItemSublist
}
}
},
@@ -122,6 +124,11 @@ export default {
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelPublisher,
value: 'publishers',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
@@ -165,6 +172,11 @@ export default {
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelPublisher,
value: 'publishers',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
@@ -185,6 +197,16 @@ export default {
value: 'tracks',
sublist: true
},
{
text: this.$strings.LabelEbooks,
value: 'ebooks',
sublist: true
},
{
text: this.$strings.LabelAbridged,
value: 'abridged',
sublist: false
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
@@ -250,21 +272,25 @@ export default {
return this.bookItems
},
selectedItemSublist() {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
},
selectedText() {
if (!this.selected) return ''
var parts = this.selected.split('.')
var filterName = this.selectItems.find((i) => i.value === parts[0])
var filterValue = null
const parts = this.selected.split('.')
const filterName = this.selectItems.find((i) => i.value === parts[0])
let filterValue = null
if (parts.length > 1) {
var decoded = this.$decode(parts[1])
if (decoded.startsWith('aut_')) {
var author = this.authors.find((au) => au.id == decoded)
const decoded = this.$decode(parts[1])
if (parts[0] === 'authors') {
const author = this.authors.find((au) => au.id == decoded)
if (author) filterValue = author.name
} else if (decoded.startsWith('ser_')) {
var series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name
} else if (parts[0] === 'series') {
if (decoded === 'no-series') {
filterValue = this.$strings.MessageNoSeries
} else {
const series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name
}
} else {
filterValue = decoded
}
@@ -297,6 +323,9 @@ export default {
languages() {
return this.filterData.languages || []
},
publishers() {
return this.filterData.publishers || []
},
progress() {
return [
{
@@ -319,6 +348,10 @@ export default {
},
tracks() {
return [
{
id: 'none',
name: this.$strings.LabelTracksNone
},
{
id: 'single',
name: this.$strings.LabelTracksSingleTrack
@@ -329,6 +362,18 @@ export default {
}
]
},
ebooks() {
return [
{
id: 'ebook',
name: this.$strings.LabelHasEbook
},
{
id: 'supplementary',
name: this.$strings.LabelHasSupplementaryEbook
}
]
},
missing() {
return [
{
@@ -386,7 +431,7 @@ export default {
]
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
const sublistItems = (this[this.sublist] || []).map((item) => {
if (typeof item === 'string') {
return {
text: item,
@@ -399,6 +444,13 @@ export default {
}
}
})
if (this.sublist === 'series') {
sublistItems.unshift({
text: this.$strings.MessageNoSeries,
value: this.$encode('no-series')
})
}
return sublistItems
},
filterData() {
return this.$store.state.libraries.filterData || {}
@@ -423,7 +475,7 @@ export default {
return
}
var val = option.value
const val = option.value
if (this.selected === val) {
this.showMenu = false
return
@@ -434,4 +486,10 @@ export default {
}
}
}
</script>
</script>
<style scoped>
.libraryFilterMenu {
max-height: calc(100vh - 125px);
}
</style>
@@ -7,11 +7,11 @@
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
@@ -1,7 +1,7 @@
<template>
<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)">
<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 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' }">
@@ -11,7 +11,7 @@
<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 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>
</template>
@@ -19,7 +19,7 @@
<div class="w-full py-1 px-4">
<div class="flex items-center justify-between">
<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" />
</div>
</div>
+4 -4
View File
@@ -1,17 +1,17 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
+4 -4
View File
@@ -7,7 +7,7 @@
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2">
<widgets-loading-spinner />
</div>
@@ -17,17 +17,17 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
<p class="text-center text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
</template>
+1 -1
View File
@@ -19,7 +19,7 @@
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
</div>
</div>
</template>
+1 -1
View File
@@ -138,7 +138,7 @@ export default {
var innerP = document.createElement('p')
innerP.textContent = this.name
innerP.className = 'text-sm font-book text-white'
innerP.className = 'text-sm text-white'
imgdiv.appendChild(innerP)
return imgdiv
+5 -2
View File
@@ -13,8 +13,8 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
<img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
<p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
@@ -58,6 +58,9 @@ export default {
sizeMultiplier() {
return this.width / 120
},
invalidCoverFontSize() {
return Math.max(this.sizeMultiplier * 0.8, 0.5)
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
+26 -11
View File
@@ -2,11 +2,11 @@
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
<div class="w-full p-8">
<div class="flex py-2">
<div class="w-1/2 px-2">
@@ -96,7 +96,13 @@
</div>
</div>
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" :label="$strings.LabelTagsAccessibleToUser" />
<div class="flex items-center">
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
<div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
</div>
</div>
</div>
</div>
@@ -185,6 +191,9 @@ export default {
value: t
}
})
},
tagsSelectionText() {
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
}
},
methods: {
@@ -193,8 +202,11 @@ export default {
if (this.$refs.modal) this.$refs.modal.setHide()
},
accessAllTagsToggled(val) {
if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = []
if (val) {
if (this.newUser.itemTagsSelected?.length) {
this.newUser.itemTagsSelected = []
}
this.newUser.permissions.selectedTagsNotAccessible = false
}
},
fetchAllTags() {
@@ -226,7 +238,7 @@ export default {
this.$toast.error('Must select at least one library')
return
}
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
this.$toast.error('Must select at least one tag')
return
}
@@ -307,12 +319,12 @@ export default {
delete: type === 'admin',
upload: type === 'admin',
accessAllLibraries: true,
accessAllTags: true
accessAllTags: true,
selectedTagsNotAccessible: false
}
},
init() {
this.fetchAllTags()
this.isNew = !this.account
if (this.account) {
this.newUser = {
@@ -322,9 +334,10 @@ export default {
isActive: this.account.isActive,
permissions: { ...this.account.permissions },
librariesAccessible: [...(this.account.librariesAccessible || [])],
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
}
} else {
this.fetchAllTags()
this.newUser = {
username: null,
password: null,
@@ -336,9 +349,11 @@ export default {
delete: false,
upload: false,
accessAllLibraries: true,
accessAllTags: true
accessAllTags: true,
selectedTagsNotAccessible: false
},
librariesAccessible: []
librariesAccessible: [],
itemTagsSelected: []
}
}
}
@@ -0,0 +1,174 @@
<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">
<div class="flex items-center justify-between">
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">Probe Audio File</ui-btn>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<template v-if="!ffprobeData">
<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>
</template>
<div v-else class="w-full">
<div class="relative">
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
<span class="material-icons">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
</button>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
audioFile: {
type: Object,
default: () => {}
},
libraryItemId: String
},
data() {
return {
probingFile: false,
ffprobeData: null,
copiedToClipboard: false
}
},
watch: {
show(newVal) {
if (newVal) {
this.ffprobeData = null
this.copiedToClipboard = false
this.probingFile = false
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
metadata() {
return this.audioFile?.metadata || {}
},
metaTags() {
return this.audioFile?.metaTags || {}
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
prettyFfprobeData() {
if (!this.ffprobeData) return ''
return JSON.stringify(this.ffprobeData, null, 2)
}
},
methods: {
getFFProbeData() {
this.probingFile = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)
.then((data) => {
console.log('Got ffprobe data', data)
this.ffprobeData = data
})
.catch((error) => {
console.error('Failed to get ffprobe data', error)
this.$toast.error('FFProbe failed')
})
.finally(() => {
this.probingFile = false
})
},
async copyFfprobeData() {
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
}
},
mounted() {}
}
</script>
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="backup-scheduler" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
</div>
</template>
<div v-if="show && newCronExpression" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="batchQuickMatch" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
+9 -3
View File
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
@@ -73,6 +73,12 @@ export default {
},
canCreateBookmark() {
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
}
},
methods: {
@@ -111,7 +117,7 @@ export default {
},
submitCreateBookmark() {
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 = {
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'">
<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">
<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">
{{ chap.title }}
</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="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>
@@ -28,7 +28,8 @@ export default {
currentChapter: {
type: Object,
default: () => null
}
},
playbackRate: Number
},
data() {
return {}
@@ -47,11 +48,15 @@ export default {
this.$emit('input', val)
}
},
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null
},
currentChapterStart() {
return this.currentChapter ? this.currentChapter.start : 0
return (this.currentChapter?.start || 0) / this._playbackRate
}
},
methods: {
@@ -61,13 +66,11 @@ export default {
scrollToChapter() {
if (!this.currentChapterId) return
var container = this.$refs.container
if (container) {
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (this.$refs.container) {
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop
var containerHeight = container.clientHeight
container.scrollTo({ top: offsetTop - containerHeight / 2 })
const containerHeight = this.$refs.container.clientHeight
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
}
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export default {
},
methods: {
clickedOption(action) {
this.$emit('action', action)
this.$emit('action', { action })
}
},
mounted() {}
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
@@ -19,13 +19,13 @@
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div>
<div class="px-1">
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }}
{{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div>
<div class="px-1">
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }}
{{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
@@ -50,19 +50,19 @@
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
<div class="px-1">
<div class="px-1 text-xs">
{{ _session.libraryId }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
<div class="px-1">
<div class="px-1 text-xs">
{{ _session.libraryItemId }}
</div>
</div>
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
<div class="px-1">
<div class="px-1 text-xs">
{{ _session.episodeId }}
</div>
</div>
@@ -81,7 +81,7 @@
</div>
<div class="w-full md:w-1/3">
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
<p class="mb-1">{{ _session.userId }}</p>
<p class="mb-1 text-xs">{{ _session.userId }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
<p class="mb-1">{{ playMethodName }}</p>
@@ -98,7 +98,8 @@
</div>
<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>
</modals-modal>
@@ -151,6 +152,15 @@ export default {
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
},
isOpenSession() {
return !!this._session.open
}
},
methods: {
@@ -182,8 +192,26 @@ export default {
var errMsg = error.response ? error.response.data || '' : ''
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() {}
}
</script>
</script>
+3 -3
View File
@@ -2,11 +2,11 @@
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<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-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</div>
</button>
<slot name="outer" />
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator />
+30 -12
View File
@@ -2,17 +2,21 @@
<modals-modal v-model="show" name="sleep-timer" :width="350" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
</div>
</template>
<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">
<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>
</div>
</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 v-else class="w-full p-4">
<div class="mb-4 flex items-center justify-center">
@@ -48,19 +52,28 @@ export default {
},
data() {
return {
customTime: null,
sleepTimes: [
{
seconds: 10,
text: '10 seconds'
},
{
seconds: 60 * 5,
text: '5 minutes'
},
{
seconds: 60 * 15,
text: '15 minutes'
},
{
seconds: 60 * 20,
text: '20 minutes'
},
{
seconds: 60 * 30,
text: '30 minutes'
},
{
seconds: 60 * 45,
text: '45 minutes'
},
{
seconds: 60 * 60,
text: '60 minutes'
@@ -72,10 +85,6 @@ export default {
{
seconds: 60 * 120,
text: '2 hours'
},
{
seconds: 60 * 180,
text: '3 hours'
}
]
}
@@ -97,8 +106,17 @@ export default {
}
},
methods: {
setTime(time) {
this.$emit('set', time.seconds)
submitCustomTime() {
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) {
this.$emit('increment', amount)
+43 -2
View File
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="edit-author" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
@@ -33,8 +33,10 @@
</div>
<div class="flex pt-2 px-2">
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
<div class="flex-grow" />
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
@@ -85,6 +87,15 @@ export default {
},
title() {
return this.$strings.HeaderUpdateAuthor
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
@@ -94,6 +105,31 @@ export default {
this.authorCopy.description = this.author.description
this.authorCopy.imagePath = this.author.imagePath
},
removeClick() {
const payload = {
message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),
callback: (confirmed) => {
if (confirmed) {
this.processing = true
this.$axios
.$delete(`/api/authors/${this.authorId}`)
.then(() => {
this.$toast.success('Author removed')
this.show = false
})
.catch((error) => {
console.error('Failed to remove author', error)
this.$toast.error('Failed to remove author')
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
async submitForm() {
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
var updatePayload = {}
@@ -151,6 +187,11 @@ export default {
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
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) => {
console.error('Failed', error)
return null
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Changelog</p>
<p class="text-3xl text-white truncate">Changelog</p>
</div>
</template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="collections" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
@@ -2,16 +2,15 @@
<modals-modal v-model="show" name="edit-collection" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderCollection }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderCollection }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<template v-if="!showImageUploader">
<form @submit.prevent="submitForm">
<div class="flex">
<div>
<div class="flex flex-wrap">
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
</div>
<div class="flex-grow px-4">
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
@@ -41,7 +40,6 @@
<ui-btn color="success">Upload</ui-btn>
</div>
</template>
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
</div>
</modals-modal>
</template>
@@ -0,0 +1,171 @@
<template>
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full px-3 py-5 md:p-12">
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
</div>
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
</div>
</div>
<div class="flex items-center pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</div>
</form>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
existingDevices: {
type: Array,
default: () => []
},
ereaderDevice: {
type: Object,
default: () => null
}
},
data() {
return {
processing: false,
newDevice: {
name: '',
email: ''
}
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.ereaderDevice ? 'Create Device' : 'Update Device'
}
},
methods: {
submitForm() {
this.$refs.ereaderNameInput.blur()
this.$refs.ereaderEmailInput.blur()
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
this.$toast.error('Name and email required')
return
}
this.newDevice.name = this.newDevice.name.trim()
this.newDevice.email = this.newDevice.email.trim()
if (!this.ereaderDevice) {
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('EReader device with that name already exists')
return
}
this.submitCreate()
} else {
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('EReader device with that name already exists')
return
}
this.submitUpdate()
}
},
submitUpdate() {
this.processing = true
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
const payload = {
ereaderDevices: [
...existingDevicesWithoutThisOne,
{
...this.newDevice
}
]
}
this.$axios
.$post(`/api/emails/ereader-devices`, payload)
.then((data) => {
this.$emit('update', data.ereaderDevices)
this.$toast.success('Device updated')
this.show = false
})
.catch((error) => {
console.error('Failed to update device', error)
this.$toast.error('Failed to update device')
})
.finally(() => {
this.processing = false
})
},
submitCreate() {
this.processing = true
const payload = {
ereaderDevices: [
...this.existingDevices,
{
...this.newDevice
}
]
}
this.$axios
.$post('/api/emails/ereader-devices', payload)
.then((data) => {
this.$emit('update', data.ereaderDevices || [])
this.$toast.success('Device added')
this.show = false
})
.catch((error) => {
console.error('Failed to add device', error)
this.$toast.error('Failed to add device')
})
.finally(() => {
this.processing = false
})
},
init() {
if (this.ereaderDevice) {
this.newDevice.name = this.ereaderDevice.name
this.newDevice.email = this.ereaderDevice.email
} else {
this.newDevice.name = ''
this.newDevice.email = ''
}
}
},
mounted() {}
}
</script>
+21 -21
View File
@@ -2,12 +2,12 @@
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
<template #outer>
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
<p class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
@@ -74,6 +74,9 @@ export default {
this.$store.commit('setEditModalTab', val)
}
},
height() {
return Math.min(this.availableHeight, 650)
},
tabs() {
return [
{
@@ -124,9 +127,6 @@ export default {
}
]
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
@@ -136,14 +136,26 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
media() {
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
if (tab.admin && !this.userIsAdminOrUp) return false
if (tab.id === 'tools' && this.isMissing) return false
if (tab.id === 'chapters' && this.isEBookOnly) return false
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
@@ -151,9 +163,6 @@ export default {
return false
})
},
height() {
return Math.min(this.availableHeight, 650)
},
tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
@@ -161,20 +170,11 @@ export default {
isMissing() {
return this.selectedLibraryItem.isMissing
},
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
isEBookOnly() {
return this.media.ebookFile && !this.media.tracks?.length
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
return this.libraryItem?.mediaType || null
},
title() {
return this.mediaMetadata.title || 'No Title'
+29 -26
View File
@@ -1,8 +1,9 @@
<template>
<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 mb-4">
<div class="relative">
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- 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 class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
@@ -16,10 +17,10 @@
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex items-center">
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected"
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
>
<ui-file-input ref="fileInput" @change="fileUploadSelected">
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
</ui-file-input>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
@@ -27,18 +28,18 @@
</form>
</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">
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
<div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center">
<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 v-if="showLocalCovers" class="flex items-center justify-center pb-2">
<template v-for="localCoverFile in localCovers">
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-preview-cover :src="localCoverFile.localPath" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
</template>
@@ -48,13 +49,13 @@
</div>
<form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<div class="w-48 px-1">
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
</div>
<div v-show="provider != 'itunes'" class="w-72 px-1">
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
@@ -127,7 +128,7 @@ export default {
},
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
},
searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
@@ -138,16 +139,19 @@ export default {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
return this.libraryItem?.id || null
},
libraryItemUpdatedAt() {
return this.libraryItem?.updatedAt || null
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
return this.libraryItem?.mediaType || null
},
isPodcast() {
return this.mediaType == 'podcast'
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
return this.libraryItem?.media || {}
},
coverPath() {
return this.media.coverPath
@@ -156,7 +160,7 @@ export default {
return this.media.metadata || {}
},
libraryFiles() {
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
return this.libraryItem?.libraryFiles || []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
@@ -168,8 +172,8 @@ export default {
return this.libraryFiles
.filter((f) => f.fileType === 'image')
.map((file) => {
var _file = { ...file }
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
const _file = { ...file }
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
return _file
})
}
@@ -222,7 +226,7 @@ export default {
this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
},
removeCover() {
if (!this.media.coverPath) {
@@ -279,21 +283,20 @@ export default {
}
if (success) {
this.$toast.success('Update Successful')
// this.$emit('close')
} else {
this.imageUrl = this.media.coverPath || ''
} else if (this.media.coverPath) {
this.imageUrl = this.media.coverPath
}
this.isProcessing = false
},
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
if (this.isPodcast) searchQuery += '&podcast=1'
return searchQuery
},
persistProvider() {
try {
localStorage.setItem('book-provider', this.provider)
localStorage.setItem('book-cover-provider', this.provider)
} catch (error) {
console.error('PersistProvider', error)
}
+3 -26
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="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-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
</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-tooltip>
<div class="flex-grow" />
<!-- desktop -->
<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>
@@ -77,9 +74,6 @@ export default {
mediaMetadata() {
return this.media.metadata || {}
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
libraryId() {
return this.libraryItem ? this.libraryItem.libraryId : null
},
@@ -129,7 +123,7 @@ export default {
rescan() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.$post(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
@@ -184,23 +178,6 @@ export default {
}
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() {
this.$nextTick(() => {
var formWrapper = document.getElementById('formWrapper')
@@ -19,21 +19,17 @@
</div>
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Sort #</th>
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
<th class="text-center w-28">{{ $strings.EpisodeDuration }}</th>
<th class="text-center w-28">{{ $strings.EpisodeSize }}</th>
<tr>
<th class="text-center w-20 min-w-20">{{ $strings.LabelEpisode }}</th>
<th class="text-left">{{ $strings.LabelEpisodeTitle }}</th>
<th class="text-center w-28">{{ $strings.LabelEpisodeDuration }}</th>
<th class="text-center w-28">{{ $strings.LabelEpisodeSize }}</th>
</tr>
<tr v-for="episode in episodes" :key="episode.id">
<td class="text-left">
<p class="px-4">{{ episode.index }}</p>
<td class="text-center w-20 min-w-20">
<p>{{ episode.episode }}</p>
</td>
<td class="text-left">
<p class="px-4">{{ episode.episode }}</p>
</td>
<td class="font-book">
<td>
{{ episode.title }}
</td>
<td class="font-mono text-center">
+1 -4
View File
@@ -1,6 +1,6 @@
<template>
<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>
</template>
@@ -30,9 +30,6 @@ export default {
media() {
return this.libraryItem.media || {}
},
libraryFiles() {
return this.libraryItem.libraryFiles || []
},
userToken() {
return this.$store.getters['user/getToken']
},
+48 -13
View File
@@ -34,13 +34,25 @@
</div>
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
</a>
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
<div class="flex flex-grow items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
</div>
<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 v-if="selectedMatchOrig.title" class="flex items-center py-2">
@@ -103,7 +115,7 @@
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-multi-select v-model="selectedMatch.genres" :items="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
</div>
</div>
@@ -164,6 +176,20 @@
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
</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">
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
@@ -179,7 +205,7 @@ export default {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
default: () => { }
}
},
data() {
@@ -209,6 +235,7 @@ export default {
explicit: true,
asin: true,
isbn: true,
abridged: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@@ -273,6 +300,12 @@ export default {
},
isPodcast() {
return this.mediaType == 'podcast'
},
genres() {
const filterData = this.$store.state.libraries.filterData || {}
const currentGenres = filterData.genres || []
const selectedMatchGenres = this.selectedMatch.genres || []
return [...new Set([...currentGenres, ...selectedMatchGenres])]
}
},
methods: {
@@ -292,9 +325,9 @@ export default {
}
},
getSearchQuery() {
if (this.isPodcast) return `term=${this.searchTitle}`
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`
return searchQuery
},
submitSearch() {
@@ -327,6 +360,7 @@ export default {
res.itunesPageUrl = res.pageUrl || null
res.itunesId = res.id || null
res.author = res.artistName || null
res.explicit = res.explicit || false
return res
})
}
@@ -352,6 +386,7 @@ export default {
explicit: true,
asin: true,
isbn: true,
abridged: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@@ -468,7 +503,6 @@ export default {
} else if (key === 'narrator') {
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'genres') {
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
updatePayload.metadata.genres = [...this.selectedMatch[key]]
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
@@ -546,6 +580,7 @@ export default {
.matchListWrapper {
height: calc(100% - 124px);
}
@media (min-width: 768px) {
.matchListWrapper {
height: calc(100% - 80px);
@@ -59,6 +59,14 @@ export default {
newMaxNewEpisodesToDownload: 0
}
},
watch: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
@@ -176,4 +184,4 @@ export default {
height: calc(100% - 80px);
max-height: calc(100% - 80px);
}
</style>
</style>
+58 -9
View File
@@ -20,7 +20,7 @@
</div>
<!-- Split to mp3 -->
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
@@ -31,7 +31,7 @@
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
</div>
</div>
</div>
</div> -->
<!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
@@ -46,8 +46,20 @@
>{{ $strings.ButtonOpenManager }}
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
</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>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
@@ -67,14 +79,11 @@ export default {
return {}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
return this.libraryItem?.id || null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
return this.libraryItem?.media || {}
},
mediaTracks() {
return this.media.tracks || []
@@ -92,9 +101,49 @@ export default {
showMp3Split() {
if (!this.mediaTracks.length) return false
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: {},
mounted() {}
methods: {
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>
@@ -6,7 +6,7 @@
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
</div>
<div class="w-full md:flex-grow px-1 py-1 md:py-0">
<ui-text-input-with-label v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
<ui-text-input-with-label ref="nameInput" v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
</div>
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
<ui-media-icon-picker v-model="icon" :label="$strings.LabelIcon" @input="iconChanged" />
@@ -20,12 +20,12 @@
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" />
<ui-editable-text ref="folderInput" v-model="folder.fullPath" :readonly="!!folder.id" type="text" class="w-full" @blur="existingFolderInputBlurred(folder)" />
<span v-show="folders.length > 1" class="material-icons text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div>
<div class="flex py-1 px-2 items-center w-full">
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
<ui-editable-text ref="newFolderInput" v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
</div>
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
@@ -67,10 +67,6 @@ export default {
value: 'podcast',
text: this.$strings.LabelPodcasts
}
// {
// value: 'music',
// text: 'Music'
// }
]
},
folderPaths() {
@@ -82,6 +78,19 @@ export default {
}
},
methods: {
checkBlurExpressionInput() {
if (this.$refs.nameInput) {
this.$refs.nameInput.blur()
}
if (this.$refs.folderInput && this.$refs.folderInput.length) {
this.$refs.folderInput.forEach((input) => {
if (input.blur) input.blur()
})
}
if (this.$refs.newFolderInput) {
this.$refs.newFolderInput.blur()
}
},
browseForFolder() {
this.showDirectoryPicker = true
},
@@ -97,6 +106,11 @@ export default {
formUpdated() {
this.$emit('update', this.getLibraryData())
},
existingFolderInputBlurred(folder) {
if (!folder.fullPath) {
this.removeFolder(folder)
}
},
newFolderInputBlurred() {
if (this.newFolderPath) {
this.folders.push({ fullPath: this.newFolderPath })
@@ -136,6 +150,7 @@ export default {
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
this.icon = this.library ? this.library.icon : 'default'
this.mediaType = this.library ? this.library.mediaType : 'book'
this.showDirectoryPicker = false
}
},
@@ -2,12 +2,12 @@
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
@@ -120,7 +120,7 @@ export default {
for (const key in this.libraryCopy) {
if (library[key] !== undefined) {
if (key === 'folders') {
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
this.libraryCopy.folders = library.folders.map((f) => ({ ...f })).filter((f) => !!f.fullPath?.trim())
} else if (key === 'settings') {
for (const settingKey in library.settings) {
this.libraryCopy.settings[settingKey] = library.settings[settingKey]
@@ -144,8 +144,6 @@ export default {
return true
},
submit() {
if (!this.validate()) return
// If custom expression input is focused then unfocus it instead of submitting
if (this.$refs.tabComponent && this.$refs.tabComponent.checkBlurExpressionInput) {
if (this.$refs.tabComponent.checkBlurExpressionInput()) {
@@ -153,6 +151,8 @@ export default {
}
}
if (!this.validate()) return
if (this.library) {
this.submitUpdateLibrary()
} else {
@@ -1,6 +1,6 @@
<template>
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
<div class="flex items-center py-2">
<div class="flex items-center py-3">
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
<p class="pl-4 text-base">
@@ -11,24 +11,44 @@
</div>
<div class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" @input="formUpdated" />
<ui-toggle-switch v-else disabled :value="false" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsDisableWatcherForLibrary }}</p>
<p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
</div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
</div>
<div v-if="mediaType == 'book'" class="py-3">
<div v-if="isBookLibrary" class="flex items-center py-3">
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsAudiobooksOnly }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
</div>
</div>
<div v-if="mediaType == 'book'" class="py-3">
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
</div>
</div>
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="hideSingleBookSeries" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsHideSingleBookSeries }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div>
</div>
</template>
@@ -45,9 +65,11 @@ export default {
return {
provider: null,
useSquareBookCovers: false,
disableWatcher: false,
enableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false
skipMatchingMediaWithIsbn: false,
audiobooksOnly: false,
hideSingleBookSeries: false
}
},
computed: {
@@ -60,6 +82,9 @@ export default {
mediaType() {
return this.library.mediaType
},
isBookLibrary() {
return this.mediaType === 'book'
},
providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
@@ -70,9 +95,11 @@ export default {
return {
settings: {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
disableWatcher: !!this.disableWatcher,
disableWatcher: !this.enableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
audiobooksOnly: !!this.audiobooksOnly,
hideSingleBookSeries: !!this.hideSingleBookSeries
}
}
},
@@ -81,9 +108,11 @@ export default {
},
init() {
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
this.disableWatcher = !!this.librarySettings.disableWatcher
this.enableWatcher = !this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
}
},
mounted() {
@@ -2,7 +2,7 @@
<modals-modal ref="modal" v-model="show" name="notification-edit" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
@@ -10,7 +10,7 @@
<div class="w-full px-3 py-5 md:p-12">
<ui-dropdown v-model="newNotification.eventName" :label="$strings.LabelNotificationEvent" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
<ui-multi-select v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-multi-select ref="urlsInput" v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-text-input-with-label v-model="newNotification.titleTemplate" :label="$strings.LabelNotificationTitleTemplate" class="mb-2" />
@@ -103,6 +103,8 @@ export default {
if (this.$refs.modal) this.$refs.modal.setHide()
},
submitForm() {
this.$refs.urlsInput?.forceBlur()
if (!this.newNotification.urls.length) {
this.$toast.error('Must enter an Apprise URL')
return
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderPlayerQueue }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderPlayerQueue }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4" style="max-height: 80vh">
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="playlists" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="edit-playlist" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderPlaylist }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderPlaylist }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
+131 -10
View File
@@ -2,17 +2,24 @@
<modals-modal v-model="show" name="podcast-episode-edit-modal" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</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">
<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>
</modals-modal>
</template>
@@ -21,8 +28,8 @@
export default {
data() {
return {
episodeItem: null,
processing: false,
selectedTab: 'details',
tabs: [
{
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: {
show: {
get() {
@@ -46,27 +76,118 @@ export default {
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
}
},
selectedTab: {
get() {
return this.$store.state.editPodcastModalTab
},
set(val) {
this.$store.commit('setEditPodcastModalTab', val)
}
},
libraryItem() {
return this.$store.state.selectedLibraryItem
},
episode() {
return this.$store.state.globals.selectedEpisode
},
selectedEpisodeId() {
return this.episode.id
},
title() {
if (!this.libraryItem) return ''
return this.libraryItem.media.metadata.title || 'Unknown'
return this.libraryItem?.media.metadata.title || 'Unknown'
},
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 : ''
},
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: {
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) {
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>
@@ -77,4 +198,4 @@ export default {
.tab.tab-selected {
height: 41px;
}
</style>
</style>
+106 -33
View File
@@ -2,32 +2,44 @@
<modals-modal v-model="show" name="podcast-episodes-modal" :width="1200" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</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 v-if="episodesCleaned.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
v-for="(episode, index) in episodes"
v-for="(episode, index) in episodesList"
:key="index"
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'"
@click="toggleSelectEpisode(index, episode)"
:class="itemEpisodeMap[episode.cleanUrl] ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? '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(episode)"
>
<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>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
<span v-if="itemEpisodeMap[episode.cleanUrl]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
<p class="break-words mb-1">{{ 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-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 class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
</div>
</div>
</div>
<div class="flex justify-end pt-4">
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" :label="selectAllLabel" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
</div>
@@ -51,8 +63,12 @@ export default {
data() {
return {
processing: false,
episodesCleaned: [],
selectedEpisodes: {},
selectAll: false
selectAll: false,
search: null,
searchTimeout: null,
searchText: null
}
},
watch: {
@@ -77,59 +93,109 @@ export default {
return this.libraryItem.media.metadata.title || 'Unknown'
},
allDownloaded() {
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl])
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
},
buttonText() {
if (!this.episodesSelected.length) return 'No Episodes Selected'
return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
if (!this.episodesSelected.length) return this.$strings.LabelNoEpisodesSelected
if (this.episodesSelected.length === 1) return `${this.$strings.LabelDownload} ${this.$strings.LabelEpisode.toLowerCase()}`
return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length])
},
itemEpisodes() {
if (!this.libraryItem) return []
return this.libraryItem.media.episodes || []
},
itemEpisodeMap() {
var map = {}
const map = {}
this.itemEpisodes.forEach((item) => {
if (item.enclosure) map[item.enclosure.url] = true
if (item.enclosure) {
const cleanUrl = this.getCleanEpisodeUrl(item.enclosure.url)
map[cleanUrl] = true
}
})
return map
},
episodesList() {
return this.episodesCleaned.filter((episode) => {
if (!this.searchText) return true
return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
})
},
selectAllLabel() {
if (this.episodesList.length === this.episodesCleaned.length) {
return this.$strings.LabelSelectAllEpisodes
}
const episodesNotDownloaded = this.episodesList.filter((ep) => !this.itemEpisodeMap[ep.cleanUrl]).length
return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded])
}
},
methods: {
/**
* RSS feed episode url is used for matching with existing downloaded episodes.
* Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests.
* These need to be removed in order to detect the same episode each time the feed is pulled.
*
* An RSS feed may include an `id` in the query string. In these cases we want to leave the `id`.
* @see https://github.com/advplyr/audiobookshelf/issues/1896
*
* @param {string} url - rss feed episode url
* @returns {string} rss feed episode url without dynamic query strings
*/
getCleanEpisodeUrl(url) {
let queryString = url.split('?')[1]
if (!queryString) return url
const searchParams = new URLSearchParams(queryString)
for (const p of Array.from(searchParams.keys())) {
if (p !== 'id') searchParams.delete(p)
}
if (!searchParams.toString()) return url
return `${url}?${searchParams.toString()}`
},
inputUpdate() {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
if (!this.search?.trim()) {
this.searchText = ''
this.checkSetIsSelectedAll()
return
}
this.searchText = this.search.toLowerCase().trim()
this.checkSetIsSelectedAll()
}, 500)
},
toggleSelectAll(val) {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
else this.$set(this.selectedEpisodes, String(i), val)
for (const episode of this.episodesList) {
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
}
},
checkSetIsSelectedAll() {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
for (const episode of this.episodesList) {
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
this.selectAll = false
return
}
}
this.selectAll = true
},
toggleSelectEpisode(index, episode) {
if (this.itemEpisodeMap[episode.enclosure.url]) return
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
toggleSelectEpisode(episode) {
if (this.itemEpisodeMap[episode.cleanUrl]) return
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
this.checkSetIsSelectedAll()
},
submit() {
var episodesToDownload = []
let episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
}
var payloadSize = JSON.stringify(episodesToDownload).length
var sizeInMb = payloadSize / 1024 / 1024
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
const payloadSize = JSON.stringify(episodesToDownload).length
const sizeInMb = payloadSize / 1024 / 1024
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
console.log('Request size', sizeInMb)
if (sizeInMb > 4.99) {
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
@@ -144,17 +210,24 @@ export default {
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
console.error('Failed to download episodes', error)
this.processing = false
this.$toast.error(errorMsg)
this.$toast.error(error.response?.data || 'Failed to download episodes')
this.selectedEpisodes = {}
this.selectAll = false
})
},
init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.episodesCleaned = this.episodes
.filter((ep) => ep.enclosure?.url)
.map((_ep) => {
return {
..._ep,
cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url)
}
})
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.selectAll = false
this.selectedEpisodes = {}
}
+26 -5
View File
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" id="podcast-wrapper" class="p-2 md:p-8 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
@@ -28,6 +28,17 @@
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="$strings.LabelGenres" />
</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">
<ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" />
</div>
@@ -82,7 +93,10 @@ export default {
itunesPageUrl: '',
itunesId: '',
itunesArtistId: '',
autoDownloadEpisodes: false
autoDownloadEpisodes: false,
language: '',
explicit: false,
type: ''
}
}
},
@@ -140,6 +154,9 @@ export default {
selectedFolderPath() {
if (!this.selectedFolder) return ''
return this.selectedFolder.fullPath
},
podcastTypes() {
return this.$store.state.globals.podcastTypes || []
}
},
methods: {
@@ -170,7 +187,9 @@ export default {
itunesPageUrl: this.podcast.itunesPageUrl,
itunesId: this.podcast.itunesId,
itunesArtistId: this.podcast.itunesArtistId,
language: this.podcast.language
language: this.podcast.language,
explicit: this.podcast.explicit,
type: this.podcast.type
},
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
}
@@ -205,9 +224,11 @@ export default {
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
this.podcast.itunesId = this._podcastData.id || ''
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.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]) {
this.selectedFolderId = this.folderItems[0].value
this.folderUpdated()
@@ -226,4 +247,4 @@ export default {
#episodes-scroll {
max-height: calc(80vh - 200px);
}
</style>
</style>
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="opml-feeds-modal" :width="1000" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
@@ -97,7 +97,7 @@ export default {
},
methods: {
toFeedMetadata(feed) {
var metadata = feed.metadata
const metadata = feed.metadata
return {
title: metadata.title,
author: metadata.author,
@@ -122,9 +122,9 @@ export default {
},
async submit() {
this.processing = true
var newFeedPayloads = this.feedMetadata.map((metadata) => {
const newFeedPayloads = this.feedMetadata.map((metadata) => {
return {
path: `${this.selectedFolderPath}\\${this.$sanitizeFilename(metadata.title)}`,
path: `${this.selectedFolderPath}/${this.$sanitizeFilename(metadata.title)}`,
folderId: this.selectedFolderId,
libraryId: this.currentLibrary.id,
media: {
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="podcast-episode-view-modal" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.LabelEpisode }}</p>
<p class="text-3xl text-white truncate">{{ $strings.LabelEpisode }}</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
@@ -8,7 +8,7 @@
<ui-text-input-with-label v-model="newEpisode.episode" :label="$strings.LabelEpisode" />
</div>
<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 class="w-2/5 p-1">
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" />
@@ -24,11 +24,17 @@
</div>
</div>
<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 v-if="enclosureUrl" class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
<div v-if="enclosureUrl" class="pb-4 pt-6">
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label>
</ui-text-input-with-label>
</div>
<div v-else class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
@@ -89,6 +95,9 @@ export default {
},
enclosureUrl() {
return this.enclosure.url
},
episodeTypes() {
return this.$store.state.globals.episodeTypes || []
}
},
methods: {
@@ -122,28 +131,43 @@ export default {
}
return updatePayload
},
submit() {
const payload = this.getUpdatePayload()
if (!Object.keys(payload).length) {
return this.$toast.info('No updates were made')
async saveAndClose() {
const wasUpdated = await this.submit()
if (wasUpdated !== null) this.$emit('close')
},
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.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
.then(() => {
this.isProcessing = false
const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => {
console.error('Failed update episode', error)
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.$emit('close')
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
console.error('Failed update episode', error)
this.isProcessing = false
this.$toast.error(errorMsg)
})
return true
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
}
return false
}
},
mounted() {}
}
</script>
</script>
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
@@ -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>
</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 v-else class="w-full">
<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" />
<p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
</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="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p>
@@ -41,7 +63,12 @@ export default {
return {
processing: false,
newFeedSlug: null,
currentFeed: null
currentFeed: null,
metadataDetails: {
preventIndexing: true,
ownerName: '',
ownerEmail: ''
}
}
},
watch: {
@@ -105,9 +132,12 @@ export default {
return
}
this.processing = true
const payload = {
serverAddress: window.origin,
slug: this.newFeedSlug
slug: this.newFeedSlug,
metadataDetails: this.metadataDetails
}
if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
@@ -123,6 +153,9 @@ export default {
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
})
.finally(() => {
this.processing = false
})
},
copyToClipboard(str) {
this.$copyToClipboard(str, this)
@@ -0,0 +1,124 @@
<template>
<modals-modal v-model="show" name="rss-feed-view-modal" :processing="processing" :width="700" :height="'unset'">
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="feed" class="w-full">
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
<div class="w-full relative">
<ui-text-input v-model="feed.feedUrl" readonly />
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
</div>
<div v-if="feed.meta" class="mt-5">
<div class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
</div>
<div>{{ feed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
</div>
<div v-if="feed.meta.ownerName" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
</div>
<div>{{ feed.meta.ownerName }}</div>
</div>
<div v-if="feed.meta.ownerEmail" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
</div>
<div>{{ feed.meta.ownerEmail }}</div>
</div>
</div>
<!-- -->
<div class="episodesTable mt-2">
<div class="bg-primary bg-opacity-40 h-12 header">
{{ $strings.LabelEpisodeTitle }}
</div>
<div class="scroller">
<div v-for="episode in feed.episodes" :key="episode.id" class="h-8 text-xs truncate">
{{ episode.title }}
</div>
</div>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
feed: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
_feed() {
return this.feed || {}
}
},
methods: {
copyToClipboard(str) {
this.$copyToClipboard(str, this)
}
},
mounted() {}
}
</script>
<style scoped>
.episodesTable {
width: 100%;
max-width: 100%;
border: 1px solid #474747;
display: flex;
flex-direction: column;
}
.episodesTable div.header {
background-color: #272727;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 4px 8px;
}
.episodesTable .scroller {
display: flex;
flex-direction: column;
max-height: 250px;
overflow-x: hidden;
overflow-y: scroll;
}
.episodesTable .scroller div {
background-color: #373838;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: flex-start;
height: 32px;
flex: 0 0 32px;
}
.episodesTable .scroller div:nth-child(even) {
background-color: #2f2f2f;
}
</style>
+21 -16
View File
@@ -15,7 +15,7 @@
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none z-10">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
@@ -38,7 +38,8 @@ export default {
currentChapter: {
type: Object,
default: () => {}
}
},
playbackRate: Number
},
data() {
return {
@@ -63,6 +64,10 @@ export default {
}
},
computed: {
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterDuration() {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentChapter.start
@@ -81,11 +86,11 @@ export default {
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const time = baseTime + (perc * duration);
const offsetX = e.offsetX
const perc = offsetX / this.trackWidth
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const time = baseTime + perc * duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
@@ -111,7 +116,7 @@ export default {
this.updateReadyTrack()
},
updateReadyTrack() {
var widthReady = Math.round(this.trackWidth * this.percentReady)
const widthReady = Math.round(this.trackWidth * this.percentReady)
if (this.readyTrackWidth === widthReady) return
this.readyTrackWidth = widthReady
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 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) {
return
}
@@ -133,7 +138,7 @@ export default {
},
setChapterTicks() {
this.chapterTicks = this.chapters.map((chap) => {
var perc = chap.start / this.duration
const perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
@@ -141,12 +146,12 @@ export default {
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
const offsetX = e.offsetX
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const progressTime = (offsetX / this.trackWidth) * duration;
const totalTime = baseTime + progressTime;
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const progressTime = (offsetX / this.trackWidth) * duration
const totalTime = baseTime + progressTime
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
@@ -167,7 +172,7 @@ export default {
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
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)
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" />
</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">
<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>
</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>
</template>
@@ -92,6 +92,11 @@ export default {
useChapterTrack: false
}
},
watch: {
playbackRate() {
this.updateTimestamp()
}
},
computed: {
sleepTimerRemainingString() {
var rounded = Math.round(this.sleepTimerRemaining)
@@ -213,18 +218,14 @@ export default {
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
if (this.playbackRate >= 10) return
this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
this.setPlaybackRate(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
if (this.playbackRate <= 0.5) return
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
this.setPlaybackRate(this.playbackRate)
},
setPlaybackRate(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
@@ -289,14 +290,13 @@ export default {
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
},
updateTimestamp() {
var ts = this.$refs.currentTimestamp
const ts = this.$refs.currentTimestamp
if (!ts) {
console.error('No timestamp el')
return
}
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
var currTimeClean = this.$secondsToTimestamp(time)
ts.innerText = currTimeClean
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
},
setBufferTime(bufferTime) {
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
@@ -312,7 +312,7 @@ export default {
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate)
this.setPlaybackRate(this.playbackRate)
},
settingsUpdated(settings) {
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 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">
<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">
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
<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>
</div>
</div>
@@ -21,7 +24,8 @@ export default {
data() {
return {
el: null,
content: null
content: null,
checkboxValue: false
}
},
watch: {
@@ -57,6 +61,18 @@ export default {
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() {
return this.type === 'yesNo'
},
@@ -84,10 +100,11 @@ export default {
this.show = false
},
confirm() {
if (this.callback) this.callback(true)
if (this.callback) this.callback(true, this.checkboxValue)
this.show = false
},
setShow() {
this.checkboxValue = this.checkboxDefaultValue
this.$eventBus.$emit('showing-prompt', true)
document.body.appendChild(this.el)
setTimeout(() => {
+121 -39
View File
@@ -1,11 +1,11 @@
<template>
<div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong
@@ -14,39 +14,38 @@
</div>
</div>
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'">
<span class="material-icons text-xl">download</span>
</a>
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
<div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-xl">menu</span>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
<div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden m-auto comicwrapper relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="overflow-hidden w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="h-full flex justify-center">
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
</div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator />
</div>
</div>
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
<ui-loading-indicator />
</div> -->
</div>
</template>
@@ -61,7 +60,13 @@ Archive.init({
export default {
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
},
data() {
return {
@@ -71,6 +76,7 @@ export default {
mainImg: null,
page: 0,
numPages: 0,
pageMenuWidth: 256,
showPageMenu: false,
showInfoMenu: false,
loadTimeout: null,
@@ -87,17 +93,79 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
canGoNext() {
return this.page < this.numPages - 1
return this.page < this.numPages
},
canGoPrev() {
return this.page > 0
return this.page > 1
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation)
},
cleanedPageNames() {
return (
this.pages?.map((p) => {
if (p.length > 50) {
let firstHalf = p.slice(0, 22)
let lastHalf = p.slice(p.length - 23)
return `${firstHalf} ... ${lastHalf}`
}
return p
}) || []
)
}
},
methods: {
clickShowPageMenu() {
this.showInfoMenu = false
this.showPageMenu = !this.showPageMenu
},
clickShowInfoMenu() {
this.showPageMenu = false
this.showInfoMenu = !this.showInfoMenu
},
updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
if (this.savedPage === this.page) {
return
}
const payload = {
ebookLocation: this.page,
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('ComicReader.updateProgress failed:', error)
})
},
clickOutside() {
if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false
@@ -110,12 +178,15 @@ export default {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
setPage(index) {
if (index < 0 || index > this.numPages - 1) {
setPage(page) {
if (page <= 0 || page > this.numPages) {
return
}
var filename = this.pages[index]
this.page = index
this.showPageMenu = false
this.showInfoMenu = false
const filename = this.pages[page - 1]
this.page = page
this.updateProgress()
return this.extractFile(filename)
},
setLoadTimeout() {
@@ -145,10 +216,11 @@ export default {
},
async extract() {
this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
})
const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject()
@@ -164,9 +236,28 @@ export default {
this.numPages = this.pages.length
// Calculate page menu size
const largestFilename = this.cleanedPageNames
.map((p) => p)
.sort((a, b) => a.length - b.length)
.pop()
const pEl = document.createElement('p')
pEl.innerText = largestFilename
pEl.style.fontSize = '0.875rem'
pEl.style.opacity = 0
pEl.style.position = 'absolute'
document.body.appendChild(pEl)
const textWidth = pEl.getBoundingClientRect()?.width
if (textWidth) {
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
}
pEl.remove()
if (this.pages.length) {
this.loading = false
await this.setPage(0)
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
await this.setPage(startPage)
this.loadedFirstPage = true
} else {
this.$toast.error('Unable to extract pages')
@@ -212,8 +303,8 @@ export default {
},
parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,5}/g)
if (!numbersinpath || !numbersinpath.length) {
var numbersinpath = basename.match(/\d+/g)
if (!numbersinpath?.length) {
return {
index: -1,
filename
@@ -249,15 +340,6 @@ export default {
<style scoped>
.pagemenu {
max-height: calc(100vh - 60px);
}
.comicimg {
height: calc(100vh - 40px);
margin: auto;
}
.comicwrapper {
width: 100vw;
height: calc(100vh - 40px);
margin-top: 20px;
max-height: calc(100% - 48px);
}
</style>
+302 -86
View File
@@ -1,19 +1,15 @@
<template>
<div class="h-full w-full">
<div class="h-full flex items-center">
<div style="width: 100px; max-width: 100px" class="h-full 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>
</div>
<div id="frame" class="w-full" style="height: 650px">
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
<div class="py-4 flex justify-center" style="height: 50px">
<p>{{ progress }}%</p>
</div>
</div>
<div style="width: 100px; max-width: 100px" class="h-full 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>
<div id="epub-reader" class="h-full w-full">
<div class="h-full flex items-center justify-center">
<button type="button" aria-label="Previous page" class="w-24 max-w-24 h-full hidden sm:flex items-center overflow-x-hidden justify-center opacity-50 hover:opacity-100">
<span v-if="hasPrev" class="material-icons text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</button>
<div id="frame" class="w-full" style="height: 80%">
<div id="viewer"></div>
</div>
<button type="button" aria-label="Next page" class="w-24 max-w-24 h-full hidden sm:flex items-center justify-center overflow-x-hidden opacity-50 hover:opacity-100">
<span v-if="hasNext" class="material-icons text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</button>
</div>
</div>
</template>
@@ -21,109 +17,329 @@
<script>
import ePub from 'epubjs'
/**
* @typedef {object} EpubReader
* @property {ePub.Book} book
* @property {ePub.Rendition} rendition
*/
export default {
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
},
data() {
return {
windowWidth: 0,
windowHeight: 0,
/** @type {ePub.Book} */
book: null,
/** @type {ePub.Rendition} */
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115,
spread: 'auto'
}
}
},
computed: {},
methods: {
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
watch: {
playerOpen() {
this.resize()
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
},
hasPrev() {
return !this.rendition?.location?.atStart
},
hasNext() {
return !this.rendition?.location?.atEnd
},
/** @returns {Array<ePub.NavItem>} */
chapters() {
return this.book?.navigation?.toc || []
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userMediaProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi
if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null
return this.userMediaProgress.ebookLocation
},
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
readerWidth() {
if (this.windowWidth < 640) return this.windowWidth
return this.windowWidth - 200
},
readerHeight() {
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
return this.windowHeight - 164
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
themeRules() {
const isDark = this.ereaderSettings.theme === 'dark'
const fontColor = isDark ? '#fff' : '#000'
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
const lineSpacing = this.ereaderSettings.lineSpacing / 100
const fontScale = this.ereaderSettings.fontScale / 100
return {
'*': {
color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`,
'line-height': lineSpacing * fontScale + 'rem!important'
},
a: {
color: `${fontColor}!important`
}
}
}
},
methods: {
updateSettings(settings) {
this.ereaderSettings = settings
if (!this.rendition) return
this.applyTheme()
const fontScale = settings.fontScale || 100
this.rendition.themes.fontSize(`${fontScale}%`)
this.rendition.spread(settings.spread || 'auto')
},
prev() {
if (this.rendition) {
this.rendition.prev()
}
if (!this.rendition?.manager) return
return this.rendition?.prev()
},
next() {
if (this.rendition) {
this.rendition.next()
if (!this.rendition?.manager) return
return this.rendition?.next()
},
goToChapter(href) {
if (!this.rendition?.manager) return
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) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
/**
* @param {object} payload
* @param {string} payload.ebookLocation - CFI of the current location
* @param {string} payload.ebookProgress - eBook Progress Percentage
*/
updateProgress(payload) {
if (!this.keepProgress) return
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.savedEbookLocation === 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() {
// var book = ePub(this.url, {
// requestHeaders: {
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
/** @type {EpubReader} */
const reader = this
this.rendition = book.renderTo('viewer', {
width: window.innerWidth - 200,
height: 600,
ignoreClass: 'annotator-hl',
/** @type {ePub.Book} */
reader.book = new ePub(reader.ebookUrl, {
width: this.readerWidth,
height: this.readerHeight - 50,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
})
/** @type {ePub.Rendition} */
reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth,
height: this.readerHeight * 0.8,
spread: 'auto',
snap: true,
manager: 'continuous',
spread: 'always'
flow: 'paginated'
})
var displayed = this.rendition.display()
book.ready
.then(() => {
console.log('Book ready')
return book.locations.generate(1600)
// load saved progress
reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
reader.rendition.on('rendered', () => {
this.applyTheme()
})
reader.book.ready.then(() => {
// set up event listeners
reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('keydown', reader.keyUp)
reader.rendition.on('touchstart', (event) => {
this.$emit('touchstart', event)
})
.then((locations) => {
// console.log('Loaded locations', locations)
// Wait for book to be rendered to get current page
displayed.then(() => {
// Get the current CFI
var currentLocation = this.rendition.currentLocation()
if (!currentLocation.start) {
console.error('No Start', currentLocation)
} else {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
// console.log('current page', currentPage)
}
reader.rendition.on('touchend', (event) => {
this.$emit('touchend', event)
})
// 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.windowHeight = window.innerHeight
this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
},
applyTheme() {
if (!this.rendition) return
this.rendition.getContents().forEach((c) => {
c.addStylesheetRules(this.themeRules)
})
}
},
mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
window.addEventListener('resize', this.resize)
this.initEpub()
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.book?.destroy()
}
}
</script>
-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>
+26 -5
View File
@@ -1,7 +1,7 @@
<template>
<div class="w-full h-full">
<div class="h-full max-h-full w-full">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-16 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" width="100%"> Loading </iframe>
</div>
</div>
@@ -15,12 +15,30 @@ import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
fileId: String
},
data() {
return {}
},
computed: {},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
}
},
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
@@ -78,8 +96,11 @@ export default {
},
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
})
var reader = new FileReader()
reader.onload = async (event) => {
+107 -8
View File
@@ -11,15 +11,19 @@
</div>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
</div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
</div>
</div>
</div>
@@ -30,17 +34,26 @@
</template>
<script>
import pdf from 'vue-pdf'
import pdf from '@teckel/vue-pdf'
export default {
components: {
pdf
},
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
},
data() {
return {
windowWidth: 0,
windowHeight: 0,
scale: 1,
rotate: 0,
loadedRatio: 0,
page: 1,
@@ -48,35 +61,121 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
fitToPageWidth() {
return this.pdfHeight * 0.6
},
pdfWidth() {
return this.pdfHeight * 0.6667
return this.fitToPageWidth * this.scale
},
pdfHeight() {
return window.innerHeight - 120
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight - 120
return this.windowHeight - 284
},
maxScale() {
return Math.floor((this.windowWidth * 10) / this.fitToPageWidth) / 10
},
canGoNext() {
return this.page < this.numPages
},
canGoPrev() {
return this.page > 1
},
canScaleUp() {
return this.scale < this.maxScale
},
canScaleDown() {
return this.scale > 1
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation)
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
pdfDocInitParams() {
return {
url: this.ebookUrl,
httpHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}
}
},
methods: {
zoomIn() {
this.scale += 0.1
},
zoomOut() {
this.scale -= 0.1
},
updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
const payload = {
ebookLocation: this.page,
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
},
loadedEvt() {
if (this.savedPage > 0 && this.savedPage <= this.numPages) {
this.page = this.savedPage
}
},
progressEvt(progress) {
this.loadedRatio = progress
},
numPagesLoaded(e) {
this.numPages = e
},
prev() {
if (this.page <= 1) return
this.page--
this.updateProgress()
},
next() {
if (this.page >= this.numPages) return
this.page++
this.updateProgress()
},
error(err) {
console.error(err)
},
resize() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
}
},
mounted() {}
mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
window.addEventListener('resize', this.resize)
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
}
}
</script>
+249 -32
View File
@@ -1,24 +1,113 @@
<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" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
<div class="absolute top-4 left-4 z-20 flex items-center">
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">menu</span>
</button>
<button v-if="hasSettings" @click="openSettings" type="button" aria-label="Ereader settings" class="mx-4 inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-1.5xl">settings</span>
</button>
</div>
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
<h1 :data-type="ebookType" class="text-lg sm:text-xl md:text-2xl mb-1 data-[type=comic]:hidden" style="line-height: 1.15; font-weight: 100">
<span style="font-weight: 600">{{ abTitle }}</span>
<span v-if="abAuthor" class="hidden md:inline"> </span>
<span v-if="abAuthor" class="hidden md:inline">{{ abAuthor }}</span>
</h1>
</div>
<div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
<button @click="close" type="button" aria-label="Close ereader" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">close</span>
</button>
</div>
<div class="absolute top-4 left-4 font-book">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
<p v-if="abAuthor">by {{ abAuthor }}</p>
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" />
<!-- 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="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC">
<div class="p-4 h-full">
<div class="flex items-center mb-2">
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">arrow_back</span>
</button>
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
</div>
<div class="tocContent">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
<ul v-if="chapter.subitems.length">
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
<!-- ereader settings modal -->
<modals-modal v-model="showSettings" name="ereader-settings-modal" :width="500" :height="'unset'" :processing="false">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
<p class="text-xl md:text-3xl text-white truncate">{{ $strings.HeaderEreaderSettings }}</p>
</div>
</template>
<div class="px-2 py-4 md:p-8 w-full text-base rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
<div class="flex items-center mb-4">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelTheme }}:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-4">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelFontScale }}:</p>
</div>
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-4">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelLineSpacing }}:</p>
</div>
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" @input="settingsUpdated" />
</div>
<div class="flex items-center">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelLayout }}:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
</div>
</div>
</modals-modal>
</div>
</template>
<script>
export default {
data() {
return {}
return {
touchstartX: 0,
touchstartY: 0,
touchendX: 0,
touchendY: 0,
touchstartTime: 0,
touchIdentifier: null,
chapters: [],
tocOpen: false,
showSettings: false,
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115,
spread: 'auto'
}
}
},
watch: {
show(newVal) {
@@ -36,14 +125,47 @@ export default {
this.$store.commit('setShowEReader', val)
}
},
ereaderTheme() {
if (this.isEpub) return this.ereaderSettings.theme
return 'dark'
},
spreadItems() {
return [
{
text: this.$strings.LabelLayoutSinglePage,
value: 'none'
},
{
text: this.$strings.LabelLayoutSplitPage,
value: 'auto'
}
]
},
themeItems() {
return [
{
text: this.$strings.LabelThemeDark,
value: 'dark'
},
{
text: this.$strings.LabelThemeLight,
value: 'light'
}
]
},
componentName() {
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
else if (this.ebookType === 'epub') return 'readers-epub-reader'
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
hasSettings() {
return this.isEpub
},
abTitle() {
return this.mediaMetadata.title
},
@@ -66,10 +188,18 @@ export default {
return this.selectedLibraryItem.folderId
},
ebookFile() {
// ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
}
return this.media.ebookFile
},
ebookFormat() {
if (!this.ebookFile) return null
// Use file extension for supplementary ebook
if (!this.ebookFile.ebookFormat) {
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
}
return this.ebookFile.ebookFormat
},
ebookType() {
@@ -91,43 +221,119 @@ export default {
isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
},
ebookUrl() {
if (!this.ebookFile) return null
let filepath = ''
if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
}
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
},
userToken() {
return this.$store.getters['user/getToken']
},
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
ebookFileId() {
return this.$store.state.ereaderFileId
},
isDarkTheme() {
return this.ereaderSettings.theme === 'dark'
}
},
methods: {
readerMounted() {
if (this.isEpub) {
this.loadEreaderSettings()
}
},
settingsUpdated() {
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
},
toggleToC() {
this.tocOpen = !this.tocOpen
this.chapters = this.$refs.readerComponent.chapters
},
openSettings() {
this.showSettings = true
},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
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) {
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
this.prev()
} else if (action === this.$hotkeys.EReader.CLOSE) {
this.close()
}
},
next() {
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
},
prev() {
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
},
handleGesture() {
// Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
const touchTimeMs = Date.now() - this.touchstartTime
if (touchTimeMs >= 1000) {
console.log('Touch too long', touchTimeMs)
return
}
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
if (touchDistance < 60) {
return
}
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
return
}
if (this.touchendX < this.touchstartX) {
this.next()
}
if (this.touchendX > this.touchstartX) {
this.prev()
}
},
touchstart(e) {
// Ignore rapid touch
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
return
}
this.touchstartX = e.touches[0].screenX
this.touchstartY = e.touches[0].screenY
this.touchstartTime = Date.now()
this.touchIdentifier = e.touches[0].identifier
},
touchend(e) {
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
return
}
this.touchendX = e.changedTouches[0].screenX
this.touchendY = e.changedTouches[0].screenY
this.handleGesture()
},
registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey)
document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend)
},
unregisterListeners() {
this.$eventBus.$off('reader-hotkey', this.hotkey)
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
},
loadEreaderSettings() {
try {
const settings = localStorage.getItem('ereaderSettings')
if (settings) {
this.ereaderSettings = JSON.parse(settings)
this.settingsUpdated()
}
} catch (error) {
console.error('Failed to load ereader settings', error)
}
},
init() {
this.registerListeners()
@@ -147,8 +353,19 @@ export default {
</script>
<style>
/* @import url(@/assets/calibre/basic.css); */
.ebook-viewer {
height: calc(100% - 96px);
.tocContent {
height: calc(100% - 36px);
overflow-y: auto;
}
#reader {
height: 100%;
}
#reader.reader-player-open {
height: calc(100% - 164px);
}
@media (max-height: 400px) {
#reader.reader-player-open {
height: 100%;
}
}
</style>
@@ -1,6 +1,6 @@
<template>
<div class="w-96 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>
<div class="relative w-96 h-72">
<div class="absolute top-0 left-0">
<template v-for="lbl in yAxisLabels">
@@ -27,7 +27,7 @@
<div class="absolute -bottom-2 left-0 flex ml-6">
<template v-for="dayObj in last7Days">
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
<p class="text-sm font-book">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
<p class="text-sm">{{ dayObj.dayOfWeekAbbr }}</p>
</div>
</template>
</div>
@@ -108,6 +108,7 @@ export default {
var _date = this.$addDaysToToday(i * -1)
days.push({
dayOfWeek: this.$formatJsDate(_date, 'EEEE'),
dayOfWeekAbbr: this.$formatJsDate(_date, 'EEE'),
date: this.$formatJsDate(_date, 'yyyy-MM-dd')
})
}
@@ -218,4 +219,4 @@ export default {
},
mounted() {}
}
</script>
</script>
+4 -5
View File
@@ -68,7 +68,7 @@ export default {
dayLabels() {
return [
{
label: 'Mon',
label: this.$formatJsDate(new Date(2023, 0, 2), 'EEE'),
style: {
transform: `translate(${-25}px, ${13}px)`,
lineHeight: '10px',
@@ -76,7 +76,7 @@ export default {
}
},
{
label: 'Wed',
label: this.$formatJsDate(new Date(2023, 0, 4), 'EEE'),
style: {
transform: `translate(${-25}px, ${13 * 3}px)`,
lineHeight: '10px',
@@ -84,7 +84,7 @@ export default {
}
},
{
label: 'Fri',
label: this.$formatJsDate(new Date(2023, 0, 6), 'EEE'),
style: {
transform: `translate(${-25}px, ${13 * 5}px)`,
lineHeight: '10px',
@@ -235,7 +235,6 @@ export default {
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
})
}
console.log('Data', this.data)
this.monthLabels = []
var lastMonth = null
@@ -270,4 +269,4 @@ export default {
},
beforeDestroy() {}
}
</script>
</script>
+17 -11
View File
@@ -6,7 +6,7 @@
</svg>
<div class="px-2">
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
</div>
</div>
@@ -14,17 +14,17 @@
<span class="material-icons text-7xl">show_chart</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
</div>
</div>
<div class="flex px-4">
<div v-if="isBookLibrary" class="flex px-4">
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
</svg>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalAuthors }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
</div>
</div>
@@ -32,7 +32,7 @@
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalSizeNum }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
</div>
</div>
@@ -40,7 +40,7 @@
<span class="material-icons-outlined text-6xl pt-1">audio_file</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ numAudioTracks }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
</div>
</div>
</div>
@@ -58,26 +58,32 @@ export default {
return {}
},
computed: {
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
user() {
return this.$store.state.user.user
},
totalItems() {
return this.libraryStats ? this.libraryStats.totalItems : 0
return this.libraryStats?.totalItems || 0
},
totalAuthors() {
return this.libraryStats ? this.libraryStats.totalAuthors : 0
return this.libraryStats?.totalAuthors || 0
},
numAudioTracks() {
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
return this.libraryStats?.numAudioTracks || 0
},
totalDuration() {
return this.libraryStats ? this.libraryStats.totalDuration : 0
return this.libraryStats?.totalDuration || 0
},
totalHours() {
return Math.round(this.totalDuration / (60 * 60))
},
totalSizePretty() {
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
var totalSize = this.libraryStats?.totalSize || 0
return this.$bytesPretty(totalSize, 1)
},
totalSizeNum() {
@@ -0,0 +1,115 @@
<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="110" @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}/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}/download?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.$strings.MessageConfirmDeleteFile,
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() {
this.$downloadFile(this.downloadUrl, this.track.metadata.filename)
}
},
mounted() {}
}
</script>
+21 -10
View File
@@ -17,18 +17,18 @@
<td>
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
</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>
<div class="w-full flex flex-row items-center justify-center">
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
<ui-btn v-if="backup.serverVersion && backup.key" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
<span class="material-icons-outlined text-2xl text-error">error_outline</span>
</ui-tooltip>
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
<button aria-label="Download Backup" class="inline-flex material-icons text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
<button aria-label="Delete Backup" class="inline-flex material-icons text-xl mx-1 text-white/70 hover:text-error" @click="deleteBackupClick(backup)">delete</button>
</div>
</td>
</tr>
@@ -46,7 +46,7 @@
<p class="text-error text-lg font-semibold">{{ $strings.MessageImportantNotice }}</p>
<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">
<ui-btn color="primary" @click="showConfirmApply = false">{{ $strings.ButtonNevermind }}</ui-btn>
<div class="flex-grow" />
@@ -71,9 +71,18 @@ export default {
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
}
},
methods: {
downloadBackup(backup) {
this.$downloadFile(`${process.env.serverUrl}/api/backups/${backup.id}/download?token=${this.userToken}`)
},
confirm() {
this.showConfirmApply = false
@@ -85,12 +94,13 @@ export default {
})
.catch((error) => {
this.isBackingUp = false
console.error('Failed', error)
this.$toast.error(this.$strings.ToastBackupRestoreFailed)
console.error('Failed to apply backup', error)
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
this.$toast.error(errorMsg)
})
},
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.$axios
.$delete(`/api/backups/${backup.id}`)
@@ -154,6 +164,7 @@ export default {
this.$axios
.$get('/api/backups')
.then((data) => {
this.$emit('loaded', data.backupLocation)
this.setBackups(data.backups || [])
})
.catch((error) => {
@@ -208,4 +219,4 @@ export default {
padding-bottom: 5px;
background-color: #333;
}
</style>
</style>

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