Compare commits

...

228 Commits

Author SHA1 Message Date
advplyr 40b342498f Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-28 18:40:34 -05:00
advplyr e220b2818a Add docker-build workflow 2022-04-28 18:40:29 -05:00
advplyr 0df36d2609 Merge pull request #523 from mediacowboy/patch-1
Update readme.md
2022-04-28 17:43:50 -05:00
MediaCowboy adfe50a841 Update readme.md
Updated the pull command to reflect the new docker repo.
2022-04-27 22:26:44 -05:00
advplyr 35925ddc1b Merge pull request #522 from selfhost-alt/skip-matching-identified-media
Add options to skip matching media items if they already have an ASIN/ISBN
2022-04-27 20:14:04 -05:00
advplyr 33dfb764fa Add:Support for openaudible folder structure (subject to change), add support for treating single audio files in the root directory as library items #401 2022-04-27 19:42:34 -05:00
advplyr 49bef2c641 Fix:Uploader removing single item from parsed upload items #530 2022-04-27 18:08:07 -05:00
advplyr ac58536501 Fix:Drag n drop folder upload 2022-04-27 18:03:00 -05:00
advplyr c344555be3 Fix:default user settings for orderBy and default to sort ascending for titles and authors #515 2022-04-27 17:20:44 -05:00
MediaCowboy 645bcc53c6 Update readme.md
Removed the --rm from the docker install command and added Docker Update section
2022-04-26 21:28:24 -05:00
Selfhost Alt 84dd06dfc4 Add options to skip matching media items if they already have an ASIN/ISBN 2022-04-26 17:36:29 -07:00
advplyr 0a73dd6437 Add:Ability to ignore directories by putting a file named .ignore inside dir #516 2022-04-26 19:11:32 -05:00
advplyr 2cc055a1ad Fix:checkbox default check color add to tailwind safelist #521 2022-04-26 18:14:11 -05:00
advplyr d8ec3bd218 Merge pull request #512 from selfhost-alt/log-empty-folder-path-on-scan
Log full path when warning about empty root
2022-04-25 19:14:54 -05:00
advplyr d189ec74c9 Update item api endpoint to include user media progress with item if using query string include=progress and optionally episode=episodeid - for mobile app downloads 2022-04-25 19:03:26 -05:00
advplyr 4291769b93 Fix:Filter checks on server to check for mediaType 2022-04-25 17:36:18 -05:00
Selfhost Alt 22900a3f67 Log full path when warning about empty root 2022-04-25 15:28:03 -07:00
advplyr 7fa08449de Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-25 16:39:02 -05:00
advplyr 4f7203fccb Update docker template 2022-04-25 16:38:57 -05:00
advplyr 0eea766931 Merge pull request #509 from jflattery/patch-1
Change default to ghcr
2022-04-25 14:58:18 -05:00
advplyr 5c054aef90 Merge pull request #508 from jflattery/patch-2
Change default to ghcr
2022-04-25 14:57:54 -05:00
Jim Flattery a1674d5da1 Change default to ghcr 2022-04-25 15:45:08 -04:00
Jim Flattery 91597a5454 Change default to ghcr 2022-04-25 15:43:58 -04:00
advplyr 11354a3e3f Version bump 2.0.3 2022-04-24 19:18:43 -05:00
advplyr dcd4f69383 Fix: set downloaded/uploaded cover owner and permissions and if creating intitial config/metadata directories at startup then set owner of those #394 2022-04-24 19:12:00 -05:00
advplyr e253939c1e Fix: upload page files table selectable filename, size and type #406 2022-04-24 18:55:26 -05:00
advplyr f25ce1c0e7 Fix: overlapping text on collections book table #410 2022-04-24 18:51:11 -05:00
advplyr 7717e57c16 Fix: add extra check for valid names and valid author name #502 2022-04-24 18:41:47 -05:00
advplyr 2e28c9b06d Add: button on issues page to remove all library items with issues #476 2022-04-24 18:25:33 -05:00
advplyr 4bc7cd2045 Fix: show books with invalid audio files and add error icon on book items #491 2022-04-24 18:05:15 -05:00
advplyr 5389115120 Add: Button on series page to mark all series as finished #452 2022-04-24 17:46:21 -05:00
advplyr 6e99cf6570 Fix: filter sort authors and series, authors page sort alphabetical #497 2022-04-24 17:15:41 -05:00
advplyr 21bdd9f9ec Fix set invalid flag to false when adding first episode to an empty podcast library item, dont show podcast errors on episode cards 2022-04-24 17:03:43 -05:00
advplyr e3ae3f7e6a Update personalized api endpoint to new optimal function that only loops through library items once 2022-04-24 16:56:30 -05:00
advplyr 74bf917150 Update readme 2022-04-24 11:14:30 -05:00
advplyr 5666b263f5 Readme updates and banner update to represent podcasts 2022-04-24 11:11:49 -05:00
advplyr fc8fec62a0 Version bump 2.0.2 2022-04-23 19:41:35 -05:00
advplyr 034d858f18 Change new podcast modal to remove episode download list #494, Fix error when importing many episodes (set max size to 5MB) #493, show podcast episodes downloading and in queue on podcast landing page 2022-04-23 19:41:06 -05:00
advplyr ebc9e1a888 Fix batch mark as finished and clear selection #490 2022-04-23 17:17:05 -05:00
advplyr c5a9c2bf5a Merge pull request #489 from selfhost-alt/configurable-backup-size
Make maximum backup size configurable
2022-04-23 17:06:59 -05:00
advplyr 3dbce8fd71 Fix:Persist playback rate #419 2022-04-23 16:51:13 -05:00
advplyr b2d299dba6 Remove open playback sessions for user when starting a new playback session 2022-04-23 16:18:34 -05:00
Selfhost Alt cb5d9a8287 Add explicit byte conversion variable to make code more self-documenting 2022-04-23 10:26:37 -07:00
Selfhost Alt f9530897c0 Add tooltip to explain the max backup size 2022-04-23 10:23:01 -07:00
Selfhost Alt 7c7e8285a4 Make maximum backup size configurable 2022-04-23 10:19:31 -07:00
advplyr 7b3f9a1e0c Add bulkInsertEntities to db to handle migrating large collections 2022-04-23 06:25:16 -05:00
advplyr 399e0ea0bc Merge pull request #486 from selfhost-alt/quickmatch-updates-media-descriptions
Set description when quick matching media
2022-04-23 06:00:59 -05:00
advplyr a47b0bce57 Merge pull request #485 from selfhost-alt/fix-scan-error
Update folder update logic to use new media path name
2022-04-23 05:59:10 -05:00
Selfhost Alt 4b60b4f73e Set description when quick matching media 2022-04-22 23:19:46 -07:00
Selfhost Alt d88b20addd Update folder update logic to use new media path name 2022-04-22 22:29:38 -07:00
advplyr 5d12cc3f23 Podcast home page shelves for currently listening episodes, newest episodes. Podcast episode card 2022-04-22 19:31:11 -05:00
advplyr 84fb7ce8b3 Merge pull request #484 from benonymity/search_fix
Fix libraryItem ID reference in global search
2022-04-22 18:03:56 -05:00
benonymity 243cc672f7 Fix libraryItem in global search, same fix as app 2022-04-22 18:58:43 -04:00
advplyr 663546dd77 Fix edit modal registering/unregistering library item listeners #483 2022-04-22 17:42:49 -05:00
advplyr 1b79b3f42d Add secondary sort by series sort title when sorting by author #274 2022-04-22 17:11:03 -05:00
advplyr d4525ad5ca Version bump 2.0.1 and Fix db function validation 2022-04-22 12:44:24 -05:00
advplyr dc9c307663 Fix user tags issue 2022-04-22 05:00:52 -05:00
advplyr 554e9ec238 Remove download button form item landing page 2022-04-22 04:53:09 -05:00
advplyr 2276228531 Fix user permissions restricted by tag #421 2022-04-21 19:29:15 -05:00
advplyr 6f7d2ef4cd Merge pull request #477 from jflattery/master
remove redunant line
2022-04-21 18:52:53 -05:00
advplyr ad3fbe7abf Add back in m4b merge downloader in experimental #478 2022-04-21 18:52:28 -05:00
jflattery c58110c7b7 remove redunant line 2022-04-21 18:08:45 +00:00
advplyr f781fa9e6b Add green finished line for series #454 2022-04-21 08:55:29 -05:00
advplyr 7f3543400a Add realtime updates to collections bookshelf 2022-04-21 08:30:44 -05:00
advplyr 1ff5637c1b Fix user issue sending POST requests to play endpoints #473 2022-04-21 07:24:54 -05:00
advplyr f2d9de5a5f Library stats page links to genres, authors, items #453, use overall days when hours > 10000 2022-04-20 18:43:39 -05:00
advplyr 8be3bebee8 Fix showing series on book landing page 2022-04-20 18:20:31 -05:00
advplyr ef88972b25 Fix total listening time stats check for strings, remove from experimental since listening sessions are created for all playbacks 2022-04-20 18:16:27 -05:00
advplyr 35f3b5863f Add library match all back updated to support v2 models 2022-04-20 18:05:09 -05:00
advplyr ff294867f8 Fix library folder check if folder exists and if not then attempt to create folder and set permissions, fix library folder check for changes before saving 2022-04-20 17:49:34 -05:00
advplyr 1c6cd7499b Remove old cover method make sure cover filename is an actual image 2022-04-20 17:34:20 -05:00
advplyr ce35ae6b03 Merge pull request #469 from jflattery/master
Increase readability of logs
2022-04-20 16:38:50 -05:00
jflattery 28c99cf17f Increase readability of logs
Add podcast title to log output when autodownload fails
2022-04-20 17:35:15 +00:00
advplyr 584e754eae Remove db log from testing 2022-04-20 08:38:24 -05:00
advplyr 68cf748e77 Fix previous version check for db migration to v2 2022-04-20 08:31:57 -05:00
advplyr 9b8f53caf6 abmetadata generator fixes 2022-04-20 07:41:45 -05:00
advplyr fdf332937f Remove match books on library item temporarily until implemented 2022-04-19 21:49:12 -05:00
advplyr 182545a729 Fix ebook scan 2022-04-19 21:10:24 -05:00
advplyr e83df2bf4b Update migration version 2022-04-19 20:55:40 -05:00
advplyr 10299e3037 Merge pull request #465 from selfhost-alt/filter-by-missing-fields
Proposal: Add a filter for media that is missing specific fields
2022-04-19 05:02:12 -05:00
advplyr 6a43672973 Merge pull request #464 from selfhost-alt/include-filter-name-in-ui
Include the type of filter being applied in the UI
2022-04-19 04:59:35 -05:00
Selfhost Alt 02bf55b401 Add a filter for media that is missing specific fields 2022-04-18 21:47:03 -07:00
Selfhost Alt f0615c2971 Include the type of filter being applied in the UI 2022-04-18 21:20:32 -07:00
advplyr 7ef44eb75b Fix episode sort by publishedAt instead of pubDate 2022-04-18 18:09:23 -05:00
advplyr 044804115b Version bump 2.0.0 2022-04-18 08:10:55 -05:00
advplyr 3b941d59a3 Merge pull request #463 from selfhost-alt/strict-asin-check
Update Audible scraper to be more strict about what it considers an ASIN and a valid ASIN query response
2022-04-18 07:06:55 -05:00
advplyr d69f6020c6 Fix podcast episode playback session duration, use podcast episode plaintext description 2022-04-17 17:52:06 -05:00
Selfhost Alt 2fc60e4e9c Handle an undefined publisher_summary when querying Audible 2022-04-16 14:57:36 -07:00
Selfhost Alt cdcfd01da2 Only consider an Audible ASIN query successful if the response contains an author 2022-04-16 11:55:58 -07:00
Selfhost Alt d6c5b6e8c6 Implement a stricter check for possible ASIN values in titles 2022-04-16 10:40:10 -07:00
advplyr 5d305c96ad Add support for WMA and AIFF audio files #449, add remove orphan streams, clean up audio mime type logic 2022-04-16 12:37:10 -05:00
advplyr 6d823f4e42 Podcast episode audio file to always use index 1 2022-04-15 20:49:13 -05:00
advplyr bd5e865a11 Merge pull request #461 from rasmuslos/master
Convert timeListened to float
2022-04-15 07:58:59 -05:00
Rasmus Krämer cd274e0844 Merge branch 'master' of https://github.com/rasmuslos/audiobookshelf 2022-04-15 12:59:45 +02:00
Rasmus Krämer e9249430c3 Parse current time as float 2022-04-15 12:59:42 +02:00
Rasmus Krämer cd5e5099f2 Merge branch 'advplyr:master' into master 2022-04-15 12:22:16 +02:00
Rasmus Krämer 09dd90e3fc Convert timeListened to int 2022-04-15 12:22:00 +02:00
advplyr a62f7a4861 Update uploader to support podcast folder structure 2022-04-14 18:24:24 -05:00
advplyr 5a26b01ffb Add LibrarySettings and update edit library modal to include settings tab 2022-04-14 17:15:52 -05:00
advplyr cbde451120 Add redirects for media types on unsupported pages 2022-04-14 12:57:34 -05:00
advplyr 8bbeae4873 Fix check podcast episodes cronjob 2022-04-14 10:15:42 -05:00
advplyr 05dff2583a Backups to store server version in zip details and check and show alert for old backups created before version 2.0.0 2022-04-13 18:51:06 -05:00
advplyr 79a82df914 Remove NFO metadata and save metadata button 2022-04-13 18:23:44 -05:00
advplyr 3f6ed6dbf9 Add Podcast match tab and find covers 2022-04-13 18:13:39 -05:00
advplyr 4edba20e9e Update podcast search page to support manually entering podcast RSS feed 2022-04-13 16:55:48 -05:00
advplyr 2c6e1cc2b5 Merge pull request #459 from jflattery/master
podcast episode number & accessibility improvements
2022-04-13 16:06:45 -05:00
jflattery e1af25d9d8 Accessible tweaks 2022-04-13 20:17:00 +00:00
jflattery 9b30a8ff4b Accessibility Labels: User Account Icon 2022-04-13 19:14:44 +00:00
jflattery b1a9de819e Improve Accessibility: Zoom Labels 2022-04-13 19:10:03 +00:00
Jim Flattery 68da974c12 Merge branch 'advplyr:master' into master 2022-04-13 11:01:47 -04:00
jflattery 8c47ccb651 Add episode number
Add episode number to list group view
2022-04-13 15:00:20 +00:00
advplyr d544ecc657 Merge pull request #458 from jflattery/master
Make sort column title more clear & add ep#
2022-04-13 08:40:13 -05:00
jflattery 9f69a8ace3 Make sort column title more clear & add ep# 2022-04-13 13:29:31 +00:00
advplyr a90cfc4d04 Fix experimental e-reader with new data model 2022-04-13 08:26:43 -05:00
advplyr 88354de495 Fix abmetadata chapter parser 2022-04-13 07:57:21 -05:00
advplyr 5b02c5185f Fix fs error library item 2022-04-13 04:55:39 -05:00
advplyr 1152e5513e Add podcast episode sorting and saving sort order 2022-04-12 18:07:13 -05:00
advplyr 8ce9b55969 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-12 17:32:59 -05:00
advplyr ccf08e9e80 Merge pull request #457 from jflattery/master
Update and dedupe packages
2022-04-12 17:32:53 -05:00
advplyr b0b1d2707d Add podcast episode date picker for pubDate 2022-04-12 17:32:27 -05:00
advplyr 469278cd1e Fix:Global search support podcasts 2022-04-12 16:54:52 -05:00
advplyr 10d9e11387 Update abmetadata file for new data model, add chapter and description section parser 2022-04-12 16:05:16 -05:00
jflattery 5328f4cddb Dedupe packages 2022-04-12 18:03:43 +00:00
jflattery 4154022ad1 Update Packages 2022-04-12 18:00:00 +00:00
advplyr 642e9787c0 Merge pull request #456 from rasmuslos/master
Fixed "select all" button
2022-04-12 07:09:15 -05:00
Rasmus Krämer da2e65c042 Merge branch 'master' of https://github.com/rasmuslos/audiobookshelf 2022-04-12 14:05:28 +02:00
Rasmus Krämer ab895fa8ed Filter all episodes when selecting all 2022-04-12 14:05:24 +02:00
Rasmus Krämer f5e892b862 allow connections from the mobile app while running in dev env 2022-04-12 13:57:45 +02:00
advplyr ac097862fc Update sorting and filtering for podcasts, add title ignore prefix to podcast metadata, check user permissions for podcast episode row UI 2022-04-11 19:42:09 -05:00
advplyr 23cc6bb210 Add published at to podcast episode row #428, Fix podcast select episodes, fix save order of podcast episode, fix remove podcast episode 2022-04-10 11:01:50 -05:00
advplyr c60807f998 Removing remaining legacy objects, remove njodb error for fileExists 2022-04-10 10:05:05 -05:00
advplyr 99e2ea228d Update chromecast with new data model 2022-04-10 06:02:53 -05:00
advplyr 8df05896b5 Fix remove media progress use libraryItemId 2022-04-09 20:30:18 -05:00
advplyr 174dac8fd4 Add collapse series, add filter by series include sequence and sort, show number of episodes on podcast card 2022-04-09 19:44:46 -05:00
advplyr 2a386ca2a9 Add sync local media progress routes for offline mobile playback session support 2022-04-09 17:56:51 -05:00
advplyr fc228013d3 Merge pull request #448 from rasmuslos/bulk-download
Added select all option to the episode selector
2022-04-09 05:36:06 -05:00
advplyr 64b824ef6b Merge pull request #445 from rasmuslos/token-env
Only fall back to the default secret when no env var is provided
2022-04-09 05:33:10 -05:00
Rasmus Krämer 96cd91a385 Added select all episodes option to episode feed 2022-04-09 11:44:31 +02:00
Rasmus Krämer 5c91c1e2c7 Added select all option to the episode selector 2022-04-09 10:25:24 +02:00
Rasmus Krämer 2df5ab0dde Only fall back to the default secret when no is provided 2022-04-09 09:25:13 +02:00
advplyr baf738f5ba Fix updating media progress object id 2022-04-08 19:27:35 -05:00
advplyr 3a7cafbb95 Update media progress object to use unique id for podcast episodes 2022-04-08 19:19:47 -05:00
advplyr 3276b04256 Fix authors filter query string 2022-04-08 18:34:30 -05:00
advplyr ac3fa31d1e Update Podcast Episode add libraryItemId, expanded returns audioTrack object 2022-04-05 19:40:40 -05:00
advplyr 6e5e638076 Update Book.js to return array of AudioTrack objects on json expand 2022-04-03 16:01:59 -05:00
advplyr 609bf4309f Merge pull request #439 from Albuca/patch-1
Change 'Current' to 'Currently'
2022-04-02 18:17:30 -05:00
Albuca 66b5c14c6b Change 'Current' to 'Currently'
Nitpicking verbiage tbh. Reference: https://github.com/advplyr/audiobookshelf/issues/431
2022-04-02 17:37:44 -05:00
advplyr e4936ed522 Add chapters to playback session 2022-04-02 11:41:17 -05:00
advplyr c201e2aa98 Add mediaPlayer to playback session 2022-04-02 11:19:57 -05:00
advplyr 3d3f20296c Add displayTitle and displayAuthor to playback session 2022-04-02 10:26:42 -05:00
advplyr 9ae71615bc Add:Match tab show current value next to new match value #431 2022-03-31 17:10:02 -05:00
advplyr 292840a0e3 Update njodb path and add proper-lockfile package 2022-03-31 16:34:24 -05:00
advplyr 84e6e6fdbe Include njodb statically & fix write stream issue 2022-03-31 16:32:50 -05:00
advplyr cfe27dff80 Add:Server setting to set custom sorting prefixes to ignore #358 2022-03-31 15:07:50 -05:00
advplyr c75895d711 Fix:Podcast scanner get embedded cover art 2022-03-28 20:23:16 -05:00
advplyr c0ff28ffff Add recent series and authors bookshelf rows on home 2022-03-27 16:16:08 -05:00
advplyr 58dfa65660 Fix update podcast episode api route; 2022-03-27 15:46:57 -05:00
advplyr 3f8e685d64 Podcasts add get episode feed and download, add edit podcast episode modal 2022-03-27 15:37:04 -05:00
advplyr 08e1782253 Fix use first accessible library depending on display order, default library id checked on server when authenticating 2022-03-27 09:45:28 -05:00
advplyr 0dd219f303 Add podcast episode auto download new episodes cron 2022-03-26 19:58:59 -05:00
advplyr d5e96a3422 Fix podcast re-scan, fix more menu item 2022-03-26 19:00:55 -05:00
advplyr 03bfecefee Podcast episode playing fix title and author 2022-03-26 18:30:58 -05:00
advplyr 12027b9a76 Podcast episode player fixes, episode table ui updates 2022-03-26 18:23:33 -05:00
advplyr 0e665e2091 Add playing podcast episodes, episode progress, podcast page, podcast home page shelves 2022-03-26 17:41:26 -05:00
advplyr e32d05ea27 Podcast library item card, edit details, batch edit 2022-03-26 15:23:25 -05:00
advplyr 5446aea910 Add Scanner support for podcasts 2022-03-26 14:29:49 -05:00
advplyr 86e7c7fc33 Merge pull request #426 from jflattery/master
Upgrade Node to v16 and update packages
2022-03-26 12:51:51 -05:00
advplyr 173b72c3b5 Add:Purge cache promp alert 2022-03-26 12:08:05 -05:00
advplyr 3150822117 New data model removing media entity for books 2022-03-26 11:59:34 -05:00
jflattery 9a96d17a30 Update NPM Packages
Update all NPM packages addressing several CVEs
2022-03-25 22:14:02 +00:00
jflattery c98409b9ae Address three CVEs
Addresses CVE-2021-3749 (HIGH), CVE-2022-0155 (HIGH), and CVE-2022-0536 (MEDIUM).
2022-03-24 17:34:34 +00:00
jflattery 0e3640c246 Upgrade Node to v16
As Node.JS v12 is EOL in April 2022, project should move to a newer version.
2022-03-24 15:38:02 +00:00
RailRoad e030b59bae Address CVE-2022-21676
Upgraded socket.io to 4.4.1 to address uncaught Exception in older version of engine.io
2022-03-24 15:19:48 +00:00
advplyr 920ca683b9 Podcast episode downloader, update podcast data model 2022-03-21 19:24:38 -05:00
advplyr 28d76d21f1 Add expand library item authors to /items/:id route 2022-03-21 05:08:33 -05:00
advplyr e1e6b46456 Create podcast manager and re-organize managers 2022-03-20 16:41:06 -05:00
advplyr 122f2a2556 New data model fix collections page & table 2022-03-20 16:16:39 -05:00
advplyr 27f1bd90f9 Add:Restrict user permissions by tag 2022-03-20 06:29:08 -05:00
advplyr f8d0384155 Migration change metadata folder from /books to /items, podcast data model updates, add podcast routes 2022-03-19 10:13:10 -05:00
advplyr 43bbfbfee3 Fix library check path and set provider, update podcast model and UI 2022-03-19 06:41:54 -05:00
advplyr deadc63dbb Add podcast add modal 2022-03-18 19:16:54 -05:00
advplyr a9b9e23f46 Library update migrate to use book mediaType, disable editing mediaType, set icon instead of media category 2022-03-18 17:09:17 -05:00
advplyr 6a06ba4327 Fix player content url, update user progress object include media entity id, update reset progress route 2022-03-18 15:31:46 -05:00
advplyr 3d2bbc7719 Fix bug with creating new series & authors on scan 2022-03-18 14:08:57 -05:00
advplyr c9ea5dd2d7 New data model backups and move backups to API endpoints 2022-03-18 13:44:29 -05:00
advplyr eea3e2583c New data model fix library stats 2022-03-18 12:37:47 -05:00
advplyr 57399bb79e Clean up ApiRouter adding MiscController, move upload and scan to api endpoints 2022-03-18 11:51:55 -05:00
advplyr 69fcb103e4 Fix:Updating author name to update author name on each library item 2022-03-18 09:38:36 -05:00
advplyr f00b120e96 New data model scanner update and change scan chunks to be based on total file size 2022-03-18 09:16:10 -05:00
advplyr 14a8f84446 New data model update bookmarks and bookmark routes to use API 2022-03-17 20:28:04 -05:00
advplyr 099ae7c776 New data model play media entity, PlaybackSessionManager 2022-03-17 19:10:47 -05:00
advplyr 1cf9e85272 New data model update MeController user progress routes 2022-03-17 13:33:22 -05:00
advplyr c4eeb1cfb7 New data model Book media type contains Audiobooks updates 2022-03-17 12:25:12 -05:00
advplyr 1dde02b170 Add user API token with copy to clipboard 2022-03-17 09:28:31 -05:00
advplyr 08e648a3bc Fix db migration 2022-03-17 09:07:02 -05:00
advplyr 755e70b4a9 Fix db migration 2022-03-17 09:04:10 -05:00
advplyr 5ff4cd2c0b Merge pull request #423 from Quietus/configurablehost
Allowed the configuration of a "HOST" parameter to enable ipv6 support.
2022-03-17 08:18:55 -05:00
advplyr e36c31c5e7 Add HOST config for docker and debian 2022-03-17 08:18:39 -05:00
Quietus d561a48229 Allowed the configuration of a "HOST" parameter to enable ipv6 support. 2022-03-17 11:06:52 +00:00
advplyr 5243a225e8 Update sample book library item 2022-03-16 19:22:16 -05:00
advplyr 4fe60465e5 New data model change of Book media type to include array of Audiobook and Ebook objects 2022-03-16 19:15:25 -05:00
advplyr 0af6ad63c1 New data model start of PlaybackSessionManager to replace StreamManager, remove podcast & ip npm package 2022-03-15 19:28:54 -05:00
advplyr 68b13ae45f New data model migration for users, bookmarks and playback sessions 2022-03-15 18:57:15 -05:00
advplyr 4c2ad3ede5 Add author edit modal & remove from experimental 2022-03-14 18:53:49 -05:00
advplyr deea6702f0 Change Library object use mediaCategory, allow adding new manual folder path, validate folder paths, fix Watcher re-init after folder path updates 2022-03-14 09:56:24 -05:00
advplyr 7348432594 New data model update for Match tab 2022-03-14 08:12:28 -05:00
advplyr 7d66f1eec9 New data model edit tracks page, match, quick match, clean out old files 2022-03-13 19:34:31 -05:00
advplyr be1e1e7ba0 New data model update stats page and routes, update users page 2022-03-13 17:33:50 -05:00
advplyr 4bdef893af New data model batch routes and batch editor 2022-03-13 17:10:48 -05:00
advplyr 6597fca576 New data model fix scan for creating series/authors and mapping ebooks 2022-03-13 13:47:36 -05:00
advplyr ea9ec13845 New data model for global search input and search page 2022-03-13 12:39:12 -05:00
advplyr 30f15d3575 Add:Authors page match authors and display author image 2022-03-13 10:35:35 -05:00
advplyr dad12537b6 New data model authors routes 2022-03-13 06:42:43 -05:00
advplyr 65df377a49 New model update audio player, stream, collections 2022-03-12 19:59:35 -06:00
advplyr 2d19208340 New model updates for series, collections, authors routes 2022-03-12 18:50:31 -06:00
advplyr 73257188f6 New data model save covers, scanner, new api routes 2022-03-12 17:45:32 -06:00
advplyr 5f4e5cd3d8 New model update details, author and series inputs with create new, compare & copy utils 2022-03-11 19:46:32 -06:00
advplyr f2be3bc95e Add multi select dropdown with query from server 2022-03-10 19:13:19 -06:00
advplyr 2a30cc428f New api routes, updating web client pages, audiobooks to libraryItem migration 2022-03-10 18:45:02 -06:00
advplyr b97ed953f7 Add db migration file to change audiobooks to library items with new data model 2022-03-09 19:23:17 -06:00
advplyr 65793f7109 Start of new data model 2022-03-08 19:31:44 -06:00
advplyr 2b7f53b0a7 Add:Support for book folders with CD# subfolders #393 2022-03-07 16:22:20 -06:00
advplyr c6eb1096e8 Add:Podcast search page 2022-03-06 19:02:06 -06:00
advplyr a907c88f66 Add:iTunes search api metadata provider #381 2022-03-06 17:26:35 -06:00
advplyr 43f48b65f8 Add:Podcast iTunes search api and iTunes provider 2022-03-06 16:32:04 -06:00
advplyr 2a4cbd48b8 Remove old API routes 2022-03-06 09:51:56 -06:00
advplyr b6e4f3a8c5 Add:Podcast RSS feed parser 2022-03-05 18:54:24 -06:00
advplyr 83976b5549 Fix:Encode filename for audio player direct plays 2022-03-05 17:28:15 -06:00
259 changed files with 43202 additions and 15817 deletions
+62
View File
@@ -0,0 +1,62 @@
---
name: Build and Push Docker Image
on:
push:
branches: [master]
tags:
- 'v*.*.*'
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
release:
types: [published, edited]
# Allows you to run workflow manually from Actions tab
workflow_dispatch:
jobs:
build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-20.04
steps:
- name: Check out
uses: actions/checkout@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
tags: |
type=edge,branch=master
type=semver,pattern={{version}}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Dockerhub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to ghcr
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Build image
uses: docker/build-push-action@v2
with:
tags: ${{ steps.meta.outputs.tags }}
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
+2
View File
@@ -4,6 +4,7 @@ node_modules/
/config/ /config/
/audiobooks/ /audiobooks/
/audiobooks2/ /audiobooks2/
/podcasts/
/media/ /media/
/metadata/ /metadata/
test/ test/
@@ -12,3 +13,4 @@ test/
/dist/ /dist/
sw.* sw.*
.DS_STORE
+2 -2
View File
@@ -1,12 +1,12 @@
### STAGE 0: Build client ### ### STAGE 0: Build client ###
FROM node:12-alpine AS build FROM node:16-alpine AS build
WORKDIR /client WORKDIR /client
COPY /client /client COPY /client /client
RUN npm install RUN npm install
RUN npm run generate RUN npm run generate
### STAGE 1: Build server ### ### STAGE 1: Build server ###
FROM node:12-alpine FROM node:16-alpine
RUN apk update && apk add --no-cache --update ffmpeg RUN apk update && apk add --no-cache --update ffmpeg
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=build /client/dist /client/dist COPY --from=build /client/dist /client/dist
+5 -2
View File
@@ -6,6 +6,7 @@ FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks" DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
DEFAULT_DATA_PATH="/usr/share/audiobookshelf" DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
DEFAULT_PORT=7331 DEFAULT_PORT=7331
DEFAULT_HOST="0.0.0.0"
CONFIG_PATH="/etc/default/audiobookshelf" CONFIG_PATH="/etc/default/audiobookshelf"
@@ -82,7 +83,8 @@ setup_config_interactive() {
CONFIG_PATH=$DATA_PATH/config CONFIG_PATH=$DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$PORT" PORT=$PORT
HOST=$DEFAULT_HOST"
echo "$config_text" echo "$config_text"
@@ -105,7 +107,8 @@ setup_config() {
CONFIG_PATH=$DEFAULT_DATA_PATH/config CONFIG_PATH=$DEFAULT_DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$DEFAULT_PORT" PORT=$DEFAULT_PORT
HOST=$DEFAULT_HOST"
echo "$config_text" echo "$config_text"
+12
View File
@@ -187,3 +187,15 @@ Bookshelf Label
opacity: 1; opacity: 1;
filter: blur(20px); filter: blur(20px);
} }
.episode-subtitle {
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-height: 16px; /* fallback */
max-height: 32px; /* fallback */
-webkit-line-clamp: 2; /* number of lines to show */
-webkit-box-orient: vertical;
}
+3 -2
View File
@@ -12,7 +12,7 @@
</div> </div>
</div> </div>
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')"> <div v-if="!isPodcast" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span> <span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div> </div>
@@ -99,7 +99,8 @@ export default {
default: () => [] default: () => []
}, },
sleepTimerSet: Boolean, sleepTimerSet: Boolean,
sleepTimerRemaining: Number sleepTimerRemaining: Number,
isPodcast: Boolean
}, },
data() { data() {
return { return {
+39 -35
View File
@@ -23,15 +23,15 @@
</div> </div>
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons">equalizer</span> <span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons">upload</span> <span class="material-icons" aria-label="Upload Media" role="button">upload</span>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons">settings</span> <span class="material-icons" aria-label="System Settings" role="button">settings</span>
</nuxt-link> </nuxt-link>
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true"> <nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
@@ -44,16 +44,16 @@
</nuxt-link> </nuxt-link>
</div> </div>
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center"> <div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1> <h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom"> <ui-tooltip v-if="!isPodcastLibrary" :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" /> <ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom"> <ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" text="Add to Collection" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" /> <ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
<template v-if="userCanUpdate && numAudiobooksSelected < 50"> <template v-if="userCanUpdate && numLibraryItemsSelected < 50">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" /> <ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</template> </template>
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" /> <ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
@@ -79,6 +79,12 @@ export default {
libraryName() { libraryName() {
return this.currentLibrary ? this.currentLibrary.name : 'unknown' return this.currentLibrary ? this.currentLibrary.name : 'unknown'
}, },
libraryMediaType() {
return this.currentLibrary ? this.currentLibrary.mediaType : null
},
isPodcastLibrary() {
return this.libraryMediaType === 'podcast'
},
isHome() { isHome() {
return this.$route.name === 'library-library' return this.$route.name === 'library-library'
}, },
@@ -94,17 +100,14 @@ export default {
username() { username() {
return this.user ? this.user.username : 'err' return this.user ? this.user.username : 'err'
}, },
numAudiobooksSelected() { numLibraryItemsSelected() {
return this.selectedAudiobooks.length return this.selectedLibraryItems.length
}, },
selectedAudiobooks() { selectedLibraryItems() {
return this.$store.state.selectedAudiobooks return this.$store.state.selectedLibraryItems
}, },
userAudiobooks() { userMediaProgress() {
return this.$store.state.user.user.audiobooks || {} return this.$store.state.user.user.mediaProgress || []
},
selectedSeries() {
return this.$store.state.audiobooks.selectedSeries
}, },
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
@@ -115,11 +118,11 @@ export default {
userCanUpload() { userCanUpload() {
return this.$store.getters['user/getUserCanUpload'] return this.$store.getters['user/getUserCanUpload']
}, },
selectedIsRead() { selectedIsFinished() {
// Find an audiobook that is not read, if none then all audiobooks read // Find an item that is not finished, if none then all items finished
return !this.selectedAudiobooks.find((ab) => { return !this.selectedLibraryItems.find((libraryItemId) => {
var userAb = this.userAudiobooks[ab] var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId)
return !userAb || !userAb.isRead return !itemProgress || !itemProgress.isFinished
}) })
}, },
processingBatch() { processingBatch() {
@@ -150,25 +153,26 @@ export default {
}, },
cancelSelectionMode() { cancelSelectionMode() {
if (this.processingBatchDelete) return if (this.processingBatchDelete) return
this.$store.commit('setSelectedAudiobooks', []) this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection') this.$eventBus.$emit('bookshelf-clear-selection')
this.isAllSelected = false this.isAllSelected = false
}, },
toggleBatchRead() { toggleBatchRead() {
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
var newIsRead = !this.selectedIsRead var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => { var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
return { return {
audiobookId: ab, id: lid,
isRead: newIsRead isFinished: newIsFinished
} }
}) })
console.log('Progress payloads', updateProgressPayloads)
this.$axios this.$axios
.patch(`/api/me/audiobook/batch/update`, updateProgressPayloads) .patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => { .then(() => {
this.$toast.success('Batch update success!') this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false) this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', []) this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection') this.$eventBus.$emit('bookshelf-clear-selection')
}) })
.catch((error) => { .catch((error) => {
@@ -178,20 +182,20 @@ export default {
}) })
}, },
batchDeleteClick() { batchDeleteClick() {
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook' var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf` var 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)) { if (confirm(confirmMsg)) {
this.processingBatchDelete = true this.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
this.$axios this.$axios
.$post(`/api/books/batch/delete`, { .$post(`/api/items/batch/delete`, {
audiobookIds: this.selectedAudiobooks libraryItemIds: this.selectedLibraryItems
}) })
.then(() => { .then(() => {
this.$toast.success('Batch delete success!') this.$toast.success('Batch delete success!')
this.processingBatchDelete = false this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false) this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', []) this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection') this.$eventBus.$emit('bookshelf-clear-selection')
}) })
.catch((error) => { .catch((error) => {
-127
View File
@@ -1,127 +0,0 @@
<template>
<div class="outer-container">
<!-- absolute positioned container -->
<div class="inner-container">
<div class="relative h-10">
<div class="table-header" id="headerdiv">
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
<thead>
<tr>
<th class="header-cell min-w-12 max-w-12"></th>
<th class="header-cell min-w-6 max-w-6"></th>
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
<th class="header-cell min-w-24 max-w-24 px-2"></th>
</tr>
</thead>
</table>
</div>
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
</div>
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
<tbody>
<template v-for="book in books">
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
books: {
type: Array,
default: () => []
}
},
data() {
return {
isScrollable: false
}
},
computed: {},
methods: {
checkIsScrolled() {
if (!this.$refs.tableBody) return
this.isScrollable = this.$refs.tableBody.scrollTop > 0
},
tableScrolled() {
this.checkIsScrolled()
},
editBook(book) {
var bookIds = this.books.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', book)
}
},
mounted() {
this.checkIsScrolled()
},
beforeDestroy() {}
}
</script>
<style>
.outer-container {
position: absolute;
top: 0;
left: 0;
overflow: visible;
height: calc(100% - 50px);
width: calc(100% - 10px);
margin: 10px;
}
.inner-container {
width: 100%;
height: 100%;
position: relative;
}
.table-header {
float: left;
overflow: hidden;
width: 100%;
}
.header-shadow {
box-shadow: 3px 8px 3px #11111155;
}
.table-body {
float: left;
height: 100%;
width: inherit;
overflow-y: scroll;
padding-right: 0px;
}
.header-cell {
background-color: #22222288;
padding: 0px 4px;
text-align: left;
height: 40px;
font-size: 0.9rem;
font-weight: semi-bold;
}
.body-cell {
text-align: left;
font-size: 0.9rem;
}
.book-row {
background-color: #22222288;
}
.book-row:nth-child(odd) {
background-color: #333;
}
.book-row.selected {
background-color: rgba(0, 255, 0, 0.05);
}
</style>
-179
View File
@@ -1,179 +0,0 @@
<template>
<tr class="book-row" :class="selected ? 'selected' : ''">
<td class="body-cell min-w-12 max-w-12">
<div class="flex justify-center">
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
</div>
</td>
<td class="body-cell min-w-6 max-w-6">
<covers-hover-book-cover :audiobook="book" />
</td>
<td class="body-cell min-w-64 max-w-64 px-2">
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
<p class="truncate">
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
</p>
</nuxt-link>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ book.book.authorFL }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ seriesText }}</p>
</td>
<td class="body-cell min-w-24 max-w-24 px-2">
<p class="truncate">{{ book.book.publishYear }}</p>
</td>
<td class="body-cell min-w-80 max-w-80 px-2">
<p class="truncate">{{ book.book.description }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ book.book.narrator }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ genresText }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ tagsText }}</p>
</td>
<td class="body-cell min-w-24 max-w-24 px-2">
<div class="flex">
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
</div>
</td>
</tr>
</template>
<script>
export default {
props: {
book: {
type: Object,
default: () => {}
},
userAudiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
isProcessingReadUpdate: false
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
audiobookId() {
return this.book.id
},
selected: {
get() {
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
},
set(val) {
if (this.processingBatch) return
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
}
},
processingBatch() {
return this.$store.state.processingBatch
},
bookObj() {
return this.book.book || {}
},
series() {
return this.bookObj.series || null
},
volumeNumber() {
return this.bookObj.volumeNumber || null
},
seriesText() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
genresText() {
if (!this.bookObj.genres) return ''
return this.bookObj.genres.join(', ')
},
tagsText() {
return (this.book.tags || []).join(', ')
},
isMissing() {
return this.book.isMissing
},
isInvalid() {
return this.book.isInvalid
},
numEbooks() {
return this.book.numEbooks
},
numTracks() {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
showReadButton() {
return this.showExperimentalFeatures && this.numEbooks
},
showPlayButton() {
return !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
methods: {
selectBtnClick() {
if (this.processingBatch) return
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
},
openEbook() {
this.$store.commit('showEReader', this.book)
},
downloadClick() {
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
},
toggleRead() {
var updatePayload = {
isRead: !this.userIsRead
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
},
startStream() {
this.$eventBus.$emit('play-audiobook', this.book.id)
},
editClick() {
this.$emit('edit', this.book)
}
},
mounted() {}
}
</script>
+89 -49
View File
@@ -7,13 +7,16 @@
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div> <div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
</div> </div>
<div v-if="loaded && !shelves.length && isRootUser" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="loaded && !shelves.length && isRootUser && !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">Audiobookshelf is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div class="flex"> <div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn> <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 Audiobooks</ui-btn> <ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div> </div>
</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>
</div>
<div v-else class="w-full flex flex-col items-center"> <div v-else class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in shelves"> <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="bookCoverAspectRatio" /> <app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
@@ -50,6 +53,9 @@ export default {
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
bookCoverWidth() { bookCoverWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize') var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio) return coverSize * 1.6 if (this.isCoverSquareAspectRatio) return coverSize * 1.6
@@ -85,7 +91,7 @@ export default {
}, },
async fetchCategories() { async fetchCategories() {
var categories = await this.$axios var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`) .$get(`/api/libraries/${this.currentLibraryId}/personalized`)
.then((data) => { .then((data) => {
return data return data
}) })
@@ -97,53 +103,61 @@ export default {
}, },
async setShelvesFromSearch() { async setShelvesFromSearch() {
var shelves = [] var shelves = []
if (this.results.audiobooks) { if (this.results.books && this.results.books.length) {
shelves.push({ shelves.push({
id: 'audiobooks', id: 'books',
label: 'Books', label: 'Books',
type: 'books', type: 'book',
entities: this.results.audiobooks.map((ab) => ab.audiobook) entities: this.results.books.map((res) => res.libraryItem)
}) })
} }
if (this.results.series) { if (this.results.podcasts && this.results.podcasts.length) {
shelves.push({
id: 'podcasts',
label: 'Podcasts',
type: 'podcast',
entities: this.results.podcasts.map((res) => res.libraryItem)
})
}
if (this.results.series && this.results.series.length) {
shelves.push({ shelves.push({
id: 'series', id: 'series',
label: 'Series', label: 'Series',
type: 'series', type: 'series',
entities: this.results.series.map((seriesObj) => { entities: this.results.series.map((seriesObj) => {
return { return {
name: seriesObj.series, name: seriesObj.series.name,
books: seriesObj.audiobooks, series: seriesObj.series,
books: seriesObj.books,
type: 'series' type: 'series'
} }
}) })
}) })
} }
if (this.results.tags) { if (this.results.tags && this.results.tags.length) {
shelves.push({ shelves.push({
id: 'tags', id: 'tags',
label: 'Tags', label: 'Tags',
type: 'tags', type: 'tags',
entities: this.results.tags.map((tagObj) => { entities: this.results.tags.map((tagObj) => {
return { return {
name: tagObj.tag, name: tagObj.name,
books: tagObj.audiobooks, books: tagObj.books || [],
type: 'tags' type: 'tags'
} }
}) })
}) })
} }
if (this.results.authors) { if (this.results.authors && this.results.authors.length) {
shelves.push({ shelves.push({
id: 'authors', id: 'authors',
label: 'Authors', label: 'Authors',
type: 'authors', type: 'authors',
entities: this.results.authors.map((a) => { entities: this.results.authors.map((a) => {
return { return {
id: a.author, ...a,
name: a.author,
numBooks: a.numBooks,
type: 'author' type: 'author'
} }
}) })
@@ -153,74 +167,98 @@ export default {
}, },
settingsUpdated(settings) {}, settingsUpdated(settings) {},
scan() { scan() {
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId) this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
}, },
audiobookAdded(audiobook) { libraryItemAdded(libraryItem) {
console.log('Audiobook added', audiobook) console.log('libraryItem added', libraryItem)
// TODO: Check if audiobook would be on this shelf // TODO: Check if libraryItem would be on this shelf
if (!this.search) { if (!this.search) {
this.fetchCategories() this.fetchCategories()
} }
}, },
audiobookUpdated(audiobook) { libraryItemUpdated(libraryItem) {
console.log('Audiobook updated', audiobook) console.log('libraryItem updated', libraryItem)
this.shelves.forEach((shelf) => { this.shelves.forEach((shelf) => {
if (shelf.type === 'books') { if (shelf.type == 'book' || shelf.type == 'podcast') {
shelf.entities = shelf.entities.map((ent) => { shelf.entities = shelf.entities.map((ent) => {
if (ent.id === audiobook.id) { if (ent.id === libraryItem.id) {
return audiobook return libraryItem
} }
return ent return ent
}) })
} else if (shelf.type === 'series') { } else if (shelf.type === 'series') {
shelf.entities.forEach((ent) => { shelf.entities.forEach((ent) => {
ent.books = ent.books.map((book) => { ent.books = ent.books.map((book) => {
if (book.id === audiobook.id) return audiobook if (book.id === libraryItem.id) return libraryItem
return book return book
}) })
}) })
} }
}) })
}, },
removeBookFromShelf(audiobook) { removeBookFromShelf(libraryItem) {
this.shelves.forEach((shelf) => { this.shelves.forEach((shelf) => {
if (shelf.type === 'books') { if (shelf.type == 'book' || shelf.type == 'podcast') {
shelf.entities = shelf.entities.filter((ent) => { shelf.entities = shelf.entities.filter((ent) => {
return ent.id !== audiobook.id return ent.id !== libraryItem.id
}) })
} else if (shelf.type === 'series') { } else if (shelf.type === 'series') {
shelf.entities.forEach((ent) => { shelf.entities.forEach((ent) => {
ent.books = ent.books.filter((book) => { ent.books = ent.books.filter((book) => {
return book.id !== audiobook.id return book.id !== libraryItem.id
}) })
}) })
} }
}) })
}, },
audiobookRemoved(audiobook) { libraryItemRemoved(libraryItem) {
this.removeBookFromShelf(audiobook) this.removeBookFromShelf(libraryItem)
}, },
audiobooksAdded(audiobooks) { libraryItemsAdded(libraryItems) {
console.log('audiobooks added', audiobooks) console.log('libraryItems added', libraryItems)
// TODO: Check if audiobook would be on this shelf // TODO: Check if audiobook would be on this shelf
if (!this.search) { if (!this.search) {
this.fetchCategories() this.fetchCategories()
} }
}, },
audiobooksUpdated(audiobooks) { libraryItemsUpdated(items) {
audiobooks.forEach((ab) => { items.forEach((li) => {
this.audiobookUpdated(ab) this.libraryItemUpdated(li)
})
},
authorUpdated(author) {
this.shelves.forEach((shelf) => {
if (shelf.type == 'authors') {
shelf.entities = shelf.entities.map((ent) => {
if (ent.id === author.id) {
return {
...ent,
...author
}
}
return ent
})
}
})
},
authorRemoved(author) {
this.shelves.forEach((shelf) => {
if (shelf.type == 'authors') {
shelf.entities = shelf.entities.filter((ent) => ent.id != author.id)
}
}) })
}, },
initListeners() { initListeners() {
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.on('audiobook_updated', this.audiobookUpdated) this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('audiobook_added', this.audiobookAdded) this.$root.socket.on('author_removed', this.authorRemoved)
this.$root.socket.on('audiobook_removed', this.audiobookRemoved) this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated) this.$root.socket.on('item_added', this.libraryItemAdded)
this.$root.socket.on('audiobooks_added', this.audiobooksAdded) this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('items_added', this.libraryItemsAdded)
} else { } else {
console.error('Error socket not initialized') console.error('Error socket not initialized')
} }
@@ -229,11 +267,13 @@ export default {
this.$store.commit('user/removeSettingsListener', 'bookshelf') this.$store.commit('user/removeSettingsListener', 'bookshelf')
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.off('audiobook_updated', this.audiobookUpdated) this.$root.socket.off('author_updated', this.authorUpdated)
this.$root.socket.off('audiobook_added', this.audiobookAdded) this.$root.socket.off('author_removed', this.authorRemoved)
this.$root.socket.off('audiobook_removed', this.audiobookRemoved) this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated) this.$root.socket.off('item_added', this.libraryItemAdded)
this.$root.socket.off('audiobooks_added', this.audiobooksAdded) this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('items_added', this.libraryItemsAdded)
} else { } else {
console.error('Error socket not initialized') console.error('Error socket not initialized')
} }
+53 -19
View File
@@ -2,9 +2,14 @@
<div class="relative"> <div class="relative">
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled"> <div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
<div class="w-full h-full pt-6"> <div class="w-full h-full pt-6">
<div v-if="shelf.type === 'books'" class="flex items-center"> <div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities"> <template v-for="(entity, index) in shelf.entities">
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectBook" @edit="editBook" /> <cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
</template>
</div>
<div v-if="shelf.type === 'episode'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities">
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editEpisode" />
</template> </template>
</div> </div>
<div v-if="shelf.type === 'series'" class="flex items-center"> <div v-if="shelf.type === 'series'" class="flex items-center">
@@ -21,8 +26,8 @@
</div> </div>
<div v-if="shelf.type === 'authors'" class="flex items-center"> <div v-if="shelf.type === 'authors'" class="flex items-center">
<template v-for="entity in shelf.entities"> <template v-for="entity in shelf.entities">
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.name)}`"> <nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.id)}`">
<cards-author-card :width="bookCoverWidth" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" /> <cards-author-card :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
</nuxt-link> </nuxt-link>
</template> </template>
</div> </div>
@@ -43,6 +48,7 @@
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight"> <div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
<span class="material-icons text-6xl text-white">chevron_right</span> <span class="material-icons text-6xl text-white">chevron_right</span>
</div> </div>
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
</div> </div>
</template> </template>
@@ -64,12 +70,9 @@ export default {
canScrollLeft: false, canScrollLeft: false,
isScrolling: false, isScrolling: false,
scrollTimer: null, scrollTimer: null,
updateTimer: null updateTimer: null,
} showAuthorModal: false,
}, selectedAuthor: null
watch: {
isSelectionMode(newVal) {
this.updateSelectionMode(newVal)
} }
}, },
computed: { computed: {
@@ -79,9 +82,6 @@ export default {
shelfHeight() { shelfHeight() {
return this.bookCoverHeight + 48 return this.bookCoverHeight + 48
}, },
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
paddingLeft() { paddingLeft() {
if (window.innerWidth < 768) return 1 if (window.innerWidth < 768) return 1
return 2.5 return 2.5
@@ -90,29 +90,55 @@ export default {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected'] > 0 return this.$store.getters['getNumLibraryItemsSelected'] > 0
} }
}, },
methods: { methods: {
clearSelectedEntities() {
this.updateSelectionMode(false)
},
editAuthor(author) {
this.selectedAuthor = author
this.showAuthorModal = true
},
editBook(audiobook) { editBook(audiobook) {
var bookIds = this.shelf.entities.map((e) => e.id) var bookIds = this.shelf.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds) this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', audiobook) this.$store.commit('showEditModal', audiobook)
}, },
editEpisode({ libraryItem, episode }) {
this.$store.commit('setSelectedLibraryItem', libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
updateSelectionMode(val) { updateSelectionMode(val) {
var selectedAudiobooks = this.$store.state.selectedAudiobooks var selectedLibraryItems = this.$store.state.selectedLibraryItems
if (this.shelf.type === 'books') { if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
this.shelf.entities.forEach((ent) => { this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-book-${ent.id}`] var component = this.$refs[`shelf-book-${ent.id}`]
if (!component || !component.length) return if (!component || !component.length) return
component = component[0] component = component[0]
component.setSelectionMode(val) component.setSelectionMode(val)
component.selected = selectedAudiobooks.includes(ent.id) component.selected = selectedLibraryItems.includes(ent.id)
})
} else if (this.shelf.type === 'episode') {
this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
}) })
} }
}, },
selectBook(audiobook) { selectItem(libraryItem) {
this.$store.commit('toggleAudiobookSelected', audiobook.id) this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', libraryItem)
})
},
itemSelectedEvt() {
this.updateSelectionMode(this.isSelectionMode)
}, },
scrolled() { scrolled() {
clearTimeout(this.scrollTimer) clearTimeout(this.scrollTimer)
@@ -156,6 +182,14 @@ export default {
this.canScrollLeft = false this.canScrollLeft = false
} }
} }
},
mounted() {
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
},
beforeDestroy() {
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
} }
} }
</script> </script>
+91 -6
View File
@@ -12,22 +12,34 @@
</nuxt-link> </nuxt-link>
</div> </div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
<template v-if="page !== 'search' && !isHome"> <template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p> <p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
<div v-else class="items-center hidden md:flex"> <div v-else class="items-center hidden md:flex w-full">
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer"> <div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-2xl text-white">west</span> <span class="material-icons text-2xl text-white">west</span>
</div> </div>
<p class="pl-4 font-book text-lg"> <p class="pl-4 font-book text-lg">
{{ selectedSeries }} {{ seriesName }}
</p> </p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3"> <div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span> <span class="font-mono">{{ numShowing }}</span>
</div> </div>
<div class="flex-grow" />
<ui-btn color="primary" small :loading="processingSeries" class="flex items-center" @click="markSeriesFinished">
<div class="h-5 w-5">
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
</svg>
</div>
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
>
</div> </div>
<div class="flex-grow hidden sm:inline-block" /> <div class="flex-grow hidden sm:inline-block" />
<ui-checkbox v-show="showSortFilters" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" /> <ui-checkbox v-show="showSortFilters && !isPodcast" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" /> <controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" /> <controls-order-select v-show="showSortFilters" 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" />
<!-- <div v-show="showSortFilters" class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md"> <!-- <div v-show="showSortFilters" class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
@@ -38,6 +50,8 @@
<span class="material-icons" style="font-size: 1.4rem">view_list</span> <span class="material-icons" style="font-size: 1.4rem">view_list</span>
</div> </div>
</div> --> </div> -->
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
</template> </template>
<template v-else-if="page === 'search'"> <template v-else-if="page === 'search'">
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer"> <div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
@@ -56,7 +70,10 @@ export default {
props: { props: {
page: String, page: String,
isHome: Boolean, isHome: Boolean,
selectedSeries: String, selectedSeries: {
type: Object,
default: () => null
},
searchQuery: String, searchQuery: String,
viewMode: String viewMode: String
}, },
@@ -66,10 +83,18 @@ export default {
hasInit: false, hasInit: false,
totalEntities: 0, totalEntities: 0,
keywordFilter: null, keywordFilter: null,
keywordTimeout: null keywordTimeout: null,
processingSeries: false,
processingIssues: false
} }
}, },
computed: { computed: {
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
isGridMode() { isGridMode() {
return this.viewMode === 'grid' return this.viewMode === 'grid'
}, },
@@ -80,6 +105,7 @@ export default {
return this.totalEntities return this.totalEntities
}, },
entityName() { entityName() {
if (this.isPodcast) return 'Podcasts'
if (!this.page) return 'Books' if (!this.page) return 'Books'
if (this.page === 'series') return 'Series' if (this.page === 'series') return 'Series'
if (this.page === 'collections') return 'Collections' if (this.page === 'collections') return 'Collections'
@@ -99,9 +125,68 @@ export default {
}, },
showLibrary() { showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
},
seriesName() {
return this.selectedSeries ? this.selectedSeries.name : null
},
seriesProgress() {
return this.selectedSeries ? this.selectedSeries.progress : null
},
seriesLibraryItemIds() {
if (!this.seriesProgress) return []
return this.seriesProgress.libraryItemIds || []
},
isSeriesFinished() {
return this.seriesProgress && !!this.seriesProgress.isFinished
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
isIssuesFilter() {
return this.filterBy === 'issues'
} }
}, },
methods: { methods: {
removeAllIssues() {
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
this.processingIssues = true
this.$axios
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
.then(() => {
this.$toast.success('Removed library items with issues')
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.processingIssues = false
})
.catch((error) => {
console.error('Failed to remove library items with issues', error)
this.$toast.error('Failed to remove library items with issues')
this.processingIssues = false
})
}
},
markSeriesFinished() {
var newIsFinished = !this.isSeriesFinished
this.processingSeries = true
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
id: lid,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Series update success')
this.selectedSeries.progress.isFinished = newIsFinished
this.processingSeries = false
})
.catch((error) => {
this.$toast.error('Series update failed')
console.error('Failed to batch update read/not read', error)
this.processingSeries = false
})
},
searchBackArrow() { searchBackArrow() {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`) this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
}, },
+3 -3
View File
@@ -9,7 +9,7 @@
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamAudiobook && isMobileLandscape ? '300px' : '65px' }"> <div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<p class="font-mono text-sm">v{{ $config.version }}</p> <p class="font-mono text-sm">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
</div> </div>
@@ -109,8 +109,8 @@ export default {
githubTagUrl() { githubTagUrl() {
return this.versionData.githubTagUrl return this.versionData.githubTagUrl
}, },
streamAudiobook() { streamLibraryItem() {
return this.$store.state.streamAudiobook return this.$store.state.streamLibraryItem
} }
}, },
methods: { methods: {
+84 -46
View File
@@ -7,15 +7,16 @@
</template> </template>
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div class="flex"> <div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn> <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 Audiobooks</ui-btn> <ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div> </div>
</div> </div>
<div v-else-if="!totalShelves && initialized" class="w-full py-16"> <div v-else-if="!totalShelves && initialized" class="w-full py-16">
<p class="text-xl text-center">{{ emptyMessage }}</p> <p class="text-xl text-center">{{ emptyMessage }}</p>
<div class="flex justify-center mt-2"> <!-- Clear filter only available on Library bookshelf -->
<div v-if="entityName === 'books'" class="flex justify-center mt-2">
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn> <ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
</div> </div>
</div> </div>
@@ -60,7 +61,6 @@ export default {
totalShelves: 0, totalShelves: 0,
bookshelfMarginLeft: 0, bookshelfMarginLeft: 0,
isSelectionMode: false, isSelectionMode: false,
isSelectAll: false,
currentSFQueryString: null, currentSFQueryString: null,
pendingReset: false, pendingReset: false,
keywordFilter: null, keywordFilter: null,
@@ -85,10 +85,16 @@ export default {
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
}, },
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
emptyMessage() { emptyMessage() {
if (this.page === 'series') return `You have no series` if (this.page === 'series') return 'You have no series'
if (this.page === 'collections') return "You haven't made any collections yet" if (this.page === 'collections') return "You haven't made any collections yet"
if (this.hasFilter) return `No Results for filter "${this.filterValue}"` if (this.hasFilter) {
if (this.filterName === 'Issues') return 'No Issues'
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
}
return 'No results' return 'No results'
}, },
entityName() { entityName() {
@@ -143,6 +149,9 @@ export default {
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
isEntityBook() { isEntityBook() {
return this.entityName === 'series-books' || this.entityName === 'books' return this.entityName === 'series-books' || this.entityName === 'books'
}, },
@@ -183,8 +192,8 @@ export default {
// Includes margin // Includes margin
return this.entityWidth + 24 return this.entityWidth + 24
}, },
selectedAudiobooks() { selectedLibraryItems() {
return this.$store.state.selectedAudiobooks || [] return this.$store.state.selectedLibraryItems || []
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120 var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
@@ -210,13 +219,12 @@ export default {
clearSelectedEntities() { clearSelectedEntities() {
this.updateBookSelectionMode(false) this.updateBookSelectionMode(false)
this.isSelectionMode = false this.isSelectionMode = false
this.isSelectAll = false
}, },
selectEntity(entity) { selectEntity(entity) {
if (this.entityName === 'books' || this.entityName === 'series-books') { if (this.entityName === 'books' || this.entityName === 'series-books') {
this.$store.commit('toggleAudiobookSelected', entity.id) this.$store.commit('toggleLibraryItemSelected', entity.id)
var newIsSelectionMode = !!this.selectedAudiobooks.length var newIsSelectionMode = !!this.selectedLibraryItems.length
if (this.isSelectionMode !== newIsSelectionMode) { if (this.isSelectionMode !== newIsSelectionMode) {
this.isSelectionMode = newIsSelectionMode this.isSelectionMode = newIsSelectionMode
this.updateBookSelectionMode(newIsSelectionMode) this.updateBookSelectionMode(newIsSelectionMode)
@@ -239,7 +247,7 @@ export default {
this.currentSFQueryString = this.buildSearchParams() this.currentSFQueryString = this.buildSearchParams()
} }
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `books/all` : this.entityName var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1` var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
@@ -300,11 +308,11 @@ export default {
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch) var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch) var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
if (!this.pagesLoaded[firstBookPage]) { if (!this.pagesLoaded[firstBookPage]) {
console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex) // console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
this.loadPage(firstBookPage) this.loadPage(firstBookPage)
} }
if (!this.pagesLoaded[lastBookPage]) { if (!this.pagesLoaded[lastBookPage]) {
console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex) // console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
this.loadPage(lastBookPage) this.loadPage(lastBookPage)
} }
@@ -332,7 +340,6 @@ export default {
this.totalEntities = 0 this.totalEntities = 0
this.currentPage = 0 this.currentPage = 0
this.isSelectionMode = false this.isSelectionMode = false
this.isSelectAll = false
this.initialized = false this.initialized = false
this.initSizeData() this.initSizeData()
@@ -374,9 +381,7 @@ export default {
let searchParams = new URLSearchParams() let searchParams = new URLSearchParams()
if (this.page === 'series-books') { if (this.page === 'series-books') {
searchParams.set('filter', `series.${this.seriesId}`) searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
searchParams.set('sort', 'book.volumeNumber')
searchParams.set('desc', 0)
} else { } else {
if (this.filterBy && this.filterBy !== 'all') { if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy) searchParams.set('filter', this.filterBy)
@@ -385,7 +390,7 @@ export default {
searchParams.set('sort', this.orderBy) searchParams.set('sort', this.orderBy)
searchParams.set('desc', this.orderDesc ? 1 : 0) searchParams.set('desc', this.orderDesc ? 1 : 0)
} }
if (this.collapseSeries) { if (this.collapseSeries && !this.isPodcast) {
searchParams.set('collapseseries', 1) searchParams.set('collapseseries', 1)
} }
} }
@@ -425,44 +430,71 @@ export default {
this.handleScroll(scrollTop) this.handleScroll(scrollTop)
// }, 250) // }, 250)
}, },
audiobookAdded(audiobook) { libraryItemAdded(libraryItem) {
console.log('Audiobook added', audiobook) console.log('libraryItem added', libraryItem)
// TODO: Check if audiobook would be on this shelf // TODO: Check if audiobook would be on this shelf
this.resetEntities() this.resetEntities()
}, },
audiobookUpdated(audiobook) { libraryItemUpdated(libraryItem) {
console.log('Audiobook updated', audiobook) console.log('Item updated', libraryItem)
if (this.entityName === 'books' || this.entityName === 'series-books') { if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id) var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) { if (indexOf >= 0) {
this.entities[indexOf] = audiobook this.entities[indexOf] = libraryItem
if (this.entityComponentRefs[indexOf]) { if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(audiobook) this.entityComponentRefs[indexOf].setEntity(libraryItem)
} }
} }
} }
}, },
audiobookRemoved(audiobook) { libraryItemRemoved(libraryItem) {
if (this.entityName === 'books' || this.entityName === 'series-books') { if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id) var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) { if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== audiobook.id) this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
this.totalEntities = this.entities.length this.totalEntities = this.entities.length
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities) this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.remountEntities() this.executeRebuild()
} }
} }
}, },
audiobooksAdded(audiobooks) { libraryItemsAdded(libraryItems) {
console.log('audiobooks added', audiobooks) console.log('items added', libraryItems)
// TODO: Check if audiobook would be on this shelf // TODO: Check if audiobook would be on this shelf
this.resetEntities() this.resetEntities()
}, },
audiobooksUpdated(audiobooks) { libraryItemsUpdated(libraryItems) {
audiobooks.forEach((ab) => { libraryItems.forEach((ab) => {
this.audiobookUpdated(ab) this.libraryItemUpdated(ab)
}) })
}, },
collectionAdded(collection) {
if (this.entityName !== 'collections') return
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
this.resetEntities()
},
collectionUpdated(collection) {
if (this.entityName !== 'collections') return
console.log(`[LazyBookshelf] collectionUpdated ${collection.id}`, collection)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
if (indexOf >= 0) {
this.entities[indexOf] = collection
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(collection)
}
}
},
collectionRemoved(collection) {
if (this.entityName !== 'collections') return
console.log(`[LazyBookshelf] collectionRemoved ${collection.id}`, collection)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== collection.id)
this.totalEntities = this.entities.length
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.executeRebuild()
}
},
initSizeData(_bookshelf) { initSizeData(_bookshelf) {
var bookshelf = _bookshelf || document.getElementById('bookshelf') var bookshelf = _bookshelf || document.getElementById('bookshelf')
if (!bookshelf) { if (!bookshelf) {
@@ -525,11 +557,14 @@ export default {
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.on('audiobook_updated', this.audiobookUpdated) this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('audiobook_added', this.audiobookAdded) this.$root.socket.on('item_added', this.libraryItemAdded)
this.$root.socket.on('audiobook_removed', this.audiobookRemoved) this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated) this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('audiobooks_added', this.audiobooksAdded) this.$root.socket.on('items_added', this.libraryItemsAdded)
this.$root.socket.on('collection_added', this.collectionAdded)
this.$root.socket.on('collection_updated', this.collectionUpdated)
this.$root.socket.on('collection_removed', this.collectionRemoved)
} else { } else {
console.error('Bookshelf - Socket not initialized') console.error('Bookshelf - Socket not initialized')
} }
@@ -546,11 +581,14 @@ export default {
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf') this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.off('audiobook_updated', this.audiobookUpdated) this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('audiobook_added', this.audiobookAdded) this.$root.socket.off('item_added', this.libraryItemAdded)
this.$root.socket.off('audiobook_removed', this.audiobookRemoved) this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated) this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('audiobooks_added', this.audiobooksAdded) this.$root.socket.off('items_added', this.libraryItemsAdded)
this.$root.socket.off('collection_added', this.collectionAdded)
this.$root.socket.off('collection_updated', this.collectionUpdated)
this.$root.socket.off('collection_removed', this.collectionRemoved)
} else { } else {
console.error('Bookshelf - Socket not initialized') console.error('Bookshelf - Socket not initialized')
} }
@@ -563,7 +601,7 @@ export default {
} }
}, },
scan() { scan() {
this.$root.socket.emit('scan', this.currentLibraryId) this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
} }
}, },
mounted() { mounted() {
+23 -33
View File
@@ -21,7 +21,7 @@
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" 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="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" 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="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" /> <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> </svg>
@@ -31,7 +31,7 @@
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link :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'"> <nuxt-link v-if="!isPodcastLibrary" :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">collections_bookmark</span> <span class="material-icons-outlined">collections_bookmark</span>
<p class="font-book pt-1.5" style="font-size: 0.9rem">Collections</p> <p class="font-book pt-1.5" style="font-size: 0.9rem">Collections</p>
@@ -39,7 +39,7 @@
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link v-if="showExperimentalFeatures" :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'"> <nuxt-link v-if="!isPodcastLibrary" :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"> <svg class="w-6 h-6" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
@@ -52,6 +52,14 @@
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary" :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'">
<icons-podcast-svg class="w-6 h-6" />
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'"> <nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
<span class="material-icons text-2xl">warning</span> <span class="material-icons text-2xl">warning</span>
@@ -62,36 +70,6 @@
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p> <p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
</div> </div>
</nuxt-link> </nuxt-link>
<!-- <nuxt-link to="/library/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'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p>
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> -->
<!-- <nuxt-link to="/library/tags" 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 === 'tags' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p>
<div v-show="paramId === 'tags'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> -->
<!-- <nuxt-link to="/library/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="paramId === 'authors' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p>
<div v-show="paramId === 'authors'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> -->
</div> </div>
</template> </template>
@@ -110,6 +88,15 @@ export default {
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
},
homePage() { homePage() {
return this.$route.name === 'library-library' return this.$route.name === 'library-library'
}, },
@@ -125,6 +112,9 @@ export default {
showLibrary() { showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
}, },
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
showingIssues() { showingIssues() {
if (!this.$route.query) return false if (!this.$route.query) return false
return this.libraryBookshelfPage && this.$route.query.filter === 'issues' return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
+85 -52
View File
@@ -1,17 +1,18 @@
<template> <template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary 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 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :audiobook="streamAudiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</nuxt-link> </nuxt-link>
<div class="flex items-start pl-24 mb-6 md:mb-0"> <div class="flex items-start pl-24 mb-6 md:mb-0">
<div> <div>
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
{{ title }} {{ title }}
</nuxt-link> </nuxt-link>
<div class="text-gray-400 flex items-center"> <div class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span> <span class="material-icons text-sm">person</span>
<p v-if="authorFL" class="pl-1.5 text-sm sm:text-base"> <p v-if="podcastAuthor">{{ podcastAuthor }}</p>
<nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">,&nbsp;</span></nuxt-link> <p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p> </p>
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p> <p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
</div> </div>
@@ -24,7 +25,6 @@
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span> <span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
</div> </div>
<audio-player <audio-player
ref="audioPlayer" ref="audioPlayer"
:chapters="chapters" :chapters="chapters"
@@ -33,6 +33,7 @@
:bookmarks="bookmarks" :bookmarks="bookmarks"
:sleep-timer-set="sleepTimerSet" :sleep-timer-set="sleepTimerSet"
:sleep-timer-remaining="sleepTimerRemaining" :sleep-timer-remaining="sleepTimerRemaining"
:is-podcast="isPodcast"
@playPause="playPause" @playPause="playPause"
@jumpForward="jumpForward" @jumpForward="jumpForward"
@jumpBackward="jumpBackward" @jumpBackward="jumpBackward"
@@ -44,7 +45,7 @@
@showSleepTimer="showSleepTimerModal = true" @showSleepTimer="showSleepTimerModal = true"
/> />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" /> <modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
</div> </div>
@@ -60,7 +61,6 @@ export default {
totalDuration: 0, totalDuration: 0,
showBookmarksModal: false, showBookmarksModal: false,
bookmarkCurrentTime: 0, bookmarkCurrentTime: 0,
bookmarkAudiobookId: null,
playerLoading: false, playerLoading: false,
isPlaying: false, isPlaying: false,
currentTime: 0, currentTime: 0,
@@ -68,7 +68,9 @@ export default {
sleepTimerSet: false, sleepTimerSet: false,
sleepTimerTime: 0, sleepTimerTime: 0,
sleepTimerRemaining: 0, sleepTimerRemaining: 0,
sleepTimer: null sleepTimer: null,
displayTitle: null,
initialPlaybackRate: 1
} }
}, },
computed: { computed: {
@@ -89,55 +91,64 @@ export default {
return -64 return -64
}, },
cover() { cover() {
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover if (this.media.coverPath) return this.media.coverPath
return 'Logo.png' return 'Logo.png'
}, },
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
}, },
userAudiobook() { userMediaProgress() {
if (!this.audiobookId) return if (!this.libraryItemId) return
return this.$store.getters['user/getUserAudiobook'](this.audiobookId) return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
userAudiobookCurrentTime() { userItemCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0 return this.userMediaProgress ? this.userMediaProgress.currentTime || 0 : 0
}, },
bookmarks() { bookmarks() {
if (!this.userAudiobook) return [] if (!this.libraryItemId) return []
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time) return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
}, },
streamAudiobook() { streamLibraryItem() {
return this.$store.state.streamAudiobook return this.$store.state.streamLibraryItem
}, },
audiobookId() { libraryItemId() {
return this.streamAudiobook ? this.streamAudiobook.id : null return this.streamLibraryItem ? this.streamLibraryItem.id : null
}, },
book() { media() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {} return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
},
isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
},
mediaMetadata() {
return this.media.metadata || {}
}, },
chapters() { chapters() {
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : [] return this.media.chapters || []
}, },
title() { title() {
return this.book.title || 'No Title' if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
return this.mediaMetadata.title || 'No Title'
}, },
author() { authors() {
return this.book.author || 'Unknown' return this.mediaMetadata.authors || []
},
authorFL() {
return this.book.authorFL
},
authorsList() {
return this.authorFL ? this.authorFL.split(', ') : []
}, },
libraryId() { libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
}, },
totalDurationPretty() { totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration) return this.$secondsToTimestamp(this.totalDuration)
},
podcastAuthor() {
if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown'
} }
}, },
methods: { methods: {
setPlaying(isPlaying) {
this.isPlaying = isPlaying
this.$store.commit('setIsPlaying', isPlaying)
},
setSleepTimer(seconds) { setSleepTimer(seconds) {
this.sleepTimerSet = true this.sleepTimerSet = true
this.sleepTimerTime = seconds this.sleepTimerTime = seconds
@@ -194,6 +205,7 @@ export default {
this.playerHandler.setVolume(volume) this.playerHandler.setVolume(volume)
}, },
setPlaybackRate(playbackRate) { setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate
this.playerHandler.setPlaybackRate(playbackRate) this.playerHandler.setPlaybackRate(playbackRate)
}, },
seek(time) { seek(time) {
@@ -217,7 +229,6 @@ export default {
} }
}, },
showBookmarks() { showBookmarks() {
this.bookmarkAudiobookId = this.audiobookId
this.bookmarkCurrentTime = this.currentTime this.bookmarkCurrentTime = this.currentTime
this.showBookmarksModal = true this.showBookmarksModal = true
}, },
@@ -227,7 +238,7 @@ export default {
}, },
closePlayer() { closePlayer() {
this.playerHandler.closePlayer() this.playerHandler.closePlayer()
this.$store.commit('setStreamAudiobook', null) this.$store.commit('setMediaPlaying', null)
}, },
streamProgress(data) { streamProgress(data) {
if (!data.numSegments) return if (!data.numSegments) return
@@ -239,13 +250,19 @@ export default {
console.error('No Audio Ref') console.error('No Audio Ref')
} }
}, },
streamOpen(stream) { sessionOpen(session) {
this.$store.commit('setStreamAudiobook', stream.audiobook) this.$store.commit('setMediaPlaying', {
this.playerHandler.prepareStream(stream) libraryItem: session.libraryItem,
episodeId: session.episodeId
})
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
},
streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session)
}, },
streamClosed(streamId) { streamClosed(streamId) {
// Stream was closed from the server // Stream was closed from the server
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) { if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to request from server') console.warn('[StreamContainer] Closing stream due to request from server')
this.playerHandler.closePlayer() this.playerHandler.closePlayer()
} }
@@ -260,7 +277,7 @@ export default {
}, },
streamError(streamId) { streamError(streamId) {
// Stream had critical error from the server // Stream had critical error from the server
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) { if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to stream error from server') console.warn('[StreamContainer] Closing stream due to stream error from server')
this.playerHandler.closePlayer() this.playerHandler.closePlayer()
} }
@@ -269,32 +286,48 @@ export default {
this.playerHandler.resetStream(startTime, streamId) this.playerHandler.resetStream(startTime, streamId)
}, },
castSessionActive(isActive) { castSessionActive(isActive) {
if (isActive && this.playerHandler.isPlayingLocalAudiobook) { if (isActive && this.playerHandler.isPlayingLocalItem) {
// Cast session started switch to cast player // Cast session started switch to cast player
this.playerHandler.switchPlayer() this.playerHandler.switchPlayer()
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) { } else if (!isActive && this.playerHandler.isPlayingCastedItem) {
// Cast session ended switch to local player // Cast session ended switch to local player
this.playerHandler.switchPlayer() this.playerHandler.switchPlayer()
} }
}, },
async playAudiobook(audiobookId) { async playLibraryItem(payload) {
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => { var libraryItemId = payload.libraryItemId
console.error('Failed to fetch full audiobook', error) var episodeId = payload.episodeId || null
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
this.playerHandler.play()
return
}
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to fetch full item', error)
return null return null
}) })
if (!audiobook) return if (!libraryItem) return
this.$store.commit('setStreamAudiobook', audiobook) this.$store.commit('setMediaPlaying', {
libraryItem,
episodeId
})
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime) this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
},
pauseItem() {
this.playerHandler.pause()
} }
}, },
mounted() { mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive) this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('play-audiobook', this.playAudiobook) this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive) this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('play-audiobook', this.playAudiobook) this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
} }
} }
</script> </script>
+58 -39
View File
@@ -1,26 +1,30 @@
<template> <template>
<div> <div @mouseover="mouseover" @mouseout="mouseout">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-lg relative"> <div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<div class="w-full h-full overflow-hidden max-w-full max-h-full relative"> <!-- Image or placeholder -->
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg"> <covers-author-image :author="author" />
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
fill="white"
/>
<path
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
fill="white"
/>
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg>
<div class="absolute bottom-0 left-0 w-full py-2 bg-black bg-opacity-25 px-2"> <!-- Author name & num books overlay -->
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p> <div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.85 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p> <p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
</div> <p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div> </div>
<!-- Search icon btn -->
<div v-show="!searching && isHovering" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
<span class="material-icons text-lg">search</span>
</div>
<div v-show="isHovering && !searching" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
<span class="material-icons text-lg">edit</span>
</div>
<!-- Loading spinner -->
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
<widgets-loading-spinner size="" />
</div>
</div>
<div v-show="nameBelow" class="w-full py-1 px-2">
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
</div> </div>
</div> </div>
</template> </template>
@@ -34,11 +38,16 @@ export default {
}, },
width: Number, width: Number,
height: Number, height: Number,
sizeMultiplier: Number sizeMultiplier: {
type: Number,
default: 1
},
nameBelow: Boolean
}, },
data() { data() {
return { return {
placeholder: '/Logo.png' searching: false,
isHovering: false
} }
}, },
computed: { computed: {
@@ -48,30 +57,40 @@ export default {
_author() { _author() {
return this.author || {} return this.author || {}
}, },
authorId() {
return this._author.id
},
name() { name() {
return this._author.name || '' return this._author.name || ''
}, },
image() {
return this._author.image || null
},
description() {
return this._author.description
},
lastUpdate() {
return this._author.lastUpdate
},
numBooks() { numBooks() {
return this._author.numBooks || 0 return this._author.numBooks || 0
},
imgSrc() {
if (!this.image) return this.placeholder
var encodedImg = this.image.replace(/%/g, '%25').replace(/#/g, '%23')
var url = new URL(encodedImg, document.baseURI)
return url.href + `?token=${this.userToken}&ts=${this.lastUpdate}`
} }
}, },
methods: {}, methods: {
mouseover() {
this.isHovering = true
},
mouseout() {
this.isHovering = false
},
async searchAuthor() {
this.searching = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
this.$toast.error('Author not found')
} else if (response.updated) {
if (response.author.imagePath) this.$toast.success('Author was updated')
else this.$toast.success('Author was updated (no image found)')
} else {
this.$toast.info('No updates were made for Author')
}
this.searching = false
}
},
mounted() {} mounted() {}
} }
</script> </script>
+13 -4
View File
@@ -1,8 +1,10 @@
<template> <template>
<div class="flex h-full px-1 overflow-hidden"> <div class="flex h-full px-1 overflow-hidden">
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" /> <div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
<covers-author-image :author="author" />
</div>
<div class="flex-grow px-2 authorSearchCardContent h-full"> <div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p> <p class="truncate text-sm">{{ name }}</p>
</div> </div>
</div> </div>
</template> </template>
@@ -10,12 +12,19 @@
<script> <script>
export default { export default {
props: { props: {
author: String author: {
type: Object,
default: () => {}
}
}, },
data() { data() {
return {} return {}
}, },
computed: {}, computed: {
name() {
return this.author.name
}
},
methods: {}, methods: {},
mounted() {} mounted() {}
} }
+12 -3
View File
@@ -1,18 +1,26 @@
<template> <template>
<div class="w-full border-b border-gray-700 pb-2"> <div class="w-full border-b border-gray-700 pb-2">
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch"> <div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
<img :src="selectedCover || '/book_placeholder.jpg'" class="h-24 object-cover" :style="{ width: 96 / bookCoverAspectRatio + 'px' }" /> <div class="h-24 bg-primary" :style="{ minWidth: 96 / bookCoverAspectRatio + 'px' }">
<div class="px-4 flex-grow"> <img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
</div>
<div v-if="!isPodcast" class="px-4 flex-grow">
<div class="flex items-center"> <div class="flex items-center">
<h1>{{ book.title }}</h1> <h1>{{ book.title }}</h1>
<div class="flex-grow" /> <div class="flex-grow" />
<p>{{ book.publishYear }}</p> <p>{{ book.publishedYear }}</p>
</div> </div>
<p class="text-gray-400">{{ book.author }}</p> <p class="text-gray-400">{{ book.author }}</p>
<div class="w-full max-h-12 overflow-hidden"> <div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs">{{ book.description }}</p> <p class="text-gray-500 text-xs">{{ book.description }}</p>
</div> </div>
</div> </div>
<div v-else class="px-4 flex-grow">
<h1>{{ book.title }}</h1>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
<p 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>
</div>
</div> </div>
<div v-if="bookCovers.length > 1" class="flex"> <div v-if="bookCovers.length > 1" class="flex">
<template v-for="cover in bookCovers"> <template v-for="cover in bookCovers">
@@ -31,6 +39,7 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
isPodcast: Boolean,
bookCoverAspectRatio: Number bookCoverAspectRatio: Number
}, },
data() { data() {
-112
View File
@@ -1,112 +0,0 @@
<template>
<div class="relative">
<div class="rounded-sm h-full relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
<covers-collection-cover ref="groupcover" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
</div> -->
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
</div>
</div>
</nuxt-link>
</div>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ collectionName }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
collection: {
type: Object,
default: () => null
},
width: {
type: Number,
default: 120
},
paddingY: {
type: Number,
default: 24
}
},
data() {
return {
isHovering: false
}
},
watch: {
width(newVal) {
this.$nextTick(() => {
if (this.$refs.groupcover && this.$refs.groupcover.init) {
this.$refs.groupcover.init()
}
})
}
},
computed: {
labelFontSize() {
if (this.coverWidth < 160) return 0.75
return 0.875
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
_collection() {
return this.collection || {}
},
groupTo() {
return `/collection/${this._collection.id}`
},
coverWidth() {
return this.width * 2
},
coverHeight() {
return this.width * 1.6
},
sizeMultiplier() {
return this.width / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
bookItems() {
return this._collection.books || []
},
collectionName() {
return this._collection.name || 'No Name'
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
toggleSelected() {
// Selected
},
clickEdit() {
this.$store.commit('globals/setEditCollection', this.collection)
},
mouseoverCard() {
this.isHovering = true
},
mouseleaveCard() {
this.isHovering = false
},
clickCard() {
this.$emit('click', this.collection)
}
}
}
</script>
+2 -14
View File
@@ -12,9 +12,6 @@
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40"> <div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
<p class="font-book text-xl">{{ bookItems.length }}</p> <p class="font-book text-xl">{{ bookItems.length }}</p>
</div> </div>
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap z-40">
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
</div>
</div> </div>
</nuxt-link> </nuxt-link>
</div> </div>
@@ -74,7 +71,7 @@ export default {
}, },
groupTo() { groupTo() {
if (this.groupType === 'series') { if (this.groupType === 'series') {
return `/library/${this.currentLibraryId}/series/${this.groupEncode}` return `/library/${this.currentLibraryId}/series/${this._group.id}`
} else if (this.groupType === 'collection') { } else if (this.groupType === 'collection') {
return `/collection/${this._group.id}` return `/collection/${this._group.id}`
} else { } else {
@@ -100,15 +97,6 @@ export default {
bookItems() { bookItems() {
return this._group.books || [] return this._group.books || []
}, },
userAudiobooks() {
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
},
userProgressItems() {
return this.bookItems.map((item) => {
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
return userAudiobook || {}
})
},
groupName() { groupName() {
return this._group.name || 'No Name' return this._group.name || 'No Name'
}, },
@@ -119,7 +107,7 @@ export default {
return `${this.groupType}.${this.$encode(this.groupName)}` return `${this.groupType}.${this.$encode(this.groupName)}`
}, },
hasValidCovers() { hasValidCovers() {
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover) var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
return !!validCovers.length return !!validCovers.length
}, },
showExperimentalFeatures() { showExperimentalFeatures() {
@@ -1,13 +1,13 @@
<template> <template>
<div class="flex items-center h-full px-1 overflow-hidden"> <div class="flex items-center h-full px-1 overflow-hidden">
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 audiobookSearchCardContent"> <div class="flex-grow px-2 audiobookSearchCardContent">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p> <p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" /> <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">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p> <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" /> <p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" /> <div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
@@ -18,7 +18,7 @@
<script> <script>
export default { export default {
props: { props: {
audiobook: { libraryItem: {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
@@ -37,17 +37,27 @@ export default {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2 if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50 return 50
}, },
book() { media() {
return this.audiobook ? this.audiobook.book || {} : {} return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
isPodcast() {
return this.mediaType == 'podcast'
},
mediaMetadata() {
return this.media.metadata || {}
}, },
title() { title() {
return this.book ? this.book.title : 'No Title' return this.mediaMetadata.title || 'No Title'
}, },
subtitle() { subtitle() {
return this.book ? this.book.subtitle : '' return this.mediaMetadata.subtitle || ''
}, },
authorFL() { authorName() {
return this.book ? this.book.authorFL : 'Unknown' if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
return this.mediaMetadata.authorName || 'Unknown'
}, },
matchHtml() { matchHtml() {
if (!this.matchText || !this.search) return '' if (!this.matchText || !this.search) return ''
@@ -69,7 +79,7 @@ export default {
html += lastPart html += lastPart
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>` if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authorFL') return `by ${html}` if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>` 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 === '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">Series: ${html}</p>`
@@ -1,7 +1,7 @@
<template> <template>
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6"> <div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full"> <div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p> <p class="text-base text-white text-opacity-80 font-mono">#{{ item.index }}</p>
</div> </div>
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')"> <div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
@@ -15,15 +15,19 @@
<div class="flex my-2 -mx-2"> <div class="flex my-2 -mx-2">
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" /> <ui-text-input-with-label v-model="itemData.title" :disabled="processing" label="Title" @input="titleUpdated" />
</div> </div>
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" /> <ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" label="Author" />
<div v-else class="w-full">
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
</div>
</div> </div>
</div> </div>
<div class="flex my-2 -mx-2"> <div v-if="!isPodcast" class="flex my-2 -mx-2">
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" label="Series" note="(optional)" /> <ui-text-input-with-label v-model="itemData.series" :disabled="processing" label="Series" note="(optional)" />
</div> </div>
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<div class="w-full"> <div class="w-full">
@@ -33,9 +37,9 @@
</div> </div>
</div> </div>
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" /> <tables-uploaded-files-table :files="item.itemFiles" title="Item Files" class="mt-8" />
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" /> <tables-uploaded-files-table v-if="item.otherFiles.length" title="Other Files" :files="item.otherFiles" />
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" /> <tables-uploaded-files-table v-if="item.ignoredFiles.length" title="Ignored Files" :files="item.ignoredFiles" />
</template> </template>
<widgets-alert v-if="uploadSuccess" type="success"> <widgets-alert v-if="uploadSuccess" type="success">
<p class="text-base">Successfully Uploaded!</p> <p class="text-base">Successfully Uploaded!</p>
@@ -55,15 +59,16 @@ import Path from 'path'
export default { export default {
props: { props: {
book: { item: {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
mediaType: String,
processing: Boolean processing: Boolean
}, },
data() { data() {
return { return {
bookData: { itemData: {
title: '', title: '',
author: '', author: '',
series: '' series: ''
@@ -75,14 +80,19 @@ export default {
} }
}, },
computed: { computed: {
isPodcast() {
return this.mediaType === 'podcast'
},
directory() { directory() {
if (!this.bookData.title) return '' if (!this.itemData.title) return ''
if (this.bookData.series && this.bookData.author) { if (this.isPodcast) return this.itemData.title
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
} else if (this.bookData.author) { if (this.itemData.series && this.itemData.author) {
return Path.join(this.bookData.author, this.bookData.title) return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
} else if (this.itemData.author) {
return Path.join(this.itemData.author, this.itemData.title)
} else { } else {
return this.bookData.title return this.itemData.title
} }
} }
}, },
@@ -96,24 +106,24 @@ export default {
this.error = '' this.error = ''
}, },
getData() { getData() {
if (!this.bookData.title) { if (!this.itemData.title) {
this.error = 'Must have a title' this.error = 'Must have a title'
return null return null
} }
this.error = '' this.error = ''
var files = this.book.bookFiles.concat(this.book.otherFiles) var files = this.item.itemFiles.concat(this.item.otherFiles)
return { return {
index: this.book.index, index: this.item.index,
...this.bookData, ...this.itemData,
files files
} }
} }
}, },
mounted() { mounted() {
if (this.book) { if (this.item) {
this.bookData.title = this.book.title this.itemData.title = this.item.title
this.bookData.author = this.book.author this.itemData.author = this.item.author
this.bookData.series = this.book.series this.itemData.series = this.item.series
} }
} }
} }
+197 -134
View File
@@ -8,20 +8,21 @@
<!-- Alternative bookshelf title/author/sort --> <!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }"> <div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }"> <p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<span v-if="volumeNumber">#{{ volumeNumber }}&nbsp;</span>{{ displayTitle }} {{ displayTitle }}
</p> </p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div> </div>
<div v-if="booksInSeries" class="absolute z-20 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">{{ booksInSeries }}</div> <div v-if="booksInSeries" class="absolute z-20 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">{{ booksInSeries }}</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10"> <div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }"> <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="font-book text-gray-300 text-center">{{ title }}</p>
</div> </div>
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> <!-- Cover Image -->
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author --> <!-- 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 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' }">
@@ -34,11 +35,11 @@
</div> </div>
</div> </div>
<!-- No progress shown for collapsed series in library --> <!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
<div v-if="!booksInSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> <div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Overlay is not shown if collapsing series in library --> <!-- Overlay is not shown if collapsing series in library -->
<div v-show="!booksInSeries && audiobook && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist"> <div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none"> <div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play"> <div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span> <span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
@@ -51,7 +52,7 @@
</div> </div>
</div> </div>
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick"> <div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span> <span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
</div> </div>
@@ -59,13 +60,13 @@
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span> <span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
</div> </div>
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore"> <div ref="moreIcon" v-show="!isSelectionMode && !recentEpisode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :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> <span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div> </div>
</div> </div>
<!-- Series name overlay --> <!-- Series name overlay -->
<div v-if="booksInSeries && audiobook && 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' }"> <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 class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
</div> </div>
@@ -76,9 +77,19 @@
</div> </div>
</ui-tooltip> </ui-tooltip>
<!-- Volume number --> <!-- Series sequence -->
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="seriesSequence && showSequence && !isHovering && !isSelectionMode" 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' }">#{{ volumeNumber }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div>
<!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode" 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>
<!-- 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' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div> </div>
</div> </div>
</template> </template>
@@ -99,7 +110,7 @@ export default {
default: 192 default: 192
}, },
bookCoverAspectRatio: Number, bookCoverAspectRatio: Number,
showVolumeNumber: Boolean, showSequence: Boolean,
bookshelfView: Number, bookshelfView: Number,
bookMount: { bookMount: {
// Book can be passed as prop or set with setEntity() // Book can be passed as prop or set with setEntity()
@@ -115,7 +126,7 @@ export default {
isHovering: false, isHovering: false,
isMoreMenuOpen: false, isMoreMenuOpen: false,
isProcessingReadUpdate: false, isProcessingReadUpdate: false,
audiobook: null, libraryItem: null,
imageReady: false, imageReady: false,
rescanning: false, rescanning: false,
selected: false, selected: false,
@@ -127,7 +138,7 @@ export default {
bookMount: { bookMount: {
handler(newVal) { handler(newVal) {
if (newVal) { if (newVal) {
this.audiobook = newVal this.libraryItem = newVal
} }
} }
} }
@@ -136,42 +147,79 @@ export default {
showExperimentalFeatures() { showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures return this.store.state.showExperimentalFeatures
}, },
_audiobook() { _libraryItem() {
return this.audiobook || {} return this.libraryItem || {}
}, },
book() { isFile() {
return this._audiobook.book || {} // Library item is not in a folder
return this._libraryItem.isFile
},
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this._libraryItem.mediaType
},
isPodcast() {
return this.mediaType === 'podcast'
}, },
placeholderUrl() { placeholderUrl() {
return '/book_placeholder.jpg' return '/book_placeholder.jpg'
}, },
bookCoverSrc() { bookCoverSrc() {
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl) return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
}, },
audiobookId() { libraryItemId() {
return this._audiobook.id return this._libraryItem.id
}, },
series() { series() {
return this.book.series // Only included when filtering by series or collapse series
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
}, },
libraryId() { libraryId() {
return this._audiobook.libraryId return this._libraryItem.libraryId
}, },
hasEbook() { hasEbook() {
return this._audiobook.numEbooks return this.media.ebookFormat
}, },
hasTracks() { numTracks() {
return this._audiobook.numTracks if (this.media.tracks) return this.media.tracks.length
return this.media.numTracks || 0 // toJSONMinified
},
numEpisodes() {
if (!this.isPodcast) return 0
return this.media.numEpisodes || 0
}, },
processingBatch() { processingBatch() {
return this.store.state.processingBatch return this.store.state.processingBatch
}, },
recentEpisode() {
// Only added to item when getting currently listening podcasts
return this._libraryItem.recentEpisode
},
recentEpisodeNumber() {
if (!this.recentEpisode) return null
if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '')
}
return this.recentEpisode.index
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
return this._libraryItem.collapsedSeries
},
booksInSeries() { booksInSeries() {
// Only added to audiobook object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
return this._audiobook.booksInSeries return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
}, },
hasCover() { hasCover() {
return !!this.book.cover return !!this.media.coverPath
}, },
squareAspectRatio() { squareAspectRatio() {
return this.bookCoverAspectRatio === 1 return this.bookCoverAspectRatio === 1
@@ -181,87 +229,95 @@ export default {
return this.width / baseSize return this.width / baseSize
}, },
title() { title() {
return this.book.title || '' return this.mediaMetadata.title || ''
}, },
playIconFontSize() { playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier) return Math.max(2, 3 * this.sizeMultiplier)
}, },
author() { author() {
return this.book.author if (this.isPodcast) return this.mediaMetadata.author
}, return this.mediaMetadata.authorName
authorFL() {
return this.book.authorFL || this.author
}, },
authorLF() { authorLF() {
return this.book.authorLF || this.author return this.mediaMetadata.authorNameLF
},
volumeNumber() {
return this.book.volumeNumber || null
}, },
displayTitle() { displayTitle() {
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) { if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
return this.title.substr(4) + ', The' return this.mediaMetadata.titleIgnorePrefix
} }
return this.title return this.title
}, },
displayAuthor() { displayAuthor() {
if (this.orderBy === 'book.authorLF') return this.authorLF if (this.isPodcast) return this.author
return this.authorFL if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author
}, },
displaySortLine() { displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs) if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs) if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt) if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false) if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size) if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null return null
}, },
episodeProgress() {
// Only used on home page currently listening podcast shelf
if (!this.recentEpisode) return null
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() { userProgress() {
return this.store.getters['user/getUserAudiobook'](this.audiobookId) if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
userProgressPercent() { userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0 return this.userProgress ? this.userProgress.progress || 0 : 0
}, },
userIsRead() { itemIsFinished() {
return this.userProgress ? !!this.userProgress.isRead : false return this.userProgress ? !!this.userProgress.isFinished : false
}, },
showError() { showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid if (this.recentEpisode) return false // Dont show podcast error on episode card
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
}, },
isStreaming() { isStreaming() {
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
}, },
showReadButton() { showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
}, },
showPlayButton() { showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.hasTracks && !this.isStreaming return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
}, },
showSmallEBookIcon() { showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
}, },
isMissing() { isMissing() {
return this._audiobook.isMissing return this._libraryItem.isMissing
}, },
isInvalid() { isInvalid() {
return this._audiobook.isInvalid return this._libraryItem.isInvalid
}, },
hasMissingParts() { numMissingParts() {
return this._audiobook.hasMissingParts if (this.isPodcast) return 0
return this.media.numMissingParts
}, },
hasInvalidParts() { numInvalidAudioFiles() {
return this._audiobook.hasInvalidParts if (this.isPodcast) return 0
return this.media.numInvalidAudioFiles
}, },
errorText() { errorText() {
if (this.isMissing) return 'Audiobook directory is missing!' if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) return 'Audiobook has no audio tracks & ebook' else if (this.isInvalid) {
var txt = '' if (this.isPodcast) return 'Podcast has no episodes'
if (this.hasMissingParts) { return 'Item has no audio tracks & ebook'
txt = `${this.hasMissingParts} missing parts.`
} }
if (this.hasInvalidParts) { var txt = ''
if (this.hasMissingParts) txt += ' ' if (this.numMissingParts) {
txt += `${this.hasInvalidParts} invalid parts.` txt += `${this.numMissingParts} missing parts.`
}
if (this.numInvalidAudioFiles) {
if (txt) txt += ' '
txt += `${this.numInvalidAudioFiles} invalid audio files.`
} }
return txt || 'Unknown Error' return txt || 'Unknown Error'
}, },
@@ -290,35 +346,30 @@ export default {
return this.store.getters['user/getIsRoot'] return this.store.getters['user/getIsRoot']
}, },
moreMenuItems() { moreMenuItems() {
var items = [ var items = []
{ if (!this.isPodcast) {
func: 'toggleRead', items = [
text: `Mark as ${this.userIsRead ? 'Not Read' : 'Read'}` {
}, func: 'toggleFinished',
{ text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
func: 'openCollections', },
text: 'Add to Collection' {
} func: 'openCollections',
] text: 'Add to Collection'
}
]
}
if (this.userCanUpdate) { if (this.userCanUpdate) {
if (this.hasTracks) { items.push({
items.push({ func: 'showEditModalFiles',
func: 'showEditModalTracks', text: 'Files'
text: 'Tracks' })
})
}
items.push({ items.push({
func: 'showEditModalMatch', func: 'showEditModalMatch',
text: 'Match' text: 'Match'
}) })
} }
if (this.userCanDownload) { if (this.userIsRoot && !this.isFile) {
items.push({
func: 'showEditModalDownload',
text: 'Download'
})
}
if (this.userIsRoot) {
items.push({ items.push({
func: 'rescan', func: 'rescan',
text: 'Re-Scan' text: 'Re-Scan'
@@ -349,11 +400,11 @@ export default {
return this.title return this.title
}, },
authorCleaned() { authorCleaned() {
if (!this.authorFL) return '' if (!this.author) return ''
if (this.authorFL.length > 30) { if (this.author.length > 30) {
return this.authorFL.slice(0, 27) + '...' return this.author.slice(0, 27) + '...'
} }
return this.authorFL return this.author
}, },
isAlternativeBookshelfView() { isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants var constants = this.$constants || this.$nuxt.$constants
@@ -370,8 +421,8 @@ export default {
this.isSelectionMode = val this.isSelectionMode = val
if (!val) this.selected = false if (!val) this.selected = false
}, },
setEntity(audiobook) { setEntity(libraryItem) {
this.audiobook = audiobook this.libraryItem = libraryItem
}, },
clickCard(e) { clickCard(e) {
if (this.isSelectionMode) { if (this.isSelectionMode) {
@@ -381,66 +432,69 @@ export default {
} else { } else {
var router = this.$router || this.$nuxt.$router var router = this.$router || this.$nuxt.$router
if (router) { if (router) {
if (this.booksInSeries) router.push(`/library/${this.libraryId}/series/${this.$encode(this.series)}`) if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
else router.push(`/audiobook/${this.audiobookId}`) else router.push(`/item/${this.libraryItemId}`)
} }
} }
}, },
editClick() { editClick() {
this.$emit('edit', this.audiobook) if (this.recentEpisode) {
return this.$emit('edit', { libraryItem: this.libraryItem, episode: this.recentEpisode })
}
this.$emit('edit', this.libraryItem)
}, },
toggleRead() { toggleFinished() {
// More menu func
var updatePayload = { var updatePayload = {
isRead: !this.userIsRead isFinished: !this.itemIsFinished
} }
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios var axios = this.$axios || this.$nuxt.$axios
axios axios
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload) .$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => { .then(() => {
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
}, },
audiobookScanComplete(result) {
this.rescanning = false
var toast = this.$toast || this.$nuxt.$toast
if (!result) {
toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
toast.success(`Re-Scan complete audiobook was updated`)
} else if (result === 'UPTODATE') {
toast.success(`Re-Scan complete audiobook was up to date`)
} else if (result === 'REMOVED') {
toast.error(`Re-Scan complete audiobook was removed`)
}
},
rescan() { rescan() {
this.rescanning = true this.rescanning = true
this._socket.once('audiobook_scan_complete', this.audiobookScanComplete) this.$axios
this._socket.emit('scan_audiobook', this.audiobookId) .$get(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
}, },
showEditModalTracks() { showEditModalFiles() {
// More menu func // More menu func
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'tracks' }) this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
}, },
showEditModalMatch() { showEditModalMatch() {
// More menu func // More menu func
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'match' }) this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
showEditModalDownload() {
// More menu func
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
}, },
openCollections() { openCollections() {
this.store.commit('setSelectedAudiobook', this.audiobook) this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowUserCollectionsModal', true) this.store.commit('globals/setShowUserCollectionsModal', true)
}, },
createMoreMenu() { createMoreMenu() {
@@ -493,17 +547,26 @@ export default {
clickShowMore() { clickShowMore() {
this.createMoreMenu() this.createMoreMenu()
}, },
clickReadEBook() { async clickReadEBook() {
this.store.commit('showEReader', this.audiobook) var libraryItem = await this.$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)
}, },
selectBtnClick() { selectBtnClick() {
if (this.processingBatch) return if (this.processingBatch) return
this.selected = !this.selected this.selected = !this.selected
this.$emit('select', this.audiobook) this.$emit('select', this.libraryItem)
}, },
play() { play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-audiobook', this.audiobookId) eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.recentEpisode ? this.recentEpisode.id : null
})
}, },
mouseover() { mouseover() {
this.isHovering = true this.isHovering = true
+24 -7
View File
@@ -7,11 +7,12 @@
<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 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="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` }"> <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' }">{{ title }}</p> <p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
</div> </div>
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
</div> -->
<div v-if="!isCategorized" 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(160, width) + 'px' }"> <div v-if="!isCategorized" 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(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> <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> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
@@ -51,12 +52,31 @@ export default {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2) if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240 return this.width / 240
}, },
seriesId() {
return this.series ? this.series.id : ''
},
title() { title() {
return this.series ? this.series.name : '' return this.series ? this.series.name : ''
}, },
books() { books() {
return this.series ? this.series.books || [] : [] return this.series ? this.series.books || [] : []
}, },
addedAt() {
return this.series ? this.series.addedAt : 0
},
seriesBookProgress() {
return this.books
.map((libraryItem) => {
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
})
.filter((p) => !!p)
},
seriesBooksFinished() {
return this.seriesBookProgress.filter((p) => p.isFinished)
},
isSeriesFinished() {
return this.books.length === this.seriesBooksFinished.length
},
store() { store() {
return this.$store || this.$nuxt.$store return this.$store || this.$nuxt.$store
}, },
@@ -64,13 +84,10 @@ export default {
return this.store.state.libraries.currentLibraryId return this.store.state.libraries.currentLibraryId
}, },
seriesBooksRoute() { seriesBooksRoute() {
return `/library/${this.currentLibraryId}/series/${this.$encode(this.title)}` return `/library/${this.currentLibraryId}/series/${this.seriesId}`
},
seriesId() {
return this.series ? this.$encode(this.title) : null
}, },
hasValidCovers() { hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.book.cover) var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
return !!validCovers.length return !!validCovers.length
} }
}, },
+9 -3
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="flex h-full px-1 overflow-hidden"> <div class="flex h-full px-1 overflow-hidden">
<covers-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-group-cover :name="name" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 seriesSearchCardContent h-full"> <div class="flex-grow px-2 seriesSearchCardContent h-full">
<p class="truncate text-sm">{{ series }}</p> <p class="truncate text-sm">{{ name }}</p>
</div> </div>
</div> </div>
</template> </template>
@@ -10,7 +10,10 @@
<script> <script>
export default { export default {
props: { props: {
series: String, series: {
type: Object,
default: () => {}
},
bookItems: { bookItems: {
type: Array, type: Array,
default: () => [] default: () => []
@@ -22,6 +25,9 @@ export default {
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['getBookCoverAspectRatio']
},
name() {
return this.series.name
} }
}, },
methods: {}, methods: {},
@@ -0,0 +1,94 @@
<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">
<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">
<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)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ 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>
</span>
</li>
</template>
</ul>
</div>
</template>
<script>
export default {
props: {
value: String,
descending: Boolean
},
data() {
return {
showMenu: false,
items: [
{
text: 'Current',
value: 'index'
},
{
text: 'Title',
value: 'title'
},
{
text: 'Episode',
value: 'episode'
},
{
text: 'Pub Date',
value: 'publishedAt'
}
]
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedDesc: {
get() {
return this.descending
},
set(val) {
this.$emit('update:descending', val)
}
},
selectedText() {
var _selected = this.selected
if (!_selected) return ''
var _sel = this.items.find((i) => i.value === _selected)
if (!_sel) return ''
return _sel.text
}
},
methods: {
clickOutside() {
this.showMenu = false
},
clickedOption(val) {
if (this.selected === val) {
this.selectedDesc = !this.selectedDesc
} else {
this.selected = val
}
this.showMenu = false
this.$nextTick(() => this.$emit('change', val))
}
}
}
</script>
+70 -10
View File
@@ -16,7 +16,7 @@
<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-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <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="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)">
<div class="flex items-center justify-between"> <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 md:text-base">{{ item.text }}</span>
@@ -67,7 +67,7 @@ export default {
return { return {
showMenu: false, showMenu: false,
sublist: null, sublist: null,
items: [ bookItems: [
{ {
text: 'All', text: 'All',
value: 'all' value: 'all'
@@ -107,6 +107,32 @@ export default {
value: 'progress', value: 'progress',
sublist: true sublist: true
}, },
{
text: 'Missing',
value: 'missing',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
}
],
podcastItems: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
},
{ {
text: 'Issues', text: 'Issues',
value: 'issues', value: 'issues',
@@ -132,18 +158,42 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
selectItems() {
if (this.isPodcast) return this.podcastItems
return this.bookItems
},
selectedItemSublist() { selectedItemSublist() {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
}, },
selectedText() { selectedText() {
if (!this.selected) return '' if (!this.selected) return ''
var parts = this.selected.split('.') var parts = this.selected.split('.')
var filterName = this.selectItems.find((i) => i.value === parts[0])
var filterValue = null
if (parts.length > 1) { if (parts.length > 1) {
return this.$decode(parts[1]) var decoded = this.$decode(parts[1])
if (decoded.startsWith('aut_')) {
var 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 {
filterValue = decoded
}
}
if (filterName && filterValue) {
return `${filterName.text}: ${filterValue}`
} else if (filterName) {
return filterName.text
} else if (filterValue) {
return filterValue
} else {
return ''
} }
var _sel = this.items.find((i) => i.value === this.selected)
if (!_sel) return ''
return _sel.text
}, },
genres() { genres() {
return this.filterData.genres || [] return this.filterData.genres || []
@@ -164,13 +214,23 @@ export default {
return this.filterData.languages || [] return this.filterData.languages || []
}, },
progress() { progress() {
return ['Read', 'Unread', 'In Progress'] return ['Finished', 'In Progress', 'Not Started']
},
missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
}, },
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {
return { if (typeof item === 'string') {
text: item, return {
value: this.$encode(item) text: item,
value: this.$encode(item)
}
} else {
return {
text: item.name,
value: this.$encode(item.id)
}
} }
}) })
}, },
+30 -18
View File
@@ -19,38 +19,47 @@
<p>No Results</p> <p>No Results</p>
</li> </li>
<template v-else> <template v-else>
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p> <p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in audiobookResults"> <template v-for="item in bookResults">
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/audiobook/${item.audiobook.id}`"> <nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" /> <cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
</li>
</template>
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
<template v-for="item in podcastResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link> </nuxt-link>
</li> </li>
</template> </template>
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p> <p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
<template v-for="item in authorResults"> <template v-for="item in authorResults">
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
<cards-author-search-card :author="item.author" /> <cards-author-search-card :author="item" />
</nuxt-link> </nuxt-link>
</li> </li>
</template> </template>
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p> <p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
<template v-for="item in seriesResults"> <template v-for="item in seriesResults">
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/series/${$encode(item.series)}`"> <nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" /> <cards-series-search-card :series="item.series" :book-items="item.books" />
</nuxt-link> </nuxt-link>
</li> </li>
</template> </template>
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p> <p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
<template v-for="item in tagResults"> <template v-for="item in tagResults">
<li :key="item.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.tag)}`"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
<cards-tag-search-card :tag="item.tag" /> <cards-tag-search-card :tag="item.name" />
</nuxt-link> </nuxt-link>
</li> </li>
</template> </template>
@@ -70,7 +79,8 @@ export default {
isTyping: false, isTyping: false,
isFetching: false, isFetching: false,
search: null, search: null,
audiobookResults: [], podcastResults: [],
bookResults: [],
authorResults: [], authorResults: [],
seriesResults: [], seriesResults: [],
tagResults: [], tagResults: [],
@@ -83,7 +93,7 @@ export default {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
totalResults() { totalResults() {
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
} }
}, },
methods: { methods: {
@@ -96,7 +106,8 @@ export default {
clearResults() { clearResults() {
this.search = null this.search = null
this.lastSearch = null this.lastSearch = null
this.audiobookResults = [] this.podcastResults = []
this.bookResults = []
this.authorResults = [] this.authorResults = []
this.seriesResults = [] this.seriesResults = []
this.tagResults = [] this.tagResults = []
@@ -136,7 +147,8 @@ export default {
// Search was canceled // Search was canceled
if (!this.isFetching) return if (!this.isFetching) return
this.audiobookResults = searchResults.audiobooks || [] this.podcastResults = searchResults.podcast || []
this.bookResults = searchResults.book || []
this.authorResults = searchResults.authors || [] this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || [] this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || [] this.tagResults = searchResults.tags || []
+41 -11
View File
@@ -8,7 +8,7 @@
</button> </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 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <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="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)">
<div class="flex items-center"> <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 text-xs">{{ item.text }}</span>
@@ -31,30 +31,48 @@ export default {
data() { data() {
return { return {
showMenu: false, showMenu: false,
items: [ bookItems: [
{ {
text: 'Title', text: 'Title',
value: 'book.title' value: 'media.metadata.title'
}, },
{ {
text: 'Author (First Last)', text: 'Author (First Last)',
value: 'book.authorFL' value: 'media.metadata.authorName'
}, },
{ {
text: 'Author (Last, First)', text: 'Author (Last, First)',
value: 'book.authorLF' value: 'media.metadata.authorNameLF'
}, },
{ {
text: 'Added At', text: 'Added At',
value: 'addedAt' value: 'addedAt'
}, },
{ {
text: 'Volume #', text: 'Size',
value: 'book.volumeNumber' value: 'size'
}, },
{ {
text: 'Duration', text: 'File Birthtime',
value: 'duration' value: 'birthtimeMs'
},
{
text: 'File Modified',
value: 'mtimeMs'
}
],
podcastItems: [
{
text: 'Title',
value: 'media.metadata.title'
},
{
text: 'Author',
value: 'media.metadata.author'
},
{
text: 'Added At',
value: 'addedAt'
}, },
{ {
text: 'Size', text: 'Size',
@@ -88,9 +106,18 @@ export default {
this.$emit('update:descending', val) this.$emit('update:descending', val)
} }
}, },
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
selectItems() {
if (this.isPodcast) return this.podcastItems
return this.bookItems
},
selectedText() { selectedText() {
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected var _selected = this.selected
var _sel = this.items.find((i) => i.value === _selected) if (!_selected) return ''
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
var _sel = this.selectItems.find((i) => i.value === _selected)
if (!_sel) return '' if (!_sel) return ''
return _sel.text return _sel.text
} }
@@ -104,6 +131,9 @@ export default {
this.selectedDesc = !this.selectedDesc this.selectedDesc = !this.selectedDesc
} else { } else {
this.selected = val this.selected = val
if (val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF') {
this.selectedDesc = false
}
} }
this.showMenu = false this.showMenu = false
this.$nextTick(() => this.$emit('change', val)) this.$nextTick(() => this.$emit('change', val))
+87
View File
@@ -0,0 +1,87 @@
<template>
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
fill="white"
/>
<path
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
fill="white"
/>
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg>
<div v-else class="w-full h-full relative">
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
</div>
</div>
</template>
<script>
export default {
props: {
author: {
type: Object,
default: () => {}
},
rounded: {
type: String,
default: 'lg'
}
},
data() {
return {
showCoverBg: false,
coverContain: true
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
imagePath() {
return this._author.imagePath
},
updatedAt() {
return this._author.updatedAt
},
imgSrc() {
if (!this.imagePath) return null
if (process.env.NODE_ENV !== 'production') {
// Testing
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
}
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
}
},
methods: {
imageLoaded() {
var aspectRatio = 1.25
if (this.$refs.wrapper) {
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
}
if (this.$refs.img) {
var { naturalWidth, naturalHeight } = this.$refs.img
var imgAr = naturalHeight / naturalWidth
var arDiff = Math.abs(imgAr - aspectRatio)
if (arDiff > 0.15) {
this.showCoverBg = true
} else {
this.showCoverBg = false
this.coverContain = false
}
}
}
},
mounted() {}
}
</script>
+20 -236
View File
@@ -5,20 +5,11 @@
<div class="absolute cover-bg" ref="coverBg" /> <div class="absolute cover-bg" ref="coverBg" />
</div> </div>
<img v-if="audiobook" 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'" /> <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 && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> <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="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<div class="la-ball-spin-clockwise la-sm"> <widgets-loading-spinner />
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -44,11 +35,10 @@
<script> <script>
export default { export default {
props: { props: {
audiobook: { libraryItem: {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
authorOverride: String,
width: { width: {
type: Number, type: Number,
default: 120 default: 120
@@ -75,12 +65,15 @@ export default {
height() { height() {
return this.width * this.bookCoverAspectRatio return this.width * this.bookCoverAspectRatio
}, },
book() { media() {
if (!this.audiobook) return {} if (!this.libraryItem) return {}
return this.audiobook.book || {} return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
}, },
title() { title() {
return this.book.title || 'No Title' return this.mediaMetadata.title || 'No Title'
}, },
titleCleaned() { titleCleaned() {
if (this.title.length > 60) { if (this.title.length > 60) {
@@ -88,9 +81,11 @@ export default {
} }
return this.title return this.title
}, },
authors() {
return this.mediaMetadata.authors || []
},
author() { author() {
if (this.authorOverride) return this.authorOverride return this.authors.map((au) => au.name).join(', ')
return this.book.author || 'Unknown'
}, },
authorCleaned() { authorCleaned() {
if (this.author.length > 30) { if (this.author.length > 30) {
@@ -102,15 +97,15 @@ export default {
return '/book_placeholder.jpg' return '/book_placeholder.jpg'
}, },
fullCoverUrl() { fullCoverUrl() {
if (!this.audiobook) return null if (!this.libraryItem) return null
var store = this.$store || this.$nuxt.$store var store = this.$store || this.$nuxt.$store
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl) return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
}, },
cover() { cover() {
return this.book.cover || this.placeholderUrl return this.media.coverPath || this.placeholderUrl
}, },
hasCover() { hasCover() {
return !!this.book.cover return !!this.media.coverPath
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120 var baseSize = this.squareAspectRatio ? 192 : 120
@@ -138,12 +133,12 @@ export default {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")` this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
} }
}, },
hideCoverBg() {},
imageLoaded() { imageLoaded() {
this.loading = false this.loading = false
this.$nextTick(() => { this.$nextTick(() => {
this.imageReady = true this.imageReady = true
}) })
if (this.$refs.cover && this.cover !== this.placeholderUrl) { if (this.$refs.cover && this.cover !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth var aspectRatio = naturalHeight / naturalWidth
@@ -168,214 +163,3 @@ export default {
} }
</script> </script>
<style>
/*!
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
* Licensed under MIT
*/
.la-ball-spin-clockwise,
.la-ball-spin-clockwise > div {
position: relative;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.la-ball-spin-clockwise {
display: block;
font-size: 0;
color: #fff;
}
.la-ball-spin-clockwise.la-dark {
color: #262626;
}
.la-ball-spin-clockwise > div {
display: inline-block;
float: none;
background-color: currentColor;
border: 0 solid currentColor;
}
.la-ball-spin-clockwise {
width: 32px;
height: 32px;
}
.la-ball-spin-clockwise > div {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
border-radius: 100%;
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
animation: ball-spin-clockwise 1s infinite ease-in-out;
}
.la-ball-spin-clockwise > div:nth-child(1) {
top: 5%;
left: 50%;
-webkit-animation-delay: -0.875s;
-moz-animation-delay: -0.875s;
-o-animation-delay: -0.875s;
animation-delay: -0.875s;
}
.la-ball-spin-clockwise > div:nth-child(2) {
top: 18.1801948466%;
left: 81.8198051534%;
-webkit-animation-delay: -0.75s;
-moz-animation-delay: -0.75s;
-o-animation-delay: -0.75s;
animation-delay: -0.75s;
}
.la-ball-spin-clockwise > div:nth-child(3) {
top: 50%;
left: 95%;
-webkit-animation-delay: -0.625s;
-moz-animation-delay: -0.625s;
-o-animation-delay: -0.625s;
animation-delay: -0.625s;
}
.la-ball-spin-clockwise > div:nth-child(4) {
top: 81.8198051534%;
left: 81.8198051534%;
-webkit-animation-delay: -0.5s;
-moz-animation-delay: -0.5s;
-o-animation-delay: -0.5s;
animation-delay: -0.5s;
}
.la-ball-spin-clockwise > div:nth-child(5) {
top: 94.9999999966%;
left: 50.0000000005%;
-webkit-animation-delay: -0.375s;
-moz-animation-delay: -0.375s;
-o-animation-delay: -0.375s;
animation-delay: -0.375s;
}
.la-ball-spin-clockwise > div:nth-child(6) {
top: 81.8198046966%;
left: 18.1801949248%;
-webkit-animation-delay: -0.25s;
-moz-animation-delay: -0.25s;
-o-animation-delay: -0.25s;
animation-delay: -0.25s;
}
.la-ball-spin-clockwise > div:nth-child(7) {
top: 49.9999750815%;
left: 5.0000051215%;
-webkit-animation-delay: -0.125s;
-moz-animation-delay: -0.125s;
-o-animation-delay: -0.125s;
animation-delay: -0.125s;
}
.la-ball-spin-clockwise > div:nth-child(8) {
top: 18.179464974%;
left: 18.1803700518%;
-webkit-animation-delay: 0s;
-moz-animation-delay: 0s;
-o-animation-delay: 0s;
animation-delay: 0s;
}
.la-ball-spin-clockwise.la-sm {
width: 16px;
height: 16px;
}
.la-ball-spin-clockwise.la-sm > div {
width: 4px;
height: 4px;
margin-top: -2px;
margin-left: -2px;
}
.la-ball-spin-clockwise.la-2x {
width: 64px;
height: 64px;
}
.la-ball-spin-clockwise.la-2x > div {
width: 16px;
height: 16px;
margin-top: -8px;
margin-left: -8px;
}
.la-ball-spin-clockwise.la-3x {
width: 96px;
height: 96px;
}
.la-ball-spin-clockwise.la-3x > div {
width: 24px;
height: 24px;
margin-top: -12px;
margin-left: -12px;
}
/*
* Animation
*/
@-webkit-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
transform: scale(0);
}
}
@-moz-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-moz-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-moz-transform: scale(0);
transform: scale(0);
}
}
@-o-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-o-transform: scale(0);
transform: scale(0);
}
}
@keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
-moz-transform: scale(1);
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
-moz-transform: scale(0);
-o-transform: scale(0);
transform: scale(0);
}
}
</style>
+2 -2
View File
@@ -13,8 +13,8 @@
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm"> <div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" /> <div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<covers-book-cover :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm"> <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" /> <div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
+1 -2
View File
@@ -63,7 +63,7 @@ export default {
}, },
methods: { methods: {
getCoverUrl(book) { getCoverUrl(book) {
return this.store.getters['audiobooks/getBookCoverSrc'](book, '') return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
}, },
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) { async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl var src = coverData.coverUrl
@@ -151,7 +151,6 @@ export default {
.map((bookItem) => { .map((bookItem) => {
return { return {
id: bookItem.id, id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem) coverUrl: this.getCoverUrl(bookItem)
} }
}) })
+1 -1
View File
@@ -22,7 +22,7 @@ export default {
return '/book_placeholder.jpg' return '/book_placeholder.jpg'
}, },
fullCoverUrl() { fullCoverUrl() {
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl) return this.$store.getters['globals/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
}, },
hasCover() { hasCover() {
return !!this.audiobook.book.cover return !!this.audiobook.book.cover
+54 -5
View File
@@ -77,6 +77,19 @@
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4"> <div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" /> <ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
</div> </div>
<div class="flex items-cen~ter my-2 max-w-md">
<div class="w-1/2">
<p>Can Access All Tags</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
</div>
</div>
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
</div>
</div> </div>
<div class="flex pt-4"> <div class="flex pt-4">
@@ -103,7 +116,9 @@ export default {
processing: false, processing: false,
newUser: {}, newUser: {},
isNew: true, isNew: true,
accountTypes: ['guest', 'user', 'admin'] accountTypes: ['guest', 'user', 'admin'],
tags: [],
loadingTags: false
} }
}, },
watch: { watch: {
@@ -135,9 +150,37 @@ export default {
}, },
libraryItems() { libraryItems() {
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id })) return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
},
itemTags() {
return this.tags.map((t) => {
return {
text: t,
value: t
}
})
} }
}, },
methods: { methods: {
accessAllTagsToggled(val) {
if (!val && !this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)
} else if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = []
}
},
fetchAllTags() {
this.loadingTags = true
this.$axios
.$get(`/api/tags`)
.then((tags) => {
this.tags = tags
this.loadingTags = false
})
.catch((error) => {
console.error('Failed to load tags', error)
this.loadingTags = false
})
},
accessAllLibrariesToggled(val) { accessAllLibrariesToggled(val) {
if (!val && !this.newUser.librariesAccessible.length) { if (!val && !this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = this.libraries.map((l) => l.id) this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
@@ -223,20 +266,25 @@ export default {
download: type !== 'guest', download: type !== 'guest',
update: type === 'admin', update: type === 'admin',
delete: type === 'admin', delete: type === 'admin',
upload: type === 'admin' upload: type === 'admin',
accessAllLibraries: true,
accessAllTags: true
} }
}, },
init() { init() {
this.fetchAllTags()
this.isNew = !this.account this.isNew = !this.account
if (this.account) { if (this.account) {
var librariesAccessible = this.account.librariesAccessible || [] console.log(this.account)
this.newUser = { this.newUser = {
username: this.account.username, username: this.account.username,
password: this.account.password, password: this.account.password,
type: this.account.type, type: this.account.type,
isActive: this.account.isActive, isActive: this.account.isActive,
permissions: { ...this.account.permissions }, permissions: { ...this.account.permissions },
librariesAccessible: [...librariesAccessible] librariesAccessible: [...(this.account.librariesAccessible || [])],
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
} }
} else { } else {
this.newUser = { this.newUser = {
@@ -249,7 +297,8 @@ export default {
update: false, update: false,
delete: false, delete: false,
upload: false, upload: false,
accessAllLibraries: true accessAllLibraries: true,
accessAllTags: true
}, },
librariesAccessible: [] librariesAccessible: []
} }
+29 -9
View File
@@ -39,7 +39,7 @@ export default {
type: Number, type: Number,
default: 0 default: 0
}, },
audiobookId: String libraryItemId: String
}, },
data() { data() {
return { return {
@@ -76,8 +76,15 @@ export default {
this.showBookmarkTitleInput = true this.showBookmarkTitleInput = true
}, },
deleteBookmark(bm) { deleteBookmark(bm) {
var bookmark = { ...bm, audiobookId: this.audiobookId } this.$axios
this.$root.socket.emit('delete_bookmark', bookmark) .$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
.then(() => {
this.$toast.success('Bookmark removed')
})
.catch((error) => {
this.$toast.error(`Failed to remove bookmark`)
console.error(error)
})
this.show = false this.show = false
}, },
clickBookmark(bm) { clickBookmark(bm) {
@@ -85,9 +92,15 @@ export default {
}, },
submitUpdateBookmark(updatedBookmark) { submitUpdateBookmark(updatedBookmark) {
var bookmark = { ...updatedBookmark } var bookmark = { ...updatedBookmark }
bookmark.audiobookId = this.audiobookId this.$axios
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
this.$root.socket.emit('update_bookmark', bookmark) .then(() => {
this.$toast.success('Bookmark updated')
})
.catch((error) => {
this.$toast.error(`Failed to update bookmark`)
console.error(error)
})
this.show = false this.show = false
}, },
submitCreateBookmark() { submitCreateBookmark() {
@@ -95,11 +108,18 @@ export default {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm') this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
} }
var bookmark = { var bookmark = {
audiobookId: this.audiobookId,
title: this.newBookmarkTitle, title: this.newBookmarkTitle,
time: this.currentTime time: Math.floor(this.currentTime)
} }
this.$root.socket.emit('create_bookmark', bookmark) this.$axios
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success('Bookmark added')
})
.catch((error) => {
this.$toast.error(`Failed to create bookmark`)
console.error(error)
})
this.newBookmarkTitle = '' this.newBookmarkTitle = ''
this.showBookmarkTitleInput = false this.showBookmarkTitleInput = false
@@ -1,45 +0,0 @@
<template>
<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-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">
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
library: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.library ? 'Update Library' : 'New Library'
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>
@@ -15,7 +15,7 @@
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96"> <div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<transition-group name="list-complete" tag="div"> <transition-group name="list-complete" tag="div">
<template v-for="collection in sortedCollections"> <template v-for="collection in sortedCollections">
<modals-collections-user-collection-item :key="collection.id" :collection="collection" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" /> <modals-collections-user-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
</template> </template>
</transition-group> </transition-group>
</div> </div>
@@ -50,7 +50,7 @@ export default {
this.loadCollections() this.loadCollections()
this.newCollectionName = '' this.newCollectionName = ''
} else { } else {
this.$store.commit('setSelectedAudiobook', null) this.$store.commit('setSelectedLibraryItem', null)
} }
} }
}, },
@@ -65,15 +65,18 @@ export default {
}, },
title() { title() {
if (this.showBatchUserCollectionModal) { if (this.showBatchUserCollectionModal) {
return `${this.selectedBookIds.length} Books Selected` return `${this.selectedBookIds.length} Items Selected`
} }
return this.selectedAudiobook ? this.selectedAudiobook.book.title : '' return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
}, },
selectedAudiobook() { bookCoverAspectRatio() {
return this.$store.state.selectedAudiobook return this.$store.getters['getBookCoverAspectRatio']
}, },
selectedAudiobookId() { selectedLibraryItem() {
return this.selectedAudiobook ? this.selectedAudiobook.id : null return this.$store.state.selectedLibraryItem
},
selectedLibraryItemId() {
return this.selectedLibraryItem ? this.selectedLibraryItem.id : null
}, },
collections() { collections() {
return this.$store.state.user.collections || [] return this.$store.state.user.collections || []
@@ -87,7 +90,7 @@ export default {
var collectionBookIds = c.books.map((b) => b.id) var collectionBookIds = c.books.map((b) => b.id)
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id)) includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
} else { } else {
includesBook = !!c.books.find((b) => b.id === this.selectedAudiobookId) includesBook = !!c.books.find((b) => b.id === this.selectedLibraryItemId)
} }
return { return {
@@ -101,7 +104,7 @@ export default {
return this.$store.state.globals.showBatchUserCollectionModal return this.$store.state.globals.showBatchUserCollectionModal
}, },
selectedBookIds() { selectedBookIds() {
return this.$store.state.selectedAudiobooks || [] return this.$store.state.selectedLibraryItems || []
}, },
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
@@ -112,7 +115,7 @@ export default {
this.$store.dispatch('user/loadUserCollections') this.$store.dispatch('user/loadUserCollections')
}, },
removeFromCollection(collection) { removeFromCollection(collection) {
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
this.processing = true this.processing = true
if (this.showBatchUserCollectionModal) { if (this.showBatchUserCollectionModal) {
@@ -132,7 +135,7 @@ export default {
} else { } else {
// Remove single book // Remove single book
this.$axios this.$axios
.$delete(`/api/collections/${collection.id}/book/${this.selectedAudiobookId}`) .$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Book removed from collection`, updatedCollection) console.log(`Book removed from collection`, updatedCollection)
this.$toast.success('Book removed from collection') this.$toast.success('Book removed from collection')
@@ -146,7 +149,7 @@ export default {
} }
}, },
addToCollection(collection) { addToCollection(collection) {
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
this.processing = true this.processing = true
if (this.showBatchUserCollectionModal) { if (this.showBatchUserCollectionModal) {
@@ -164,10 +167,10 @@ export default {
this.processing = false this.processing = false
}) })
} else { } else {
if (!this.selectedAudiobookId) return if (!this.selectedLibraryItemId) return
this.$axios this.$axios
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedAudiobookId }) .$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Book added to collection`, updatedCollection) console.log(`Book added to collection`, updatedCollection)
this.$toast.success('Book added to collection') this.$toast.success('Book added to collection')
@@ -181,12 +184,12 @@ export default {
} }
}, },
submitCreateCollection() { submitCreateCollection() {
if (!this.newCollectionName || (!this.selectedAudiobookId && !this.selectedBookIds.length)) { if (!this.newCollectionName || (!this.selectedLibraryItemId && !this.selectedBookIds.length)) {
return return
} }
this.processing = true this.processing = true
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedAudiobookId] var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedLibraryItemId]
var newCollection = { var newCollection = {
books: books, books: books,
libraryId: this.currentLibraryId, libraryId: this.currentLibraryId,
@@ -0,0 +1,160 @@
<template>
<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>
</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">
<form @submit.prevent="submitForm">
<div class="flex">
<div class="w-40 p-2">
<div class="w-full h-45 relative">
<covers-author-image :author="author" />
<div v-show="!processing" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
</div>
</div>
</div>
<div class="flex-grow">
<div class="flex">
<div class="w-3/4 p-2">
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" label="Name" />
</div>
<div class="flex-grow p-2">
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
</div>
</div>
<div class="p-2">
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
</div>
<div class="flex pt-2 px-2">
<ui-btn type="button" @click="searchAuthor">Quick Match</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div>
</div>
</div>
</form>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
author: {
type: Object,
default: () => {}
}
},
data() {
return {
authorCopy: {
name: '',
asin: '',
description: ''
},
processing: false
}
},
watch: {
author: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
authorId() {
if (!this.author) return ''
return this.author.id
},
title() {
return 'Edit Author'
}
},
methods: {
init() {
this.authorCopy.name = this.author.name
this.authorCopy.asin = this.author.asin
this.authorCopy.description = this.author.description
},
async submitForm() {
var keysToCheck = ['name', 'asin', 'description']
var updatePayload = {}
keysToCheck.forEach((key) => {
if (this.authorCopy[key] !== this.author[key]) {
updatePayload[key] = this.authorCopy[key]
}
})
if (!Object.keys(updatePayload).length) {
this.$toast.info('No updates are necessary')
return
}
this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to update author')
return null
})
if (result) {
if (result.updated) this.$toast.success('Author updated')
else this.$toast.info('No updates were needed')
}
this.processing = false
},
async removeCover() {
var updatePayload = {
imagePath: null,
relImagePath: null
}
this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to remove image')
return null
})
if (result && result.updated) {
this.$toast.success('Author image removed')
}
this.processing = false
},
async searchAuthor() {
if (!this.authorCopy.name) {
this.$toast.error('Must enter an author name')
return
}
this.processing = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
this.$toast.error('Author not found')
} else if (response.updated) {
if (response.author.imagePath) this.$toast.success('Author was updated')
else this.$toast.success('Author was updated (no image found)')
} else {
this.$toast.info('No updates were made for Author')
}
this.processing = false
}
},
mounted() {},
beforeDestroy() {}
}
</script>
@@ -4,7 +4,7 @@
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> --> <!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
<div class="w-20 max-w-20 text-center"> <div class="w-20 max-w-20 text-center">
<!-- <img src="/Logo.png" /> --> <!-- <img src="/Logo.png" /> -->
<covers-collection-cover :book-items="books" :width="80" :height="40 * 1.6" /> <covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
<div class="flex-grow overflow-hidden px-2"> <div class="flex-grow overflow-hidden px-2">
<!-- <template v-if="isEditing"> <!-- <template v-if="isEditing">
@@ -38,7 +38,8 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
highlight: Boolean highlight: Boolean,
bookCoverAspectRatio: Number
}, },
data() { data() {
return { return {
@@ -1,201 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
<template v-for="(authorName, index) in searchAuthors">
<cards-search-author-card :key="index" :author-name="authorName" @match="setSelectedMatch" />
</template>
<div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p>
</div>
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-2">
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-3">Update Author Details</p>
</div>
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatch.image" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.image" />
<img :src="selectedMatch.image" class="w-24 object-contain ml-4" />
<ui-text-input-with-label v-model="selectedMatch.image" :disabled="!selectedMatchUsage.image" label="Image" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.name" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.name" />
<ui-text-input-with-label v-model="selectedMatch.name" :disabled="!selectedMatchUsage.name" label="Name" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" />
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">Update</ui-btn>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
searchAuthors: [],
audiobookId: null,
searchAuthor: null,
lastSearch: null,
hasSearched: false,
selectedMatch: null,
selectedMatchUsage: {
image: true,
name: true,
description: true
}
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
}
},
methods: {
// getSearchQuery() {
// return `q=${this.searchAuthor}`
// },
// submitSearch() {
// if (!this.searchTitle) {
// this.$toast.warning('Search title is required')
// return
// }
// this.runSearch()
// },
// async runSearch() {
// var searchQuery = this.getSearchQuery()
// if (this.lastSearch === searchQuery) return
// this.selectedMatch = null
// this.isProcessing = true
// this.lastSearch = searchQuery
// var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
// console.error('Failed', error)
// return []
// })
// if (result) {
// this.selectedMatch = result
// }
// this.isProcessing = false
// this.hasSearched = true
// },
init() {
this.selectedMatch = null
// this.selectedMatchUsage = {
// title: true,
// subtitle: true,
// cover: true,
// author: true,
// description: true,
// isbn: true,
// publisher: true,
// publishYear: true
// }
if (this.audiobook.id !== this.audiobookId) {
this.selectedMatch = null
this.hasSearched = false
this.audiobookId = this.audiobook.id
}
if (!this.audiobook.book || !this.audiobook.book.authorFL) {
this.searchAuthors = []
return
}
this.searchAuthors = (this.audiobook.book.authorFL || '').split(', ')
},
selectMatch(match) {
this.selectedMatch = match
},
buildMatchUpdatePayload() {
var updatePayload = {}
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
updatePayload[key] = this.selectedMatch[key]
}
}
return updatePayload
},
async submitMatchUpdate() {
var updatePayload = this.buildMatchUpdatePayload()
if (!Object.keys(updatePayload).length) {
return
}
this.isProcessing = true
if (updatePayload.cover) {
var coverPayload = {
url: updatePayload.cover
}
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Cover Updated')
} else {
this.$toast.error('Book Cover Failed to Update')
}
console.log('Updated cover')
delete updatePayload.cover
}
if (Object.keys(updatePayload).length) {
var bookUpdatePayload = {
book: updatePayload
}
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Details Updated')
this.selectedMatch = null
this.$emit('selectTab', 'details')
} else {
this.$toast.error('Book Details Failed to Update')
}
} else {
this.selectedMatch = null
}
this.isProcessing = false
},
setSelectedMatch(authorMatchObj) {
this.selectedMatch = authorMatchObj
}
}
}
</script>
<style>
.matchListWrapper {
height: calc(100% - 80px);
}
</style>
@@ -1,59 +0,0 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<template v-for="chapter in chapters">
<tr :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</template>
</table>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
chapters: []
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {},
methods: {
init() {
this.chapters = this.audiobook.chapters || []
}
}
}
</script>
@@ -1,342 +0,0 @@
<template>
<div class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm">
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<div class="flex -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.title" label="Title" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-text-input-with-label v-model="details.author" label="Author" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
</div>
</div>
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
</div>
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.language" label="Language" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
</div>
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
</div>
</div>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex-grow" />
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<ui-btn type="submit">Submit</ui-btn>
</div>
</div>
</form>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
details: {
title: null,
subtitle: null,
description: null,
author: null,
narrator: null,
series: null,
volumeNumber: null,
publishYear: null,
publisher: null,
language: null,
isbn: null,
asin: null,
genres: []
},
newTags: [],
resettingProgress: false,
isScrollable: false,
savingMetadata: false,
rescanning: false,
quickMatching: false
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
isMissing() {
return !!this.audiobook && !!this.audiobook.isMissing
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
genres() {
return this.filterData.genres || []
},
tags() {
return this.filterData.tags || []
},
series() {
return this.filterData.series || []
},
filterData() {
return this.$store.state.libraries.filterData || {}
},
libraryId() {
return this.audiobook ? this.audiobook.libraryId : null
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
},
libraryScan() {
if (!this.libraryId) return null
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
}
},
methods: {
quickMatch() {
this.quickMatching = true
var matchOptions = {
provider: this.libraryProvider,
title: this.details.title,
author: this.details.author !== this.book.author ? this.details.author : null
}
this.$axios
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
.then((res) => {
this.quickMatching = false
if (res.warning) {
this.$toast.warning(res.warning)
} else if (res.updated) {
this.$toast.success('Audiobook details updated')
} else {
this.$toast.info('No updates were made')
}
})
.catch((error) => {
var errMsg = error.response ? error.response.data || '' : ''
console.error('Failed to match', error)
this.$toast.error(errMsg || 'Failed to match')
this.quickMatching = false
})
},
audiobookScanComplete(result) {
this.rescanning = false
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete audiobook was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete audiobook was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete audiobook was removed`)
}
},
rescan() {
this.rescanning = true
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
this.$root.socket.emit('scan_audiobook', this.audiobookId)
},
saveMetadataComplete(result) {
this.savingMetadata = false
if (result.error) {
this.$toast.error(result.error)
} else if (result.audiobookId) {
var { savedPath } = result
if (!savedPath) {
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
} else {
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
}
}
},
saveMetadata() {
this.savingMetadata = true
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata', this.audiobookId)
},
submitForm() {
if (this.isProcessing) {
return
}
this.isProcessing = true
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
this.$refs.seriesDropdown.blur()
}
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
this.$refs.genresSelect.forceBlur()
}
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
this.$refs.tagsSelect.forceBlur()
}
this.$nextTick(this.handleForm)
},
async handleForm() {
const updatePayload = {
book: this.details,
tags: this.newTags
}
var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
this.$toast.success('Update Successful')
this.$emit('close')
}
},
init() {
this.details.title = this.book.title
this.details.subtitle = this.book.subtitle
this.details.description = this.book.description
this.details.author = this.book.author
this.details.narrator = this.book.narrator
this.details.genres = this.book.genres || []
this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber
this.details.publishYear = this.book.publishYear
this.details.publisher = this.book.publisher || null
this.details.language = this.book.language || null
this.details.isbn = this.book.isbn || null
this.details.asin = this.book.asin || null
this.newTags = this.audiobook.tags || []
},
deleteAudiobook() {
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/books/${this.audiobookId}`)
.then(() => {
console.log('Audiobook removed')
this.$toast.success('Audiobook Removed')
this.$emit('close')
this.isProcessing = false
})
.catch((error) => {
console.error('Remove Audiobook failed', error)
this.isProcessing = false
})
}
},
checkIsScrollable() {
this.$nextTick(() => {
if (this.$refs.formWrapper) {
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
this.isScrollable = true
} else {
this.isScrollable = false
}
}
})
},
setResizeObserver() {
try {
this.$nextTick(() => {
const resizeObserver = new ResizeObserver(() => {
this.checkIsScrollable()
})
resizeObserver.observe(this.$refs.formWrapper)
})
} catch (error) {
console.error('Failed to set resize observer')
}
}
},
mounted() {
this.setResizeObserver()
}
}
</script>
<style scoped>
.details-form-wrapper {
height: calc(100% - 70px);
max-height: calc(100% - 70px);
}
</style>
@@ -1,215 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<div>
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
</div>
</div>
</div>
</div>
<div class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<div>
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
<p v-else>Zip 1 File</p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
</div>
</div>
</div>
</div>
<div v-if="showM4bDownload" class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
</div>
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
<div class="w-full h-full flex items-center justify-center">
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
<p class="w-24 font-mono pl-8 text-right">
{{ downloadAmount }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tempDisable: false,
isDownloading: false,
downloadPercent: '0',
downloadAmount: '0 KB'
}
},
watch: {
singleDownloadStatus(newVal) {
if (newVal) {
this.tempDisable = false
}
}
},
computed: {
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
_audiobook() {
return this.audiobook || {}
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
},
singleAudioDownload() {
return this.downloads.find((d) => d.type === 'singleAudio')
},
singleDownloadStatus() {
return this.singleAudioDownload ? this.singleAudioDownload.status : false
},
zipDownload() {
return this.downloads.find((d) => d.type === 'zip')
},
zipDownloadStatus() {
return this.zipDownload ? this.zipDownload.status : false
},
isSingleTrack() {
if (!this.audiobook.tracks) return false
return this.audiobook.tracks.length === 1
},
singleTrackPath() {
if (!this.isSingleTrack) return null
return this.audiobook.tracks[0].path
},
audioFiles() {
return this.audiobook ? this.audiobook.audioFiles || [] : []
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
totalFiles() {
return this.audioFiles.length + this.otherFiles.length
},
showM4bDownload() {
return !this._audiobook.isMissing && !this._audiobook.isInvalid && this._audiobook.tracks.length
}
},
methods: {
startZipDownload() {
// console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'zip'
}
this.$root.socket.emit('download', downloadPayload)
},
startSingleAudioDownload() {
// console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'singleAudio',
includeMetadata: true,
includeCover: true
}
this.$root.socket.emit('download', downloadPayload)
},
downloadWithProgress(download) {
var downloadId = download.id
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
var filename = download.filename
this.isDownloading = true
var request = new XMLHttpRequest()
request.responseType = 'blob'
request.open('get', downloadUrl, true)
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
request.send()
request.onreadystatechange = () => {
if (request.readyState === 4) {
this.isDownloading = false
}
if (request.readyState == 4 && request.status == 200) {
const url = window.URL.createObjectURL(request.response)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
setTimeout(() => {
if (anchor) anchor.remove()
}, 1000)
}
}
request.onerror = (err) => {
console.error('Download error', err)
this.isDownloading = false
}
request.onprogress = (e) => {
const percent_complete = Math.floor((e.loaded / e.total) * 100)
this.downloadAmount = this.$bytesPretty(e.loaded)
this.downloadPercent = percent_complete
// const duration = (new Date().getTime() - startTime) / 1000
// const bps = e.loaded / duration
// const kbps = Math.floor(bps / 1024)
// const time = (e.total - e.loaded) / bps
// const seconds = Math.floor(time % 60)
// const minutes = Math.floor(time / 60)
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
}
}
},
mounted() {}
}
</script>
@@ -1,115 +0,0 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="mb-4">
<template v-if="hasTracks">
<div class="w-full bg-primary px-4 py-2 flex items-center">
<p class="pr-4">Audio Tracks</p>
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracksCleaned">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</template>
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
</div>
<tables-all-files-table :audiobook="audiobook" />
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tracks: null,
showFullPath: false
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
hasTracks() {
return this.audiobook.tracks.length
}
},
methods: {
init() {
this.tracks = this.audiobook.tracks
}
}
}
</script>
@@ -1,286 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
</div>
</form>
<div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p>
</div>
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
<p>No Results</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
<template v-for="(res, index) in searchResults">
<cards-book-match-card :key="index" :book="res" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
</template>
</div>
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-2">
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-3">Update Book Details</p>
</div>
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatch.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.title" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.title" />
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.author" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.author" />
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.narrator" />
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" />
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publisher" />
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.publishYear" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publishYear" />
<ui-text-input-with-label v-model="selectedMatch.publishYear" :disabled="!selectedMatchUsage.publishYear" label="Publish Year" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.series" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.series" />
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.isbn" />
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.asin" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.asin" />
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" class="flex-grow ml-4" />
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">Update</ui-btn>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
audiobookId: null,
searchTitle: null,
searchAuthor: null,
lastSearch: null,
provider: 'google',
searchResults: [],
hasSearched: false,
selectedMatch: null,
selectedMatchUsage: {
title: true,
subtitle: true,
cover: true,
author: true,
narrator: true,
description: true,
publisher: true,
publishYear: true,
series: true,
volumeNumber: true,
asin: true,
isbn: true
}
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
providers() {
return this.$store.state.scanners.providers
}
},
methods: {
persistProvider() {
try {
localStorage.setItem('book-provider', this.provider)
} catch (error) {
console.error('PersistProvider', error)
}
},
getSearchQuery() {
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
submitSearch() {
if (!this.searchTitle) {
this.$toast.warning('Search title is required')
return
}
this.persistProvider()
this.runSearch()
},
async runSearch() {
var searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.searchResults = []
this.isProcessing = true
this.lastSearch = searchQuery
var results = await this.$axios.$get(`/api/search/books?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
results = results.filter((res) => {
return !!res.title
})
this.searchResults = results
this.isProcessing = false
this.hasSearched = true
},
init() {
this.selectedMatch = null
this.selectedMatchUsage = {
title: true,
subtitle: true,
cover: true,
author: true,
narrator: true,
description: true,
publisher: true,
publishYear: true,
series: true,
volumeNumber: true,
asin: true,
isbn: true
}
if (this.audiobook.id !== this.audiobookId) {
this.searchResults = []
this.hasSearched = false
this.audiobookId = this.audiobook.id
}
if (!this.audiobook.book || !this.audiobook.book.title) {
this.searchTitle = null
this.searchAuthor = null
return
}
this.searchTitle = this.audiobook.book.title
this.searchAuthor = this.audiobook.book.authorFL || ''
this.provider = localStorage.getItem('book-provider') || 'google'
},
selectMatch(match) {
this.selectedMatch = match
},
buildMatchUpdatePayload() {
var updatePayload = {}
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
updatePayload[key] = this.selectedMatch[key]
}
}
return updatePayload
},
async submitMatchUpdate() {
var updatePayload = this.buildMatchUpdatePayload()
if (!Object.keys(updatePayload).length) {
return
}
this.isProcessing = true
if (updatePayload.cover) {
var coverPayload = {
url: updatePayload.cover
}
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Cover Updated')
} else {
this.$toast.error('Book Cover Failed to Update')
}
console.log('Updated cover')
delete updatePayload.cover
}
if (Object.keys(updatePayload).length) {
var bookUpdatePayload = {
book: updatePayload
}
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Details Updated')
this.selectedMatch = null
this.$emit('selectTab', 'details')
} else {
this.$toast.error('Book Details Failed to Update')
}
} else {
this.selectedMatch = null
}
this.isProcessing = false
}
}
}
</script>
<style>
.matchListWrapper {
height: calc(100% - 80px);
}
</style>
@@ -1,110 +0,0 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<template v-if="hasTracks">
<div class="w-full bg-primary px-4 py-2 flex items-center">
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracksCleaned">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</template>
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tracks: null,
showFullPath: false
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
hasTracks() {
return this.audiobook.tracks.length
}
},
methods: {
init() {
this.tracks = this.audiobook.tracks
}
}
}
</script>
@@ -5,7 +5,7 @@
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p> <p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
</div> </div>
</template> </template>
<div class="absolute -top-10 left-0 w-full flex"> <div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in availableTabs"> <template v-for="tab in availableTabs">
<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 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>
</template> </template>
@@ -18,8 +18,8 @@
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div> <div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
</div> </div>
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300"> <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
<component v-if="audiobook && show" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" /> <component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div> </div>
</modals-modal> </modals-modal>
</template> </template>
@@ -29,49 +29,44 @@ export default {
data() { data() {
return { return {
processing: false, processing: false,
audiobook: null, libraryItem: null,
fetchOnShow: false,
tabs: [ tabs: [
{ {
id: 'details', id: 'details',
title: 'Details', title: 'Details',
component: 'modals-edit-tabs-details' component: 'modals-item-tabs-details'
}, },
{ {
id: 'cover', id: 'cover',
title: 'Cover', title: 'Cover',
component: 'modals-edit-tabs-cover' component: 'modals-item-tabs-cover'
}, },
// {
// id: 'tracks',
// title: 'Tracks',
// component: 'modals-edit-tabs-tracks'
// },
{ {
id: 'chapters', id: 'chapters',
title: 'Chapters', title: 'Chapters',
component: 'modals-edit-tabs-chapters' component: 'modals-item-tabs-chapters'
},
{
id: 'episodes',
title: 'Episodes',
component: 'modals-item-tabs-episodes'
}, },
{ {
id: 'files', id: 'files',
title: 'Files', title: 'Files',
component: 'modals-edit-tabs-files' component: 'modals-item-tabs-files'
},
{
id: 'download',
title: 'Download',
component: 'modals-edit-tabs-download'
}, },
{ {
id: 'match', id: 'match',
title: 'Match', title: 'Match',
component: 'modals-edit-tabs-match' component: 'modals-item-tabs-match'
},
{
id: 'merge',
title: 'Merge',
component: 'modals-item-tabs-merge',
experimental: true
} }
// {
// id: 'authors',
// title: 'Authors',
// component: 'modals-edit-tabs-authors'
// }
] ]
} }
}, },
@@ -89,12 +84,7 @@ export default {
this.selectedTab = availableTabIds[0] this.selectedTab = availableTabIds[0]
} }
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) { this.libraryItem = null
if (this.fetchOnShow) this.fetchFull()
return
}
this.fetchOnShow = false
this.audiobook = null
this.init() this.init()
this.registerListeners() this.registerListeners()
} else { } else {
@@ -120,22 +110,26 @@ export default {
this.$store.commit('setEditModalTab', val) this.$store.commit('setEditModalTab', val)
} }
}, },
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
availableTabs() { availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return [] if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => { return this.tabs.filter((tab) => {
if (tab.id === 'download' && this.isMissing) return false if (tab.experimental && !this.showExperimentalFeatures) return false
if ((tab.id === 'download' || tab.id === 'files' || tab.id === 'authors') && this.userCanDownload) return true if (tab.id === 'merge' && (this.isMissing || this.mediaType !== 'book')) return false
if (tab.id !== 'download' && tab.id !== 'files' && tab.id !== 'authors' && this.userCanUpdate) return true if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
if (tab.id === 'match' && this.userCanUpdate && this.showExperimentalFeatures) return true if (this.mediaType == 'book' && tab.id == 'episodes') return false
if ((tab.id === 'merge' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'merge' && tab.id !== 'files' && this.userCanUpdate) return true
if (tab.id === 'match' && this.userCanUpdate) return true
return false return false
}) })
}, },
@@ -148,26 +142,32 @@ export default {
return _tab ? _tab.component : '' return _tab ? _tab.component : ''
}, },
isMissing() { isMissing() {
return this.selectedAudiobook.isMissing return this.selectedLibraryItem.isMissing
}, },
selectedAudiobook() { selectedLibraryItem() {
return this.$store.state.selectedAudiobook || {} return this.$store.state.selectedLibraryItem || {}
}, },
selectedAudiobookId() { selectedLibraryItemId() {
return this.selectedAudiobook.id return this.selectedLibraryItem.id
}, },
book() { media() {
return this.audiobook ? this.audiobook.book || {} : {} return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
}, },
title() { title() {
return this.book.title || 'No Title' return this.mediaMetadata.title || 'No Title'
}, },
bookshelfBookIds() { bookshelfBookIds() {
return this.$store.state.bookshelfBookIds || [] return this.$store.state.bookshelfBookIds || []
}, },
currentBookshelfIndex() { currentBookshelfIndex() {
if (!this.bookshelfBookIds.length) return 0 if (!this.bookshelfBookIds.length) return 0
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId) return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedLibraryItemId)
}, },
canGoPrev() { canGoPrev() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0 return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
@@ -181,15 +181,18 @@ export default {
if (this.currentBookshelfIndex - 1 < 0) return if (this.currentBookshelfIndex - 1 < 0) return
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1] var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
this.processing = true this.processing = true
var prevBook = await this.$axios.$get(`/api/books/${prevBookId}`).catch((error) => { var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book' var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
return null return null
}) })
this.processing = false this.processing = false
if (prevBook) { if (prevBook) {
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab }) this.unregisterListeners()
this.$nextTick(this.init) this.libraryItem = prevBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', prevBook)
this.$nextTick(this.registerListeners)
} else { } else {
console.error('Book not found', prevBookId) console.error('Book not found', prevBookId)
} }
@@ -198,15 +201,18 @@ export default {
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
this.processing = true this.processing = true
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1] var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
var nextBook = await this.$axios.$get(`/api/books/${nextBookId}`).catch((error) => { var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book' var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
return null return null
}) })
this.processing = false this.processing = false
if (nextBook) { if (nextBook) {
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab }) this.unregisterListeners()
this.$nextTick(this.init) this.libraryItem = nextBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', nextBook)
this.$nextTick(this.registerListeners)
} else { } else {
console.error('Book not found', nextBookId) console.error('Book not found', nextBookId)
} }
@@ -216,23 +222,19 @@ export default {
this.selectedTab = tab this.selectedTab = tab
} }
}, },
audiobookUpdated() { libraryItemUpdated(expandedLibraryItem) {
if (!this.show) this.fetchOnShow = true this.libraryItem = expandedLibraryItem
else {
this.fetchFull()
}
}, },
init() { init() {
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
this.fetchFull() this.fetchFull()
}, },
async fetchFull() { async fetchFull() {
try { try {
this.processing = true this.processing = true
this.audiobook = await this.$axios.$get(`/api/books/${this.selectedAudiobookId}`) this.libraryItem = await this.$axios.$get(`/api/items/${this.selectedLibraryItemId}?expanded=1`)
this.processing = false this.processing = false
} catch (error) { } catch (error) {
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error) console.error('Failed to fetch audiobook', this.selectedLibraryItemId, error)
this.processing = false this.processing = false
this.show = false this.show = false
} }
@@ -246,9 +248,11 @@ export default {
}, },
registerListeners() { registerListeners() {
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
}, },
unregisterListeners() { unregisterListeners() {
this.$eventBus.$off('modal-hotkey', this.hotkey) this.$eventBus.$off('modal-hotkey', this.hotkey)
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
} }
}, },
mounted() {}, mounted() {},
@@ -258,7 +262,7 @@ export default {
} }
</script> </script>
<style> <style scoped>
.tab { .tab {
height: 40px; height: 40px;
} }
@@ -0,0 +1,55 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4">
<div v-if="chapters.length" class="w-full p-4 bg-primary">
<p>Audiobook Chapters</p>
</div>
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</table>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
chapters() {
return this.media.chapters || []
}
},
methods: {}
}
</script>
@@ -2,9 +2,9 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative"> <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
<div class="flex"> <div class="flex">
<div class="relative"> <div class="relative">
<covers-book-cover :audiobook="audiobook" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay --> <!-- book cover overlay -->
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100"> <div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" /> <div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover"> <div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
<span class="material-icons">delete</span> <span class="material-icons">delete</span>
@@ -31,7 +31,7 @@
<div v-if="showLocalCovers" class="flex items-center justify-center"> <div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers"> <template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)"> <div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }"> <div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" /> <img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
</div> </div>
@@ -47,9 +47,9 @@
<ui-dropdown v-model="provider" :items="providers" label="Provider" small /> <ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div> </div>
<div class="w-72 px-1"> <div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" /> <ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
</div> </div>
<div class="w-72 px-1"> <div v-show="provider != 'itunes'" class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" /> <ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div> </div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn> <ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
@@ -82,7 +82,7 @@
export default { export default {
props: { props: {
processing: Boolean, processing: Boolean,
audiobook: { libraryItem: {
type: Object, type: Object,
default: () => {} default: () => {}
} }
@@ -98,25 +98,11 @@ export default {
showLocalCovers: false, showLocalCovers: false,
previewUpload: null, previewUpload: null,
selectedFile: null, selectedFile: null,
providers: [
{
text: 'Google Books',
value: 'google'
},
{
text: 'Open Library',
value: 'openlibrary'
},
{
text: 'Audible',
value: 'audible'
}
],
provider: 'google' provider: 'google'
} }
}, },
watch: { watch: {
audiobook: { libraryItem: {
immediate: true, immediate: true,
handler(newVal) { handler(newVal) {
if (newVal) { if (newVal) {
@@ -134,23 +120,41 @@ export default {
this.$emit('update:processing', val) this.$emit('update:processing', val)
} }
}, },
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
},
searchTitleLabel() {
if (this.provider == 'audible') return 'Search Title or ASIN'
else if (this.provider == 'itunes') return 'Search Term'
return 'Search Title'
},
coverAspectRatio() { coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio') return this.$store.getters['getServerSetting']('coverAspectRatio')
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6 return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
}, },
audiobookId() { libraryItemId() {
return this.audiobook ? this.audiobook.id : null return this.libraryItem ? this.libraryItem.id : null
}, },
book() { mediaType() {
return this.audiobook ? this.audiobook.book || {} : {} return this.libraryItem ? this.libraryItem.mediaType : null
}, },
audiobookPath() { isPodcast() {
return this.audiobook ? this.audiobook.path : null return this.mediaType == 'podcast'
}, },
otherFiles() { media() {
return this.audiobook ? this.audiobook.otherFiles || [] : [] return this.libraryItem ? this.libraryItem.media || {} : {}
},
coverPath() {
return this.media.coverPath
},
mediaMetadata() {
return this.media.metadata || {}
},
libraryFiles() {
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
}, },
userCanUpload() { userCanUpload() {
return this.$store.getters['user/getUserCanUpload'] return this.$store.getters['user/getUserCanUpload']
@@ -159,12 +163,11 @@ export default {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
localCovers() { localCovers() {
return this.otherFiles return this.libraryFiles
.filter((f) => f.filetype === 'image') .filter((f) => f.fileType === 'image')
.map((file) => { .map((file) => {
var _file = { ...file } var _file = { ...file }
var imgRelPath = _file.path.replace(this.audiobookPath, '') _file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
_file.localPath = `/s/book/${this.audiobookId}/${imgRelPath}`
return _file return _file
}) })
} }
@@ -176,7 +179,7 @@ export default {
form.set('cover', this.selectedFile) form.set('cover', this.selectedFile)
this.$axios this.$axios
.$post(`/api/books/${this.audiobook.id}/cover`, form) .$post(`/api/items/${this.libraryItemId}/cover`, form)
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
this.$toast.error(data.error) this.$toast.error(data.error)
@@ -209,17 +212,18 @@ export default {
}, },
init() { init() {
this.showLocalCovers = false this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) { if (this.coversFound.length && (this.searchTitle !== this.mediaMetadata.title || this.searchAuthor !== this.mediaMetadata.authorName)) {
this.coversFound = [] this.coversFound = []
this.hasSearched = false this.hasSearched = false
} }
this.imageUrl = this.book.cover || '' this.imageUrl = this.media.coverPath || ''
this.searchTitle = this.book.title || '' this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.book.authorFL || '' this.searchAuthor = this.mediaMetadata.authorName || ''
this.provider = localStorage.getItem('book-provider') || 'openlibrary' if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
}, },
removeCover() { removeCover() {
if (!this.book.cover) { if (!this.media.coverPath) {
this.imageUrl = '' this.imageUrl = ''
return return
} }
@@ -229,7 +233,7 @@ export default {
this.updateCover(this.imageUrl) this.updateCover(this.imageUrl)
}, },
async updateCover(cover) { async updateCover(cover) {
if (cover === this.book.cover) { if (cover === this.coverPath) {
console.warn('Cover has not changed..', cover) console.warn('Cover has not changed..', cover)
return return
} }
@@ -237,9 +241,21 @@ export default {
this.isProcessing = true this.isProcessing = true
var success = false var success = false
// Download cover from url and use if (!cover) {
if (cover.startsWith('http:') || cover.startsWith('https:')) { // Remove cover
success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, { url: cover }).catch((error) => { success = await this.$axios
.$delete(`/api/items/${this.libraryItemId}/cover`)
.then(() => true)
.catch((error) => {
console.error('Failed to remove cover', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
})
} else if (cover.startsWith('http:') || cover.startsWith('https:')) {
// Download cover from url and use
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
console.error('Failed to download cover from url', error) console.error('Failed to download cover from url', error)
if (error.response && error.response.data) { if (error.response && error.response.data) {
this.$toast.error(error.response.data) this.$toast.error(error.response.data)
@@ -249,11 +265,9 @@ export default {
} else { } else {
// Update local cover url // Update local cover url
const updatePayload = { const updatePayload = {
book: { cover
cover: cover
}
} }
success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => { success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => {
console.error('Failed to update', error) console.error('Failed to update', error)
if (error.response && error.response.data) { if (error.response && error.response.data) {
this.$toast.error(error.response.data) this.$toast.error(error.response.data)
@@ -263,15 +277,16 @@ export default {
} }
if (success) { if (success) {
this.$toast.success('Update Successful') this.$toast.success('Update Successful')
this.$emit('close') // this.$emit('close')
} else { } else {
this.imageUrl = this.book.cover || '' this.imageUrl = this.media.coverPath || ''
} }
this.isProcessing = false this.isProcessing = false
}, },
getSearchQuery() { getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}` 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 return searchQuery
}, },
persistProvider() { persistProvider() {
@@ -296,23 +311,7 @@ export default {
this.hasSearched = true this.hasSearched = true
}, },
setCover(coverFile) { setCover(coverFile) {
this.isProcessing = true this.updateCover(coverFile.metadata.path)
this.$axios
.$patch(`/api/books/${this.audiobook.id}/coverfile`, coverFile)
.then((data) => {
console.log('response data', data)
if (data && typeof data === 'string') {
this.$toast.success(data)
}
this.isProcessing = false
})
.catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
this.isProcessing = false
})
} }
} }
} }
@@ -0,0 +1,231 @@
<template>
<div class="w-full h-full relative">
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
<div class="flex-grow" />
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<ui-btn @click="submitForm">Submit</ui-btn>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
resettingProgress: false,
isScrollable: false,
rescanning: false,
quickMatching: false
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
isFile() {
return !!this.libraryItem && this.libraryItem.isFile
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
isMissing() {
return !!this.libraryItem && !!this.libraryItem.isMissing
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
mediaMetadata() {
return this.media.metadata || {}
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
libraryId() {
return this.libraryItem ? this.libraryItem.libraryId : null
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
},
libraryScan() {
if (!this.libraryId) return null
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
}
},
methods: {
quickMatch() {
if (this.quickMatching) return
if (!this.$refs.itemDetailsEdit) return
var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()
if (!title) {
this.$toast.error('Must have a title for quick match')
return
}
this.quickMatching = true
var matchOptions = {
provider: this.libraryProvider,
title: title || null,
author: author || null
}
this.$axios
.$post(`/api/items/${this.libraryItemId}/match`, matchOptions)
.then((res) => {
this.quickMatching = false
if (res.warning) {
this.$toast.warning(res.warning)
} else if (res.updated) {
this.$toast.success('Item details updated')
} else {
this.$toast.info('No updates were made')
}
})
.catch((error) => {
var errMsg = error.response ? error.response.data || '' : ''
console.error('Failed to match', error)
this.$toast.error(errMsg || 'Failed to match')
this.quickMatching = false
})
},
rescan() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
},
submitForm() {
if (this.isProcessing) {
return
}
if (!this.$refs.itemDetailsEdit) {
return
}
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
if (!updatedDetails.hasChanges) {
this.$toast.info('No changes were made')
return
}
this.updateDetails(updatedDetails)
},
async updateDetails(updatedDetails) {
this.isProcessing = true
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updateResult) {
if (updateResult.updated) {
this.$toast.success('Item details updated')
// this.$emit('close')
} else {
this.$toast.info('No updates were necessary')
}
}
},
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')
if (formWrapper) {
if (formWrapper.scrollHeight > formWrapper.clientHeight) {
this.isScrollable = true
} else {
this.isScrollable = false
}
}
})
},
setResizeObserver() {
try {
var formWrapper = document.getElementById('formWrapper')
if (formWrapper) {
this.$nextTick(() => {
const resizeObserver = new ResizeObserver(() => {
this.checkIsScrollable()
})
resizeObserver.observe(formWrapper)
})
}
} catch (error) {
console.error('Failed to set resize observer')
}
}
},
mounted() {
this.setResizeObserver()
}
}
</script>
<style scoped>
.details-form-wrapper {
height: calc(100% - 70px);
max-height: calc(100% - 70px);
}
</style>
@@ -0,0 +1,97 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4">
<!-- <div class="flex items-center mb-4">
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p>
<div class="flex-grow" />
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check for new episodes</ui-btn>
</div> -->
<div v-if="episodes.length" class="w-full p-4 bg-primary">
<p>Podcast Episodes</p>
</div>
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">No Episodes</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Sort #</th>
<th class="text-left whitespace-nowrap">Episode #</th>
<th class="text-left">Title</th>
<th class="text-center w-28">Duration</th>
<th class="text-center w-28">Size</th>
</tr>
<tr v-for="episode in episodes" :key="episode.id">
<td class="text-left">
<p class="px-4">{{ episode.index }}</p>
</td>
<td class="text-left">
<p class="px-4">{{ episode.episode }}</p>
</td>
<td class="font-book">
{{ episode.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(episode.duration) }}
</td>
<td class="font-mono text-center">
{{ $bytesPretty(episode.size) }}
</td>
</tr>
</table>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
checkingNewEpisodes: false
}
},
computed: {
autoDownloadEpisodes() {
return !!this.media.autoDownloadEpisodes
},
lastEpisodeCheck() {
return this.media.lastEpisodeCheck
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
episodes() {
return this.media.episodes || []
}
},
methods: {
checkForNewEpisodes() {
this.checkingNewEpisodes = true
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
.then((response) => {
if (response.episodes && response.episodes.length) {
console.log('New episodes', response.episodes.length)
this.$toast.success(`${response.episodes.length} new episodes found!`)
} else {
this.$toast.info('No new episodes found')
}
this.checkingNewEpisodes = false
})
.catch((error) => {
console.error('Failed', error)
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
this.$toast.error(errorMsg)
this.checkingNewEpisodes = false
})
}
}
}
</script>
@@ -0,0 +1,58 @@
<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" />
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
tracks: [],
showFullPath: false
}
},
watch: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
media() {
return this.libraryItem.media || {}
},
libraryFiles() {
return this.libraryItem.libraryFiles || []
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.libraryItem.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
}
},
methods: {
init() {
this.tracks = this.media.tracks || []
}
}
}
</script>
@@ -0,0 +1,411 @@
<template>
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
</div>
<div v-show="provider != 'itunes'" class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
</div>
</form>
<div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p>
</div>
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
<p>No Results</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
<template v-for="(res, index) in searchResults">
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
</template>
</div>
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-2">
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-3">Update Book Details</p>
</div>
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatch.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.title" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.title" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" />
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.title || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" />
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.subtitle || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.author" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.author" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" />
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.authorName || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.narrator" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" />
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.narratorName || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" />
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publisher" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" />
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publisher || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.publishedYear" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publishedYear" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" label="Published Year" />
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publishedYear || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.series" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.series" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" />
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.isbn" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.isbn || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.asin" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.asin" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.asin || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.itunesId" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.itunesId" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesId || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.feedUrl" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.feedUrl" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.feedUrl || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.itunesPageUrl" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesPageUrl || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.releaseDate" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.releaseDate" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" label="Release Date" />
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.releaseDate || '' }}</p>
</div>
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">Update</ui-btn>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
libraryItemId: null,
searchTitle: null,
searchAuthor: null,
lastSearch: null,
provider: 'google',
searchResults: [],
hasSearched: false,
selectedMatch: null,
selectedMatchUsage: {
title: true,
subtitle: true,
cover: true,
author: true,
narrator: true,
description: true,
publisher: true,
publishedYear: true,
series: true,
volumeNumber: true,
asin: true,
isbn: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
feedUrl: true,
releaseDate: true
}
}
},
watch: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
},
searchTitleLabel() {
if (this.provider == 'audible') return 'Search Title or ASIN'
else if (this.provider == 'itunes') return 'Search Term'
return 'Search Title'
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
isPodcast() {
return this.mediaType == 'podcast'
}
},
methods: {
persistProvider() {
try {
localStorage.setItem('book-provider', this.provider)
} catch (error) {
console.error('PersistProvider', error)
}
},
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}`
return searchQuery
},
submitSearch() {
if (!this.searchTitle) {
this.$toast.warning('Search title is required')
return
}
this.persistProvider()
this.runSearch()
},
async runSearch() {
var searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.searchResults = []
this.isProcessing = true
this.lastSearch = searchQuery
var searchEntity = this.isPodcast ? 'podcast' : 'books'
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
// console.log('Got search results', results)
results = (results || []).filter((res) => {
return !!res.title
})
if (this.isPodcast) {
// Map to match PodcastMetadata keys
results = results.map((res) => {
res.itunesPageUrl = res.pageUrl || null
res.itunesId = res.id || null
res.author = res.artistName || null
return res
})
}
this.searchResults = results || []
this.isProcessing = false
this.hasSearched = true
},
init() {
this.selectedMatch = null
this.selectedMatchUsage = {
title: true,
subtitle: true,
cover: true,
author: true,
narrator: true,
description: true,
publisher: true,
publishedYear: true,
series: true,
volumeNumber: true,
asin: true,
isbn: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
feedUrl: true,
releaseDate: true
}
if (this.libraryItem.id !== this.libraryItemId) {
this.searchResults = []
this.hasSearched = false
this.libraryItemId = this.libraryItem.id
}
if (!this.libraryItem.media || !this.libraryItem.media.metadata.title) {
this.searchTitle = null
this.searchAuthor = null
return
}
this.searchTitle = this.libraryItem.media.metadata.title
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
},
selectMatch(match) {
this.selectedMatch = match
},
buildMatchUpdatePayload() {
var updatePayload = {}
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
if (key === 'series') {
var seriesItem = {
id: `new-${Math.floor(Math.random() * 10000)}`,
name: this.selectedMatch[key],
sequence: volumeNumber
}
updatePayload.series = [seriesItem]
} else if (key === 'author' && !this.isPodcast) {
var authorItem = {
id: `new-${Math.floor(Math.random() * 10000)}`,
name: this.selectedMatch[key]
}
updatePayload.authors = [authorItem]
} else if (key === 'narrator') {
updatePayload.narrators = [this.selectedMatch[key]]
} else if (key === 'itunesId') {
updatePayload.itunesId = Number(this.selectedMatch[key])
} else if (key !== 'volumeNumber') {
updatePayload[key] = this.selectedMatch[key]
}
}
}
return updatePayload
},
async submitMatchUpdate() {
var updatePayload = this.buildMatchUpdatePayload()
if (!Object.keys(updatePayload).length) {
return
}
this.isProcessing = true
if (updatePayload.cover) {
var coverPayload = {
url: updatePayload.cover
}
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Item Cover Updated')
} else {
this.$toast.error('Item Cover Failed to Update')
}
console.log('Updated cover')
delete updatePayload.cover
}
if (Object.keys(updatePayload).length) {
var mediaUpdatePayload = {
metadata: updatePayload
}
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (updateResult) {
if (updateResult.updated) {
this.$toast.success('Item details updated')
} else {
this.$toast.info('No detail updates were necessary')
}
this.selectedMatch = null
this.$emit('selectTab', 'details')
} else {
this.$toast.error('Item Details Failed to Update')
}
} else {
this.selectedMatch = null
}
this.isProcessing = false
}
}
}
</script>
<style>
.matchListWrapper {
height: calc(100% - 80px);
}
</style>
@@ -0,0 +1,214 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startAudiobookMerge">Start Merge</ui-btn>
<div v-else>
<div class="flex">
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
</div>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
</div>
</div>
</div>
</div>
<p class="text-left text-base mb-4 py-4">
<span class="text-error">* <strong>Experimental</strong></span
>&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
</p>
<p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p>
<p v-else-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
<div class="w-full h-full flex items-center justify-center">
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
<p class="w-24 font-mono pl-8 text-right">
{{ downloadAmount }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
tempDisable: false,
isDownloading: false,
downloadPercent: '0',
downloadAmount: '0 KB'
}
},
watch: {
abmergeStatus(newVal) {
if (newVal) {
this.tempDisable = false
}
}
},
computed: {
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.libraryItemId)
},
abmergeDownload() {
return this.downloads.find((d) => d.type === 'abmerge')
},
abmergeStatus() {
return this.abmergeDownload ? this.abmergeDownload.status : false
},
libraryFiles() {
return this.libraryItem.libraryFiles
},
totalFiles() {
return this.libraryFiles.length
},
mediaTracks() {
return this.media.tracks || []
},
isSingleM4b() {
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
},
showM4bDownload() {
if (this.libraryItem.isMissing || !this.mediaTracks.length) return false
return !this.isSingleM4b && this.mediaTracks.length > 0
}
},
methods: {
removeDownload() {
if (!this.abmergeDownload) return
if (!confirm(`Are you sure you want to remove this merge download?`)) return
var downloadId = this.abmergeDownload.id
this.tempDisable = true
this.$axios
.$delete(`/api/download/${downloadId}`)
.then(() => {
this.tempDisable = false
this.$toast.success('Merge download deleted')
this.$store.commit('downloads/removeDownload', { id: downloadId })
})
.catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
this.tempDisable = false
})
},
startAudiobookMerge() {
this.tempDisable = true
this.$axios
.$get(`/api/audiobook-merge/${this.libraryItemId}`)
.then(() => {
this.tempDisable = false
})
.catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
this.tempDisable = false
})
},
downloadWithProgress(download) {
var downloadId = download.id
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
var filename = download.filename
this.isDownloading = true
var request = new XMLHttpRequest()
request.responseType = 'blob'
request.open('get', downloadUrl, true)
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
request.send()
request.onreadystatechange = () => {
if (request.readyState === 4) {
this.isDownloading = false
}
if (request.readyState == 4 && request.status == 200) {
const url = window.URL.createObjectURL(request.response)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
setTimeout(() => {
if (anchor) anchor.remove()
}, 1000)
}
}
request.onerror = (err) => {
console.error('Download error', err)
this.isDownloading = false
}
request.onprogress = (e) => {
const percent_complete = Math.floor((e.loaded / e.total) * 100)
this.downloadAmount = this.$bytesPretty(e.loaded)
this.downloadPercent = percent_complete
// const duration = (new Date().getTime() - startTime) / 1000
// const bps = e.loaded / duration
// const kbps = Math.floor(bps / 1024)
// const time = (e.total - e.loaded) / bps
// const seconds = Math.floor(time % 60)
// const minutes = Math.floor(time / 60)
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
}
},
loadDownloads() {
this.$axios
.$get(`/api/downloads`)
.then((data) => {
var pendingDownloads = data.pendingDownloads.map((pd) => {
pd.download.status = this.$constants.DownloadStatus.PENDING
return pd.download
})
var downloads = data.downloads.map((d) => {
d.status = this.$constants.DownloadStatus.READY
return d
})
var allDownloads = downloads.concat(pendingDownloads)
this.$store.commit('downloads/setDownloads', allDownloads)
})
.catch((error) => {
console.error('Failed to load downloads', error)
})
}
},
mounted() {
this.loadDownloads()
}
}
</script>
@@ -1,20 +1,18 @@
<template> <template>
<div class="w-full h-full px-4 py-2 mb-4"> <div class="w-full h-full px-4 py-2 mb-4">
<div v-show="showDirectoryPicker" class="flex items-center py-1 mb-2">
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
<p class="px-4 text-xl">{{ title }}</p>
</div>
<div v-if="!showDirectoryPicker" class="w-full h-full py-4"> <div v-if="!showDirectoryPicker" class="w-full h-full py-4">
<div class="flex flex-wrap md:flex-nowrap -mx-1"> <div class="flex flex-wrap md:flex-nowrap -mx-1">
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
<ui-dropdown v-model="mediaType" :items="mediaTypes" label="Media Type" :disabled="!isNew" small @input="changedMediaType" />
</div>
<div class="w-full md:flex-grow px-1 py-1 md:py-0"> <div class="w-full md:flex-grow px-1 py-1 md:py-0">
<ui-text-input-with-label v-model="name" label="Library Name" /> <ui-text-input-with-label v-model="name" label="Library Name" @blur="nameBlurred" />
</div> </div>
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0"> <div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
<ui-media-type-picker v-model="mediaType" /> <ui-media-icon-picker v-model="icon" @input="iconChanged" />
</div> </div>
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0"> <div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small /> <ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small @input="formUpdated" />
</div> </div>
</div> </div>
@@ -22,36 +20,26 @@
<p class="px-1 text-sm font-semibold">Folders</p> <p class="px-1 text-sm font-semibold">Folders</p>
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2"> <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> <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" type="text" class="w-full" /> <ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" />
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span> <span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div> </div>
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p> <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="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
</div>
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn> <ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
</div> </div>
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
<div class="flex items-center">
<div class="flex-grow" />
<ui-btn v-show="!disableSubmit" color="success" :disabled="disableSubmit" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
</div>
</div>
</div> </div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" /> <modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
<div v-if="!showDirectoryPicker">
<div class="flex items-center pt-2">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" />
<ui-toggle-switch v-else disabled :value="false" />
<p class="pl-4 text-lg">Disable folder watcher for library</p>
</div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
</div>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
isNew: Boolean,
library: { library: {
type: Object, type: Object,
default: () => null default: () => null
@@ -61,40 +49,73 @@ export default {
data() { data() {
return { return {
name: '', name: '',
provider: '', provider: 'google',
mediaType: '', icon: '',
folders: [], folders: [],
showDirectoryPicker: false, showDirectoryPicker: false,
disableWatcher: false newFolderPath: '',
mediaType: null,
mediaTypes: [
{
value: 'book',
text: 'Books'
},
{
value: 'podcast',
text: 'Podcasts'
}
]
} }
}, },
computed: { computed: {
title() {
if (this.showDirectoryPicker) return 'Choose a Folder'
return ''
},
folderPaths() { folderPaths() {
return this.folders.map((f) => f.fullPath) return this.folders.map((f) => f.fullPath)
}, },
disableSubmit() {
if (!this.library) {
return false
}
var newfolderpaths = this.folderPaths.join(',')
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
return newfolderpaths === origfolderpaths && this.name === this.library.name && this.provider === this.library.provider && this.disableWatcher === this.library.disableWatcher && this.mediaType === this.library.mediaType
},
providers() { providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers return this.$store.state.scanners.providers
},
globalWatcherDisabled() {
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
} }
}, },
methods: { methods: {
getLibraryData() {
return {
name: this.name,
provider: this.provider,
folders: this.folders,
icon: this.icon,
mediaType: this.mediaType
}
},
formUpdated() {
this.$emit('update', this.getLibraryData())
},
newFolderInputBlurred() {
if (this.newFolderPath) {
this.folders.push({ fullPath: this.newFolderPath })
this.newFolderPath = ''
this.formUpdated()
}
},
iconChanged() {
this.formUpdated()
},
nameBlurred() {
if (this.name !== this.library.name) {
this.formUpdated()
}
},
changedMediaType() {
this.provider = this.providers[0].value
this.formUpdated()
},
selectFolder(fullPath) {
this.folders.push({ fullPath })
this.showDirectoryPicker = false
this.formUpdated()
},
removeFolder(folder) { removeFolder(folder) {
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath) this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
this.formUpdated()
}, },
backArrowPress() { backArrowPress() {
if (this.showDirectoryPicker) { if (this.showDirectoryPicker) {
@@ -103,94 +124,11 @@ export default {
}, },
init() { init() {
this.name = this.library ? this.library.name : '' this.name = this.library ? this.library.name : ''
this.provider = this.library ? this.library.provider : '' this.provider = this.library ? this.library.provider : 'google'
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : [] this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
this.disableWatcher = this.library ? !!this.library.disableWatcher : false this.icon = this.library ? this.library.icon : 'default'
this.mediaType = this.library ? this.library.mediaType : 'default' this.mediaType = this.library ? this.library.mediaType : 'book'
this.showDirectoryPicker = false this.showDirectoryPicker = false
},
selectFolder(fullPath) {
this.folders.push({ fullPath })
this.showDirectoryPicker = false
},
submit() {
if (this.library) {
this.updateLibrary()
} else {
this.createLibrary()
}
},
updateLibrary() {
if (!this.name) {
this.$toast.error('Library must have a name')
return
}
if (!this.folders.length) {
this.$toast.error('Library must have at least 1 path')
return
}
var newLibraryPayload = {
name: this.name,
provider: this.provider,
folders: this.folders,
mediaType: this.mediaType,
icon: this.mediaType,
disableWatcher: this.disableWatcher
}
this.$emit('update:processing', true)
this.$axios
.$patch(`/api/libraries/${this.library.id}`, newLibraryPayload)
.then((res) => {
this.$emit('update:processing', false)
this.$emit('close')
this.$toast.success(`Library "${res.name}" updated successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to update library')
}
this.$emit('update:processing', false)
})
},
createLibrary() {
if (!this.name) {
this.$toast.error('Library must have a name')
return
}
if (!this.folders.length) {
this.$toast.error('Library must have at least 1 path')
return
}
var newLibraryPayload = {
name: this.name,
provider: this.provider,
folders: this.folders,
mediaType: this.mediaType,
icon: this.mediaType,
disableWatcher: this.disableWatcher
}
this.$emit('update:processing', true)
this.$axios
.$post('/api/libraries', newLibraryPayload)
.then((res) => {
this.$emit('update:processing', false)
this.$emit('close')
this.$toast.success(`Library "${res.name}" created successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to create library')
}
this.$emit('update:processing', false)
})
} }
}, },
mounted() { mounted() {
@@ -0,0 +1,220 @@
<template>
<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-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>
</template>
</div>
<div class="px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<component v-if="libraryCopy && show" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-opacity-10">
<div class="flex justify-end">
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
library: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false,
selectedTab: 'details',
tabs: [
{
id: 'details',
title: 'Details',
component: 'modals-libraries-edit-library'
},
{
id: 'settings',
title: 'Settings',
component: 'modals-libraries-library-settings'
}
],
libraryCopy: null
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.library ? 'Update Library' : 'New Library'
},
buttonText() {
return this.library ? 'Update Library' : 'Create New Library'
},
tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
}
},
watch: {
show: {
handler(newVal) {
if (newVal) this.init()
}
}
},
methods: {
selectTab(tab) {
this.selectedTab = tab
},
updateLibrary(library) {
this.mapLibraryToCopy(library)
},
getNewLibraryData() {
return {
name: '',
provider: 'google',
folders: [],
icon: 'database',
mediaType: 'book',
settings: {
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
}
}
},
init() {
this.selectedTab = 'details'
this.libraryCopy = this.getNewLibraryData()
if (this.library) {
this.mapLibraryToCopy(this.library)
}
},
mapLibraryToCopy(library) {
for (const key in this.libraryCopy) {
if (library[key] !== undefined) {
if (key === 'folders') {
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
} else if (key === 'settings') {
this.libraryCopy.settings = { ...library.settings }
} else {
this.libraryCopy[key] = library[key]
}
}
}
},
validate() {
if (!this.libraryCopy.name) {
this.$toast.error('Library must have a name')
return false
}
if (!this.libraryCopy.folders.length) {
this.$toast.error('Library must have at least 1 path')
return false
}
return true
},
submit() {
if (!this.validate()) return
if (this.library) {
this.submitUpdateLibrary()
} else {
this.submitCreateLibrary()
}
},
getLibraryUpdatePayload() {
var updatePayload = {}
for (const key in this.libraryCopy) {
if (key === 'folders') {
if (this.libraryCopy.folders.map((f) => f.fullPath).join(',') !== this.library.folders.map((f) => f.fullPath).join(',')) {
updatePayload.folders = [...this.libraryCopy.folders]
}
} else if (key === 'settings') {
for (const settingsKey in this.libraryCopy.settings) {
if (this.libraryCopy.settings[settingsKey] !== this.library.settings[settingsKey]) {
if (!updatePayload.settings) updatePayload.settings = {}
updatePayload.settings[settingsKey] = this.libraryCopy.settings[settingsKey]
}
}
} else if (key !== 'mediaType' && this.libraryCopy[key] !== this.library[key]) {
updatePayload[key] = this.libraryCopy[key]
}
}
return updatePayload
},
submitUpdateLibrary() {
var newLibraryPayload = this.getLibraryUpdatePayload()
if (!Object.keys(newLibraryPayload).length) {
this.$toast.info('No updates are necessary')
return
}
this.processing = true
this.$axios
.$patch(`/api/libraries/${this.library.id}`, newLibraryPayload)
.then((res) => {
this.processing = false
this.show = false
this.$toast.success(`Library "${res.name}" updated successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to update library')
}
this.processing = false
})
},
submitCreateLibrary() {
this.processing = true
this.$axios
.$post('/api/libraries', this.libraryCopy)
.then((res) => {
this.processing = false
this.show = false
this.$toast.success(`Library "${res.name}" created successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to create library')
}
this.processing = false
})
}
},
mounted() {},
beforeDestroy() {}
}
</script>
<style scoped>
.tab {
height: 40px;
}
.tab.tab-selected {
height: 41px;
}
</style>
@@ -1,10 +1,14 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full bg-bg absolute top-0 left-0 px-4 py-4 z-10">
<div class="flex items-center py-1 mb-2">
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
<p class="px-4 text-xl">Choose a Folder</p>
</div>
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2"> <div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p> <p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
</div> </div>
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4"> <div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4 folder-container">
<div class="w-1/2 border-r border-bg"> <div class="w-1/2 border-r border-bg h-full overflow-y-auto">
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack"> <div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2">..</p> <p class="text-base font-mono px-2">..</p>
@@ -15,7 +19,7 @@
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span> <span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
</div> </div>
</div> </div>
<div class="w-1/2"> <div class="w-1/2 h-full overflow-y-auto">
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)"> <div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p> <p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
@@ -30,12 +34,8 @@
<p class="text-gray-300">Note: folders already mapped will not be shown</p> <p class="text-gray-300">Note: folders already mapped will not be shown</p>
</div> </div>
<div class="absolute bottom-0 left-0 w-full py-4 px-8"> <div class="w-full py-2">
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn> <ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
<!-- <div class="flex items-center">
<div class="flex-grow" />
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
</div> -->
</div> </div>
</div> </div>
</template> </template>
@@ -64,7 +64,6 @@ export default {
computed: { computed: {
_directories() { _directories() {
return this.directories.map((d) => { return this.directories.map((d) => {
console.log('Directories', d)
var isUsed = !!this.paths.find((path) => path.endsWith(d.path)) var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
var isSelected = d.path === this.selectedPath var isSelected = d.path === this.selectedPath
var classes = [] var classes = []
@@ -162,4 +161,9 @@ export default {
.dir-item.dir-used { .dir-item.dir-used {
background-color: rgba(255, 25, 0, 0.1); background-color: rgba(255, 25, 0, 0.1);
} }
.folder-container {
max-height: calc(100% - 130px);
height: calc(100% - 130px);
min-height: calc(100% - 130px);
}
</style> </style>
@@ -0,0 +1,81 @@
<template>
<div class="w-full h-full px-4 py-1 mb-4">
<div class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
<ui-toggle-switch v-else disabled :value="false" />
<p class="pl-4 text-lg">Disable folder watcher for library</p>
</div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
</div>
<div class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
</div>
</div>
<div class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
library: {
type: Object,
default: () => null
},
processing: Boolean
},
data() {
return {
provider: null,
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
}
},
computed: {
librarySettings() {
return this.library.settings || {}
},
globalWatcherDisabled() {
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
},
mediaType() {
return this.library.mediaType
},
providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
}
},
methods: {
getLibraryData() {
return {
settings: {
disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
}
}
},
formUpdated() {
this.$emit('update', this.getLibraryData())
},
init() {
this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
}
},
mounted() {
this.init()
}
}
</script>
@@ -0,0 +1,138 @@
<template>
<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>
</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">
<div class="flex flex-wrap">
<div class="w-1/3 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
</div>
<div class="w-1/3 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
</div>
<div class="w-1/3 p-1">
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
</div>
<div class="w-full p-1">
<ui-text-input-with-label v-model="newEpisode.title" label="Title" />
</div>
<div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
</div>
<div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
</div>
</div>
<div class="flex justify-end pt-4">
<ui-btn @click="submit">Submit</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
processing: false,
newEpisode: {
episode: null,
episodeType: null,
title: null,
subtitle: null,
description: null,
pubDate: null,
publishedAt: null
},
pubDateInput: null
}
},
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showEditPodcastEpisode
},
set(val) {
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
}
},
libraryItem() {
return this.$store.state.selectedLibraryItem
},
episode() {
return this.$store.state.globals.selectedEpisode
},
episodeId() {
return this.episode ? this.episode.id : null
},
title() {
if (!this.libraryItem) return ''
return this.libraryItem.media.metadata.title || 'Unknown'
}
},
methods: {
updatePubDate(val) {
if (val) {
this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx')
this.newEpisode.publishedAt = new Date(val).valueOf()
} else {
this.newEpisode.pubDate = null
this.newEpisode.publishedAt = null
}
},
init() {
this.newEpisode.episode = this.episode.episode || ''
this.newEpisode.episodeType = this.episode.episodeType || ''
this.newEpisode.title = this.episode.title || ''
this.newEpisode.subtitle = this.episode.subtitle || ''
this.newEpisode.description = this.episode.description || ''
this.newEpisode.pubDate = this.episode.pubDate || ''
this.newEpisode.publishedAt = this.episode.publishedAt
this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null
},
getUpdatePayload() {
var updatePayload = {}
for (const key in this.newEpisode) {
if (this.newEpisode[key] != this.episode[key]) {
updatePayload[key] = this.newEpisode[key]
}
}
return updatePayload
},
submit() {
const payload = this.getUpdatePayload()
if (!Object.keys(payload).length) {
return this.$toast.info('No updates were made')
}
this.processing = true
this.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
.then(() => {
this.processing = false
this.$toast.success('Podcast episode updated')
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed update episode'
console.error('Failed update episode', error)
this.processing = false
this.$toast.error(errorMsg)
})
}
},
mounted() {}
}
</script>
@@ -0,0 +1,172 @@
<template>
<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>
</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 ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
<div
v-for="(episode, index) in episodes"
:key="index"
class="relative"
:class="episode.enclosure && 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)"
>
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="episode.enclosure && 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" />
</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>
<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>
<!-- <span class="material-icons cursor-pointer text-lg hover:text-success" @click="saveEpisode(episode)">save</span> -->
</div>
</div>
</div>
<div class="flex justify-end pt-4">
<div class="relative">
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" :disabled="allDownloaded" />
</div>
<div class="px-8 py-2">
<p :class="!allDownloaded ? 'font-semibold text-gray-200' : 'text-gray-400'">Select all episodes</p>
</div>
</div>
<ui-btn :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episodes: {
type: Array,
default: () => []
}
},
data() {
return {
processing: false,
selectedEpisodes: {}
}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectAll: {
get() {
return this.episodesSelected.length == this.episodes.filter((_, index) => !(this.episodes[index].enclosure && this.itemEpisodeMap[this.episodes[index].enclosure.url])).length
},
set(val) {
for (const key in this.selectedEpisodes) {
this.selectedEpisodes[key] = val
}
}
},
title() {
if (!this.libraryItem) return ''
return this.libraryItem.media.metadata.title || 'Unknown'
},
allDownloaded() {
return Object.values(this.episodes).filter((episode) => !(episode.enclosure && this.itemEpisodeMap[episode.enclosure.url])).length === 0
},
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' : ''}`
},
itemEpisodes() {
if (!this.libraryItem) return []
return this.libraryItem.media.episodes || []
},
itemEpisodeMap() {
var map = {}
this.itemEpisodes.forEach((item) => {
if (item.enclosure) map[item.enclosure.url] = true
})
return map
}
},
methods: {
toggleSelectEpisode(index) {
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
},
submit() {
var episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
}
var payloadSize = JSON.stringify(episodesToDownload).length
var sizeInMb = payloadSize / 1024 / 1024
var 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`)
}
this.processing = true
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
.then(() => {
this.processing = false
this.$toast.success('Started downloading episodes')
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)
})
},
init() {
for (let i = 0; i < this.episodes.length; i++) {
var episode = this.episodes[i]
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
// Do not include episodes already downloaded
this.$set(this.selectedEpisodes, String(i), false)
}
}
}
},
mounted() {}
}
</script>
<style scoped>
#podcast-wrapper {
min-height: 400px;
max-height: 80vh;
}
#episodes-scroll {
max-height: calc(80vh - 200px);
}
</style>
@@ -0,0 +1,229 @@
<template>
<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-2/3 overflow-hidden">
<p class="font-book 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 class="w-full p-4">
<p class="text-lg font-semibold mb-2">Details</p>
<div v-if="podcast.imageUrl" class="p-1 w-full">
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
</div>
<div class="flex">
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
</div>
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.author" label="Author" />
</div>
</div>
<div class="flex">
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
</div>
<div class="w-full md:w-1/2 p-2">
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
</div>
</div>
<div class="p-2 w-full">
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
</div>
<div class="flex">
<div class="w-full md:w-1/2 p-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
</div>
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
</div>
</div>
</div>
<div class="flex items-center py-4">
<div class="flex-grow" />
<div class="px-4">
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
import Path from 'path'
export default {
props: {
value: Boolean,
podcastData: {
type: Object,
default: () => null
},
podcastFeedData: {
type: Object,
default: () => null
}
},
data() {
return {
processing: false,
selectedFolderId: null,
fullPath: null,
podcast: {
title: '',
author: '',
description: '',
releaseDate: '',
genres: [],
feedUrl: '',
feedImageUrl: '',
itunesPageUrl: '',
itunesId: '',
itunesArtistId: '',
autoDownloadEpisodes: false
}
}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this._podcastData.title
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
folders() {
if (!this.currentLibrary) return []
return this.currentLibrary.folders || []
},
folderItems() {
return this.folders.map((fold) => {
return {
value: fold.id,
text: fold.fullPath
}
})
},
_podcastData() {
return this.podcastData || {}
},
feedMetadata() {
if (!this.podcastFeedData) return {}
return this.podcastFeedData.metadata || {}
},
episodes() {
if (!this.podcastFeedData) return []
return this.podcastFeedData.episodes || []
},
selectedFolder() {
return this.folders.find((f) => f.id === this.selectedFolderId)
},
selectedFolderPath() {
if (!this.selectedFolder) return ''
return this.selectedFolder.fullPath
}
},
methods: {
titleUpdated() {
this.folderUpdated()
},
folderUpdated() {
if (!this.selectedFolderPath || !this.podcast.title) {
this.fullPath = ''
return
}
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
},
submit() {
const podcastPayload = {
path: this.fullPath,
folderId: this.selectedFolderId,
libraryId: this.currentLibrary.id,
media: {
metadata: {
title: this.podcast.title,
author: this.podcast.author,
description: this.podcast.description,
releaseDate: this.podcast.releaseDate,
genres: [...this.podcast.genres],
feedUrl: this.podcast.feedUrl,
imageUrl: this.podcast.imageUrl,
itunesPageUrl: this.podcast.itunesPageUrl,
itunesId: this.podcast.itunesId,
itunesArtistId: this.podcast.itunesArtistId,
language: this.podcast.language
},
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
}
}
console.log('Podcast payload', podcastPayload)
this.processing = true
this.$axios
.$post('/api/podcasts', podcastPayload)
.then((libraryItem) => {
this.processing = false
this.$toast.success('Podcast created successfully')
this.show = false
this.$router.push(`/item/${libraryItem.id}`)
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
console.error('Failed to create podcast', error)
this.processing = false
this.$toast.error(errorMsg)
})
},
init() {
// Prefer using itunes podcast data but not always passed in if manually entering rss feed
this.podcast.title = this._podcastData.title || this.feedMetadata.title || ''
this.podcast.author = this._podcastData.artistName || this.feedMetadata.author || ''
this.podcast.description = this._podcastData.description || this.feedMetadata.descriptionPlain || ''
this.podcast.releaseDate = this._podcastData.releaseDate || ''
this.podcast.genres = this._podcastData.genres || this.feedMetadata.categories || []
this.podcast.feedUrl = this._podcastData.feedUrl || this.feedMetadata.feedUrl || ''
this.podcast.imageUrl = this._podcastData.cover || this.feedMetadata.image || ''
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.autoDownloadEpisodes = false
if (this.folderItems[0]) {
this.selectedFolderId = this.folderItems[0].value
this.folderUpdated()
}
}
},
mounted() {}
}
</script>
<style scoped>
#podcast-wrapper {
min-height: 400px;
max-height: 80vh;
}
#episodes-scroll {
max-height: calc(80vh - 200px);
}
</style>
+42 -51
View File
@@ -18,10 +18,7 @@
<script> <script>
export default { export default {
data() { data() {
return { return {}
ebookType: '',
ebookUrl: ''
}
}, },
watch: { watch: {
show(newVal) { show(newVal) {
@@ -47,46 +44,65 @@ export default {
return null return null
}, },
abTitle() { abTitle() {
return this.selectedAudiobook.book.title return this.mediaMetadata.title
}, },
abAuthor() { abAuthor() {
return this.selectedAudiobook.book.author return this.mediaMetadata.authorName
}, },
selectedAudiobook() { selectedLibraryItem() {
return this.$store.state.selectedAudiobook return this.$store.state.selectedLibraryItem || {}
},
media() {
return this.selectedLibraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
}, },
libraryId() { libraryId() {
return this.selectedAudiobook.libraryId return this.selectedLibraryItem.libraryId
}, },
folderId() { folderId() {
return this.selectedAudiobook.folderId return this.selectedLibraryItem.folderId
}, },
ebooks() { ebookFile() {
return this.selectedAudiobook.ebooks || [] return this.media.ebookFile
}, },
epubEbook() { ebookFormat() {
return this.ebooks.find((eb) => eb.ext === '.epub') if (!this.ebookFile) return null
return this.ebookFile.ebookFormat
}, },
mobiEbook() { ebookType() {
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3') if (this.isMobi) return 'mobi'
else if (this.isEpub) return 'epub'
else if (this.isPdf) return 'pdf'
else if (this.isComic) return 'comic'
return null
}, },
pdfEbook() { isEpub() {
return this.ebooks.find((eb) => eb.ext === '.pdf') return this.ebookFormat == 'epub'
}, },
comicEbook() { isMobi() {
return this.ebooks.find((eb) => eb.ext === '.cbz' || eb.ext === '.cbr') return this.ebookFormat == 'mobi' || this.ebookFormat == 'azw3'
},
isPdf() {
return this.ebookFormat == 'pdf'
},
isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
},
ebookUrl() {
if (!this.ebookFile) return null
var itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
var relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
return `/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
}, },
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
},
selectedAudiobookFile() {
return this.$store.state.selectedAudiobookFile
} }
}, },
methods: { methods: {
getEbookUrl(path) {
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
},
hotkey(action) { hotkey(action) {
console.log('Reader hotkey', action) console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return if (!this.$refs.readerComponent) return
@@ -107,31 +123,6 @@ export default {
}, },
init() { init() {
this.registerListeners() this.registerListeners()
if (this.selectedAudiobookFile) {
this.ebookUrl = this.getEbookUrl(this.selectedAudiobookFile.path)
if (this.selectedAudiobookFile.ext === '.pdf') {
this.ebookType = 'pdf'
} else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') {
this.ebookType = 'mobi'
} else if (this.selectedAudiobookFile.ext === '.epub') {
this.ebookType = 'epub'
} else if (this.selectedAudiobookFile.ext === '.cbr' || this.selectedAudiobookFile.ext === '.cbz') {
this.ebookType = 'comic'
}
} else if (this.epubEbook) {
this.ebookType = 'epub'
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
} else if (this.mobiEbook) {
this.ebookType = 'mobi'
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
} else if (this.pdfEbook) {
this.ebookType = 'pdf'
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
} else if (this.comicEbook) {
this.ebookType = 'comic'
this.ebookUrl = this.getEbookUrl(this.comicEbook.path)
}
}, },
close() { close() {
this.unregisterListeners() this.unregisterListeners()
+16 -10
View File
@@ -5,16 +5,16 @@
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" /> <path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
</svg> </svg>
<div class="px-2"> <div class="px-2">
<p class="text-4xl md:text-5xl font-bold">{{ totalBooks }}</p> <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">Books in Library</p> <p class="font-book text-xs md:text-sm text-white text-opacity-80">Items in Library</p>
</div> </div>
</div> </div>
<div class="flex px-4"> <div class="flex px-4">
<span class="material-icons text-7xl">show_chart</span> <span class="material-icons text-7xl">show_chart</span>
<div class="px-1"> <div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalAudiobookHours }}</p> <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">Overall Hours</p> <p class="font-book text-xs md:text-sm text-white text-opacity-80">Overall {{ useOverallHours ? 'Hours' : 'Days' }}</p>
</div> </div>
</div> </div>
@@ -61,8 +61,8 @@ export default {
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
}, },
totalBooks() { totalItems() {
return this.libraryStats ? this.libraryStats.totalBooks : 0 return this.libraryStats ? this.libraryStats.totalItems : 0
}, },
totalAuthors() { totalAuthors() {
return this.libraryStats ? this.libraryStats.totalAuthors : 0 return this.libraryStats ? this.libraryStats.totalAuthors : 0
@@ -70,12 +70,11 @@ export default {
numAudioTracks() { numAudioTracks() {
return this.libraryStats ? this.libraryStats.numAudioTracks : 0 return this.libraryStats ? this.libraryStats.numAudioTracks : 0
}, },
totalAudiobookDuration() { totalDuration() {
return this.libraryStats ? this.libraryStats.totalDuration : 0 return this.libraryStats ? this.libraryStats.totalDuration : 0
}, },
totalAudiobookHours() { totalHours() {
var totalHours = Math.round(this.totalAudiobookDuration / (60 * 60)) return Math.round(this.totalDuration / (60 * 60))
return totalHours
}, },
totalSizePretty() { totalSizePretty() {
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0 var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
@@ -86,6 +85,13 @@ export default {
}, },
totalSizeMod() { totalSizeMod() {
return this.totalSizePretty.split(' ')[1] return this.totalSizePretty.split(' ')[1]
},
useOverallHours() {
return this.totalHours < 10000
},
totalTime() {
if (this.useOverallHours) return this.totalHours
return Math.round(this.totalHours / 24)
} }
}, },
methods: {}, methods: {},
-109
View File
@@ -1,109 +0,0 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 py-2 flex items-center cursor-pointer">
<p class="pr-4">All Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
</div>
<div class="w-full">
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left px-4">Path</th>
<th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr>
<template v-for="file in allFiles">
<tr :key="file.path">
<td class="font-book pl-2">
{{ showFullPath ? file.fullPath : file.path }}
</td>
<td class="text-xs">
<p>{{ file.filetype }}</p>
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
showFullPath: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userToken() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
otherFiles() {
return this.audiobook.otherFiles || []
},
audioFiles() {
return this.audiobook.audioFiles || []
},
audioFilesCleaned() {
return this.audioFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: 'audio'
}
})
},
otherFilesCleaned() {
return this.otherFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: af.filetype
}
})
},
allFiles() {
return this.audioFilesCleaned.concat(this.otherFilesCleaned)
}
},
methods: {
getRelativePath(path) {
var filePath = path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return filePath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
},
mounted() {}
}
</script>
+37 -29
View File
@@ -13,17 +13,20 @@
<th class="hidden sm:table-cell w-20 md:w-28">Size</th> <th class="hidden sm:table-cell w-20 md:w-28">Size</th>
<th class="w-36"></th> <th class="w-36"></th>
</tr> </tr>
<tr v-for="backup in backups" :key="backup.id"> <tr v-for="backup in backups" :key="backup.id" :class="!backup.serverVersion ? 'bg-error bg-opacity-10' : ''">
<td> <td>
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p> <p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
</td> </td>
<td class="hidden sm:table-cell font-sans text-base">{{ backup.datePretty }}</td> <td class="hidden sm:table-cell font-sans text-sm">{{ backup.datePretty }}</td>
<td class="hidden sm:table-cell font-mono md:text-base text-xs">{{ $bytesPretty(backup.fileSize) }}</td> <td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
<td> <td>
<div class="w-full flex flex-row items-center justify-center"> <div class="w-full flex flex-row items-center justify-center">
<ui-btn small color="primary" @click="applyBackup(backup)">Apply</ui-btn> <ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
<a :href="`/metadata/${backup.path.replace(/%/g, '%25').replace(/#/g, '%23')}?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> <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-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-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> <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>
</div> </div>
@@ -42,7 +45,7 @@
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300"> <div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-error text-lg font-semibold">Important Notice!</p> <p class="text-error text-lg font-semibold">Important Notice!</p>
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p> <p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories.</p> <p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backup up or overwritten.</p>
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p> <p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p> <p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
@@ -77,14 +80,24 @@ export default {
methods: { methods: {
confirm() { confirm() {
this.showConfirmApply = false this.showConfirmApply = false
this.$root.socket.once('apply_backup_complete', this.applyBackupComplete)
this.$root.socket.emit('apply_backup', this.selectedBackup.id) this.$axios
.$get(`/api/backups/${this.selectedBackup.id}/apply`)
.then(() => {
this.isBackingUp = false
location.replace('/config/backups?backup=1')
})
.catch((error) => {
this.isBackingUp = false
console.error('Failed', error)
this.$toast.error('Failed to apply backup')
})
}, },
deleteBackupClick(backup) { deleteBackupClick(backup) {
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) { if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
this.processing = true this.processing = true
this.$axios this.$axios
.$delete(`/api/backup/${backup.id}`) .$delete(`/api/backups/${backup.id}`)
.then((backups) => { .then((backups) => {
console.log('Backup deleted', backups) console.log('Backup deleted', backups)
this.$store.commit('setBackups', backups) this.$store.commit('setBackups', backups)
@@ -98,29 +111,24 @@ export default {
}) })
} }
}, },
applyBackupComplete(success) {
if (success) {
// this.$toast.success('Backup Applied, refresh the page')
location.replace('/config/backups?backup=1')
} else {
this.$toast.error('Failed to apply backup')
}
},
applyBackup(backup) { applyBackup(backup) {
this.selectedBackup = backup this.selectedBackup = backup
this.showConfirmApply = true this.showConfirmApply = true
}, },
backupComplete(backups) {
this.isBackingUp = false
if (backups) {
this.$toast.success('Backup Successful')
this.$store.commit('setBackups', backups)
} else this.$toast.error('Backup Failed')
},
clickCreateBackup() { clickCreateBackup() {
this.isBackingUp = true this.isBackingUp = true
this.$root.socket.once('backup_complete', this.backupComplete) this.$axios
this.$root.socket.emit('create_backup') .$post('/api/backups')
.then((backups) => {
this.isBackingUp = false
this.$toast.success('Backup Successful')
this.$store.commit('setBackups', backups)
})
.catch((error) => {
this.isBackingUp = false
console.error('Failed', error)
this.$toast.error('Backup Failed')
})
}, },
backupUploaded(file) { backupUploaded(file) {
var form = new FormData() var form = new FormData()
@@ -129,7 +137,7 @@ export default {
this.processing = true this.processing = true
this.$axios this.$axios
.$post('/api/backup/upload', form) .$post('/api/backups/upload', form)
.then((result) => { .then((result) => {
console.log('Upload backup result', result) console.log('Upload backup result', result)
this.$store.commit('setBackups', result) this.$store.commit('setBackups', result)
@@ -171,11 +179,11 @@ export default {
text-align: center; text-align: center;
} }
#backups tr:nth-child(even) { #backups tr:nth-child(even):not(.bg-error) {
background-color: #3a3a3a; background-color: #3a3a3a;
} }
#backups tr:not(.staticrow):hover { #backups tr:not(.staticrow):not(.bg-error):hover {
background-color: #444; background-color: #444;
} }
@@ -6,7 +6,7 @@
<p class="font-mono text-sm">{{ books.length }}</p> <p class="font-mono text-sm">{{ books.length }}</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<p v-if="totalDuration">{{ totalDurationPretty }}</p> <!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> -->
</div> </div>
<draggable v-model="booksCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate"> <draggable v-model="booksCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'collection-book' : null"> <transition-group type="transition" :name="!drag ? 'collection-book' : null">
@@ -56,16 +56,6 @@ export default {
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6 return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
},
totalDuration() {
var _total = 0
this.books.forEach((book) => {
_total += book.duration
})
return _total
},
totalDurationPretty() {
return this.$elapsedPretty(this.totalDuration)
} }
}, },
methods: { methods: {
@@ -1,14 +1,11 @@
<template> <template>
<div class="w-full my-2"> <div class="w-full my-2">
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar"> <div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-2 md:pr-4">Other Files</p> <p class="pr-2 md:pr-4">Library Files</p>
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center"> <div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ files.length }}</span> <span class="text-sm font-mono">{{ files.length }}</span>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> -->
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn> <ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''"> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span> <span class="material-icons text-4xl">expand_more</span>
@@ -19,22 +16,25 @@
<table class="text-sm tracksTable"> <table class="text-sm tracksTable">
<tr class="font-book"> <tr class="font-book">
<th class="text-left px-4">Path</th> <th class="text-left px-4">Path</th>
<th class="text-left w-24 min-w-24">Size</th>
<th class="text-left px-4 w-24">Filetype</th> <th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th> <th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
</tr> </tr>
<template v-for="file in otherFilesCleaned"> <template v-for="file in files">
<tr :key="file.path"> <tr :key="file.path">
<td class="font-book pl-2"> <td class="font-book px-4">
{{ showFullPath ? file.fullPath : file.path }} {{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.metadata.size) }}
</td> </td>
<td class="text-xs"> <td class="text-xs">
<div class="flex items-center"> <div class="flex items-center">
<span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span> <p>{{ file.fileType }}</p>
<p>{{ file.filetype }}</p>
</div> </div>
</td> </td>
<td v-if="userCanDownload && !isMissing" class="text-center"> <td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> <a :href="`/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td> </td>
</tr> </tr>
</template> </template>
@@ -51,10 +51,9 @@ export default {
type: Array, type: Array,
default: () => [] default: () => []
}, },
audiobook: { libraryItemId: String,
type: Object, isMissing: Boolean,
default: () => null expanded: Boolean // start expanded
}
}, },
data() { data() {
return { return {
@@ -63,44 +62,20 @@ export default {
} }
}, },
computed: { computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
otherFilesCleaned() {
return this.files.map((file) => {
var filePath = file.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...file,
relativePath: filePath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
isMissing() {
return this.audiobook.isMissing
},
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
} }
}, },
methods: { methods: {
readEbookClick(file) {
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
},
clickBar() { clickBar() {
this.showFiles = !this.showFiles this.showFiles = !this.showFiles
} }
}, },
mounted() {} mounted() {
this.showFiles = this.expanded
}
} }
</script> </script>
+12 -30
View File
@@ -1,14 +1,14 @@
<template> <template>
<div class="w-full my-2"> <div class="w-full my-2">
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar"> <div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-2 md:pr-4">Audio Tracks</p> <p class="pr-2 md:pr-4">{{ title }}</p>
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center"> <div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span> <span class="text-sm font-mono">{{ tracks.length }}</span>
</div> </div>
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> --> <!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn> <ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-2 md:mr-4"> <nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
<ui-btn small color="primary">Manage Tracks</ui-btn> <ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> </nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''"> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
@@ -25,20 +25,20 @@
<th class="text-left w-20">Duration</th> <th class="text-left w-20">Duration</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th> <th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr> </tr>
<template v-for="track in tracksCleaned"> <template v-for="track in tracks">
<tr :key="track.index"> <tr :key="track.index">
<td class="text-center"> <td class="text-center">
<p>{{ track.index }}</p> <p>{{ track.index }}</p>
</td> </td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td> <td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
<td class="font-mono"> <td class="font-mono">
{{ $bytesPretty(track.size) }} {{ $bytesPretty(track.metadata.size) }}
</td> </td>
<td class="font-mono"> <td class="font-mono">
{{ $secondsToTimestamp(track.duration) }} {{ $secondsToTimestamp(track.duration) }}
</td> </td>
<td v-if="userCanDownload" class="text-center"> <td v-if="userCanDownload" class="text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> <a :href="`/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td> </td>
</tr> </tr>
</template> </template>
@@ -51,14 +51,16 @@
<script> <script>
export default { export default {
props: { props: {
title: {
type: String,
default: 'Audio Tracks'
},
tracks: { tracks: {
type: Array, type: Array,
default: () => [] default: () => []
}, },
audiobook: { libraryItemId: String,
type: Object, isFile: Boolean
default: () => null
}
}, },
data() { data() {
return { return {
@@ -67,26 +69,6 @@ export default {
} }
}, },
computed: { computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent> <div class="w-full my-4">
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar"> <div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">{{ title }}</p> <p class="pr-4">{{ title }}</p>
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span> <span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
+5 -20
View File
@@ -26,11 +26,11 @@
</td> </td>
<td class="text-sm">{{ user.type }}</td> <td class="text-sm">{{ user.type }}</td>
<td class="hidden lg:table-cell"> <td class="hidden lg:table-cell">
<div v-if="usersOnline[user.id] && usersOnline[user.id].stream && usersOnline[user.id].stream.audiobook && usersOnline[user.id].stream.audiobook.book"> <div v-if="usersOnline[user.id] && usersOnline[user.id].session && usersOnline[user.id].session.libraryItem && usersOnline[user.id].session.libraryItem.media">
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.audiobook.book.title || '' }}</p> <p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
</div> </div>
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)"> <div v-else-if="user.mostRecent">
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p> <p class="truncate text-xs">Last: {{ user.mostRecent.metadata.title }}</p>
</div> </div>
</td> </td>
<td class="text-xs font-mono hidden sm:table-cell"> <td class="text-xs font-mono hidden sm:table-cell">
@@ -76,28 +76,13 @@ export default {
currentUserId() { currentUserId() {
return this.$store.state.user.user.id return this.$store.state.user.user.id
}, },
userStream() {
return this.$store.state.streamAudiobook
},
usersOnline() { usersOnline() {
var usermap = {} var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream })) this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
return usermap return usermap
} }
}, },
methods: { methods: {
getLastRead(audiobooks) {
var abs = Object.values(audiobooks).filter((ab) => {
return ab.progress > 0
})
if (abs.length) {
abs = abs.sort((a, b) => b.lastUpdate - a.lastUpdate)
// Book object is attached on request
if (abs[0].book) return abs[0].book.title
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
}
return null
},
deleteUserClick(user) { deleteUserClick(user) {
if (this.isDeletingUser) return if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) { if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
@@ -7,20 +7,19 @@
</div> </div>
</div> </div>
<div class="h-full relative" :style="{ width: coverWidth + 'px' }"> <div class="h-full relative" :style="{ width: coverWidth + 'px' }">
<covers-book-cover :audiobook="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn"> <div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick"> <div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
<span class="material-icons">play_arrow</span> <span class="material-icons">play_arrow</span>
</div> </div>
</div> </div>
</div> </div>
<div class="w-80 h-full px-2 flex items-center"> <div class="flex-grow max-w-md h-full px-2 flex items-center">
<div> <div class="truncate px-1">
<nuxt-link :to="`/audiobook/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link> <nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
<nuxt-link :to="`/library/${book.libraryId}/bookshelf?filter=authors.${$encode(bookAuthor)}`" class="truncate block text-gray-400 text-sm hover:underline">{{ bookAuthor }}</nuxt-link>
</div> </div>
</div> </div>
<div class="flex-grow flex items-center"> <div class="w-20 flex items-center">
<p class="font-mono text-sm">{{ bookDuration }}</p> <p class="font-mono text-sm">{{ bookDuration }}</p>
</div> </div>
@@ -28,15 +27,10 @@
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span> <span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
</div> --> </div> -->
</div> </div>
<!-- <div class="absolute top-0 left-0 z-40 bg-red-500 w-full h-full">
<div class="w-24 h-full absolute top-0 -right-24 transform transition-transform" :class="isHovering ? 'translate-x-0' : '-translate-x-24'">
<span class="material-icons">edit</span>
</div>
</div> -->
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'"> <div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top"> <ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" borderless class="mx-1 mt-0.5" @click="toggleRead" /> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
</ui-tooltip> </ui-tooltip>
<div class="mx-1" :class="isHovering ? '' : 'ml-6'"> <div class="mx-1" :class="isHovering ? '' : 'ml-6'">
<ui-icon-btn icon="edit" borderless @click="clickEdit" /> <ui-icon-btn icon="edit" borderless @click="clickEdit" />
@@ -68,12 +62,6 @@ export default {
} }
}, },
watch: { watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
},
isDragging: { isDragging: {
handler(newVal) { handler(newVal) {
if (newVal) { if (newVal) {
@@ -83,17 +71,23 @@ export default {
} }
}, },
computed: { computed: {
_book() { media() {
return this.book.book || {} return this.book.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
tracks() {
return this.media.tracks || []
}, },
bookTitle() { bookTitle() {
return this._book.title || '' return this.mediaMetadata.title || ''
}, },
bookAuthor() { bookAuthor() {
return this._book.authorFL || '' return (this.mediaMetadata.authors || []).map((au) => au.name).join(', ')
}, },
bookDuration() { bookDuration() {
return this.$secondsToTimestamp(this.book.duration) return this.$secondsToTimestamp(this.media.duration)
}, },
isMissing() { isMissing() {
return this.book.isMissing return this.book.isMissing
@@ -101,23 +95,17 @@ export default {
isInvalid() { isInvalid() {
return this.book.isInvalid return this.book.isInvalid
}, },
numTracks() {
return this.book.numTracks
},
isStreaming() { isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.book.id return this.$store.getters['getLibraryItemIdStreaming'] === this.book.id
}, },
showPlayBtn() { showPlayBtn() {
return !this.isMissing && !this.isInvalid && !this.isStreaming && this.numTracks return !this.isMissing && !this.isInvalid && !this.isStreaming && this.tracks.length
}, },
userAudiobooks() { itemProgress() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} return this.$store.getters['user/getUserMediaProgress'](this.book.id)
}, },
userAudiobook() { userIsFinished() {
return this.userAudiobooks[this.book.id] || null return this.itemProgress ? !!this.itemProgress.isFinished : false
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
}, },
coverWidth() { coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.6 if (this.bookCoverAspectRatio === 1) return 50 * 1.6
@@ -133,26 +121,28 @@ export default {
this.isHovering = false this.isHovering = false
}, },
playClick() { playClick() {
this.$eventBus.$emit('play-audiobook', this.book.id) this.$eventBus.$emit('play-item', {
libraryItemId: this.book.id
})
}, },
clickEdit() { clickEdit() {
this.$emit('edit', this.book) this.$emit('edit', this.book)
}, },
toggleRead() { toggleFinished() {
var updatePayload = { var updatePayload = {
isRead: !this.isRead isFinished: !this.userIsFinished
} }
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
this.$axios this.$axios
.$patch(`/api/me/audiobook/${this.book.id}`, updatePayload) .$patch(`/api/me/progress/${this.book.id}`, updatePayload)
.then(() => { .then(() => {
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
this.$toast.success(`"${this.bookTitle}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
}, },
removeClick() { removeClick() {
@@ -9,11 +9,11 @@
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag"> <draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies"> <template v-for="library in libraryCopies">
<div :key="library.id" class="item"> <div :key="library.id" class="item">
<modals-libraries-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" /> <tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
</div> </div>
</template> </template>
</draggable> </draggable>
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" /> <modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p> <p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
@@ -7,13 +7,13 @@
</svg> </svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p> <p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="success" @click.stop="scan">Scan</ui-btn> <ui-btn v-show="isHovering && !libraryScan" small color="success" @click.stop="scan">Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn> <ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn> <ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span> <span v-show="isHovering && !libraryScan && showEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span> <span v-show="!libraryScan && isHovering && showEdit && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin"> <div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
<svg viewBox="0 0 24 24" class="w-6 h-6"> <svg viewBox="0 0 24 24" class="w-6 h-6">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
@@ -49,20 +49,17 @@ export default {
libraryScan() { libraryScan() {
return this.$store.getters['scanners/getLibraryScan'](this.library.id) return this.$store.getters['scanners/getLibraryScan'](this.library.id)
}, },
canEdit() { mediaType() {
return this.$store.getters['user/getIsRoot'] return this.library.mediaType
}, },
canDelete() { isBookLibrary() {
return this.$store.getters['user/getIsRoot'] return this.mediaType === 'book'
},
canScan() {
return this.$store.getters['user/getIsRoot']
} }
}, },
methods: { methods: {
matchAll() { matchAll() {
this.$axios this.$axios
.$post(`/api/libraries/${this.library.id}/matchbooks`) .$post(`/api/libraries/${this.library.id}/matchall`)
.then(() => { .then(() => {
console.log('Starting scan for matches') console.log('Starting scan for matches')
}) })
@@ -76,10 +73,10 @@ export default {
this.$emit('edit', this.library) this.$emit('edit', this.library)
}, },
scan() { scan() {
this.$root.socket.emit('scan', this.library.id) this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
}, },
forceScan() { forceScan() {
this.$root.socket.emit('scan', this.library.id, { forceRescan: true }) this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
}, },
deleteClick() { deleteClick() {
if (this.isMain) return if (this.isMain) return
@@ -0,0 +1,175 @@
<template>
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="episode" class="flex items-center h-24">
<div v-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
</div>
</div>
<div class="flex-grow px-2">
<p class="text-sm font-semibold">
{{ title }}
</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ description }}</p>
<div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</div>
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
</ui-tooltip>
<p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
</div>
</div>
<div class="w-24 min-w-24" />
</div>
<div class="w-24 min-w-24 -right-0 absolute top-0 h-full transform transition-transform" :class="!isHovering ? 'translate-x-32' : 'translate-x-0'">
<div class="flex h-full items-center">
<div class="mx-1">
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
</div>
<div class="mx-1">
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
</div>
</div>
</div>
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
</div>
</template>
<script>
export default {
props: {
libraryItemId: String,
episode: {
type: Object,
default: () => {}
},
isDragging: Boolean
},
data() {
return {
isProcessingReadUpdate: false,
processingRemove: false,
isHovering: false
}
},
watch: {
isDragging: {
handler(newVal) {
if (newVal) {
this.isHovering = false
}
}
}
},
computed: {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
audioFile() {
return this.episode.audioFile
},
title() {
return this.episode.title || ''
},
description() {
if (this.episode.subtitle) return this.episode.subtitle
var desc = this.episode.description || ''
return desc
},
duration() {
return this.$secondsToTimestamp(this.episode.duration)
},
isStreaming() {
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
},
streamIsPlaying() {
return this.$store.state.streamIsPlaying && this.isStreaming
},
itemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
},
itemProgressPercent() {
return this.itemProgress ? this.itemProgress.progress : 0
},
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
timeRemaining() {
if (this.streamIsPlaying) return 'Playing'
if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration)
if (this.userIsFinished) return 'Finished'
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
return `${this.$elapsedPretty(remaining)} left`
},
publishedAt() {
return this.episode.publishedAt
}
},
methods: {
mouseover() {
if (this.isDragging) return
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickEdit() {
this.$emit('edit', this.episode)
},
playClick() {
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
})
}
},
toggleFinished() {
var updatePayload = {
isFinished: !this.userIsFinished
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
removeClick() {
if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) {
this.processingRemove = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
.then((updatedPodcast) => {
console.log(`Episode removed from podcast`, updatedPodcast)
this.$toast.success('Episode removed from podcast')
this.processingRemove = false
})
.catch((error) => {
console.error('Failed to remove episode from podcast', error)
this.$toast.error('Failed to remove episode from podcast')
this.processingRemove = false
})
}
}
}
}
</script>
@@ -0,0 +1,152 @@
<template>
<div class="w-full py-6">
<div class="flex items-center mb-4">
<p class="text-lg mb-0 font-semibold">Episodes</p>
<div class="flex-grow" />
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" />
<div v-if="userCanUpdate" class="w-12">
<ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" />
</div>
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'episode' : null">
<template v-for="episode in episodesCopy">
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
</template>
</transition-group>
</draggable>
</div>
</template>
<script>
import draggable from 'vuedraggable'
export default {
components: {
draggable
},
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
sortKey: 'index',
sortDesc: true,
drag: false,
episodesCopy: [],
orderChanged: false,
savingOrder: false
}
},
watch: {
libraryItem: {
handler(newVal) {
this.init()
}
}
},
computed: {
dragOptions() {
return {
animation: 200,
group: 'description',
ghostClass: 'ghost',
disabled: !this.userCanUpdate
}
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
episodes() {
return this.media.episodes || []
}
},
methods: {
changeSort() {
this.episodesCopy.sort((a, b) => {
if (this.sortDesc) {
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
}
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
})
this.orderChanged = this.checkHasOrderChanged()
},
checkHasOrderChanged() {
for (let i = 0; i < this.episodesCopy.length; i++) {
var epc = this.episodesCopy[i]
var ep = this.episodes[i]
if (epc.index != ep.index) {
return true
}
}
return false
},
editEpisode(episode) {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
draggableUpdate() {
this.orderChanged = this.checkHasOrderChanged()
},
async saveOrder() {
if (!this.userCanUpdate) return
this.savingOrder = true
var episodesUpdate = {
episodes: this.episodesCopy.map((b) => b.id)
}
await this.$axios
.$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
.then((podcast) => {
console.log('Podcast updated', podcast)
this.$toast.success('Saved episode order')
this.orderChanged = false
})
.catch((error) => {
console.error('Failed to update podcast', error)
this.$toast.error('Failed to save podcast episode order')
})
this.savingOrder = false
},
init() {
this.episodesCopy = this.episodes.map((ep) => {
return {
...ep
}
})
}
},
mounted() {
this.init()
}
}
</script>
<style>
.episode-item {
transition: all 0.4s ease;
}
.episode-enter-from,
.episode-leave-to {
opacity: 0;
transform: translateX(30px);
}
.episode-leave-active {
position: absolute;
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList"> <nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<slot /> <slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> --> <!-- <span class="material-icons animate-spin">refresh</span> -->
+20 -9
View File
@@ -1,10 +1,10 @@
<template> <template>
<label class="flex justify-start items-center cursor-pointer"> <label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
<div class="border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center" :class="wrapperClass"> <div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<input v-model="selected" type="checkbox" class="opacity-0 absolute cursor-pointer" /> <input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg> <svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div> </div>
<div v-if="label" class="select-none pl-1 text-gray-100" :class="labelClass">{{ label }}</div> <div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
</label> </label>
</template> </template>
@@ -18,10 +18,19 @@ export default {
type: String, type: String,
default: 'white' default: 'white'
}, },
borderColor: {
type: String,
default: 'gray-400'
},
checkColor: { checkColor: {
type: String, type: String,
default: 'green-500' default: 'green-500'
} },
labelClass: {
type: String,
default: ''
},
disabled: Boolean
}, },
data() { data() {
return {} return {}
@@ -36,15 +45,17 @@ export default {
} }
}, },
wrapperClass() { wrapperClass() {
var classes = [`bg-${this.checkboxBg}`] var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
if (this.small) classes.push('w-4 h-4') if (this.small) classes.push('w-4 h-4')
else classes.push('w-6 h-6') else classes.push('w-6 h-6')
return classes.join(' ') return classes.join(' ')
}, },
labelClass() { labelClassname() {
if (this.small) return 'text-xs md:text-sm' if (this.labelClass) return this.labelClass
return '' var classes = ['pl-1']
if (this.small) classes.push('text-xs md:text-sm')
return classes.join(' ')
}, },
svgClass() { svgClass() {
var classes = [`text-${this.checkColor}`] var classes = [`text-${this.checkColor}`]
+13 -3
View File
@@ -1,12 +1,12 @@
<template> <template>
<div class="relative w-full" v-click-outside="clickOutsideObj"> <div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold">{{ label }}</p> <p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border border-gray-600 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span> <span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
</span> </span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">expand_more</span> <span class="material-icons">expand_more</span>
</span> </span>
</button> </button>
@@ -63,6 +63,16 @@ export default {
}, },
selectedText() { selectedText() {
return this.selectedItem ? this.selectedItem.text : '' return this.selectedItem ? this.selectedItem.text : ''
},
buttonClass() {
var classes = []
if (this.small) classes.push('h-9')
else classes.push('h-10')
if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary bg-opacity-70 border-opacity-70 text-gray-400')
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
return classes.join(' ')
} }
}, },
methods: { methods: {
-1
View File
@@ -33,7 +33,6 @@ export default {
var _files = Array.from(e.target.files) var _files = Array.from(e.target.files)
if (_files && _files.length) { if (_files && _files.length) {
var file = _files[0] var file = _files[0]
console.log('File', file)
this.$emit('change', file) this.$emit('change', file)
} }
} }
+10 -4
View File
@@ -1,6 +1,11 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled" :class="className" @click="clickBtn"> <button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<span :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<span v-else :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
</button> </button>
</template> </template>
@@ -14,7 +19,8 @@ export default {
default: 'primary' default: 'primary'
}, },
outlined: Boolean, outlined: Boolean,
borderless: Boolean borderless: Boolean,
loading: Boolean
}, },
data() { data() {
return {} return {}
@@ -34,7 +40,7 @@ export default {
}, },
methods: { methods: {
clickBtn(e) { clickBtn(e) {
if (this.disabled) { if (this.disabled || this.loading) {
e.preventDefault() e.preventDefault()
return return
} }
+9 -8
View File
@@ -8,7 +8,7 @@
</div> </div>
</form> </form>
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded 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 ref="menu" v-show="isFocused && itemsToShow.length" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded 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">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
@@ -47,7 +47,7 @@ export default {
data() { data() {
return { return {
isFocused: false, isFocused: false,
currentSearch: null, // currentSearch: null,
typingTimeout: null, typingTimeout: null,
textInput: null textInput: null
} }
@@ -70,12 +70,13 @@ export default {
} }
}, },
itemsToShow() { itemsToShow() {
if (!this.currentSearch || !this.textInput || this.textInput === this.input) { if (!this.editable) return this.items
return this.items if (!this.textInput || this.textInput === this.input) {
return []
} }
return this.items.filter((i) => { return this.items.filter((i) => {
var iValue = String(i).toLowerCase() var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase()) return iValue.includes(this.textInput.toLowerCase())
}) })
} }
}, },
@@ -83,7 +84,7 @@ export default {
keydownInput() { keydownInput() {
clearTimeout(this.typingTimeout) clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => { this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput // this.currentSearch = this.textInput
}, 100) }, 100)
}, },
inputFocus() { inputFocus() {
@@ -127,11 +128,11 @@ export default {
if (val && !this.items.includes(val)) { if (val && !this.items.includes(val)) {
this.$emit('newItem', val) this.$emit('newItem', val)
} }
this.currentSearch = null // this.currentSearch = null
}, },
clickedOption(e, item) { clickedOption(e, item) {
this.textInput = null this.textInput = null
this.currentSearch = null // this.currentSearch = null
this.input = item this.input = item
if (this.$refs.input) this.$refs.input.blur() if (this.$refs.input) this.$refs.input.blur()
} }
@@ -4,22 +4,15 @@
<button type="button" :disabled="disabled" class="relative h-full w-full border border-gray-600 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-primary text-gray-100 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="relative h-full w-full border border-gray-600 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-primary text-gray-100 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center">
<widgets-library-icon :icon="selected" class="mr-2" /> <widgets-library-icon :icon="selected" />
<span class="block truncate text-sm">{{ selectedName }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">expand_more</span>
</span> </span>
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="type in types"> <template v-for="type in types">
<li :key="type.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="select(type)"> <li :key="type.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400 flex justify-center" id="listbox-option-0" role="option" @click="select(type)">
<div class="flex items-center px-3"> <widgets-library-icon :icon="type.id" />
<widgets-library-icon :icon="type.id" class="mr-2" />
<span class="font-normal block truncate font-sans text-sm">{{ type.name }}</span>
</div>
</li> </li>
</template> </template>
</ul> </ul>
@@ -34,7 +27,7 @@ export default {
disabled: Boolean, disabled: Boolean,
label: { label: {
type: String, type: String,
default: 'Media Type' default: 'Icon'
} }
}, },
data() { data() {
@@ -47,23 +40,23 @@ export default {
showMenu: false, showMenu: false,
types: [ types: [
{ {
id: 'default', id: 'database',
name: 'Default' name: 'Database'
}, },
{ {
id: 'audiobooks', id: 'audiobook',
name: 'Audiobooks' name: 'Audiobooks'
}, },
{ {
id: 'books', id: 'book',
name: 'Books' name: 'Books'
}, },
{ {
id: 'podcasts', id: 'podcast',
name: 'Podcasts' name: 'Podcasts'
}, },
{ {
id: 'comics', id: 'comic',
name: 'Comics' name: 'Comics'
} }
] ]
@@ -72,7 +65,7 @@ export default {
computed: { computed: {
selected: { selected: {
get() { get() {
return this.value || 'default' return this.value || 'database'
}, },
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
@@ -82,7 +75,7 @@ export default {
return this.types.find((t) => t.id === this.selected) return this.types.find((t) => t.id === this.selected)
}, },
selectedName() { selectedName() {
return this.selectedItem ? this.selectedItem.name : 'Default' return this.selectedItem ? this.selectedItem.name : 'Database'
} }
}, },
methods: { methods: {
+1 -1
View File
@@ -5,7 +5,7 @@
<span class="block truncate">{{ label }}</span> <span class="block truncate">{{ label }}</span>
</span> </span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span> <span class="material-icons text-gray-100" aria-label="User Account" role="button">person</span>
</span> </span>
</button> </button>
+18 -5
View File
@@ -3,14 +3,15 @@
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative"> <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
</div> </div>
{{ item }} {{ item }}
</div> </div>
<input ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> <input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div> </div>
</form> </form>
@@ -47,7 +48,9 @@ export default {
default: () => [] default: () => []
}, },
label: String, label: String,
disabled: Boolean disabled: Boolean,
readonly: Boolean,
showEdit: Boolean
}, },
data() { data() {
return { return {
@@ -67,7 +70,7 @@ export default {
computed: { computed: {
selected: { selected: {
get() { get() {
return this.value return this.value || []
}, },
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
@@ -76,6 +79,13 @@ export default {
showMenu() { showMenu() {
return this.isFocused return this.isFocused
}, },
wrapperClass() {
var classes = []
if (this.disabled) classes.push('bg-black-300')
else classes.push('bg-primary')
if (!this.readonly) classes.push('cursor-text')
return classes.join(' ')
},
itemsToShow() { itemsToShow() {
if (!this.currentSearch || !this.textInput) { if (!this.currentSearch || !this.textInput) {
return this.items return this.items
@@ -88,6 +98,9 @@ export default {
} }
}, },
methods: { methods: {
editItem(item) {
this.$emit('edit', item)
},
keydownInput() { keydownInput() {
clearTimeout(this.typingTimeout) clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => { this.typingTimeout = setTimeout(() => {
+2 -1
View File
@@ -62,7 +62,7 @@ export default {
}, },
selectedItems() { selectedItems() {
return (this.value || []).map((v) => { return (this.value || []).map((v) => {
return this.items.find((i) => i.value === v) || {} return this.items.find((i) => i.value === v) || { text: v, value: v }
}) })
} }
}, },
@@ -113,6 +113,7 @@ export default {
removeItem(itemValue) { removeItem(itemValue) {
var remaining = this.selected.filter((i) => i !== itemValue) var remaining = this.selected.filter((i) => i !== itemValue)
this.$emit('input', remaining) this.$emit('input', remaining)
this.$nextTick(() => { this.$nextTick(() => {
this.recalcMenuPos() this.recalcMenuPos()
}) })
@@ -0,0 +1,283 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span v-if="showEdit" class="material-icons text-white hover:text-warning mr-1" style="font-size: 1rem" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
</div>
{{ item[textKey] }}
</div>
<div v-if="showEdit" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
</div>
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 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">
<template v-for="item in itemsToShow">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
</div>
<span v-if="getIsSelected(item.id)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
</span>
</li>
</template>
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
<div class="flex items-center justify-center">
<span class="font-normal">No items</span>
</div>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Array,
default: () => []
},
endpoint: String,
label: String,
disabled: Boolean,
readonly: Boolean,
showEdit: Boolean,
textKey: {
type: String,
default: 'name'
}
},
data() {
return {
textInput: null,
currentSearch: null,
searching: false,
typingTimeout: null,
isFocused: false,
menu: null,
items: []
}
},
watch: {
showMenu(newVal) {
if (newVal) this.setListener()
else this.removeListener()
}
},
computed: {
selected: {
get() {
return this.value || []
},
set(val) {
this.$emit('input', val)
}
},
userToken() {
return this.$store.getters['user/getToken']
},
wrapperClass() {
var classes = []
if (this.disabled) classes.push('bg-black-300')
else classes.push('bg-primary')
if (!this.readonly) classes.push('cursor-text')
return classes.join(' ')
},
showMenu() {
return this.isFocused && this.currentSearch
},
itemsToShow() {
return this.items
}
},
methods: {
addItem() {
this.$emit('add')
},
editItem(item) {
this.$emit('edit', item)
},
getIsSelected(itemValue) {
return !!this.selected.find((i) => i.id === itemValue)
},
async search() {
if (this.searching) return
this.currentSearch = this.textInput
this.searching = true
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => {
console.error('Failed to get search results', error)
return []
})
this.items = results || []
this.searching = false
},
keydownInput() {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.search()
}, 250)
this.setInputWidth()
},
setInputWidth() {
setTimeout(() => {
var value = this.$refs.input.value
var len = value.length * 7 + 24
this.$refs.input.style.width = len + 'px'
this.recalcMenuPos()
}, 50)
},
recalcMenuPos() {
if (!this.menu) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page
return this.forceBlur()
}
var menuHeight = this.menu.clientHeight
var top = boundingBox.y + boundingBox.height - 4
if (top + menuHeight > window.innerHeight - 20) {
// Reverse menu to open upwards
top = boundingBox.y - menuHeight - 4
}
this.menu.style.top = top + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.remove()
document.body.appendChild(this.menu)
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
inputFocus() {
if (!this.menu) {
this.unmountMountMenu()
}
this.isFocused = true
this.$nextTick(this.recalcMenuPos)
},
inputBlur() {
if (!this.isFocused) return
setTimeout(() => {
if (document.activeElement === this.$refs.input) {
return
}
this.isFocused = false
if (this.textInput) this.submitForm()
}, 50)
},
focus() {
if (this.$refs.input) this.$refs.input.focus()
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
},
forceBlur() {
this.isFocused = false
if (this.textInput) this.submitForm()
if (this.$refs.input) this.$refs.input.blur()
},
clickedOption(e, item) {
if (e) {
e.stopPropagation()
e.preventDefault()
}
if (this.$refs.input) this.$refs.input.focus()
var newSelected = null
if (this.getIsSelected(item.id)) {
newSelected = this.selected.filter((s) => s.id !== item.id)
this.$emit('removedItem', item.id)
} else {
newSelected = this.selected.concat([item])
}
this.textInput = null
this.currentSearch = null
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
clickWrapper() {
if (this.disabled) return
if (this.showMenu) {
return this.blur()
}
this.focus()
},
removeItem(itemId) {
var remaining = this.selected.filter((i) => i.id !== itemId)
this.$emit('input', remaining)
this.$emit('removedItem', itemId)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
insertNewItem(item) {
this.selected.push(item)
this.$emit('input', this.selected)
this.$emit('newItem', item)
this.textInput = null
this.currentSearch = null
this.$nextTick(() => {
this.blur()
})
},
submitForm() {
if (!this.textInput) return
var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => {
return i === cleaned
})
if (matchesItem) {
this.clickedOption(null, matchesItem)
} else {
this.insertNewItem({
id: `new-${Date.now()}`,
name: this.textInput
})
}
},
scroll() {
this.recalcMenuPos()
},
setListener() {
document.addEventListener('scroll', this.scroll, true)
},
removeListener() {
document.removeEventListener('scroll', this.scroll, true)
}
},
mounted() {},
beforeDestroy() {
if (this.menu) this.menu.remove()
}
}
</script>
<style scoped>
input {
border-style: inherit !important;
}
input:read-only {
color: #aaa;
background-color: #444;
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
<input ref="input" v-model="textInput" :disabled="disabled" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
<ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded 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">
<template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
</div>
<span v-if="isItemSelected(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
</span>
</li>
</template>
<li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
<div class="flex items-center justify-center">
<span class="font-normal">No items</span>
</div>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
props: {
value: String,
disabled: Boolean,
label: String,
endpoint: String
},
data() {
return {
isFocused: false,
currentSearch: null,
typingTimeout: null,
textInput: null,
searching: false,
items: [],
selectedItemObject: null
}
},
watch: {
value: {
immediate: true,
handler(newVal) {
this.textInput = newVal
}
}
},
computed: {
input: {
get() {
return this.value || ''
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
isItemSelected(item) {
return !!this.input.toLowerCase() === item.name
},
async search() {
if (this.searching) return
this.currentSearch = this.textInput
this.searching = true
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15`).catch((error) => {
console.error('Failed to get search results', error)
return []
})
this.items = results || []
this.searching = false
},
keydownInput() {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.search()
}, 250)
},
inputFocus() {
this.isFocused = true
},
blur() {
// Handle blur immediately
this.isFocused = false
if (this.inputName.toLowerCase() !== this.textInput.toLowerCase()) {
var val = this.textInput ? this.textInput.trim() : null
if (val) {
this.submitForm()
}
}
if (this.$refs.input) {
this.$refs.input.blur()
}
},
inputBlur() {
if (!this.isFocused) return
setTimeout(() => {
if (document.activeElement === this.$refs.input) {
return
}
this.isFocused = false
if (this.input !== this.textInput) {
var val = this.textInput ? this.textInput.trim() : null
if (val) {
this.setItem(val)
}
}
}, 50)
},
submitForm() {
var val = this.textInput ? this.textInput.trim() : null
if (val) {
this.setItem(val)
}
},
setItem(itemText) {
if (!this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())) {
var newItem = {
id: `new-${Date.now()}`,
name: val
}
this.$emit('selected', newItem)
this.input = val
} else {
var item = this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())
this.$emit('selected', item)
this.input = item.name
}
this.currentSearch = null
},
clickedOption(e, item) {
this.textInput = item.name
this.currentSearch = null
this.input = item.name
this.selectedItemObject = item
this.$emit('selected', item)
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
}
</script>
+3
View File
@@ -83,4 +83,7 @@ input:read-only {
color: #bbb; color: #bbb;
background-color: #444; background-color: #444;
} }
input::-webkit-calendar-picker-indicator {
filter: invert(1);
}
</style> </style>
+5 -1
View File
@@ -3,7 +3,7 @@
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''"> <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> {{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p> </p>
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :type="type" class="w-full" /> <ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
</div> </div>
</template> </template>
@@ -17,6 +17,7 @@ export default {
type: String, type: String,
default: 'text' default: 'text'
}, },
readonly: Boolean,
disabled: Boolean disabled: Boolean
}, },
data() { data() {
@@ -37,6 +38,9 @@ export default {
if (this.$refs.input && this.$refs.input.blur) { if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur() this.$refs.input.blur()
} }
},
inputBlurred() {
this.$emit('blur')
} }
}, },
mounted() {} mounted() {}
+6 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<textarea v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" /> <textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
</template> </template>
<script> <script>
@@ -31,6 +31,11 @@ export default {
methods: { methods: {
change(e) { change(e) {
this.$emit('change', e.target.value) this.$emit('change', e.target.value)
},
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
} }
}, },
mounted() {} mounted() {}
+8 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<ui-textarea-input v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" /> <ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
</div> </div>
</template> </template>
@@ -29,7 +29,13 @@ export default {
} }
} }
}, },
methods: {}, methods: {
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
}
},
mounted() {} mounted() {}
} }
</script> </script>
@@ -0,0 +1,78 @@
<template>
<div class="w-full">
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
Missing Parts <span class="text-sm">({{ missingParts.length }})</span>
</p>
<p class="text-sm font-mono">{{ missingPartChunks.join(', ') }}</p>
</div>
<div v-if="invalidParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
</p>
<div>
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
</div>
</div>
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
</div>
</template>
<script>
export default {
props: {
libraryItemId: String,
media: {
type: Object,
default: () => {}
},
isFile: Boolean
},
data() {
return {}
},
computed: {
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
var currentIndex = this.missingParts[0]
var currentChunk = [this.missingParts[0]]
for (let i = 1; i < this.missingParts.length; i++) {
var partIndex = this.missingParts[i]
if (currentIndex === partIndex - 1) {
currentChunk.push(partIndex)
currentIndex = partIndex
} else {
// console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex)
if (currentChunk.length === 0) {
console.error('How is current chunk 0?', currentChunk.join(', '))
}
chunks.push(currentChunk)
currentChunk = [partIndex]
currentIndex = partIndex
}
}
if (currentChunk.length) {
chunks.push(currentChunk)
}
chunks = chunks.map((chunk) => {
if (chunk.length === 1) return chunk[0]
else return `${chunk[0]}-${chunk[chunk.length - 1]}`
})
return chunks
},
missingParts() {
return this.media.missingParts || []
},
invalidParts() {
return this.media.invalidParts || []
}
},
methods: {},
mounted() {}
}
</script>
@@ -0,0 +1,350 @@
<template>
<div class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm">
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<div class="flex -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="flex-grow px-1">
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
</div>
</div>
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
</div>
<div class="flex-grow px-1 pt-6">
<div class="flex justify-center">
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
</div>
</div>
</form>
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
<div class="absolute top-0 right-0 p-4">
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
</div>
<form @submit.prevent="submitSeriesForm">
<div class="bg-bg rounded-lg p-8" @click.stop>
<div class="flex">
<div class="flex-grow p-1 min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
</div>
<div class="w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
</div>
</div>
<div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">Save</ui-btn>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
selectedSeries: {},
showSeriesForm: false,
details: {
title: null,
subtitle: null,
description: null,
authors: [],
narrators: [],
series: [],
publishedYear: null,
publisher: null,
language: null,
isbn: null,
asin: null,
genres: [],
explicit: false
},
newTags: []
}
},
watch: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
genres() {
return this.filterData.genres || []
},
tags() {
return this.filterData.tags || []
},
series() {
return this.filterData.series || []
},
narrators() {
return this.filterData.narrators || []
},
filterData() {
return this.$store.state.libraries.filterData || {}
},
existingSeriesNames() {
// Only show series names not already selected
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
},
seriesItems: {
get() {
return this.details.series.map((se) => {
return {
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
...se
}
})
},
set(val) {
this.details.series = val
}
}
},
methods: {
getDetails() {
this.forceBlur()
return this.checkForChanges()
},
getTitleAndAuthorName() {
this.forceBlur()
return {
title: this.details.title,
author: (this.details.authors || []).map((au) => au.name).join(', ')
}
},
mapBatchDetails(batchDetails) {
for (const key in batchDetails) {
if (key === 'tags') {
this.newTags = [...batchDetails.tags]
} else if (key === 'genres' || key === 'narrators') {
this.details[key] = [...batchDetails[key]]
} else if (key === 'authors' || key === 'series') {
this.details[key] = batchDetails[key].map((i) => ({ ...i }))
} else {
this.details[key] = batchDetails[key]
}
}
},
forceBlur() {
if (this.$refs.titleInput) this.$refs.titleInput.blur()
if (this.$refs.subtitleInput) this.$refs.subtitleInput.blur()
if (this.$refs.publishYearInput) this.$refs.publishYearInput.blur()
if (this.$refs.descriptionInput) this.$refs.descriptionInput.blur()
if (this.$refs.isbnInput) this.$refs.isbnInput.blur()
if (this.$refs.asinInput) this.$refs.asinInput.blur()
if (this.$refs.publisherInput) this.$refs.publisherInput.blur()
if (this.$refs.languageInput) this.$refs.languageInput.blur()
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
this.$refs.authorsSelect.forceBlur()
}
if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) {
this.$refs.narratorsSelect.forceBlur()
}
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
this.$refs.genresSelect.forceBlur()
}
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
this.$refs.tagsSelect.forceBlur()
}
},
cancelSeriesForm() {
this.showSeriesForm = false
},
editSeriesItem(series) {
var _series = this.details.series.find((se) => se.id === series.id)
if (!_series) return
this.selectedSeries = {
..._series
}
this.showSeriesForm = true
},
addNewSeries() {
this.selectedSeries = {
id: `new-${Date.now()}`,
name: '',
sequence: ''
}
this.showSeriesForm = true
},
submitSeriesForm() {
if (!this.selectedSeries.name) {
this.$toast.error('Must enter a series')
return
}
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur()
}
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
if (existingSeriesIndex < 0 && seriesSameName) {
this.selectedSeries.id = seriesSameName.id
}
if (existingSeriesIndex >= 0) {
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
} else {
this.details.series.push({
...this.selectedSeries
})
}
this.showSeriesForm = false
},
stringArrayEqual(array1, array2) {
// return false if different
if (array1.length !== array2.length) return false
for (var item of array1) {
if (!array2.includes(item)) return false
}
return true
},
objectArrayEqual(array1, array2) {
const isIterable = (value) => {
return Symbol.iterator in Object(value)
}
if (!isIterable(array1) || !isIterable(array2)) {
console.error(array1, array2)
throw new Error('Invalid arrays passed in')
}
// array of objects with id key
if (array1.length !== array2.length) return false
for (var item of array1) {
var matchingItem = array2.find((a) => a.id === item.id)
if (!matchingItem) return false
for (var key in item) {
if (item[key] !== matchingItem[key]) {
// console.log('Object array item keys changed', key, item[key], matchingItem[key])
return false
}
}
}
return true
},
checkForChanges() {
var metadata = {}
for (const key in this.details) {
var newValue = this.details[key]
var oldValue = this.mediaMetadata[key]
// Key cleared out or key first populated
if ((!newValue && oldValue) || (newValue && !oldValue)) {
metadata[key] = newValue
} else if (key === 'narrators' || key === 'genres') {
// Check array of strings
if (!this.stringArrayEqual(newValue, oldValue)) {
metadata[key] = [...newValue]
}
} else if (key === 'authors' || key === 'series') {
if (!this.objectArrayEqual(newValue, oldValue)) {
metadata[key] = newValue.map((v) => ({ ...v }))
}
} else if (newValue && newValue != oldValue) {
// Intentional !=
metadata[key] = newValue
}
}
var updatePayload = {}
if (!!Object.keys(metadata).length) updatePayload.metadata = metadata
if (!this.stringArrayEqual(this.newTags, this.media.tags || [])) {
updatePayload.tags = [...this.newTags]
}
return {
updatePayload,
hasChanges: !!Object.keys(updatePayload).length
}
},
init() {
this.details.title = this.mediaMetadata.title
this.details.subtitle = this.mediaMetadata.subtitle
this.details.description = this.mediaMetadata.description
this.details.authors = (this.mediaMetadata.authors || []).map((se) => ({ ...se }))
this.details.narrators = [...(this.mediaMetadata.narrators || [])]
this.details.genres = [...(this.mediaMetadata.genres || [])]
this.details.series = (this.mediaMetadata.series || []).map((se) => ({ ...se }))
this.details.publishedYear = this.mediaMetadata.publishedYear
this.details.publisher = this.mediaMetadata.publisher || null
this.details.language = this.mediaMetadata.language || null
this.details.isbn = this.mediaMetadata.isbn || null
this.details.asin = this.mediaMetadata.asin || null
this.details.explicit = !!this.mediaMetadata.explicit
this.newTags = [...(this.media.tags || [])]
},
submitForm() {
this.$emit('submit')
}
},
mounted() {}
}
</script>
@@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent> <div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span> <span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">remove</span>
<p class="px-2 font-mono">{{ bookCoverWidth }}</p> <p class="px-2 font-mono">{{ bookCoverWidth }}</p>
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span> <span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">add</span>
</div> </div>
</div> </div>
</template> </template>

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