Compare commits

...

192 Commits

Author SHA1 Message Date
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
253 changed files with 42388 additions and 15760 deletions

4
.gitignore vendored
View File

@@ -4,6 +4,7 @@ node_modules/
/config/
/audiobooks/
/audiobooks2/
/podcasts/
/media/
/metadata/
test/
@@ -11,4 +12,5 @@ test/
/client/dist/
/dist/
sw.*
sw.*
.DS_STORE

View File

@@ -1,12 +1,12 @@
### STAGE 0: Build client ###
FROM node:12-alpine AS build
FROM node:16-alpine AS build
WORKDIR /client
COPY /client /client
RUN npm install
RUN npm run generate
### STAGE 1: Build server ###
FROM node:12-alpine
FROM node:16-alpine
RUN apk update && apk add --no-cache --update ffmpeg
ENV NODE_ENV=production
COPY --from=build /client/dist /client/dist

View File

@@ -6,6 +6,7 @@ FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
DEFAULT_PORT=7331
DEFAULT_HOST="0.0.0.0"
CONFIG_PATH="/etc/default/audiobookshelf"
@@ -82,7 +83,8 @@ setup_config_interactive() {
CONFIG_PATH=$DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$PORT"
PORT=$PORT
HOST=$DEFAULT_HOST"
echo "$config_text"
@@ -105,7 +107,8 @@ setup_config() {
CONFIG_PATH=$DEFAULT_DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$DEFAULT_PORT"
PORT=$DEFAULT_PORT
HOST=$DEFAULT_HOST"
echo "$config_text"

View File

@@ -187,3 +187,15 @@ Bookshelf Label
opacity: 1;
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;
}

View File

@@ -12,7 +12,7 @@
</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>
</div>
@@ -99,7 +99,8 @@ export default {
default: () => []
},
sleepTimerSet: Boolean,
sleepTimerRemaining: Number
sleepTimerRemaining: Number,
isPodcast: Boolean
},
data() {
return {

View File

@@ -23,15 +23,15 @@
</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">
<span class="material-icons">equalizer</span>
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
</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">
<span class="material-icons">upload</span>
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
</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">
<span class="material-icons">settings</span>
<span class="material-icons" aria-label="System Settings" role="button">settings</span>
</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">
@@ -44,16 +44,16 @@
</nuxt-link>
</div>
<div v-show="numAudiobooksSelected" 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>
<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">{{ numLibraryItemsSelected }} Selected</h1>
<div class="flex-grow" />
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
<ui-tooltip v-if="!isPodcastLibrary" :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</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-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" />
</template>
<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() {
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
},
libraryMediaType() {
return this.currentLibrary ? this.currentLibrary.mediaType : null
},
isPodcastLibrary() {
return this.libraryMediaType === 'podcast'
},
isHome() {
return this.$route.name === 'library-library'
},
@@ -94,17 +100,14 @@ export default {
username() {
return this.user ? this.user.username : 'err'
},
numAudiobooksSelected() {
return this.selectedAudiobooks.length
numLibraryItemsSelected() {
return this.selectedLibraryItems.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems
},
userAudiobooks() {
return this.$store.state.user.user.audiobooks || {}
},
selectedSeries() {
return this.$store.state.audiobooks.selectedSeries
userMediaProgress() {
return this.$store.state.user.user.mediaProgress || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
@@ -115,11 +118,11 @@ export default {
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
selectedIsRead() {
// Find an audiobook that is not read, if none then all audiobooks read
return !this.selectedAudiobooks.find((ab) => {
var userAb = this.userAudiobooks[ab]
return !userAb || !userAb.isRead
selectedIsFinished() {
// Find an item that is not finished, if none then all items finished
return !this.selectedLibraryItems.find((libraryItemId) => {
var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId)
return !itemProgress || !itemProgress.isFinished
})
},
processingBatch() {
@@ -150,25 +153,26 @@ export default {
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedAudiobooks', [])
this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection')
this.isAllSelected = false
},
toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
var newIsRead = !this.selectedIsRead
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
return {
audiobookId: ab,
isRead: newIsRead
id: lid,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/audiobook/batch/update`, updateProgressPayloads)
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection')
})
.catch((error) => {
@@ -178,20 +182,20 @@ export default {
})
},
batchDeleteClick() {
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
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 items from Audiobookshelf`
if (confirm(confirmMsg)) {
this.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/books/batch/delete`, {
audiobookIds: this.selectedAudiobooks
.$post(`/api/items/batch/delete`, {
libraryItemIds: this.selectedLibraryItems
})
.then(() => {
this.$toast.success('Batch delete success!')
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection')
})
.catch((error) => {

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>

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>

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>
<div v-if="loaded && !shelves.length && isRootUser" 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>
<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">{{ libraryName }} Library is empty!</p>
<div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div>
</div>
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
<p class="text-center text-xl font-book py-4">No results for query</p>
</div>
<div v-else class="w-full flex flex-col items-center">
<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" />
@@ -50,6 +53,9 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
bookCoverWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
@@ -85,7 +91,7 @@ export default {
},
async fetchCategories() {
var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`)
.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`)
.then((data) => {
return data
})
@@ -97,53 +103,61 @@ export default {
},
async setShelvesFromSearch() {
var shelves = []
if (this.results.audiobooks) {
if (this.results.books && this.results.books.length) {
shelves.push({
id: 'audiobooks',
id: 'books',
label: 'Books',
type: 'books',
entities: this.results.audiobooks.map((ab) => ab.audiobook)
type: 'book',
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({
id: 'series',
label: 'Series',
type: 'series',
entities: this.results.series.map((seriesObj) => {
return {
name: seriesObj.series,
books: seriesObj.audiobooks,
name: seriesObj.series.name,
series: seriesObj.series,
books: seriesObj.books,
type: 'series'
}
})
})
}
if (this.results.tags) {
if (this.results.tags && this.results.tags.length) {
shelves.push({
id: 'tags',
label: 'Tags',
type: 'tags',
entities: this.results.tags.map((tagObj) => {
return {
name: tagObj.tag,
books: tagObj.audiobooks,
name: tagObj.name,
books: tagObj.books || [],
type: 'tags'
}
})
})
}
if (this.results.authors) {
if (this.results.authors && this.results.authors.length) {
shelves.push({
id: 'authors',
label: 'Authors',
type: 'authors',
entities: this.results.authors.map((a) => {
return {
id: a.author,
name: a.author,
numBooks: a.numBooks,
...a,
type: 'author'
}
})
@@ -153,74 +167,98 @@ export default {
},
settingsUpdated(settings) {},
scan() {
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
},
audiobookAdded(audiobook) {
console.log('Audiobook added', audiobook)
// TODO: Check if audiobook would be on this shelf
libraryItemAdded(libraryItem) {
console.log('libraryItem added', libraryItem)
// TODO: Check if libraryItem would be on this shelf
if (!this.search) {
this.fetchCategories()
}
},
audiobookUpdated(audiobook) {
console.log('Audiobook updated', audiobook)
libraryItemUpdated(libraryItem) {
console.log('libraryItem updated', libraryItem)
this.shelves.forEach((shelf) => {
if (shelf.type === 'books') {
if (shelf.type == 'book' || shelf.type == 'podcast') {
shelf.entities = shelf.entities.map((ent) => {
if (ent.id === audiobook.id) {
return audiobook
if (ent.id === libraryItem.id) {
return libraryItem
}
return ent
})
} else if (shelf.type === 'series') {
shelf.entities.forEach((ent) => {
ent.books = ent.books.map((book) => {
if (book.id === audiobook.id) return audiobook
if (book.id === libraryItem.id) return libraryItem
return book
})
})
}
})
},
removeBookFromShelf(audiobook) {
removeBookFromShelf(libraryItem) {
this.shelves.forEach((shelf) => {
if (shelf.type === 'books') {
if (shelf.type == 'book' || shelf.type == 'podcast') {
shelf.entities = shelf.entities.filter((ent) => {
return ent.id !== audiobook.id
return ent.id !== libraryItem.id
})
} else if (shelf.type === 'series') {
shelf.entities.forEach((ent) => {
ent.books = ent.books.filter((book) => {
return book.id !== audiobook.id
return book.id !== libraryItem.id
})
})
}
})
},
audiobookRemoved(audiobook) {
this.removeBookFromShelf(audiobook)
libraryItemRemoved(libraryItem) {
this.removeBookFromShelf(libraryItem)
},
audiobooksAdded(audiobooks) {
console.log('audiobooks added', audiobooks)
libraryItemsAdded(libraryItems) {
console.log('libraryItems added', libraryItems)
// TODO: Check if audiobook would be on this shelf
if (!this.search) {
this.fetchCategories()
}
},
audiobooksUpdated(audiobooks) {
audiobooks.forEach((ab) => {
this.audiobookUpdated(ab)
libraryItemsUpdated(items) {
items.forEach((li) => {
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() {
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
if (this.$root.socket) {
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
this.$root.socket.on('audiobook_added', this.audiobookAdded)
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('author_removed', this.authorRemoved)
this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('item_added', this.libraryItemAdded)
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 {
console.error('Error socket not initialized')
}
@@ -229,11 +267,13 @@ export default {
this.$store.commit('user/removeSettingsListener', 'bookshelf')
if (this.$root.socket) {
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
this.$root.socket.off('audiobook_added', this.audiobookAdded)
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
this.$root.socket.off('author_updated', this.authorUpdated)
this.$root.socket.off('author_removed', this.authorRemoved)
this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('item_added', this.libraryItemAdded)
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 {
console.error('Error socket not initialized')
}

View File

@@ -2,9 +2,14 @@
<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 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">
<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>
</div>
<div v-if="shelf.type === 'series'" class="flex items-center">
@@ -21,8 +26,8 @@
</div>
<div v-if="shelf.type === 'authors'" class="flex items-center">
<template v-for="entity in shelf.entities">
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.name)}`">
<cards-author-card :width="bookCoverWidth" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.id)}`">
<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>
</template>
</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">
<span class="material-icons text-6xl text-white">chevron_right</span>
</div>
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
</div>
</template>
@@ -64,12 +70,9 @@ export default {
canScrollLeft: false,
isScrolling: false,
scrollTimer: null,
updateTimer: null
}
},
watch: {
isSelectionMode(newVal) {
this.updateSelectionMode(newVal)
updateTimer: null,
showAuthorModal: false,
selectedAuthor: null
}
},
computed: {
@@ -79,9 +82,6 @@ export default {
shelfHeight() {
return this.bookCoverHeight + 48
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
paddingLeft() {
if (window.innerWidth < 768) return 1
return 2.5
@@ -90,29 +90,55 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected'] > 0
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
clearSelectedEntities() {
this.updateSelectionMode(false)
},
editAuthor(author) {
this.selectedAuthor = author
this.showAuthorModal = true
},
editBook(audiobook) {
var bookIds = this.shelf.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
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) {
var selectedAudiobooks = this.$store.state.selectedAudiobooks
if (this.shelf.type === 'books') {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-book-${ent.id}`]
if (!component || !component.length) return
component = component[0]
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) {
this.$store.commit('toggleAudiobookSelected', audiobook.id)
selectItem(libraryItem) {
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', libraryItem)
})
},
itemSelectedEvt() {
this.updateSelectionMode(this.isSelectionMode)
},
scrolled() {
clearTimeout(this.scrollTimer)
@@ -156,6 +182,14 @@ export default {
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>

View File

@@ -12,7 +12,7 @@
</nuxt-link>
</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">
<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>
<div v-else class="items-center hidden md:flex">
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
@@ -27,7 +27,7 @@
</div>
<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-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">
@@ -70,6 +70,9 @@ export default {
}
},
computed: {
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
isGridMode() {
return this.viewMode === 'grid'
},
@@ -80,6 +83,7 @@ export default {
return this.totalEntities
},
entityName() {
if (this.isPodcast) return 'Podcasts'
if (!this.page) return 'Books'
if (this.page === 'series') return 'Series'
if (this.page === 'collections') return 'Collections'

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" />
</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>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
</div>
@@ -109,8 +109,8 @@ export default {
githubTagUrl() {
return this.versionData.githubTagUrl
},
streamAudiobook() {
return this.$store.state.streamAudiobook
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {

View File

@@ -7,15 +7,16 @@
</template>
<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">
<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 v-else-if="!totalShelves && initialized" class="w-full py-16">
<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>
</div>
</div>
@@ -60,7 +61,6 @@ export default {
totalShelves: 0,
bookshelfMarginLeft: 0,
isSelectionMode: false,
isSelectAll: false,
currentSFQueryString: null,
pendingReset: false,
keywordFilter: null,
@@ -85,10 +85,16 @@ export default {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
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.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'
},
entityName() {
@@ -143,6 +149,9 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
isEntityBook() {
return this.entityName === 'series-books' || this.entityName === 'books'
},
@@ -183,8 +192,8 @@ export default {
// Includes margin
return this.entityWidth + 24
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks || []
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems || []
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
@@ -210,13 +219,12 @@ export default {
clearSelectedEntities() {
this.updateBookSelectionMode(false)
this.isSelectionMode = false
this.isSelectAll = false
},
selectEntity(entity) {
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) {
this.isSelectionMode = newIsSelectionMode
this.updateBookSelectionMode(newIsSelectionMode)
@@ -239,7 +247,7 @@ export default {
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 fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
@@ -300,11 +308,11 @@ export default {
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
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)
}
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)
}
@@ -332,7 +340,6 @@ export default {
this.totalEntities = 0
this.currentPage = 0
this.isSelectionMode = false
this.isSelectAll = false
this.initialized = false
this.initSizeData()
@@ -374,9 +381,7 @@ export default {
let searchParams = new URLSearchParams()
if (this.page === 'series-books') {
searchParams.set('filter', `series.${this.seriesId}`)
searchParams.set('sort', 'book.volumeNumber')
searchParams.set('desc', 0)
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
} else {
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
@@ -385,7 +390,7 @@ export default {
searchParams.set('sort', this.orderBy)
searchParams.set('desc', this.orderDesc ? 1 : 0)
}
if (this.collapseSeries) {
if (this.collapseSeries && !this.isPodcast) {
searchParams.set('collapseseries', 1)
}
}
@@ -425,44 +430,71 @@ export default {
this.handleScroll(scrollTop)
// }, 250)
},
audiobookAdded(audiobook) {
console.log('Audiobook added', audiobook)
libraryItemAdded(libraryItem) {
console.log('libraryItem added', libraryItem)
// TODO: Check if audiobook would be on this shelf
this.resetEntities()
},
audiobookUpdated(audiobook) {
console.log('Audiobook updated', audiobook)
libraryItemUpdated(libraryItem) {
console.log('Item updated', libraryItem)
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) {
this.entities[indexOf] = audiobook
this.entities[indexOf] = libraryItem
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') {
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) {
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.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.remountEntities()
this.executeRebuild()
}
}
},
audiobooksAdded(audiobooks) {
console.log('audiobooks added', audiobooks)
libraryItemsAdded(libraryItems) {
console.log('items added', libraryItems)
// TODO: Check if audiobook would be on this shelf
this.resetEntities()
},
audiobooksUpdated(audiobooks) {
audiobooks.forEach((ab) => {
this.audiobookUpdated(ab)
libraryItemsUpdated(libraryItems) {
libraryItems.forEach((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) {
var bookshelf = _bookshelf || document.getElementById('bookshelf')
if (!bookshelf) {
@@ -525,11 +557,14 @@ export default {
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
if (this.$root.socket) {
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
this.$root.socket.on('audiobook_added', this.audiobookAdded)
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('item_added', this.libraryItemAdded)
this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('items_added', this.libraryItemsAdded)
this.$root.socket.on('collection_added', this.collectionAdded)
this.$root.socket.on('collection_updated', this.collectionUpdated)
this.$root.socket.on('collection_removed', this.collectionRemoved)
} else {
console.error('Bookshelf - Socket not initialized')
}
@@ -546,11 +581,14 @@ export default {
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
if (this.$root.socket) {
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
this.$root.socket.off('audiobook_added', this.audiobookAdded)
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('item_added', this.libraryItemAdded)
this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('items_added', this.libraryItemsAdded)
this.$root.socket.off('collection_added', this.collectionAdded)
this.$root.socket.off('collection_updated', this.collectionUpdated)
this.$root.socket.off('collection_removed', this.collectionRemoved)
} else {
console.error('Bookshelf - Socket not initialized')
}
@@ -563,7 +601,7 @@ export default {
}
},
scan() {
this.$root.socket.emit('scan', this.currentLibraryId)
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
}
},
mounted() {

View File

@@ -21,7 +21,7 @@
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</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">
<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>
@@ -31,7 +31,7 @@
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</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>
<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" />
</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">
<path
fill="currentColor"
@@ -52,6 +52,14 @@
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</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'">
<span class="material-icons text-2xl">warning</span>
@@ -62,36 +70,6 @@
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
</div>
</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>
</template>
@@ -110,6 +88,15 @@ export default {
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() {
return this.$route.name === 'library-library'
},

View File

@@ -1,17 +1,18 @@
<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">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :audiobook="streamAudiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<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="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</nuxt-link>
<div class="flex items-start pl-24 mb-6 md:mb-0">
<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 }}
</nuxt-link>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p v-if="authorFL" class="pl-1.5 text-sm sm:text-base">
<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-if="podcastAuthor">{{ podcastAuthor }}</p>
<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 v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
</div>
@@ -24,7 +25,6 @@
<div class="flex-grow" />
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
</div>
<audio-player
ref="audioPlayer"
:chapters="chapters"
@@ -33,6 +33,7 @@
:bookmarks="bookmarks"
:sleep-timer-set="sleepTimerSet"
:sleep-timer-remaining="sleepTimerRemaining"
:is-podcast="isPodcast"
@playPause="playPause"
@jumpForward="jumpForward"
@jumpBackward="jumpBackward"
@@ -44,7 +45,7 @@
@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" />
</div>
@@ -60,7 +61,6 @@ export default {
totalDuration: 0,
showBookmarksModal: false,
bookmarkCurrentTime: 0,
bookmarkAudiobookId: null,
playerLoading: false,
isPlaying: false,
currentTime: 0,
@@ -68,7 +68,9 @@ export default {
sleepTimerSet: false,
sleepTimerTime: 0,
sleepTimerRemaining: 0,
sleepTimer: null
sleepTimer: null,
displayTitle: null,
initialPlaybackRate: 1
}
},
computed: {
@@ -89,55 +91,64 @@ export default {
return -64
},
cover() {
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover
if (this.media.coverPath) return this.media.coverPath
return 'Logo.png'
},
user() {
return this.$store.state.user.user
},
userAudiobook() {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userAudiobookCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
userItemCurrentTime() {
return this.userMediaProgress ? this.userMediaProgress.currentTime || 0 : 0
},
bookmarks() {
if (!this.userAudiobook) return []
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
if (!this.libraryItemId) return []
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
},
streamAudiobook() {
return this.$store.state.streamAudiobook
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
audiobookId() {
return this.streamAudiobook ? this.streamAudiobook.id : null
libraryItemId() {
return this.streamLibraryItem ? this.streamLibraryItem.id : null
},
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
media() {
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
},
isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
},
mediaMetadata() {
return this.media.metadata || {}
},
chapters() {
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
return this.media.chapters || []
},
title() {
return this.book.title || 'No Title'
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
return this.mediaMetadata.title || 'No Title'
},
author() {
return this.book.author || 'Unknown'
},
authorFL() {
return this.book.authorFL
},
authorsList() {
return this.authorFL ? this.authorFL.split(', ') : []
authors() {
return this.mediaMetadata.authors || []
},
libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
podcastAuthor() {
if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown'
}
},
methods: {
setPlaying(isPlaying) {
this.isPlaying = isPlaying
this.$store.commit('setIsPlaying', isPlaying)
},
setSleepTimer(seconds) {
this.sleepTimerSet = true
this.sleepTimerTime = seconds
@@ -194,6 +205,7 @@ export default {
this.playerHandler.setVolume(volume)
},
setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate
this.playerHandler.setPlaybackRate(playbackRate)
},
seek(time) {
@@ -217,7 +229,6 @@ export default {
}
},
showBookmarks() {
this.bookmarkAudiobookId = this.audiobookId
this.bookmarkCurrentTime = this.currentTime
this.showBookmarksModal = true
},
@@ -227,7 +238,7 @@ export default {
},
closePlayer() {
this.playerHandler.closePlayer()
this.$store.commit('setStreamAudiobook', null)
this.$store.commit('setMediaPlaying', null)
},
streamProgress(data) {
if (!data.numSegments) return
@@ -239,13 +250,19 @@ export default {
console.error('No Audio Ref')
}
},
streamOpen(stream) {
this.$store.commit('setStreamAudiobook', stream.audiobook)
this.playerHandler.prepareStream(stream)
sessionOpen(session) {
this.$store.commit('setMediaPlaying', {
libraryItem: session.libraryItem,
episodeId: session.episodeId
})
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
},
streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session)
},
streamClosed(streamId) {
// 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')
this.playerHandler.closePlayer()
}
@@ -260,7 +277,7 @@ export default {
},
streamError(streamId) {
// 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')
this.playerHandler.closePlayer()
}
@@ -269,32 +286,48 @@ export default {
this.playerHandler.resetStream(startTime, streamId)
},
castSessionActive(isActive) {
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
if (isActive && this.playerHandler.isPlayingLocalItem) {
// Cast session started switch to cast player
this.playerHandler.switchPlayer()
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
} else if (!isActive && this.playerHandler.isPlayingCastedItem) {
// Cast session ended switch to local player
this.playerHandler.switchPlayer()
}
},
async playAudiobook(audiobookId) {
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
console.error('Failed to fetch full audiobook', error)
async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId
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
})
if (!audiobook) return
this.$store.commit('setStreamAudiobook', audiobook)
if (!libraryItem) return
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() {
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() {
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>

View File

@@ -1,26 +1,30 @@
<template>
<div>
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-lg relative">
<div class="w-full h-full overflow-hidden max-w-full max-h-full relative">
<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">
<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 @mouseover="mouseover" @mouseout="mouseout">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder -->
<covers-author-image :author="author" />
<div class="absolute bottom-0 left-0 w-full py-2 bg-black bg-opacity-25 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.85 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
<!-- Author name & num books overlay -->
<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 font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
<!-- 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>
</template>
@@ -34,11 +38,16 @@ export default {
},
width: Number,
height: Number,
sizeMultiplier: Number
sizeMultiplier: {
type: Number,
default: 1
},
nameBelow: Boolean
},
data() {
return {
placeholder: '/Logo.png'
searching: false,
isHovering: false
}
},
computed: {
@@ -48,30 +57,40 @@ export default {
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
name() {
return this._author.name || ''
},
image() {
return this._author.image || null
},
description() {
return this._author.description
},
lastUpdate() {
return this._author.lastUpdate
},
numBooks() {
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() {}
}
</script>

View File

@@ -1,8 +1,10 @@
<template>
<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">
<p class="truncate text-sm">{{ author }}</p>
<p class="truncate text-sm">{{ name }}</p>
</div>
</div>
</template>
@@ -10,12 +12,19 @@
<script>
export default {
props: {
author: String
author: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {},
computed: {
name() {
return this.author.name
}
},
methods: {},
mounted() {}
}

View File

@@ -1,18 +1,26 @@
<template>
<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">
<img :src="selectedCover || '/book_placeholder.jpg'" class="h-24 object-cover" :style="{ width: 96 / bookCoverAspectRatio + 'px' }" />
<div class="px-4 flex-grow">
<div class="h-24 bg-primary" :style="{ minWidth: 96 / bookCoverAspectRatio + 'px' }">
<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">
<h1>{{ book.title }}</h1>
<div class="flex-grow" />
<p>{{ book.publishYear }}</p>
<p>{{ book.publishedYear }}</p>
</div>
<p class="text-gray-400">{{ book.author }}</p>
<div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs">{{ book.description }}</p>
</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 v-if="bookCovers.length > 1" class="flex">
<template v-for="cover in bookCovers">
@@ -31,6 +39,7 @@ export default {
type: Object,
default: () => {}
},
isPodcast: Boolean,
bookCoverAspectRatio: Number
},
data() {

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>

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">
<p class="font-book text-xl">{{ bookItems.length }}</p>
</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>
</nuxt-link>
</div>
@@ -74,7 +71,7 @@ export default {
},
groupTo() {
if (this.groupType === 'series') {
return `/library/${this.currentLibraryId}/series/${this.groupEncode}`
return `/library/${this.currentLibraryId}/series/${this._group.id}`
} else if (this.groupType === 'collection') {
return `/collection/${this._group.id}`
} else {
@@ -100,15 +97,6 @@ export default {
bookItems() {
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() {
return this._group.name || 'No Name'
},
@@ -119,7 +107,7 @@ export default {
return `${this.groupType}.${this.$encode(this.groupName)}`
},
hasValidCovers() {
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
return !!validCovers.length
},
showExperimentalFeatures() {

View File

@@ -1,13 +1,13 @@
<template>
<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">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey !== '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" />
<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>
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
},
@@ -37,17 +37,27 @@ export default {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
media() {
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() {
return this.book ? this.book.title : 'No Title'
return this.mediaMetadata.title || 'No Title'
},
subtitle() {
return this.book ? this.book.subtitle : ''
return this.mediaMetadata.subtitle || ''
},
authorFL() {
return this.book ? this.book.authorFL : 'Unknown'
authorName() {
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
return this.mediaMetadata.authorName || 'Unknown'
},
matchHtml() {
if (!this.matchText || !this.search) return ''
@@ -69,7 +79,7 @@ export default {
html += lastPart
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 === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`

View File

@@ -1,7 +1,7 @@
<template>
<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">
<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 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="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 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 class="flex my-2 -mx-2">
<div v-if="!isPodcast" class="flex my-2 -mx-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 class="w-1/2 px-2">
<div class="w-full">
@@ -33,9 +37,9 @@
</div>
</div>
<tables-uploaded-files-table :files="book.bookFiles" title="Book 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="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
<tables-uploaded-files-table :files="item.itemFiles" title="Item Files" class="mt-8" />
<tables-uploaded-files-table v-if="item.otherFiles.length" title="Other Files" :files="item.otherFiles" />
<tables-uploaded-files-table v-if="item.ignoredFiles.length" title="Ignored Files" :files="item.ignoredFiles" />
</template>
<widgets-alert v-if="uploadSuccess" type="success">
<p class="text-base">Successfully Uploaded!</p>
@@ -55,15 +59,16 @@ import Path from 'path'
export default {
props: {
book: {
item: {
type: Object,
default: () => {}
},
mediaType: String,
processing: Boolean
},
data() {
return {
bookData: {
itemData: {
title: '',
author: '',
series: ''
@@ -75,14 +80,19 @@ export default {
}
},
computed: {
isPodcast() {
return this.mediaType === 'podcast'
},
directory() {
if (!this.bookData.title) return ''
if (this.bookData.series && this.bookData.author) {
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
} else if (this.bookData.author) {
return Path.join(this.bookData.author, this.bookData.title)
if (!this.itemData.title) return ''
if (this.isPodcast) return this.itemData.title
if (this.itemData.series && this.itemData.author) {
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 {
return this.bookData.title
return this.itemData.title
}
}
},
@@ -96,24 +106,24 @@ export default {
this.error = ''
},
getData() {
if (!this.bookData.title) {
if (!this.itemData.title) {
this.error = 'Must have a title'
return null
}
this.error = ''
var files = this.book.bookFiles.concat(this.book.otherFiles)
var files = this.item.itemFiles.concat(this.item.otherFiles)
return {
index: this.book.index,
...this.bookData,
index: this.item.index,
...this.itemData,
files
}
}
},
mounted() {
if (this.book) {
this.bookData.title = this.book.title
this.bookData.author = this.book.author
this.bookData.series = this.book.series
if (this.item) {
this.itemData.title = this.item.title
this.itemData.author = this.item.author
this.itemData.series = this.item.series
}
}
}

View File

@@ -8,20 +8,21 @@
<!-- Alternative bookshelf title/author/sort -->
<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' }">
<span v-if="volumeNumber">#{{ volumeNumber }}&nbsp;</span>{{ displayTitle }}
{{ displayTitle }}
</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>
</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 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>
</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 -->
<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>
<!-- No progress shown for collapsed series in library -->
<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>
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
<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 -->
<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 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>
@@ -51,7 +52,7 @@
</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>
</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>
</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>
</div>
</div>
<!-- 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>
</div>
@@ -76,9 +77,19 @@
</div>
</ui-tooltip>
<!-- Volume number -->
<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` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
<!-- Series sequence -->
<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' }">#{{ 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>
</template>
@@ -99,7 +110,7 @@ export default {
default: 192
},
bookCoverAspectRatio: Number,
showVolumeNumber: Boolean,
showSequence: Boolean,
bookshelfView: Number,
bookMount: {
// Book can be passed as prop or set with setEntity()
@@ -115,7 +126,7 @@ export default {
isHovering: false,
isMoreMenuOpen: false,
isProcessingReadUpdate: false,
audiobook: null,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
@@ -127,7 +138,7 @@ export default {
bookMount: {
handler(newVal) {
if (newVal) {
this.audiobook = newVal
this.libraryItem = newVal
}
}
}
@@ -136,42 +147,75 @@ export default {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_audiobook() {
return this.audiobook || {}
_libraryItem() {
return this.libraryItem || {}
},
book() {
return this._audiobook.book || {}
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this._libraryItem.mediaType
},
isPodcast() {
return this.mediaType === 'podcast'
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
bookCoverSrc() {
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
},
audiobookId() {
return this._audiobook.id
libraryItemId() {
return this._libraryItem.id
},
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() {
return this._audiobook.libraryId
return this._libraryItem.libraryId
},
hasEbook() {
return this._audiobook.numEbooks
return this.media.ebookFormat
},
hasTracks() {
return this._audiobook.numTracks
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() {
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() {
// Only added to audiobook object when collapseSeries is enabled
return this._audiobook.booksInSeries
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
@@ -181,87 +225,86 @@ export default {
return this.width / baseSize
},
title() {
return this.book.title || ''
return this.mediaMetadata.title || ''
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
if (this.isPodcast) return this.mediaMetadata.author
return this.mediaMetadata.authorName
},
authorLF() {
return this.book.authorLF || this.author
},
volumeNumber() {
return this.book.volumeNumber || null
return this.mediaMetadata.authorNameLF
},
displayTitle() {
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
return this.mediaMetadata.titleIgnorePrefix
}
return this.title
},
displayAuthor() {
if (this.orderBy === 'book.authorLF') return this.authorLF
return this.authorFL
if (this.isPodcast) return this.author
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size)
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
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() {
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
return this.numMissingParts || this.isMissing || this.isInvalid
},
isStreaming() {
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
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() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
},
isMissing() {
return this._audiobook.isMissing
return this._libraryItem.isMissing
},
isInvalid() {
return this._audiobook.isInvalid
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._audiobook.hasMissingParts
},
hasInvalidParts() {
return this._audiobook.hasInvalidParts
numMissingParts() {
if (this.isPodcast) return 0
return this.media.numMissingParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
else if (this.isInvalid) return 'Audiobook has no audio tracks & ebook'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) {
if (this.isPodcast) return 'Podcast has no episodes'
return 'Item has no audio tracks & ebook'
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
var txt = ''
if (this.numMissingParts) {
txt = `${this.numMissingParts} missing parts.`
}
return txt || 'Unknown Error'
},
@@ -290,34 +333,29 @@ export default {
return this.store.getters['user/getIsRoot']
},
moreMenuItems() {
var items = [
{
func: 'toggleRead',
text: `Mark as ${this.userIsRead ? 'Not Read' : 'Read'}`
},
{
func: 'openCollections',
text: 'Add to Collection'
}
]
var items = []
if (!this.isPodcast) {
items = [
{
func: 'toggleFinished',
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
},
{
func: 'openCollections',
text: 'Add to Collection'
}
]
}
if (this.userCanUpdate) {
if (this.hasTracks) {
items.push({
func: 'showEditModalTracks',
text: 'Tracks'
})
}
items.push({
func: 'showEditModalFiles',
text: 'Files'
})
items.push({
func: 'showEditModalMatch',
text: 'Match'
})
}
if (this.userCanDownload) {
items.push({
func: 'showEditModalDownload',
text: 'Download'
})
}
if (this.userIsRoot) {
items.push({
func: 'rescan',
@@ -349,11 +387,11 @@ export default {
return this.title
},
authorCleaned() {
if (!this.authorFL) return ''
if (this.authorFL.length > 30) {
return this.authorFL.slice(0, 27) + '...'
if (!this.author) return ''
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.authorFL
return this.author
},
isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
@@ -370,8 +408,8 @@ export default {
this.isSelectionMode = val
if (!val) this.selected = false
},
setEntity(audiobook) {
this.audiobook = audiobook
setEntity(libraryItem) {
this.libraryItem = libraryItem
},
clickCard(e) {
if (this.isSelectionMode) {
@@ -381,66 +419,69 @@ export default {
} else {
var router = this.$router || this.$nuxt.$router
if (router) {
if (this.booksInSeries) router.push(`/library/${this.libraryId}/series/${this.$encode(this.series)}`)
else router.push(`/audiobook/${this.audiobookId}`)
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
else router.push(`/item/${this.libraryItemId}`)
}
}
},
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() {
// More menu func
toggleFinished() {
var updatePayload = {
isRead: !this.userIsRead
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
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) => {
console.error('Failed', error)
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() {
this.rescanning = true
this._socket.once('audiobook_scan_complete', this.audiobookScanComplete)
this._socket.emit('scan_audiobook', this.audiobookId)
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
})
},
showEditModalTracks() {
showEditModalFiles() {
// More menu func
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'tracks' })
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
},
showEditModalMatch() {
// More menu func
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'match' })
},
showEditModalDownload() {
// More menu func
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
openCollections() {
this.store.commit('setSelectedAudiobook', this.audiobook)
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowUserCollectionsModal', true)
},
createMoreMenu() {
@@ -493,17 +534,26 @@ export default {
clickShowMore() {
this.createMoreMenu()
},
clickReadEBook() {
this.store.commit('showEReader', this.audiobook)
async clickReadEBook() {
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() {
if (this.processingBatch) return
this.selected = !this.selected
this.$emit('select', this.audiobook)
this.$emit('select', this.libraryItem)
},
play() {
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() {
this.isHovering = true

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 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` }">
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
</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 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>
@@ -51,12 +52,28 @@ export default {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
seriesId() {
return this.series ? this.series.id : ''
},
title() {
return this.series ? this.series.name : ''
},
books() {
return this.series ? this.series.books || [] : []
},
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() {
return this.$store || this.$nuxt.$store
},
@@ -64,13 +81,10 @@ export default {
return this.store.state.libraries.currentLibraryId
},
seriesBooksRoute() {
return `/library/${this.currentLibraryId}/series/${this.$encode(this.title)}`
},
seriesId() {
return this.series ? this.$encode(this.title) : null
return `/library/${this.currentLibraryId}/series/${this.seriesId}`
},
hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
return !!validCovers.length
}
},

View File

@@ -1,8 +1,8 @@
<template>
<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">
<p class="truncate text-sm">{{ series }}</p>
<p class="truncate text-sm">{{ name }}</p>
</div>
</div>
</template>
@@ -10,7 +10,10 @@
<script>
export default {
props: {
series: String,
series: {
type: Object,
default: () => {}
},
bookItems: {
type: Array,
default: () => []
@@ -22,6 +25,9 @@ export default {
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
name() {
return this.series.name
}
},
methods: {},

View File

@@ -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>

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">
<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)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
@@ -67,7 +67,7 @@ export default {
return {
showMenu: false,
sublist: null,
items: [
bookItems: [
{
text: 'All',
value: 'all'
@@ -107,6 +107,32 @@ export default {
value: 'progress',
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',
value: 'issues',
@@ -132,18 +158,42 @@ export default {
this.$emit('input', val)
}
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
selectItems() {
if (this.isPodcast) return this.podcastItems
return this.bookItems
},
selectedItemSublist() {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
},
selectedText() {
if (!this.selected) return ''
var parts = this.selected.split('.')
var filterName = this.selectItems.find((i) => i.value === parts[0])
var filterValue = null
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() {
return this.filterData.genres || []
@@ -164,13 +214,23 @@ export default {
return this.filterData.languages || []
},
progress() {
return ['Read', 'Unread', 'In Progress']
return ['Finished', 'In Progress', 'Not Started']
},
missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Volume Number', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
if (typeof item === 'string') {
return {
text: item,
value: this.$encode(item)
}
} else {
return {
text: item.name,
value: this.$encode(item.id)
}
}
})
},

View File

@@ -19,38 +19,47 @@
<p>No Results</p>
</li>
<template v-else>
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in audiobookResults">
<li :key="item.audiobook.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}`">
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
<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 bookResults">
<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>
</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>
</li>
</template>
<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">
<li :key="item.author" 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)}`">
<cards-author-search-card :author="item.author" />
<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.id)}`">
<cards-author-search-card :author="item" />
</nuxt-link>
</li>
</template>
<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">
<li :key="item.series" 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)}`">
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
<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/${item.series.id}`">
<cards-series-search-card :series="item.series" :book-items="item.books" />
</nuxt-link>
</li>
</template>
<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">
<li :key="item.tag" 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)}`">
<cards-tag-search-card :tag="item.tag" />
<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.name)}`">
<cards-tag-search-card :tag="item.name" />
</nuxt-link>
</li>
</template>
@@ -70,7 +79,8 @@ export default {
isTyping: false,
isFetching: false,
search: null,
audiobookResults: [],
podcastResults: [],
bookResults: [],
authorResults: [],
seriesResults: [],
tagResults: [],
@@ -83,7 +93,7 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
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: {
@@ -96,7 +106,8 @@ export default {
clearResults() {
this.search = null
this.lastSearch = null
this.audiobookResults = []
this.podcastResults = []
this.bookResults = []
this.authorResults = []
this.seriesResults = []
this.tagResults = []
@@ -136,7 +147,8 @@ export default {
// Search was canceled
if (!this.isFetching) return
this.audiobookResults = searchResults.audiobooks || []
this.podcastResults = searchResults.podcast || []
this.bookResults = searchResults.book || []
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || []

View File

@@ -8,7 +8,7 @@
</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">
<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)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
@@ -31,30 +31,48 @@ export default {
data() {
return {
showMenu: false,
items: [
bookItems: [
{
text: 'Title',
value: 'book.title'
value: 'media.metadata.title'
},
{
text: 'Author (First Last)',
value: 'book.authorFL'
value: 'media.metadata.authorName'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
value: 'media.metadata.authorNameLF'
},
{
text: 'Added At',
value: 'addedAt'
},
{
text: 'Volume #',
value: 'book.volumeNumber'
text: 'Size',
value: 'size'
},
{
text: 'Duration',
value: 'duration'
text: 'File Birthtime',
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',
@@ -88,9 +106,18 @@ export default {
this.$emit('update:descending', val)
}
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
selectItems() {
if (this.isPodcast) return this.podcastItems
return this.bookItems
},
selectedText() {
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
var _sel = this.items.find((i) => i.value === _selected)
var _selected = this.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 ''
return _sel.text
}

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>

View File

@@ -5,20 +5,11 @@
<div class="absolute cover-bg" ref="coverBg" />
</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'" />
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2">
<div class="la-ball-spin-clockwise la-sm">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<widgets-loading-spinner />
</div>
</div>
</div>
@@ -44,11 +35,10 @@
<script>
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
@@ -75,12 +65,15 @@ export default {
height() {
return this.width * this.bookCoverAspectRatio
},
book() {
if (!this.audiobook) return {}
return this.audiobook.book || {}
media() {
if (!this.libraryItem) return {}
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.book.title || 'No Title'
return this.mediaMetadata.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 60) {
@@ -88,9 +81,11 @@ export default {
}
return this.title
},
authors() {
return this.mediaMetadata.authors || []
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
return this.authors.map((au) => au.name).join(', ')
},
authorCleaned() {
if (this.author.length > 30) {
@@ -102,15 +97,15 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
if (!this.audiobook) return null
if (!this.libraryItem) return null
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() {
return this.book.cover || this.placeholderUrl
return this.media.coverPath || this.placeholderUrl
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
@@ -138,12 +133,12 @@ export default {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
}
},
hideCoverBg() {},
imageLoaded() {
this.loading = false
this.$nextTick(() => {
this.imageReady = true
})
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
@@ -168,214 +163,3 @@ export default {
}
</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>

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 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 v-if="books.length > 1" :audiobook="books[1]" :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" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<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" />

View File

@@ -63,7 +63,7 @@ export default {
},
methods: {
getCoverUrl(book) {
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
},
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl
@@ -151,7 +151,6 @@ export default {
.map((bookItem) => {
return {
id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem)
}
})

View File

@@ -22,7 +22,7 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
return this.$store.getters['globals/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
},
hasCover() {
return !!this.audiobook.book.cover

View File

@@ -77,6 +77,19 @@
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
</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 class="flex pt-4">
@@ -103,7 +116,9 @@ export default {
processing: false,
newUser: {},
isNew: true,
accountTypes: ['guest', 'user', 'admin']
accountTypes: ['guest', 'user', 'admin'],
tags: [],
loadingTags: false
}
},
watch: {
@@ -135,9 +150,37 @@ export default {
},
libraryItems() {
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
},
itemTags() {
return this.tags.map((t) => {
return {
text: t,
value: t
}
})
}
},
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) {
if (!val && !this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
@@ -223,20 +266,25 @@ export default {
download: type !== 'guest',
update: type === 'admin',
delete: type === 'admin',
upload: type === 'admin'
upload: type === 'admin',
accessAllLibraries: true,
accessAllTags: true
}
},
init() {
this.fetchAllTags()
this.isNew = !this.account
if (this.account) {
var librariesAccessible = this.account.librariesAccessible || []
console.log(this.account)
this.newUser = {
username: this.account.username,
password: this.account.password,
type: this.account.type,
isActive: this.account.isActive,
permissions: { ...this.account.permissions },
librariesAccessible: [...librariesAccessible]
librariesAccessible: [...(this.account.librariesAccessible || [])],
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
}
} else {
this.newUser = {
@@ -249,7 +297,8 @@ export default {
update: false,
delete: false,
upload: false,
accessAllLibraries: true
accessAllLibraries: true,
accessAllTags: true
},
librariesAccessible: []
}

View File

@@ -39,7 +39,7 @@ export default {
type: Number,
default: 0
},
audiobookId: String
libraryItemId: String
},
data() {
return {
@@ -76,8 +76,15 @@ export default {
this.showBookmarkTitleInput = true
},
deleteBookmark(bm) {
var bookmark = { ...bm, audiobookId: this.audiobookId }
this.$root.socket.emit('delete_bookmark', bookmark)
this.$axios
.$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
},
clickBookmark(bm) {
@@ -85,9 +92,15 @@ export default {
},
submitUpdateBookmark(updatedBookmark) {
var bookmark = { ...updatedBookmark }
bookmark.audiobookId = this.audiobookId
this.$root.socket.emit('update_bookmark', bookmark)
this.$axios
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success('Bookmark updated')
})
.catch((error) => {
this.$toast.error(`Failed to update bookmark`)
console.error(error)
})
this.show = false
},
submitCreateBookmark() {
@@ -95,11 +108,18 @@ export default {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
}
var bookmark = {
audiobookId: this.audiobookId,
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.showBookmarkTitleInput = false

View File

@@ -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>

View File

@@ -15,7 +15,7 @@
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<transition-group name="list-complete" tag="div">
<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>
</transition-group>
</div>
@@ -50,7 +50,7 @@ export default {
this.loadCollections()
this.newCollectionName = ''
} else {
this.$store.commit('setSelectedAudiobook', null)
this.$store.commit('setSelectedLibraryItem', null)
}
}
},
@@ -65,15 +65,18 @@ export default {
},
title() {
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() {
return this.$store.state.selectedAudiobook
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
selectedAudiobookId() {
return this.selectedAudiobook ? this.selectedAudiobook.id : null
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem
},
selectedLibraryItemId() {
return this.selectedLibraryItem ? this.selectedLibraryItem.id : null
},
collections() {
return this.$store.state.user.collections || []
@@ -87,7 +90,7 @@ export default {
var collectionBookIds = c.books.map((b) => b.id)
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
} else {
includesBook = !!c.books.find((b) => b.id === this.selectedAudiobookId)
includesBook = !!c.books.find((b) => b.id === this.selectedLibraryItemId)
}
return {
@@ -101,7 +104,7 @@ export default {
return this.$store.state.globals.showBatchUserCollectionModal
},
selectedBookIds() {
return this.$store.state.selectedAudiobooks || []
return this.$store.state.selectedLibraryItems || []
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
@@ -112,7 +115,7 @@ export default {
this.$store.dispatch('user/loadUserCollections')
},
removeFromCollection(collection) {
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
this.processing = true
if (this.showBatchUserCollectionModal) {
@@ -132,7 +135,7 @@ export default {
} else {
// Remove single book
this.$axios
.$delete(`/api/collections/${collection.id}/book/${this.selectedAudiobookId}`)
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
.then((updatedCollection) => {
console.log(`Book removed from collection`, updatedCollection)
this.$toast.success('Book removed from collection')
@@ -146,7 +149,7 @@ export default {
}
},
addToCollection(collection) {
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
this.processing = true
if (this.showBatchUserCollectionModal) {
@@ -164,10 +167,10 @@ export default {
this.processing = false
})
} else {
if (!this.selectedAudiobookId) return
if (!this.selectedLibraryItemId) return
this.$axios
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedAudiobookId })
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
.then((updatedCollection) => {
console.log(`Book added to collection`, updatedCollection)
this.$toast.success('Book added to collection')
@@ -181,12 +184,12 @@ export default {
}
},
submitCreateCollection() {
if (!this.newCollectionName || (!this.selectedAudiobookId && !this.selectedBookIds.length)) {
if (!this.newCollectionName || (!this.selectedLibraryItemId && !this.selectedBookIds.length)) {
return
}
this.processing = true
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedAudiobookId]
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedLibraryItemId]
var newCollection = {
books: books,
libraryId: this.currentLibraryId,

View File

@@ -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>

View File

@@ -4,7 +4,7 @@
<!-- <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">
<!-- <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 class="flex-grow overflow-hidden px-2">
<!-- <template v-if="isEditing">
@@ -38,7 +38,8 @@ export default {
type: Object,
default: () => {}
},
highlight: Boolean
highlight: Boolean,
bookCoverAspectRatio: Number
},
data() {
return {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -5,7 +5,7 @@
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
</div>
</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">
<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>
@@ -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>
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<component v-if="audiobook && show" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
<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="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div>
</modals-modal>
</template>
@@ -29,49 +29,44 @@ export default {
data() {
return {
processing: false,
audiobook: null,
fetchOnShow: false,
libraryItem: null,
tabs: [
{
id: 'details',
title: 'Details',
component: 'modals-edit-tabs-details'
component: 'modals-item-tabs-details'
},
{
id: 'cover',
title: 'Cover',
component: 'modals-edit-tabs-cover'
component: 'modals-item-tabs-cover'
},
// {
// id: 'tracks',
// title: 'Tracks',
// component: 'modals-edit-tabs-tracks'
// },
{
id: 'chapters',
title: 'Chapters',
component: 'modals-edit-tabs-chapters'
component: 'modals-item-tabs-chapters'
},
{
id: 'episodes',
title: 'Episodes',
component: 'modals-item-tabs-episodes'
},
{
id: 'files',
title: 'Files',
component: 'modals-edit-tabs-files'
},
{
id: 'download',
title: 'Download',
component: 'modals-edit-tabs-download'
component: 'modals-item-tabs-files'
},
{
id: '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]
}
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
if (this.fetchOnShow) this.fetchFull()
return
}
this.fetchOnShow = false
this.audiobook = null
this.libraryItem = null
this.init()
this.registerListeners()
} else {
@@ -120,22 +110,26 @@ export default {
this.$store.commit('setEditModalTab', val)
}
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.id === 'download' && this.isMissing) return false
if ((tab.id === 'download' || tab.id === 'files' || tab.id === 'authors') && this.userCanDownload) return true
if (tab.id !== 'download' && tab.id !== 'files' && tab.id !== 'authors' && this.userCanUpdate) return true
if (tab.id === 'match' && this.userCanUpdate && this.showExperimentalFeatures) return true
if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.id === 'merge' && (this.isMissing || this.mediaType !== 'book')) return false
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
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
})
},
@@ -148,26 +142,32 @@ export default {
return _tab ? _tab.component : ''
},
isMissing() {
return this.selectedAudiobook.isMissing
return this.selectedLibraryItem.isMissing
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook || {}
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedAudiobookId() {
return this.selectedAudiobook.id
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
title() {
return this.book.title || 'No Title'
return this.mediaMetadata.title || 'No Title'
},
bookshelfBookIds() {
return this.$store.state.bookshelfBookIds || []
},
currentBookshelfIndex() {
if (!this.bookshelfBookIds.length) return 0
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedLibraryItemId)
},
canGoPrev() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
@@ -181,15 +181,18 @@ export default {
if (this.currentBookshelfIndex - 1 < 0) return
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
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'
this.$toast.error(errorMsg)
return null
})
this.processing = false
if (prevBook) {
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
this.$nextTick(this.init)
this.unregisterListeners()
this.libraryItem = prevBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', prevBook)
this.$nextTick(this.registerListeners)
} else {
console.error('Book not found', prevBookId)
}
@@ -198,15 +201,18 @@ export default {
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
this.processing = true
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'
this.$toast.error(errorMsg)
return null
})
this.processing = false
if (nextBook) {
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
this.$nextTick(this.init)
this.unregisterListeners()
this.libraryItem = nextBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', nextBook)
this.$nextTick(this.registerListeners)
} else {
console.error('Book not found', nextBookId)
}
@@ -216,23 +222,19 @@ export default {
this.selectedTab = tab
}
},
audiobookUpdated() {
if (!this.show) this.fetchOnShow = true
else {
this.fetchFull()
}
libraryItemUpdated(expandedLibraryItem) {
this.libraryItem = expandedLibraryItem
},
init() {
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
this.fetchFull()
},
async fetchFull() {
try {
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
} catch (error) {
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
console.error('Failed to fetch audiobook', this.selectedLibraryItemId, error)
this.processing = false
this.show = false
}
@@ -246,9 +248,11 @@ export default {
},
registerListeners() {
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
},
unregisterListeners() {
this.$eventBus.$off('modal-hotkey', this.hotkey)
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
}
},
mounted() {},
@@ -258,7 +262,7 @@ export default {
}
</script>
<style>
<style scoped>
.tab {
height: 40px;
}

View File

@@ -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>

View File

@@ -2,9 +2,9 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
<div class="flex">
<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 -->
<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="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>
@@ -31,7 +31,7 @@
<div v-if="showLocalCovers" class="flex items-center justify-center">
<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' }">
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
</div>
@@ -47,9 +47,9 @@
<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" />
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
</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" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
@@ -82,7 +82,7 @@
export default {
props: {
processing: Boolean,
audiobook: {
libraryItem: {
type: Object,
default: () => {}
}
@@ -98,25 +98,11 @@ export default {
showLocalCovers: false,
previewUpload: null,
selectedFile: null,
providers: [
{
text: 'Google Books',
value: 'google'
},
{
text: 'Open Library',
value: 'openlibrary'
},
{
text: 'Audible',
value: 'audible'
}
],
provider: 'google'
}
},
watch: {
audiobook: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) {
@@ -134,23 +120,41 @@ export default {
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() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
audiobookPath() {
return this.audiobook ? this.audiobook.path : null
isPodcast() {
return this.mediaType == 'podcast'
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
coverPath() {
return this.media.coverPath
},
mediaMetadata() {
return this.media.metadata || {}
},
libraryFiles() {
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
@@ -159,12 +163,11 @@ export default {
return this.$store.getters['user/getToken']
},
localCovers() {
return this.otherFiles
.filter((f) => f.filetype === 'image')
return this.libraryFiles
.filter((f) => f.fileType === 'image')
.map((file) => {
var _file = { ...file }
var imgRelPath = _file.path.replace(this.audiobookPath, '')
_file.localPath = `/s/book/${this.audiobookId}/${imgRelPath}`
_file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
return _file
})
}
@@ -176,7 +179,7 @@ export default {
form.set('cover', this.selectedFile)
this.$axios
.$post(`/api/books/${this.audiobook.id}/cover`, form)
.$post(`/api/items/${this.libraryItemId}/cover`, form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
@@ -209,17 +212,18 @@ export default {
},
init() {
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.hasSearched = false
}
this.imageUrl = this.book.cover || ''
this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.authorFL || ''
this.provider = localStorage.getItem('book-provider') || 'openlibrary'
this.imageUrl = this.media.coverPath || ''
this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
},
removeCover() {
if (!this.book.cover) {
if (!this.media.coverPath) {
this.imageUrl = ''
return
}
@@ -229,7 +233,7 @@ export default {
this.updateCover(this.imageUrl)
},
async updateCover(cover) {
if (cover === this.book.cover) {
if (cover === this.coverPath) {
console.warn('Cover has not changed..', cover)
return
}
@@ -237,9 +241,21 @@ export default {
this.isProcessing = true
var success = false
// Download cover from url and use
if (cover.startsWith('http:') || cover.startsWith('https:')) {
success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
if (!cover) {
// Remove cover
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)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
@@ -249,11 +265,9 @@ export default {
} else {
// Update local cover url
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)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
@@ -263,15 +277,16 @@ export default {
}
if (success) {
this.$toast.success('Update Successful')
this.$emit('close')
// this.$emit('close')
} else {
this.imageUrl = this.book.cover || ''
this.imageUrl = this.media.coverPath || ''
}
this.isProcessing = false
},
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
if (this.isPodcast) searchQuery += '&podcast=1'
return searchQuery
},
persistProvider() {
@@ -296,23 +311,7 @@ export default {
this.hasSearched = true
},
setCover(coverFile) {
this.isProcessing = true
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
})
this.updateCover(coverFile.metadata.path)
}
}
}

View File

@@ -0,0 +1,228 @@
<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" :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)
}
},
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>

View File

@@ -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>

View File

@@ -0,0 +1,68 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<template v-for="audiobook in audiobooks">
<tables-tracks-table :key="audiobook.id" :title="`Audiobook Tracks (${audiobook.name})`" :audiobook-id="audiobook.id" :tracks="audiobook.tracks" class="mb-4" />
</template>
<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
},
audiobooks() {
return this.media.audiobooks || []
},
ebooks() {
return this.media.ebooks || []
}
},
methods: {
init() {
this.tracks = this.media.tracks || []
}
}
}
</script>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,20 +1,18 @@
<template>
<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 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">
<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 class="w-1/2 md:w-72 px-1 py-1 md:py-0">
<ui-media-type-picker v-model="mediaType" />
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
<ui-media-icon-picker v-model="icon" @input="iconChanged" />
</div>
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0">
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small />
<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 @input="formUpdated" />
</div>
</div>
@@ -22,36 +20,26 @@
<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">
<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>
</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>
</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>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @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>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
</div>
</template>
<script>
export default {
props: {
isNew: Boolean,
library: {
type: Object,
default: () => null
@@ -61,40 +49,73 @@ export default {
data() {
return {
name: '',
provider: '',
mediaType: '',
provider: 'google',
icon: '',
folders: [],
showDirectoryPicker: false,
disableWatcher: false
newFolderPath: '',
mediaType: null,
mediaTypes: [
{
value: 'book',
text: 'Books'
},
{
value: 'podcast',
text: 'Podcasts'
}
]
}
},
computed: {
title() {
if (this.showDirectoryPicker) return 'Choose a Folder'
return ''
},
folderPaths() {
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() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
},
globalWatcherDisabled() {
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
}
},
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) {
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
this.formUpdated()
},
backArrowPress() {
if (this.showDirectoryPicker) {
@@ -103,94 +124,11 @@ export default {
},
init() {
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.disableWatcher = this.library ? !!this.library.disableWatcher : false
this.mediaType = this.library ? this.library.mediaType : 'default'
this.icon = this.library ? this.library.icon : 'default'
this.mediaType = this.library ? this.library.mediaType : 'book'
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() {

View File

@@ -0,0 +1,218 @@
<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
}
}
},
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>

View File

@@ -1,10 +1,14 @@
<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">
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
</div>
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4">
<div class="w-1/2 border-r border-bg">
<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 h-full overflow-y-auto">
<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>
<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>
</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)">
<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>
@@ -30,12 +34,8 @@
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
</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>
<!-- <div class="flex items-center">
<div class="flex-grow" />
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
</div> -->
</div>
</div>
</template>
@@ -64,7 +64,6 @@ export default {
computed: {
_directories() {
return this.directories.map((d) => {
console.log('Directories', d)
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
var isSelected = d.path === this.selectedPath
var classes = []
@@ -162,4 +161,9 @@ export default {
.dir-item.dir-used {
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>

View File

@@ -0,0 +1,63 @@
<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>
</template>
<script>
export default {
props: {
library: {
type: Object,
default: () => null
},
processing: Boolean
},
data() {
return {
provider: null,
disableWatcher: 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
}
}
},
formUpdated() {
this.$emit('update', this.getLibraryData())
},
init() {
this.disableWatcher = !!this.librarySettings.disableWatcher
}
},
mounted() {
this.init()
}
}
</script>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -18,10 +18,7 @@
<script>
export default {
data() {
return {
ebookType: '',
ebookUrl: ''
}
return {}
},
watch: {
show(newVal) {
@@ -47,46 +44,65 @@ export default {
return null
},
abTitle() {
return this.selectedAudiobook.book.title
return this.mediaMetadata.title
},
abAuthor() {
return this.selectedAudiobook.book.author
return this.mediaMetadata.authorName
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
media() {
return this.selectedLibraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
libraryId() {
return this.selectedAudiobook.libraryId
return this.selectedLibraryItem.libraryId
},
folderId() {
return this.selectedAudiobook.folderId
return this.selectedLibraryItem.folderId
},
ebooks() {
return this.selectedAudiobook.ebooks || []
ebookFile() {
return this.media.ebookFile
},
epubEbook() {
return this.ebooks.find((eb) => eb.ext === '.epub')
ebookFormat() {
if (!this.ebookFile) return null
return this.ebookFile.ebookFormat
},
mobiEbook() {
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
ebookType() {
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() {
return this.ebooks.find((eb) => eb.ext === '.pdf')
isEpub() {
return this.ebookFormat == 'epub'
},
comicEbook() {
return this.ebooks.find((eb) => eb.ext === '.cbz' || eb.ext === '.cbr')
isMobi() {
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() {
return this.$store.getters['user/getToken']
},
selectedAudiobookFile() {
return this.$store.state.selectedAudiobookFile
}
},
methods: {
getEbookUrl(path) {
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
@@ -107,31 +123,6 @@ export default {
},
init() {
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() {
this.unregisterListeners()

View File

@@ -5,16 +5,16 @@
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
</svg>
<div class="px-2">
<p class="text-4xl md:text-5xl font-bold">{{ totalBooks }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books in Library</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">Items in Library</p>
</div>
</div>
<div class="flex px-4">
<span class="material-icons text-7xl">show_chart</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalAudiobookHours }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Overall Hours</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 {{ useOverallHours ? 'Hours' : 'Days' }}</p>
</div>
</div>
@@ -61,8 +61,8 @@ export default {
user() {
return this.$store.state.user.user
},
totalBooks() {
return this.libraryStats ? this.libraryStats.totalBooks : 0
totalItems() {
return this.libraryStats ? this.libraryStats.totalItems : 0
},
totalAuthors() {
return this.libraryStats ? this.libraryStats.totalAuthors : 0
@@ -70,12 +70,11 @@ export default {
numAudioTracks() {
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
},
totalAudiobookDuration() {
totalDuration() {
return this.libraryStats ? this.libraryStats.totalDuration : 0
},
totalAudiobookHours() {
var totalHours = Math.round(this.totalAudiobookDuration / (60 * 60))
return totalHours
totalHours() {
return Math.round(this.totalDuration / (60 * 60))
},
totalSizePretty() {
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
@@ -86,6 +85,13 @@ export default {
},
totalSizeMod() {
return this.totalSizePretty.split(' ')[1]
},
useOverallHours() {
return this.totalHours < 10000
},
totalTime() {
if (this.useOverallHours) return this.totalHours
return Math.round(this.totalHours / 24)
}
},
methods: {},

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>

View File

@@ -13,17 +13,20 @@
<th class="hidden sm:table-cell w-20 md:w-28">Size</th>
<th class="w-36"></th>
</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>
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
</td>
<td class="hidden sm:table-cell font-sans text-base">{{ 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-sans text-sm">{{ backup.datePretty }}</td>
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
<td>
<div class="w-full flex flex-row items-center justify-center">
<ui-btn 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>
</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">
<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">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-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: {
confirm() {
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) {
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
this.processing = true
this.$axios
.$delete(`/api/backup/${backup.id}`)
.$delete(`/api/backups/${backup.id}`)
.then((backups) => {
console.log('Backup deleted', 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) {
this.selectedBackup = backup
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() {
this.isBackingUp = true
this.$root.socket.once('backup_complete', this.backupComplete)
this.$root.socket.emit('create_backup')
this.$axios
.$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) {
var form = new FormData()
@@ -129,7 +137,7 @@ export default {
this.processing = true
this.$axios
.$post('/api/backup/upload', form)
.$post('/api/backups/upload', form)
.then((result) => {
console.log('Upload backup result', result)
this.$store.commit('setBackups', result)
@@ -171,11 +179,11 @@ export default {
text-align: center;
}
#backups tr:nth-child(even) {
#backups tr:nth-child(even):not(.bg-error) {
background-color: #3a3a3a;
}
#backups tr:not(.staticrow):hover {
#backups tr:not(.staticrow):not(.bg-error):hover {
background-color: #444;
}

View File

@@ -6,7 +6,7 @@
<p class="font-mono text-sm">{{ books.length }}</p>
</div>
<div class="flex-grow" />
<p v-if="totalDuration">{{ totalDurationPretty }}</p>
<!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> -->
</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">
<transition-group type="transition" :name="!drag ? 'collection-book' : null">
@@ -56,16 +56,6 @@ export default {
},
bookCoverAspectRatio() {
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: {

View File

@@ -1,14 +1,11 @@
<template>
<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">
<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">
<span class="text-sm font-mono">{{ files.length }}</span>
</div>
<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>
<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>
@@ -19,22 +16,25 @@
<table class="text-sm tracksTable">
<tr class="font-book">
<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 v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
</tr>
<template v-for="file in otherFilesCleaned">
<template v-for="file in files">
<tr :key="file.path">
<td class="font-book pl-2">
{{ showFullPath ? file.fullPath : file.path }}
<td class="font-book px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<div class="flex items-center">
<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>
</td>
<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>
</tr>
</template>
@@ -51,10 +51,9 @@ export default {
type: Array,
default: () => []
},
audiobook: {
type: Object,
default: () => null
}
libraryItemId: String,
isMissing: Boolean,
expanded: Boolean // start expanded
},
data() {
return {
@@ -63,44 +62,20 @@ export default {
}
},
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() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
methods: {
readEbookClick(file) {
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
},
clickBar() {
this.showFiles = !this.showFiles
}
},
mounted() {}
mounted() {
this.showFiles = this.expanded
}
}
</script>

View File

@@ -1,14 +1,14 @@
<template>
<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">
<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">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<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/${audiobookId}/edit`" class="mr-2 md:mr-4">
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
<ui-btn small color="primary">Manage Tracks</ui-btn>
</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' : ''">
@@ -25,20 +25,20 @@
<th class="text-left w-20">Duration</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr>
<template v-for="track in tracksCleaned">
<template v-for="track in tracks">
<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-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
{{ $bytesPretty(track.metadata.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<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>
</tr>
</template>
@@ -51,14 +51,15 @@
<script>
export default {
props: {
title: {
type: String,
default: 'Audio Tracks'
},
tracks: {
type: Array,
default: () => []
},
audiobook: {
type: Object,
default: () => null
}
libraryItemId: String
},
data() {
return {
@@ -67,26 +68,6 @@ export default {
}
},
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() {
return this.$store.getters['user/getToken']
},

View File

@@ -26,11 +26,11 @@
</td>
<td class="text-sm">{{ user.type }}</td>
<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">
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.audiobook.book.title || '' }}</p>
<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">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
</div>
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)">
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p>
<div v-else-if="user.mostRecent">
<p class="truncate text-xs">Last: {{ user.mostRecent.metadata.title }}</p>
</div>
</td>
<td class="text-xs font-mono hidden sm:table-cell">
@@ -76,28 +76,13 @@ export default {
currentUserId() {
return this.$store.state.user.user.id
},
userStream() {
return this.$store.state.streamAudiobook
},
usersOnline() {
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
}
},
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) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {

View File

@@ -7,7 +7,7 @@
</div>
</div>
<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="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>
@@ -16,8 +16,7 @@
</div>
<div class="w-80 h-full px-2 flex items-center">
<div>
<nuxt-link :to="`/audiobook/${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>
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
</div>
</div>
<div class="flex-grow flex items-center">
@@ -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>
</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="flex h-full items-center">
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" borderless class="mx-1 mt-0.5" @click="toggleRead" />
<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>
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
@@ -68,12 +62,6 @@ export default {
}
},
watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
},
isDragging: {
handler(newVal) {
if (newVal) {
@@ -83,17 +71,23 @@ export default {
}
},
computed: {
_book() {
return this.book.book || {}
media() {
return this.book.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
tracks() {
return this.media.tracks || []
},
bookTitle() {
return this._book.title || ''
return this.mediaMetadata.title || ''
},
bookAuthor() {
return this._book.authorFL || ''
return (this.mediaMetadata.authors || []).map((au) => au.name).join(', ')
},
bookDuration() {
return this.$secondsToTimestamp(this.book.duration)
return this.$secondsToTimestamp(this.media.duration)
},
isMissing() {
return this.book.isMissing
@@ -101,23 +95,17 @@ export default {
isInvalid() {
return this.book.isInvalid
},
numTracks() {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.book.id
return this.$store.getters['getLibraryItemIdStreaming'] === this.book.id
},
showPlayBtn() {
return !this.isMissing && !this.isInvalid && !this.isStreaming && this.numTracks
return !this.isMissing && !this.isInvalid && !this.isStreaming && this.tracks.length
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
itemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.book.id)
},
userAudiobook() {
return this.userAudiobooks[this.book.id] || null
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
@@ -133,26 +121,28 @@ export default {
this.isHovering = false
},
playClick() {
this.$eventBus.$emit('play-audiobook', this.book.id)
this.$eventBus.$emit('play-item', {
libraryItemId: this.book.id
})
},
clickEdit() {
this.$emit('edit', this.book)
},
toggleRead() {
toggleFinished() {
var updatePayload = {
isRead: !this.isRead
isFinished: !this.userIsFinished
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/audiobook/${this.book.id}`, updatePayload)
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
.then(() => {
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) => {
console.error('Failed', error)
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() {

View File

@@ -9,11 +9,11 @@
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies">
<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>
</template>
</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>

View File

@@ -7,13 +7,13 @@
</svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<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 && canScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan" small color="success" @click.stop="scan">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="!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="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 && !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">
<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" />
@@ -49,20 +49,17 @@ export default {
libraryScan() {
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
},
canEdit() {
return this.$store.getters['user/getIsRoot']
mediaType() {
return this.library.mediaType
},
canDelete() {
return this.$store.getters['user/getIsRoot']
},
canScan() {
return this.$store.getters['user/getIsRoot']
isBookLibrary() {
return this.mediaType === 'book'
}
},
methods: {
matchAll() {
this.$axios
.$post(`/api/libraries/${this.library.id}/matchbooks`)
.$post(`/api/libraries/${this.library.id}/matchall`)
.then(() => {
console.log('Starting scan for matches')
})
@@ -76,10 +73,10 @@ export default {
this.$emit('edit', this.library)
},
scan() {
this.$root.socket.emit('scan', this.library.id)
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
},
forceScan() {
this.$root.socket.emit('scan', this.library.id, { forceRescan: true })
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
},
deleteClick() {
if (this.isMain) return

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
<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 />
<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> -->

View File

@@ -1,10 +1,10 @@
<template>
<label class="flex justify-start items-center cursor-pointer">
<div class="border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<input v-model="selected" type="checkbox" class="opacity-0 absolute cursor-pointer" />
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<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>
</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>
</template>
@@ -18,10 +18,19 @@ export default {
type: String,
default: 'white'
},
borderColor: {
type: String,
default: 'gray-400'
},
checkColor: {
type: String,
default: 'green-500'
}
},
labelClass: {
type: String,
default: ''
},
disabled: Boolean
},
data() {
return {}
@@ -36,15 +45,17 @@ export default {
}
},
wrapperClass() {
var classes = [`bg-${this.checkboxBg}`]
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
if (this.small) classes.push('w-4 h-4')
else classes.push('w-6 h-6')
return classes.join(' ')
},
labelClass() {
if (this.small) return 'text-xs md:text-sm'
return ''
labelClassname() {
if (this.labelClass) return this.labelClass
var classes = ['pl-1']
if (this.small) classes.push('text-xs md:text-sm')
return classes.join(' ')
},
svgClass() {
var classes = [`text-${this.checkColor}`]

View File

@@ -1,12 +1,12 @@
<template>
<div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold">{{ 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">
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<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="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</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 class="material-icons">expand_more</span>
</span>
</button>
@@ -63,6 +63,16 @@ export default {
},
selectedText() {
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: {

View File

@@ -33,7 +33,6 @@ export default {
var _files = Array.from(e.target.files)
if (_files && _files.length) {
var file = _files[0]
console.log('File', file)
this.$emit('change', file)
}
}

View File

@@ -1,6 +1,11 @@
<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">
<span :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
<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">
<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>
</template>
@@ -14,7 +19,8 @@ export default {
default: 'primary'
},
outlined: Boolean,
borderless: Boolean
borderless: Boolean,
loading: Boolean
},
data() {
return {}
@@ -34,7 +40,7 @@ export default {
},
methods: {
clickBtn(e) {
if (this.disabled) {
if (this.disabled || this.loading) {
e.preventDefault()
return
}

View File

@@ -8,7 +8,7 @@
</div>
</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">
<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">
@@ -47,7 +47,7 @@ export default {
data() {
return {
isFocused: false,
currentSearch: null,
// currentSearch: null,
typingTimeout: null,
textInput: null
}
@@ -70,12 +70,13 @@ export default {
}
},
itemsToShow() {
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
return this.items
if (!this.editable) return this.items
if (!this.textInput || this.textInput === this.input) {
return []
}
return this.items.filter((i) => {
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
return iValue.includes(this.textInput.toLowerCase())
})
}
},
@@ -83,7 +84,7 @@ export default {
keydownInput() {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput
// this.currentSearch = this.textInput
}, 100)
},
inputFocus() {
@@ -127,11 +128,11 @@ export default {
if (val && !this.items.includes(val)) {
this.$emit('newItem', val)
}
this.currentSearch = null
// this.currentSearch = null
},
clickedOption(e, item) {
this.textInput = null
this.currentSearch = null
// this.currentSearch = null
this.input = item
if (this.$refs.input) this.$refs.input.blur()
}

View File

@@ -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">
<span class="flex items-center">
<widgets-library-icon :icon="selected" class="mr-2" />
<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>
<widgets-library-icon :icon="selected" />
</span>
</button>
<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">
<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)">
<div class="flex items-center px-3">
<widgets-library-icon :icon="type.id" class="mr-2" />
<span class="font-normal block truncate font-sans text-sm">{{ type.name }}</span>
</div>
<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)">
<widgets-library-icon :icon="type.id" />
</li>
</template>
</ul>
@@ -34,7 +27,7 @@ export default {
disabled: Boolean,
label: {
type: String,
default: 'Media Type'
default: 'Icon'
}
},
data() {
@@ -47,23 +40,23 @@ export default {
showMenu: false,
types: [
{
id: 'default',
name: 'Default'
id: 'database',
name: 'Database'
},
{
id: 'audiobooks',
id: 'audiobook',
name: 'Audiobooks'
},
{
id: 'books',
id: 'book',
name: 'Books'
},
{
id: 'podcasts',
id: 'podcast',
name: 'Podcasts'
},
{
id: 'comics',
id: 'comic',
name: 'Comics'
}
]
@@ -72,7 +65,7 @@ export default {
computed: {
selected: {
get() {
return this.value || 'default'
return this.value || 'database'
},
set(val) {
this.$emit('input', val)
@@ -82,7 +75,7 @@ export default {
return this.types.find((t) => t.id === this.selected)
},
selectedName() {
return this.selectedItem ? this.selectedItem.name : 'Default'
return this.selectedItem ? this.selectedItem.name : 'Database'
}
},
methods: {

View File

@@ -5,7 +5,7 @@
<span class="block truncate">{{ label }}</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">person</span>
<span class="material-icons text-gray-100" aria-label="User Account" role="button">person</span>
</span>
</button>

View File

@@ -3,14 +3,15 @@
<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: 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 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 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 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">
<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>
</div>
{{ item }}
</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>
</form>
@@ -47,7 +48,9 @@ export default {
default: () => []
},
label: String,
disabled: Boolean
disabled: Boolean,
readonly: Boolean,
showEdit: Boolean
},
data() {
return {
@@ -67,7 +70,7 @@ export default {
computed: {
selected: {
get() {
return this.value
return this.value || []
},
set(val) {
this.$emit('input', val)
@@ -76,6 +79,13 @@ export default {
showMenu() {
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() {
if (!this.currentSearch || !this.textInput) {
return this.items
@@ -88,6 +98,9 @@ export default {
}
},
methods: {
editItem(item) {
this.$emit('edit', item)
},
keydownInput() {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {

View File

@@ -62,7 +62,7 @@ export default {
},
selectedItems() {
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) {
var remaining = this.selected.filter((i) => i !== itemValue)
this.$emit('input', remaining)
this.$nextTick(() => {
this.recalcMenuPos()
})

View File

@@ -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>

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>

View File

@@ -83,4 +83,7 @@ input:read-only {
color: #bbb;
background-color: #444;
}
input::-webkit-calendar-picker-indicator {
filter: invert(1);
}
</style>

View File

@@ -3,7 +3,7 @@
<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>
</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>
</template>
@@ -17,6 +17,7 @@ export default {
type: String,
default: 'text'
},
readonly: Boolean,
disabled: Boolean
},
data() {
@@ -37,6 +38,9 @@ export default {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
},
inputBlurred() {
this.$emit('blur')
}
},
mounted() {}

View File

@@ -1,5 +1,5 @@
<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>
<script>
@@ -31,6 +31,11 @@ export default {
methods: {
change(e) {
this.$emit('change', e.target.value)
},
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
}
},
mounted() {}

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full">
<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>
</template>
@@ -29,7 +29,13 @@ export default {
}
}
},
methods: {},
methods: {
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,77 @@
<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" :library-item-id="libraryItemId" class="mt-6" />
</div>
</template>
<script>
export default {
props: {
libraryItemId: String,
media: {
type: Object,
default: () => {}
}
},
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>

View File

@@ -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>

View File

@@ -1,9 +1,9 @@
<template>
<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>
<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>
<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>
</template>

View File

@@ -0,0 +1,241 @@
<template>
<div class="la-ball-spin-clockwise" :class="`${size}`">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</template>
<script>
export default {
props: {
size: {
type: String,
default: 'la-sm'
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</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>

View File

@@ -0,0 +1,233 @@
<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="authorInput" v-model="details.author" label="Author" />
</div>
</div>
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
<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/4 px-1">
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
</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 class="flex-grow px-1 pt-6">
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
</form>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
details: {
title: null,
author: null,
description: null,
releaseDate: null,
genres: [],
feedUrl: null,
imageUrl: null,
itunesPageUrl: null,
itunesId: null,
itunesArtistId: null,
explicit: false,
language: null
},
autoDownloadEpisodes: 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 || []
},
filterData() {
return this.$store.state.libraries.filterData || {}
}
},
methods: {
getDetails() {
this.forceBlur()
return this.checkForChanges()
},
getTitleAndAuthorName() {
this.forceBlur()
return {
title: this.details.title,
author: this.details.author
}
},
mapBatchDetails(batchDetails) {
for (const key in batchDetails) {
if (key === 'tags') {
this.newTags = [...batchDetails.tags]
} else if (key === 'genres') {
this.details[key] = [...batchDetails[key]]
} else {
this.details[key] = batchDetails[key]
}
}
},
forceBlur() {
if (this.$refs.titleInput) this.$refs.titleInput.blur()
if (this.$refs.authorInput) this.$refs.authorInput.blur()
if (this.$refs.releaseDateInput) this.$refs.releaseDateInput.blur()
if (this.$refs.descriptionInput) this.$refs.descriptionInput.blur()
if (this.$refs.feedUrlInput) this.$refs.feedUrlInput.blur()
if (this.$refs.itunesIdInput) this.$refs.itunesIdInput.blur()
if (this.$refs.languageInput) this.$refs.languageInput.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()
}
},
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 === 'genres') {
// Check array of strings
if (!this.stringArrayEqual(newValue, oldValue)) {
metadata[key] = [...newValue]
}
} 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]
}
if (this.media.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
updatePayload.autoDownloadEpisodes = !!this.autoDownloadEpisodes
}
return {
updatePayload,
hasChanges: !!Object.keys(updatePayload).length
}
},
init() {
this.details.title = this.mediaMetadata.title
this.details.author = this.mediaMetadata.author || ''
this.details.description = this.mediaMetadata.description || ''
this.details.releaseDate = this.mediaMetadata.releaseDate || ''
this.details.genres = [...(this.mediaMetadata.genres || [])]
this.details.feedUrl = this.mediaMetadata.feedUrl || ''
this.details.imageUrl = this.mediaMetadata.imageUrl || ''
this.details.itunesPageUrl = this.mediaMetadata.itunesPageUrl || ''
this.details.itunesId = this.mediaMetadata.itunesId || ''
this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || ''
this.details.language = this.mediaMetadata.language || ''
this.details.explicit = !!this.mediaMetadata.explicit
this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes
this.newTags = [...(this.media.tags || [])]
},
submitForm() {
this.$emit('submit')
}
},
mounted() {}
}
</script>

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