Compare commits

...

308 Commits

Author SHA1 Message Date
advplyr
a3dc79121e Version bump 2.2.2 2022-11-05 15:06:10 -05:00
advplyr
9627f58541 Merge pull request #1110 from keaganhilliard/tone-json
Added tone json file support
2022-11-05 13:13:49 -05:00
advplyr
1118b8b782 Metadata embed and m4b merge fixes and cleanup 2022-11-05 13:13:52 -05:00
advplyr
36626d43a1 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-05 07:36:58 -05:00
advplyr
af9a87f8bd Tone version bump v0.1.2 2022-11-05 07:36:53 -05:00
advplyr
056de09645 Merge pull request #1111 from ruoti/fix-sortIgnorePrefix
Makes it so the when sorting and ignoring prefixes, prefixes are actually ignored
2022-11-04 17:48:19 -05:00
advplyr
f5c394c96d Merge pull request #1106 from ruoti/livereload-server
Makes the dev target support auto reloading of the server
2022-11-04 17:14:57 -05:00
Keagan Hilliard
3824154c15 Forgot to update the merge 2022-11-03 10:20:32 -06:00
Keagan Hilliard
586c8a550a Removed a noisy log and limit chapter embedding to items with only 1 audiofile 2022-11-03 10:09:49 -06:00
Keagan Hilliard
d57effe97c Fixed a couple of issues, should be working well now 2022-11-03 09:32:50 -06:00
Scott Ruoti
473257f65e Makes it so the when sorting and ignoring prefixes, they are actually ignored 2022-11-03 00:14:07 -04:00
Keagan Hilliard
c1938f78c2 Added json file support 2022-11-02 19:40:50 -06:00
Scott Ruoti
e97171d953 Makes the dev target support auto reloading of the server 2022-11-02 19:51:41 -04:00
advplyr
c6e9fe6513 Update:Chapter lookup modal show # of chapters found vs current # of chapters #1070 2022-11-02 17:28:26 -05:00
advplyr
765a11f135 Update:Increase db lockfile stale time to 2 mins #1095 2022-10-29 17:37:56 -05:00
advplyr
491bb04877 Update:Library folder picker note to debian installs 2022-10-29 15:42:34 -05:00
advplyr
fbbcbb4af1 Add:Series filters #712 2022-10-29 15:33:38 -05:00
advplyr
ce133cd6f2 Add:Series sort #712 2022-10-29 11:17:51 -05:00
advplyr
dc4c30d791 Update readme add matrix invite link 2022-10-29 10:28:12 -05:00
advplyr
e752b4071d Update:Cleanup bookshelf toolbars & fix siderail icon 2022-10-28 18:10:19 -05:00
advplyr
685b4e77eb Remove old viewMode code 2022-10-28 17:27:06 -05:00
advplyr
1a35def375 Update:default sorting ignore prefixes to just be the #869 2022-10-27 17:50:56 -05:00
advplyr
76d55e72df Update:Collections page book list show authors and update UI for mobile #943 2022-10-27 17:46:51 -05:00
advplyr
8127ee7e56 Fix:Debian preinst wget to specify output filename #1092 2022-10-26 17:13:56 -05:00
advplyr
efecf7ed82 Update:Podcast episode auto download schedule setting for max new episodes to download #1091 2022-10-26 16:55:16 -05:00
advplyr
ac46548c4d Fix:Comic reader for comics that have subfolders containing images #811 2022-10-25 17:49:08 -05:00
advplyr
40384dd442 Add:Podcast episode filters and default to filter out completed episodes #940 2022-10-24 17:57:08 -05:00
advplyr
05b4124761 Update comic reader to look for number up to 5 digits in filename for sorting 2022-10-23 11:48:00 -05:00
advplyr
e1e10dca50 Update:Default library view to detail instead of bookshelf view & update settings copy 2022-10-22 09:13:20 -05:00
advplyr
0e96465d74 Remove old coverAspectRatio server setting 2022-10-22 09:01:00 -05:00
advplyr
88e9dabaaa Update:fallback to comment meta tag for book descriptions 2022-10-22 08:37:56 -05:00
advplyr
d65ab0e35d Fix:Read pdf error by downgrading vue-pdf version to 4.2.0 2022-10-21 16:19:59 -05:00
advplyr
f55559e9a3 Add:Support for webm and webma audio files #1079 2022-10-20 17:24:51 -05:00
advplyr
4ea1e4460a Remove old library icons 2022-10-19 10:56:54 -05:00
advplyr
b16e69ee86 Update:New library icons and picker using icon font 2022-10-18 12:09:36 -05:00
advplyr
6b8d71c0b0 Add:absicons font to replace library icons 2022-10-17 18:02:25 -05:00
advplyr
cb762c97a8 Fix:Podcast parsing pubDate from RSS feed #1072 2022-10-16 16:24:05 -05:00
advplyr
77139c7256 Add:Support for shift selecting multiple library items #1020 2022-10-15 17:17:40 -05:00
advplyr
4cf43bc105 Fix:Local covers not showing in covers tab 2022-10-15 15:55:17 -05:00
advplyr
588b8ff209 Fix:Collection covers 2022-10-15 15:45:39 -05:00
advplyr
62a8301938 Readme update 2022-10-15 15:42:52 -05:00
advplyr
ce4e48cbd7 Add:Region support for audible chapter lookup 2022-10-15 15:31:07 -05:00
advplyr
067d90474b Add:Collapsed series finished progress bar #1062 2022-10-14 17:59:00 -05:00
advplyr
e0e69fb164 Fix book log 2022-10-13 18:01:21 -05:00
advplyr
365610d918 Fix:multi select dropdown items remove button #1055 2022-10-11 16:56:06 -05:00
advplyr
fdece944f4 Remove leftover string in chapter editor 2022-10-08 17:37:43 -05:00
advplyr
d7952dab04 Fix:Setting book chapters from audio files #1052 2022-10-08 17:32:46 -05:00
advplyr
bec599f325 Update:30s timeout for file downloading axios request #1050 2022-10-08 17:15:37 -05:00
advplyr
affcc03c61 Update debian service with audiobookshelf group 2022-10-08 17:12:23 -05:00
advplyr
db18c71857 Version bump 2.2.1 2022-10-08 16:48:20 -05:00
advplyr
dcc223949a Update:Add note for number of backups to keep #1041 2022-10-08 16:30:21 -05:00
advplyr
6a6d384d88 Update:Scanner folder name parse sequence starting with decimal and cast to number 2022-10-08 15:42:38 -05:00
advplyr
cd57667444 Fix:Library item edit modal clear loading indicator when changing tabs 2022-10-07 17:22:23 -05:00
advplyr
3900db14d3 Add:Multi-region audible & audnexus support #731 2022-10-07 17:18:28 -05:00
advplyr
1fa94cbfad Update tone to v0.1.1 2022-10-07 16:21:47 -05:00
advplyr
793233e782 Fix:Authors page to only include library items from the current library #1049 2022-10-06 17:06:06 -05:00
advplyr
94012e5dff Add:Chapter editor shift all chapters by X seconds #927 2022-10-05 18:01:42 -05:00
advplyr
d440a9fd6a Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-10-04 17:43:43 -05:00
advplyr
928c6cf5b3 Fix:iTunes returning artist names with & instead of all comma separated #1022 2022-10-04 17:41:26 -05:00
advplyr
23a25d420c Fix:Escape ebook URLs #1039 2022-10-04 17:29:26 -05:00
advplyr
dc779a3fc5 Merge pull request #1031 from Undergrid/Issue_1030
Fix a crash under certain circumstances when updating tags when quick… (Issue #1030)
2022-10-03 09:50:12 -05:00
Nick Thomson
876badbeea Fix a crash under certain circumstances when updating tags when quick matching. 2022-10-03 01:38:52 +01:00
advplyr
8563bdde74 Update:Podcast episode downloads using episode title as filename without prefixing episode num 2022-10-02 17:12:44 -05:00
advplyr
803c9699ef Version bump 2.2.0 2022-10-02 15:54:05 -05:00
advplyr
c254dc5144 Add:Button for testing scan probes in audiobook tracks table 2022-10-02 15:24:32 -05:00
advplyr
d22b475539 Update tools copy 2022-10-02 14:49:24 -05:00
advplyr
142205f060 Add:Purge items cache button and api endpoint 2022-10-02 14:46:48 -05:00
advplyr
02d997897c Add:Cancel m4b merge button #1008 2022-10-02 14:31:04 -05:00
advplyr
39979ff8a3 Add:Tasks widget in appbar for merging m4bs & remove old m4b merge routes 2022-10-02 14:16:17 -05:00
advplyr
441b8c5bb7 Update:M4b Merge tool moved to manage page 2022-10-02 11:53:53 -05:00
advplyr
d456ec2786 Fix:Local covers path for localhost 2022-10-02 10:07:24 -05:00
advplyr
a729ce1512 Fix:Metadata embed tool chapters list 2022-10-02 08:44:38 -05:00
advplyr
3949896d88 Fix:Disable multi select input and series input widget 2022-10-01 17:15:21 -05:00
advplyr
14e5e11344 Cleaned series match & renaming volumeNumber to sequence 2022-10-01 17:01:22 -05:00
advplyr
c23f31216a Fix:iTunes crash on matching genres #1025 2022-10-01 16:51:22 -05:00
advplyr
cd04533eea Update:Setting up paths to eventually support subdirectory 2022-10-01 16:07:30 -05:00
advplyr
6701551289 Fix:Ensure podcast library item folder exists before downloading episodes #1019 2022-09-30 16:55:31 -05:00
advplyr
1a4833f873 Add:Chapter editor lookup chapters and apply titles only #991 2022-09-29 18:06:13 -05:00
advplyr
3a7639f690 Update:Chapter editor lookup modal add color legend and style improvements #657 2022-09-29 17:55:45 -05:00
advplyr
63c55f08dc Add:Remove episodes from continue listening shelf #919 2022-09-28 17:57:27 -05:00
advplyr
98e79f144c Add:Remove item from continue listening shelf #919 2022-09-28 17:45:39 -05:00
advplyr
3b9236a7ce Fix:More menu item height 2022-09-28 17:14:20 -05:00
advplyr
ac30a971c5 Fix:Clean user data on server start removing invalid media progress items 2022-09-28 17:12:27 -05:00
advplyr
9ee6eaade9 Add:Hide series from home page option #919 2022-09-27 17:48:45 -05:00
advplyr
8c32fed911 Update:Match tab show current genres, tags and description #976 2022-09-27 16:49:14 -05:00
advplyr
f36a5eae6d Update:Audiobook merge to set metadata with tone and replace m4b in library item #594 2022-09-26 18:07:31 -05:00
advplyr
b7bdaac163 Fix:Trim whitespace when parsing audio file meta tags #997 2022-09-25 17:15:19 -05:00
advplyr
162a1b7971 Add:Purge media progress button & api endpoint for items that no longer exist #921 2022-09-25 17:11:39 -05:00
advplyr
97da73baf3 Update:Experimental metadata embed tool to use tone 2022-09-25 15:56:06 -05:00
advplyr
b6e3559aba Update:Notification config UI for mobile #996 2022-09-25 11:50:41 -05:00
advplyr
39a13e3610 Add:Notification system max queue and max failed attempts settings #996 2022-09-25 10:42:26 -05:00
advplyr
7aa89f16c9 Add:Notification system queueing and queue limit #996 2022-09-25 10:19:44 -05:00
advplyr
88726bed86 Update:Notification system descriptions #996 2022-09-25 09:46:45 -05:00
advplyr
a35b35c062 Merge pull request #1005 from Undergrid/multi_select_quick_match
Multi select quick match
2022-09-24 17:46:51 -05:00
Undergrid
951afaa568 Update client/components/modals/BatchQuickMatchModel.vue
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-09-24 23:40:07 +01:00
Undergrid
5e8979876f Update client/components/modals/BatchQuickMatchModel.vue
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-09-24 23:39:37 +01:00
Undergrid
eb0ef8c696 Update client/components/modals/BatchQuickMatchModel.vue
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-09-24 23:38:58 +01:00
Undergrid
066b6c13c6 Update client/components/modals/BatchQuickMatchModel.vue
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-09-24 23:38:51 +01:00
Undergrid
014ad668a5 Update server/controllers/LibraryItemController.js
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-09-24 23:38:44 +01:00
Undergrid
62c59c634c Update server/controllers/LibraryItemController.js
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-09-24 23:38:18 +01:00
Undergrid
f3f2d614b1 Update client/components/modals/BatchQuickMatchModel.vue
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-09-24 23:37:59 +01:00
Undergrid
7fd70c1c86 Update client/components/modals/BatchQuickMatchModel.vue
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-09-24 23:37:54 +01:00
Undergrid
46a3974b79 Update client/components/modals/BatchQuickMatchModel.vue
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-09-24 23:37:43 +01:00
advplyr
f851cde1f4 Merge pull request #1007 from Undergrid/Issue_1004
Issue 1004
2022-09-24 17:36:42 -05:00
advplyr
0f772fd3cf Update server/libs/nodeFfprobe/index.js 2022-09-24 17:36:29 -05:00
Nick Thomson
dd0d2e9f55 Fix tabs 2022-09-24 22:51:17 +01:00
Nick Thomson
022c506eda Possible fix for issue #1004 2022-09-24 22:50:21 +01:00
Nick Thomson
dd8577354b Fixing tabs again. 2022-09-24 22:20:49 +01:00
Nick Thomson
3e7a76574b Switch to using the websocket for confirmation of batch updates, allowing the main request to be done asynchronously 2022-09-24 22:17:36 +01:00
advplyr
0ef2a2e4b6 Update:Notifications onTest for testing and parse title/body template #996 2022-09-24 16:15:16 -05:00
advplyr
8e8046541e Add:Notification edit/delete and UI updates #996 2022-09-24 14:03:14 -05:00
Nick Thomson
2d6f9bab8b Added totals of updated and unmatched books to toast shown at completion of batch quick match. 2022-09-24 18:57:09 +01:00
Nick Thomson
11e3cf4f19 Initialise the selected provider to the default for the library when the batch quick match is first opened or if the user has switched libraries. 2022-09-24 18:23:33 +01:00
advplyr
37a3fdb606 Notifications UI update and delete endpoint 2022-09-23 18:10:03 -05:00
Nick Thomson
9983fe7d66 Fix another whitespace issue 2022-09-23 19:39:20 +01:00
Nick Thomson
731cf8e4ed Fix whitespace issues 2022-09-23 19:37:30 +01:00
Nick Thomson
c3f2e606dd Clarified behaviour of Update options in batch quick match dialog and added flag in quickMatchLibraryItem to override the default system settings 2022-09-23 18:53:30 +01:00
Nick Thomson
dbb62069ef Implementation of batch quick match API and related options dialog 2022-09-23 17:51:34 +01:00
advplyr
b08ad8785e Notification create/update events UI 2022-09-22 18:12:48 -05:00
advplyr
ff04eb8d5e Add:Notification settings, notification manager trigger #996 2022-09-21 18:01:10 -05:00
advplyr
9a7503cde2 Start adding notification manager 2022-09-20 18:08:41 -05:00
Nick Thomson
7d4e7ce2c0 Initial commit 2022-09-19 16:29:24 +01:00
advplyr
565bb4cd6b Update:Add author name to author quickmatch toast #992 2022-09-18 17:02:19 -05:00
advplyr
be592a04d0 Update:Author names ignore periods when checking for existing authors #993 2022-09-18 16:58:20 -05:00
advplyr
ae4ac392c6 Add:Podcasts latest episodes page 2022-09-17 15:23:33 -05:00
advplyr
f6b6c0a41e Add:API endpoint for podcasts to get most recent unfinished episodes for all podcasts in the library 2022-09-16 16:59:16 -05:00
advplyr
83e4a8f4ed Add .vscode settings.json 2022-09-16 13:38:21 -05:00
advplyr
70ef09f451 Add:Podcast quickmatch attempts quick matching unmatched episodes #983 2022-09-15 18:35:56 -05:00
advplyr
b91b320006 Update:Sync progress request timeout to 3s 2022-09-13 16:50:27 -05:00
advplyr
d139fffa96 Update:Backup Apply to Restore #981 2022-09-12 16:55:59 -05:00
advplyr
845fc0794e Fix debian FFPROBE_PATH 2022-09-11 16:57:36 -05:00
advplyr
ac6c885878 Update debian preinst to add TONE_PATH variable if not in existing config 2022-09-11 16:55:33 -05:00
advplyr
b2b5111c50 Fix TONE_PATH in toneProber 2022-09-11 16:42:28 -05:00
advplyr
e11629a161 Fix:.ignore files not working inside library item subdirs #979 2022-09-11 16:22:07 -05:00
advplyr
ff2fb2b2ba Add: tone download in debian packager 2022-09-11 16:05:53 -05:00
advplyr
b9a9c0e717 Revert sample docker-compose 2022-09-11 15:36:32 -05:00
advplyr
c16e6d19ae Add:Experimental tone library for scanning metadata 2022-09-11 15:35:06 -05:00
advplyr
0e98620939 Remove back arrow on toolbar 2022-09-10 09:10:29 -05:00
advplyr
e32f51f58a Fix:Add podcast modal for mobile screen sizes #975 2022-09-09 17:40:06 -05:00
advplyr
1ec12a547e Merge pull request #974 from Zibbp/master
Persist Volume in Local Storage
2022-09-08 16:51:05 -05:00
Zibbp
baedced83f feat(player): persist volume in local storage 2022-09-08 10:02:40 -05:00
advplyr
174decf8da Version bump 2.1.5 2022-09-05 15:45:44 -05:00
advplyr
0700f12896 Fix:Podcast episode sort by published at 2022-09-03 08:31:37 -05:00
advplyr
3dc848a106 Update:Podcast episodes look for new episodes after this date add input to set the max # of episodes to download 2022-09-03 08:06:52 -05:00
advplyr
c17612a233 Merge pull request #961 from barrycarey/issue-694-clear-issues
Pass lib ID to toolbar so it can refresh state when clearing issues.
2022-09-02 17:58:01 -05:00
advplyr
7313d151f8 Fix:Remove token secret from PPA build 2022-09-02 17:54:40 -05:00
advplyr
97dc9fbccf Update debian default port to 13378 2022-09-02 17:53:43 -05:00
advplyr
9a87e4af73 Add:Quick match podcast button 2022-09-02 17:50:09 -05:00
barry
4ccb4243f7 Pass lib ID to toolbar so it can refresh state when clearing issues. Fixes #694 2022-09-02 18:20:38 -04:00
barry
eb25ca7af5 Pass lib ID to toolbar so it can refresh state when clearing issues. Fixes #694 2022-09-01 21:03:41 -04:00
advplyr
872d5178e6 Update podcast episode queue order 2022-09-01 17:11:57 -05:00
advplyr
d11501b2c6 Remove add to collection menu item from podcast cards 2022-09-01 16:24:17 -05:00
advplyr
7e05804bcf Update:Lock file update scans from watcher and queue file updates so that 2 watcher scans never occur simultaneously #906 2022-08-31 17:39:02 -05:00
advplyr
a73b72a07b Fix:No Series filter on book library #956 2022-08-31 16:45:50 -05:00
advplyr
8ec4bd4279 Fix:User permissions for collection API routes and UI #951 2022-08-31 15:46:10 -05:00
advplyr
e362456895 Update:Reverse order for audiobook RSS feed episodes #952 2022-08-31 15:14:33 -05:00
advplyr
8cd7de25ad Merge pull request #955 from barrycarey/issue-929-html-char-parsing
Ability to decode HTML Entities when all tags are stripped. Fixes #929
2022-08-31 15:08:19 -05:00
barry
99ea7866c5 Optional match on ending ; 2022-08-30 21:15:18 -04:00
barry
3194b4cd87 Ability to decode HTML Entities when all tags are stripped. Fixes #929 2022-08-30 19:20:35 -04:00
advplyr
149f52b33c Update:Scrollbar width and color for Firefox #950 2022-08-29 16:55:32 -05:00
advplyr
575ec9d00b Fix:Update library item RSS feed if item was updated #939 2022-08-28 15:41:51 -05:00
advplyr
40e999fcae Fix:Chapter page navigating away while playing chapter does not stop audio #945 2022-08-28 15:11:14 -05:00
advplyr
ac57b2b867 Fix:Re-Scan item using context menu on library page #948 2022-08-28 15:04:45 -05:00
advplyr
3cafa87eda Add:Podcast episode table batch mark as finished #941 2022-08-28 14:47:31 -05:00
advplyr
dee4ca3559 Add:Local setting for autoplay next item in queue #603 2022-08-28 14:21:28 -05:00
advplyr
772c7b3217 Set podcast episode queue when playing from home page 2022-08-28 13:54:14 -05:00
advplyr
c0dd58a94e Add:Player queue for podcast episodes & autoplay next episode #603 2022-08-28 13:12:38 -05:00
advplyr
91e116969a Merge pull request #946 from barrycarey/issue-138-clear-episode-dl-selection
Clear selectedEpisodes on download.
2022-08-28 08:49:17 -05:00
advplyr
1f37e32f91 Podcast episode feed modal refactor 2022-08-28 08:48:41 -05:00
barry
221061ea30 added method to clear selected episodes 2022-08-28 08:22:51 -04:00
barry
1e8e45431d Clear selectedEpisodes on download. Move item onClick to checkbox so you can't select existing downloads 2022-08-27 23:40:02 -04:00
advplyr
381a81e4bb Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-08-27 17:28:06 -05:00
advplyr
be28b9899e Update:Audio player does not open on load 2022-08-27 17:27:55 -05:00
advplyr
37ca139195 Merge pull request #942 from ronaldheft/patch-1
Fix currentTime not updating on the local session
2022-08-26 19:43:12 -05:00
Ron Heft
6b02779e0f Fix currentTime not updating on the local session 2022-08-26 20:28:41 -04:00
advplyr
ff6d95dc4d Remove unused card 2022-08-24 08:17:04 -05:00
advplyr
e611d7a8fd Update:Local session sync lock to prevent duplicate inserts 2022-08-23 18:10:06 -05:00
advplyr
67f6cd3c56 Fix:Search page tags category items 2022-08-23 16:07:53 -05:00
advplyr
d0ab13865c Version bump 2.1.4 2022-08-20 19:37:31 -05:00
advplyr
33ae93e61e Fix:Add new podcast crash #920 2022-08-20 19:32:37 -05:00
advplyr
3b961c424f Version bump 2.1.3 2022-08-20 16:57:36 -05:00
advplyr
389b603d7d Update:Show episode rss feed url at the bottom of episode edit modal 2022-08-20 16:38:08 -05:00
advplyr
721de0a343 Remove old square covers config 2022-08-20 16:13:55 -05:00
advplyr
0aadf579f3 Update:Backups include author images #781 2022-08-20 15:10:31 -05:00
advplyr
4ec217e5d0 Fix:App bar for mobile screen, UI updates for schedule tabs 2022-08-20 14:32:38 -05:00
advplyr
0f01f21a0a Update:Remove auto download checkbox from edit podcast details tab, move max episodes to keep input to the schedule tab 2022-08-20 14:21:58 -05:00
advplyr
46668854ad Add:Schedule podcast new episode checks 2022-08-19 18:41:58 -05:00
advplyr
a690dfe671 Clear published year sort for podcast library 2022-08-19 15:07:47 -05:00
advplyr
7528e8df41 Add:Sort library by published year #918 2022-08-19 14:16:25 -05:00
advplyr
8224ca7650 Add:Set schedule for automatic backups #822 2022-08-18 18:46:42 -05:00
advplyr
a574d06e22 Update cron expression builder add daily interval in dropdown 2022-08-18 17:56:52 -05:00
advplyr
dd9a072231 Update:Cron scheduler set minutes/hourly interval, update mobile screen sizes #655 2022-08-17 19:19:01 -05:00
advplyr
2304f37cbe Add:Schedule periodic library scans #655 2022-08-17 18:44:21 -05:00
advplyr
0c20988e18 Add:Cron expression builder advanced view 2022-08-17 17:37:20 -05:00
advplyr
9a57fcad40 Add start of library scan scheduling and cron expression builder 2022-08-16 18:24:47 -05:00
advplyr
01333b6401 Update:Match tab select all/select none checkbox 2022-08-15 18:00:11 -05:00
advplyr
8509ca3249 Update:Show cover image on match tab after selecting match #899 2022-08-15 17:44:58 -05:00
advplyr
7a69afdcd9 Add:Podcast auto-download option to delete an episode if it exceeds X max episodes to keep #903 2022-08-15 17:35:13 -05:00
advplyr
2c0c53bbf1 Add:Multi-select for podcast episodes and batch delete, Update:Episode row ui for mobile screens 2022-08-14 12:34:21 -05:00
advplyr
9f200ece99 Add:API endpoint to get continue listening items across all libraries for android auto 2022-08-14 10:24:41 -05:00
advplyr
c5f91ec508 Add:Separate setting for alt bookshelf view on home page 2022-08-13 18:18:42 -05:00
advplyr
d06c61b329 Add:Library specific setting for use square covers and remove from server settings #387 2022-08-13 13:56:37 -05:00
advplyr
be4f11a60e Update:Edit library modal styling 2022-08-13 12:44:43 -05:00
advplyr
0c5db214d1 Add:Delete playback session button and api route 2022-08-13 12:24:19 -05:00
advplyr
1ad9ea92b6 Update:OPF parser return array of authors and narrators without attempting to parse names #907 2022-08-12 17:30:05 -05:00
advplyr
d15120eb5f Fix:Audible match results incorrect runtime format #904 2022-08-12 16:56:34 -05:00
advplyr
b9deb32b20 Update:Book item page show subtitle under title and series above author #898 2022-08-07 10:38:13 -05:00
advplyr
dd2d61f38e Version bump 2.1.2 2022-08-06 17:05:23 -05:00
advplyr
ca2c2f2702 Fix:Episode match encode title query string 2022-08-06 16:51:25 -05:00
advplyr
1fc929ab33 Update:Min width on libraries dropdown 2022-08-06 16:41:26 -05:00
advplyr
f5495d64a9 Update:Show resolution under covers in covers tab #885 2022-08-06 08:25:31 -05:00
advplyr
d6afb17bf2 Fix:Library stats page overflowing under side nav #896 2022-08-06 08:10:12 -05:00
advplyr
2cba9d8f4a Fix:Finished items showing yellow bar on item page 2022-08-06 08:00:28 -05:00
advplyr
e02169907d Add:Filter for RSS feeds open #893 2022-08-06 07:58:19 -05:00
advplyr
24a142e718 Add:RSS feed icon over library item covers when feed is open #893 2022-08-05 19:23:18 -05:00
advplyr
2cb4f972d7 Update:Collections page show total duration 2022-08-04 18:22:35 -05:00
advplyr
513d946faa Update:Updates to collections page for mobile screen sizes 2022-08-04 18:06:39 -05:00
advplyr
87d1f457ba Update:Editing series on book in edit modal sets focus to first available input #889 2022-08-03 18:38:08 -05:00
advplyr
8810f90226 Update:Cleanup edit modal match tab for mobile screens 2022-08-03 18:27:38 -05:00
advplyr
3d3571013f Add:Show audiobook duration and narrator in audible match results #886 2022-08-03 18:06:25 -05:00
advplyr
605a6d8b25 Update:Match tab searches for results when opening tab #882 2022-08-03 17:53:19 -05:00
advplyr
1bfa4b31f2 Fix:Audiobook RSS feed offset pubDate for each track to ensure correct order #888 2022-08-03 17:01:28 -05:00
advplyr
7a14b49aea Fix:Continue series shelf showing in reverse order 2022-08-02 16:41:52 -05:00
advplyr
95ac74d748 Fix:Overdrive chapter parser crash server on invalid meta data #880 2022-08-01 18:28:56 -05:00
advplyr
fddf850a41 Add:Cron validation api endpoint 2022-08-01 18:06:22 -05:00
advplyr
d93d4f3236 Update:Auto-download new podcast episode check max failed attempts to 24 2022-07-31 14:00:17 -05:00
advplyr
91f15d5a23 Update:Scanned in podcast episodes remove file ext from title 2022-07-31 13:52:34 -05:00
advplyr
516c5c3308 Add:Podcast episode match tab and find episode by title api route 2022-07-31 13:12:37 -05:00
advplyr
f702c02859 Add click to play session at timestamp in users playback sessions table 2022-07-30 18:20:15 -05:00
advplyr
ad88de0571 Version bump 2.1.1 2022-07-30 17:14:59 -05:00
advplyr
b64a651b27 Update:Series page support sort ignore prefix #866 2022-07-30 17:07:54 -05:00
advplyr
06b8d1194c Fix:Library collapsed series to respect ignore prefixes setting #866 2022-07-30 16:18:26 -05:00
advplyr
377ae7ab19 Update /matchall api endpoint to GET 2022-07-30 15:52:13 -05:00
advplyr
53cf6edd6a Update:Show prompt before marking item as finished that has progress #805 2022-07-30 12:40:43 -05:00
advplyr
92bedeac15 Update:Click chapter times in chapters table to jump to timestamp 2022-07-30 12:25:15 -05:00
advplyr
3cf8b9dca9 Update start playback from bookmark time confirm to new confirm prompt 2022-07-30 11:53:48 -05:00
advplyr
bcc2f847f9 Add:Click timestamp in listening sessions table to open playback at timestamp #798, add confirm prompt 2022-07-30 11:36:04 -05:00
advplyr
f1421f351b Merge pull request #871 from arabshapt/feature/use-svg-instead-of-png
feature: use svg instead of png where possible for better quality
2022-07-30 08:45:20 -05:00
advplyr
ed23feaf3f Fix typo Received Ping 2022-07-30 08:37:35 -05:00
arabshapt
668ebf8550 feature: use svg instead of png where possible for better quality 2022-07-30 13:58:20 +02:00
advplyr
a8c7905f6d Add:Bookmarks icon btn on library item page and ability to open player at specified time #796 2022-07-29 19:06:52 -05:00
advplyr
45cd39ac0c Update:Remain on same tab in item edit modal when navigating next/prev #867 2022-07-29 18:19:19 -05:00
advplyr
21e1f62c65 Fix:Merging chapters from multiple files skipping chapters #857 2022-07-29 18:15:41 -05:00
advplyr
8416f2d6be Fix:Remove invalid playback sessions on server start #868 2022-07-29 17:13:46 -05:00
advplyr
3b4ac3a230 Fix:Long library names overflow #858 2022-07-26 18:54:32 -05:00
advplyr
6244909332 Update:Timestamp input support over 99 hours and focus input #657 2022-07-25 19:32:04 -05:00
advplyr
5db949e4a7 Update:Save after editing chapters redirects to previous page #827 2022-07-25 18:57:00 -05:00
advplyr
c453d3e8c7 Update:Chapter editor to use timestamp input for chapter start time with toggle to show seconds #657 2022-07-25 18:40:11 -05:00
advplyr
9d7ffdfcd0 Update docker file healthcheck to use /healthcheck instead of /ping 2022-07-24 15:46:19 -05:00
advplyr
976427b0b3 Fix:Set correct mime type for m4b file static requests 2022-07-24 13:32:05 -05:00
advplyr
6cbfd8679b Update:Changing author name to match another authors name will merge the authors #487 2022-07-24 12:00:36 -05:00
advplyr
217bbb4a8e Merge pull request #842 from revilo951/master
Add restart and fix volumes
2022-07-20 17:49:43 -05:00
advplyr
9916a1e8f6 Fix:Watcher scanner to ignore non-media files that are not inside library item folders #834 2022-07-19 08:33:32 -05:00
advplyr
372101592c Version bump 2.1.0 2022-07-18 18:41:51 -05:00
advplyr
18123664ee Fix:RSS Feed cover, Update:Remove experimental scanner 2022-07-18 18:39:51 -05:00
advplyr
2e6e4f970c Remove comments from scanner 2022-07-18 18:17:50 -05:00
advplyr
1c9e56ce2e Fix:Libraries table to use draggable handle for items so they can be clicked and edited #839 2022-07-18 17:44:01 -05:00
advplyr
9e7b84f289 Update:JWT signing 2022-07-18 17:19:16 -05:00
revilo951
7b83ab8970 Add restart and fix volumes
Added `restart: unless-stopped`
Adjust volumes - probably shouldn't be scattering the volumes around the root dir?
https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3
2022-07-18 10:12:02 +10:00
advplyr
86ee4dcff2 Update:Scanner adjustable number of parallel audio probes to use less CPU 2022-07-16 18:54:34 -05:00
advplyr
277a5fa37c Version bump 2.0.24 2022-07-14 19:51:33 -05:00
advplyr
51b87912f8 Update:Collapsed series shows series name instead of first book title and only sorts when sorting by title #629 2022-07-14 19:00:52 -05:00
advplyr
653019921e Add:Support for OGA file extension #804, Update:Mime type for m4b and m4a to audio/mp4 2022-07-14 18:32:00 -05:00
advplyr
ccc291067d Fix:Truetype fonts format 2022-07-14 18:06:37 -05:00
advplyr
af7e3a03f0 Fix:Audio player when using chapter track then playing an item without chapters 2022-07-13 19:38:34 -05:00
advplyr
7c40d26857 Fix:Sync local mobile app progress replacing local media progress id causing duplicate media progress in mobile 2022-07-13 19:18:49 -05:00
advplyr
6c507de501 Remove client marked dependency 2022-07-12 16:21:32 -05:00
advplyr
482a4340f5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-07-12 16:19:54 -05:00
advplyr
21e704e12c Fix:Show toasts below appbar & toolbar #819 2022-07-12 16:11:23 -05:00
advplyr
2b91bff1af Fix:Ordering newly scanned in audio tracks properly #823 2022-07-12 15:02:08 -05:00
advplyr
d11f9608b4 Remove old audio file scanner 2022-07-12 14:35:43 -05:00
advplyr
2b0b691b69 Merge pull request #821 from jmt-gh/whats_new_modal
Add ability to view current version's changelog from within ABS
2022-07-09 17:32:33 -05:00
advplyr
5dfd5c4971 Remove marked dependency 2022-07-09 17:29:30 -05:00
advplyr
201f1bff3e Add marked package in static libs 2022-07-09 17:27:30 -05:00
jmt-gh
a22ebb257f add style comments 2022-07-08 20:45:09 -07:00
jmt-gh
bf6e87d4bc update formatting 2022-07-08 20:34:32 -07:00
jmt-gh
b823a93ae2 integrate modal to sidenavs 2022-07-08 20:29:18 -07:00
jmt-gh
05afd12682 add new changelog modal 2022-07-08 20:28:34 -07:00
jmt-gh
997e23150e extract current version changelog information 2022-07-08 20:26:30 -07:00
advplyr
3c5bf376b5 Remove archiver-utils dependency 2022-07-08 18:11:09 -05:00
advplyr
bca2cfda13 Update:Remove log listener for root user & only set when on logger config page 2022-07-07 17:25:52 -05:00
advplyr
916b41d587 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-07-06 20:12:22 -05:00
advplyr
ab08d83c04 Remove archiver dependency 2022-07-06 20:12:14 -05:00
advplyr
415e0a7b5a Remove dependency date-and-time 2022-07-06 19:18:27 -05:00
advplyr
d301c12acd Remove dependency express-rate-limit 2022-07-06 19:14:47 -05:00
advplyr
7aa7e662b2 Remove dependency express-fileupload 2022-07-06 19:10:25 -05:00
advplyr
1dbfb5637a Remove bcryptjs dependency 2022-07-06 19:01:27 -05:00
advplyr
4e1aacb44f Remove command-line-args dependency 2022-07-06 18:56:13 -05:00
advplyr
954cf3e14e Remove jsonwebtoken dependency 2022-07-06 18:45:43 -05:00
advplyr
b61ecefce4 Remove fluent-ffmpeg dependency 2022-07-06 17:38:19 -05:00
advplyr
8562b8d1b3 Remove node-ffprobe dependency 2022-07-06 17:07:08 -05:00
advplyr
06ec2159f5 Update bug.yaml 2022-07-06 07:18:03 -05:00
advplyr
68b565505e Update bug.yaml 2022-07-06 07:13:39 -05:00
advplyr
83ff2752dd Update bug.yaml 2022-07-06 07:10:27 -05:00
advplyr
d0af1c3c9a Remove fs-extra dependency 2022-07-05 19:53:01 -05:00
advplyr
1ad46d4fb8 Add missing dependency license files 2022-07-05 19:33:43 -05:00
advplyr
d3dd13eae5 Remove node-stream-zip dependency 2022-07-05 19:24:16 -05:00
advplyr
f27982d887 Update:Default backup schedule to 1:30 to avoid conflict with new episode checks #761 2022-07-05 17:38:17 -05:00
advplyr
624a44f572 Fix:Quick match split multiple comma separated authors #808 2022-07-05 17:26:14 -05:00
advplyr
e623bf7fde Remove rra dependency 2022-07-04 19:19:38 -05:00
advplyr
6fc70b8656 Remove LibGen provider and package 2022-07-04 19:14:52 -05:00
advplyr
354cefb9f4 Update:Update isFile flag on check scan data for library items 2022-07-03 15:04:41 -05:00
advplyr
a78aa88dbc Fix:Audiobooks incorrectly flagged as single file root audiobooks #714 2022-07-03 14:41:07 -05:00
advplyr
9ac2453676 Merge pull request #802 from jmt-gh/getMediaProgress_fix
fix getMediaProgress always returning 404
2022-07-03 12:20:24 -05:00
advplyr
bb70800b4e Merge pull request #801 from mcdinner/missing-cache-directory
Remove cachePathExists property (Issue #800)
2022-07-03 12:19:51 -05:00
jmt-gh
855272a558 fix getMediaProgress not returning properly 2022-07-03 10:15:40 -07:00
mcdinner
ebb2c5f791 Remove cachePathExists property (Issue #800)
Remove cachePathsExist property to ensure missing cache directories are recreated when EnsureCachePaths() called.
2022-07-03 16:35:12 +02:00
advplyr
2e466bb164 Update tailwindcss 2022-07-02 20:02:43 -05:00
506 changed files with 47366 additions and 5334 deletions

View File

@@ -9,6 +9,12 @@ body:
- type: markdown
attributes:
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
- type: markdown
attributes:
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
- type: markdown
attributes:
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
- type: textarea
id: what-happened
attributes:
@@ -27,6 +33,7 @@ body:
id: version
attributes:
label: Audiobookshelf version
description: Do not put 'Latest version', please put the actual version here
placeholder: "e.g. v1.6.60"
validations:
required: true

1
.gitignore vendored
View File

@@ -11,7 +11,6 @@ test/
/client/.nuxt/
/client/dist/
/dist/
library/
sw.*
.DS_STORE

20
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"vetur.format.defaultFormatterOptions": {
"prettier": {
"semi": false,
"singleQuote": true,
"printWidth": 400,
"proseWrap": "never",
"trailingComma": "none"
},
"prettyhtml": {
"printWidth": 400,
"singleQuote": false,
"wrapAttributes": false,
"sortAttributes": false
}
},
"editor.formatOnSave": true,
"editor.detectIndentation": true,
"editor.tabSize": 2
}

View File

@@ -6,7 +6,9 @@ RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###
FROM sandreas/tone:v0.1.2 AS tone
FROM node:16-alpine
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
@@ -14,6 +16,7 @@ RUN apk update && \
tzdata \
ffmpeg
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist
COPY index.js package* /
COPY server server
@@ -25,5 +28,5 @@ HEALTHCHECK \
--interval=30s \
--timeout=3s \
--start-period=10s \
CMD curl -f http://127.0.0.1/ping || exit 1
CMD curl -f http://127.0.0.1/healthcheck || exit 1
CMD ["npm", "start"]

View File

@@ -5,11 +5,9 @@ set -o pipefail
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
CONFIG_PATH="/etc/default/audiobookshelf"
DEFAULT_PORT=7331
DEFAULT_PORT=13378
DEFAULT_HOST="0.0.0.0"
add_user() {
: "${1:?'User was not defined'}"
declare -r user="$1"
@@ -51,7 +49,8 @@ add_group() {
install_ffmpeg() {
echo "Starting FFMPEG Install"
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.2/tone-0.1.2-linux-x64.tar.gz --output-document=tone-0.1.2-linux-x64.tar.gz"
if ! cd "$FFMPEG_INSTALL_DIR"; then
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
@@ -64,13 +63,26 @@ install_ffmpeg() {
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
rm ffmpeg-git-amd64-static.tar.xz
echo "Good to go on Ffmpeg... hopefully"
# Temp downloading tone library to the ffmpeg dir
echo "Getting tone.."
$WGET_TONE
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
rm tone-0.1.2-linux-x64.tar.gz
echo "Good to go on Ffmpeg (& tone)... hopefully"
}
setup_config() {
if [ -f "$CONFIG_PATH" ]; then
echo "Existing config found."
cat $CONFIG_PATH
# TONE_PATH variable added in 2.1.6, if it doesnt exist then add it
if ! grep -q "TONE_PATH" "$CONFIG_PATH"; then
echo "Adding TONE_PATH to existing config"
echo "TONE_PATH=$FFMPEG_INSTALL_DIR/tone" >> "$CONFIG_PATH"
fi
else
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
@@ -83,11 +95,12 @@ setup_config() {
echo "Creating default config."
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
CONFIG_PATH=$DEFAULT_DATA_DIR/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$DEFAULT_PORT
HOST=$DEFAULT_HOST"
CONFIG_PATH=$DEFAULT_DATA_DIR/config
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
PORT=$DEFAULT_PORT
HOST=$DEFAULT_HOST"
echo "$config_text"

View File

@@ -10,6 +10,7 @@ ExecStart=/usr/share/audiobookshelf/audiobookshelf
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
User=audiobookshelf
Group=audiobookshelf
PermissionsStartOnly=true
[Install]

106
client/assets/absicons.css Normal file
View File

@@ -0,0 +1,106 @@
@font-face {
font-family: 'absicons';
src: url('~static/fonts/absicons/absicons.eot?2jfq33');
src: url('~static/fonts/absicons/absicons.eot?2jfq33#iefix') format('embedded-opentype'),
url('~static/fonts/absicons/absicons.ttf?2jfq33') format('truetype'),
url('~static/fonts/absicons/absicons.woff?2jfq33') format('woff'),
url('~static/fonts/absicons/absicons.svg?2jfq33#absicons') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
.abs-icons {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'absicons' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-books-1:before {
content: "\e905";
}
.icon-microphone-1:before {
content: "\e902";
}
.icon-radio:before {
content: "\e903";
}
.icon-podcast:before {
content: "\e904";
}
.icon-audiobookshelf:before {
content: "\e900";
}
.icon-database:before {
content: "\e906";
}
.icon-microphone-2:before {
content: "\e901";
}
.icon-headphones:before {
content: "\e910";
}
.icon-music:before {
content: "\e911";
}
.icon-video:before {
content: "\e914";
}
.icon-microphone-3:before {
content: "\e91e";
}
.icon-book-1:before {
content: "\e91f";
}
.icon-books-2:before {
content: "\e920";
}
.icon-file-picture:before {
content: "\e927";
}
.icon-database-1:before {
content: "\e964";
}
.icon-rocket:before {
content: "\e9a5";
}
.icon-power:before {
content: "\e9b5";
}
.icon-star:before {
content: "\e9d9";
}
.icon-heart:before {
content: "\e9da";
}
.icon-rss:before {
content: "\ea9b";
}

View File

@@ -2,6 +2,7 @@
@import './transitions.css';
@import './draggable.css';
@import './defaultStyles.css';
@import './absicons.css';
:root {
--bookshelf-texture-img: url(/textures/wood_default.jpg);
@@ -22,6 +23,10 @@
#bookshelf {
height: calc(100% - 40px);
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
/* For Firefox */
scrollbar-width: thin;
scrollbar-color: #855620 rgba(0, 0, 0, 0);
}
.bookshelf-row {
@@ -225,4 +230,18 @@ Bookshelf Label
-webkit-line-clamp: 2;
/* number of lines to show */
-webkit-box-orient: vertical;
}
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
padding-top: 104px;
}
.app-bar .Vue-Toastification__container.top-right {
padding-top: 64px;
}
.no-bars .Vue-Toastification__container.top-right {
padding-top: 8px;
}

View File

@@ -1,34 +1,40 @@
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
background-color: rgba(255, 255, 255, 0.25);
}
.list-group {
min-height: 30px;
}
#librariesTable .item {
cursor: n-resize;
}
.drag-handle {
cursor: n-resize;
}
.list-group-item:not(.exclude) {
cursor: n-resize;
}
.list-group-item.exclude {
cursor: not-allowed;
}
.list-group-item:not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
background-color: rgba(0, 0, 0, 0.25);
}
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
@@ -36,6 +42,7 @@
.list-group-item.exclude:not(.ghost) {
background-color: rgba(255, 0, 0, 0.25);
}
.list-group-item.exclude:not(.ghost):hover {
background-color: rgba(223, 0, 0, 0.25);
}

View File

@@ -2,14 +2,14 @@
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/fonts/MaterialIcons.woff2) format('woff2');
src: url(~static/fonts/MaterialIcons.woff2) format('woff2');
}
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-weight: 400;
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
src: url(~static/fonts/MaterialIconsOutlined.woff2) format('woff2');
}
.material-icons {
@@ -54,7 +54,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -64,7 +64,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -74,7 +74,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -84,7 +84,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -94,7 +94,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
@@ -104,7 +104,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
@@ -114,7 +114,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
@@ -124,7 +124,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -134,7 +134,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -144,7 +144,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -154,7 +154,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -164,7 +164,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
@@ -174,7 +174,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
@@ -184,7 +184,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
@@ -194,7 +194,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -204,7 +204,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -214,7 +214,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -224,7 +224,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -234,7 +234,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
@@ -244,7 +244,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
@@ -254,7 +254,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
@@ -264,7 +264,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -274,7 +274,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -284,7 +284,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -294,7 +294,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -304,7 +304,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
@@ -314,7 +314,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
@@ -324,7 +324,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -334,6 +334,6 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -3,19 +3,19 @@
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
<div class="flex h-full items-center">
<nuxt-link to="/">
<img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
<img src="~static/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
</nuxt-link>
<nuxt-link to="/">
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
</nuxt-link>
<ui-libraries-dropdown />
<ui-libraries-dropdown class="mr-2" />
<controls-global-search v-if="currentLibrary" class="" />
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
<div class="flex-grow" />
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
<widgets-notification-widget class="hidden md:block" />
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
@@ -49,6 +49,9 @@
<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 v-if="userIsAdminOrUp && !isPodcastLibrary" text="Quick Match Selected" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
</ui-tooltip>
<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>
@@ -150,7 +153,7 @@ export default {
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection')
this.$eventBus.$emit('bookshelf_clear_selection')
this.isAllSelected = false
},
toggleBatchRead() {
@@ -158,7 +161,7 @@ export default {
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
return {
id: lid,
libraryItemId: lid,
isFinished: newIsFinished
}
})
@@ -169,7 +172,7 @@ export default {
this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection')
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error('Batch update failed')
@@ -192,7 +195,7 @@ export default {
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection')
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error('Batch delete failed')
@@ -210,6 +213,9 @@ export default {
},
setBookshelfTotalEntities(totalEntities) {
this.totalEntities = totalEntities
},
batchAutoMatchClick() {
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
}
},
mounted() {

View File

@@ -16,10 +16,10 @@
<!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
<template v-for="(shelf, index) in shelves">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
</widgets-item-slider>
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
</widgets-episode-slider>
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
@@ -33,7 +33,7 @@
<!-- Regular bookshelf view -->
<div v-else class="w-full">
<template v-for="(shelf, index) in shelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
</template>
</div>
</div>
@@ -54,7 +54,8 @@ export default {
keywordFilterTimeout: null,
scannerParseSubtitle: false,
wrapperClientWidth: 0,
shelves: []
shelves: [],
lastItemIndexSelected: -1
}
},
computed: {
@@ -70,11 +71,8 @@ export default {
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
bookshelfView() {
return this.$store.getters['getServerSetting']('bookshelfView')
},
isAlternativeBookshelfView() {
return this.bookshelfView === this.$constants.BookshelfView.TITLES
return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.DETAIL
},
bookCoverWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
@@ -82,20 +80,72 @@ export default {
return coverSize
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
isCoverSquareAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
},
bookCoverAspectRatio() {
return this.isCoverSquareAspectRatio ? 1 : 1.6
return this.coverAspectRatio == 1
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.bookCoverWidth / baseSize
},
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems || []
}
},
methods: {
selectEntity({ entity, shiftKey }, shelfIndex) {
const shelf = this.shelves[shelfIndex]
const entityShelfIndex = shelf.entities.findIndex((ent) => ent.id === entity.id)
const indexOf = shelf.shelfStartIndex + entityShelfIndex
const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedLibraryItems.includes(entity.id)) {
this.lastItemIndexSelected = indexOf
} else {
this.lastItemIndexSelected = -1
}
if (shiftKey && lastLastItemIndexSelected >= 0) {
var loopStart = indexOf
var loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected
loopEnd = indexOf
}
const flattenedEntitiesArray = []
this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities))
var isSelecting = false
// If any items in this range is not selected then select all otherwise unselect all
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = flattenedEntitiesArray[i]
if (thisEntity) {
if (!this.selectedLibraryItems.includes(thisEntity.id)) {
isSelecting = true
break
}
}
}
if (isSelecting) this.lastItemIndexSelected = indexOf
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = flattenedEntitiesArray[i]
if (thisEntity) {
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting })
} else {
console.error('Invalid entity index', i)
}
}
} else {
this.$store.commit('toggleLibraryItemSelected', entity.id)
}
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', entity)
})
},
async init() {
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
@@ -116,6 +166,12 @@ export default {
console.error('Failed to fetch categories', error)
return []
})
let totalEntityCount = 0
for (const shelf of categories) {
shelf.shelfStartIndex = totalEntityCount
totalEntityCount += shelf.entities.length
}
this.shelves = categories
},
async setShelvesFromSearch() {
@@ -181,7 +237,6 @@ export default {
}
this.shelves = shelves
},
settingsUpdated(settings) {},
scan() {
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
@@ -193,6 +248,15 @@ export default {
this.$toast.error('Failed to start scan')
})
},
userUpdated(user) {
if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) {
this.removeAllSeriesFromContinueSeries(user.seriesHideFromContinueListening)
}
if (user.mediaProgress.length) {
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
this.removeItemsFromContinueListening(mediaProgressToHide)
}
},
libraryItemAdded(libraryItem) {
console.log('libraryItem added', libraryItem)
// TODO: Check if libraryItem would be on this shelf
@@ -250,6 +314,45 @@ export default {
this.libraryItemUpdated(li)
})
},
removeAllSeriesFromContinueSeries(seriesIds) {
this.shelves.forEach((shelf) => {
if (shelf.type == 'book' && shelf.id == 'continue-series') {
// Filter out series books from continue series shelf
shelf.entities = shelf.entities.filter((ent) => {
if (ent.media.metadata.series && seriesIds.includes(ent.media.metadata.series.id)) return false
return true
})
}
})
},
removeItemsFromContinueListening(mediaProgressItems) {
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
if (continueListeningShelf) {
if (continueListeningShelf.type === 'book') {
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id)) return false
return true
})
} else if (continueListeningShelf.type === 'episode') {
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
if (!ent.recentEpisode) return true // Should always have this here
if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id && mp.episodeId === ent.recentEpisode.id)) return false
return true
})
}
}
// this.shelves.forEach((shelf) => {
// if (shelf.id == 'continue-listening') {
// if (shelf.type == 'book') {
// // Filter out books from continue listening shelf
// shelf.entities = shelf.entities.filter((ent) => {
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
// return true
// })
// }
// }
// })
},
authorUpdated(author) {
this.shelves.forEach((shelf) => {
if (shelf.type == 'authors') {
@@ -273,9 +376,8 @@ export default {
})
},
initListeners() {
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
if (this.$root.socket) {
this.$root.socket.on('user_updated', this.userUpdated)
this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('author_removed', this.authorRemoved)
this.$root.socket.on('item_updated', this.libraryItemUpdated)
@@ -291,6 +393,7 @@ export default {
this.$store.commit('user/removeSettingsListener', 'bookshelf')
if (this.$root.socket) {
this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('author_updated', this.authorUpdated)
this.$root.socket.off('author_removed', this.authorRemoved)
this.$root.socket.off('item_updated', this.libraryItemUpdated)

View File

@@ -4,12 +4,26 @@
<div class="w-full h-full pt-6">
<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="selectItem" @edit="editItem" />
<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" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
</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" @editPodcast="editItem" @edit="editEpisode" />
<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"
:continue-listening-shelf="continueListeningShelf"
class="relative mx-2"
@hook:updated="updatedBookCard"
@select="selectItem"
@editPodcast="editItem"
@edit="editEpisode"
/>
</template>
</div>
<div v-if="shelf.type === 'series'" class="flex items-center">
@@ -17,6 +31,11 @@
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
</template>
</div>
<div v-if="shelf.type === 'tags'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-group-card :key="entity.name" :group="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
</template>
</div>
<div v-if="shelf.type === 'authors'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
@@ -52,7 +71,8 @@ export default {
},
sizeMultiplier: Number,
bookCoverWidth: Number,
bookCoverAspectRatio: Number
bookCoverAspectRatio: Number,
continueListeningShelf: Boolean
},
data() {
return {
@@ -118,11 +138,8 @@ export default {
})
}
},
selectItem(libraryItem) {
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', libraryItem)
})
selectItem(payload) {
this.$emit('selectEntity', payload)
},
itemSelectedEvt() {
this.updateSelectionMode(this.isSelectionMode)
@@ -171,11 +188,11 @@ export default {
}
},
mounted() {
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
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('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
}
}

View File

@@ -1,16 +1,16 @@
<template>
<div class="w-full h-20 md:h-10 relative">
<div class="flex md:hidden h-10 items-center">
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="isHomePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">Home</p>
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="isLibraryPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">Library</p>
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">Series</p>
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">Collections</p>
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
@@ -18,13 +18,10 @@
</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' && page !== 'podcast-search' && !isHome">
<template v-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
<div v-else class="items-center hidden md:flex w-full">
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-2xl text-white">west</span>
</div>
<p class="pl-4 font-book text-lg">
<p class="pl-2 font-book text-lg">
{{ seriesName }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
@@ -40,29 +37,20 @@
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
</svg>
</div>
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
>
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span>
</ui-btn>
</div>
<div class="flex-grow hidden sm:inline-block" />
<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">
<div class="h-full px-2 text-white flex items-center rounded-l-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'grid')">
<span class="material-icons" style="font-size: 1.4rem">view_module</span>
</div>
<div class="h-full px-2 text-white flex items-center rounded-r-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="!isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'list')">
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
</div>
</div> -->
<ui-checkbox v-if="isLibraryPage && !isPodcastLibrary" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<controls-library-filter-select v-if="isLibraryPage" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
<controls-library-sort-select v-if="isLibraryPage" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
<controls-library-filter-select v-if="isSeriesPage" v-model="seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
<controls-sort-select v-if="isSeriesPage" v-model="seriesSortBy" :descending.sync="seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
</template>
<template v-else-if="page === 'search'">
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-3xl text-white">west</span>
</div>
<div class="flex-grow" />
<p>Search results for "{{ searchQuery }}"</p>
<div class="flex-grow" />
@@ -85,7 +73,6 @@ export default {
default: () => null
},
searchQuery: String,
viewMode: String,
authors: {
type: Array,
default: () => []
@@ -96,11 +83,27 @@ export default {
settings: {},
hasInit: false,
totalEntities: 0,
keywordFilter: null,
keywordTimeout: null,
processingSeries: false,
processingIssues: false,
processingAuthors: false
processingAuthors: false,
seriesSortItems: [
{
text: 'Name',
value: 'name'
},
{
text: 'Number of Books',
value: 'numBooks'
},
{
text: 'Date Added',
value: 'addedAt'
},
{
text: 'Total Duration',
value: 'totalDuration'
}
]
}
},
computed: {
@@ -113,28 +116,6 @@ export default {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
isGridMode() {
return this.viewMode === 'grid'
},
showSortFilters() {
return this.page === ''
},
numShowing() {
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'
return ''
},
paramId() {
return this.$route.params ? this.$route.params.id || '' : ''
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@@ -144,14 +125,30 @@ export default {
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
homePage() {
isLibraryPage() {
return this.page === ''
},
isSeriesPage() {
return this.page === 'series'
},
isCollectionsPage() {
return this.page === 'collections'
},
isHomePage() {
return this.$route.name === 'library-library'
},
libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id'
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
},
showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
numShowing() {
return this.totalEntities
},
entityName() {
if (this.isPodcastLibrary) return 'Podcasts'
if (!this.page) return 'Books'
if (this.isSeriesPage) return 'Series'
if (this.isCollectionsPage) return 'Collections'
return ''
},
seriesName() {
return this.selectedSeries ? this.selectedSeries.name : null
@@ -172,8 +169,29 @@ export default {
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
seriesSortBy: {
get() {
return this.$store.state.libraries.seriesSortBy
},
set(val) {
this.$store.commit('libraries/setSeriesSortBy', val)
}
},
seriesSortDesc: {
get() {
return this.$store.state.libraries.seriesSortDesc
},
set(val) {
this.$store.commit('libraries/setSeriesSortDesc', val)
}
},
seriesFilterBy: {
get() {
return this.$store.state.libraries.seriesFilterBy
},
set(val) {
this.$store.commit('libraries/setSeriesFilterBy', val)
}
}
},
methods: {
@@ -215,6 +233,7 @@ export default {
this.$toast.success('Removed library items with issues')
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.processingIssues = false
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
})
.catch((error) => {
console.error('Failed to remove library items with issues', error)
@@ -228,7 +247,7 @@ export default {
this.processingSeries = true
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
id: lid,
libraryItemId: lid,
isFinished: newIsFinished
}
})
@@ -246,18 +265,18 @@ export default {
this.processingSeries = false
})
},
searchBackArrow() {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
},
seriesBackArrow() {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
},
updateOrder() {
this.saveSettings()
},
updateFilter() {
this.saveSettings()
},
updateSeriesSort() {
this.$eventBus.$emit('series-sort-updated')
},
updateSeriesFilter() {
this.$eventBus.$emit('series-sort-updated')
},
updateCollapseSeries() {
this.saveSettings()
},
@@ -274,15 +293,6 @@ export default {
},
setBookshelfTotalEntities(totalEntities) {
this.totalEntities = totalEntities
},
keywordFilterInput() {
clearTimeout(this.keywordTimeout)
this.keywordTimeout = setTimeout(() => {
this.keywordUpdated(this.keywordFilter)
}, 1000)
},
keywordUpdated() {
this.$eventBus.$emit('bookshelf-keyword-filter', this.keywordFilter)
}
},
mounted() {
@@ -302,4 +312,4 @@ export default {
#toolbar {
box-shadow: 0px 8px 6px #111111aa;
}
</style>
</style>

View File

@@ -11,12 +11,14 @@
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<div class="flex justify-between">
<p class="font-mono text-sm">v{{ $config.version }}</p>
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
</div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
</div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
</div>
</template>
@@ -26,7 +28,9 @@ export default {
isOpen: Boolean
},
data() {
return {}
return {
showChangelogModal: false
}
},
computed: {
Source() {
@@ -78,6 +82,11 @@ export default {
id: 'config-log',
title: 'Logs',
path: '/config/log'
},
{
id: 'config-notifications',
title: 'Notifications',
path: '/config/notifications'
}
]
@@ -129,18 +138,24 @@ export default {
githubTagUrl() {
return this.versionData.githubTagUrl
},
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {
clickChangelog(){
this.showChangelogModal = true
},
clickOutside() {
if (!this.isOpen) return
this.closeDrawer()
},
closeDrawer() {
this.$emit('update:isOpen', false)
}
},
}
}
</script>

View File

@@ -61,7 +61,8 @@ export default {
keywordFilter: null,
currScrollTop: 0,
resizeTimeout: null,
mountWindowWidth: 0
mountWindowWidth: 0,
lastItemIndexSelected: -1
}
},
watch: {
@@ -88,6 +89,7 @@ export default {
if (this.page === 'collections') return "You haven't made any collections yet"
if (this.hasFilter) {
if (this.filterName === 'Issues') return 'No Issues'
else if (this.filterName === 'Feed-open') return 'No RSS feeds are open'
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
}
return 'No results'
@@ -96,6 +98,15 @@ export default {
if (!this.page) return 'books'
return this.page
},
seriesSortBy() {
return this.$store.state.libraries.seriesSortBy
},
seriesSortDesc() {
return this.$store.state.libraries.seriesSortDesc
},
seriesFilterBy() {
return this.$store.state.libraries.seriesFilterBy
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
@@ -109,23 +120,19 @@ export default {
return this.$store.getters['user/getUserSetting']('collapseSeries')
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookshelfView() {
return this.$store.getters['getServerSetting']('bookshelfView')
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
sortingIgnorePrefix() {
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
},
isCoverSquareAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
return this.coverAspectRatio == 1
},
bookshelfView() {
return this.$store.getters['getBookshelfView']
},
isAlternativeBookshelfView() {
// if (!this.isEntityBook) return false // Only used for bookshelf showing books
return this.bookshelfView === this.$constants.BookshelfView.TITLES
},
bookCoverAspectRatio() {
return this.isCoverSquareAspectRatio ? 1 : 1.6
return this.bookshelfView === this.$constants.BookshelfView.DETAIL
},
hasFilter() {
return this.filterBy && this.filterBy !== 'all'
@@ -215,9 +222,55 @@ export default {
this.updateBookSelectionMode(false)
this.isSelectionMode = false
},
selectEntity(entity) {
selectEntity(entity, shiftKey) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
this.$store.commit('toggleLibraryItemSelected', entity.id)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedLibraryItems.includes(entity.id)) {
this.lastItemIndexSelected = indexOf
} else {
this.lastItemIndexSelected = -1
}
if (shiftKey && lastLastItemIndexSelected >= 0) {
var loopStart = indexOf
var loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected
loopEnd = indexOf
}
var isSelecting = false
// If any items in this range is not selected then select all otherwise unselect all
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = this.entities[i]
if (thisEntity && !thisEntity.collapsedSeries) {
if (!this.selectedLibraryItems.includes(thisEntity.id)) {
isSelecting = true
break
}
}
}
if (isSelecting) this.lastItemIndexSelected = indexOf
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = this.entities[i]
if (thisEntity.collapsedSeries) {
console.warn('Ignoring collapsed series')
continue
}
const entityComponentRef = this.entityComponentRefs[i]
if (thisEntity && entityComponentRef) {
entityComponentRef.selected = isSelecting
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting })
} else {
console.error('Invalid entity index', i)
}
}
} else {
this.$store.commit('toggleLibraryItemSelected', entity.id)
}
var newIsSelectionMode = !!this.selectedLibraryItems.length
if (this.isSelectionMode !== newIsSelectionMode) {
@@ -232,6 +285,9 @@ export default {
this.entityComponentRefs[key].setSelectionMode(isSelectionMode)
}
}
if (!isSelectionMode) {
this.lastItemIndexSelected = -1
}
},
async fetchEntites(page = 0) {
var startIndex = page * this.booksPerFetch
@@ -370,12 +426,16 @@ export default {
this.$nextTick(this.remountEntities)
},
buildSearchParams() {
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
if (this.page === 'search' || this.page === 'collections') {
return ''
}
let searchParams = new URLSearchParams()
if (this.page === 'series-books') {
if (this.page === 'series') {
searchParams.set('sort', this.seriesSortBy)
searchParams.set('desc', this.seriesSortDesc ? 1 : 0)
searchParams.set('filter', this.seriesFilterBy)
} else if (this.page === 'series-books') {
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
} else {
if (this.filterBy && this.filterBy !== 'all') {
@@ -411,6 +471,12 @@ export default {
return false
},
seriesSortUpdated() {
var wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) {
this.resetEntities()
}
},
settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) {
@@ -422,10 +488,7 @@ export default {
scroll(e) {
if (!e || !e.target) return
var { scrollTop } = e.target
// clearTimeout(this.scrollTimeout)
// this.scrollTimeout = setTimeout(() => {
this.handleScroll(scrollTop)
// }, 250)
},
libraryItemAdded(libraryItem) {
console.log('libraryItem added', libraryItem)
@@ -557,7 +620,8 @@ export default {
}
})
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$on('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('socket_init', this.socketInit)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
@@ -581,7 +645,9 @@ export default {
if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll)
}
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$off('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('socket_init', this.socketInit)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')

View File

@@ -14,6 +14,14 @@
<div v-show="homePage" 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/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons">format_list_bulleted</span>
<p class="font-book pt-1" style="font-size: 0.9rem">Latest</p>
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? '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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -56,7 +64,7 @@
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<icons-podcast-svg class="w-6 h-6" />
<span class="abs-icons icon-podcast text-xl"></span>
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
@@ -75,17 +83,21 @@
</nuxt-link>
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
<p class="font-mono text-xs text-center text-gray-300 leading-3 mb-1">v{{ $config.version }}</p>
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
</div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
</div>
</template>
<script>
export default {
data() {
return {}
return {
showChangelogModal: false
}
},
computed: {
Source() {
@@ -119,6 +131,9 @@ export default {
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
},
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
homePage() {
return this.$route.name === 'library-library'
},
@@ -150,17 +165,21 @@ export default {
hasUpdate() {
return !!this.versionData.hasUpdate
},
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() {
return this.versionData.githubTagUrl
},
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {},
methods: {
clickChangelog() {
this.showChangelogModal = true
}
},
mounted() {}
}
</script>

View File

@@ -2,7 +2,7 @@
<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">
<div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</nuxt-link>
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
<div>
@@ -44,11 +44,14 @@
@close="closePlayer"
@showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
/>
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
</div>
</template>
@@ -66,6 +69,7 @@ export default {
isPlaying: false,
currentTime: 0,
showSleepTimerModal: false,
showPlayerQueueItemsModal: false,
sleepTimerSet: false,
sleepTimerTime: 0,
sleepTimerRemaining: 0,
@@ -77,16 +81,13 @@ export default {
},
computed: {
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
bookCoverWidth() {
return 88
},
bookCoverPosTop() {
if (this.bookCoverAspectRatio === 1) return -10
if (this.coverAspectRatio == 1) return -10
return -64
},
cover() {
@@ -141,9 +142,39 @@ export default {
podcastAuthor() {
if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown'
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
}
},
methods: {
mediaFinished(libraryItemId, episodeId) {
// Play next item in queue
if (!this.playerQueueItems.length || !this.$store.state.playerQueueAutoPlay) {
// TODO: Set media finished flag so play button will play next queue item
return
}
var currentQueueIndex = this.playerQueueItems.findIndex((i) => {
if (episodeId) return i.libraryItemId === libraryItemId && i.episodeId === episodeId
return i.libraryItemId === libraryItemId
})
if (currentQueueIndex < 0) {
console.error('Media finished not found in queue', this.playerQueueItems)
return
}
if (currentQueueIndex === this.playerQueueItems.length - 1) {
console.log('Finished last item in queue')
return
}
const nextItemInQueue = this.playerQueueItems[currentQueueIndex + 1]
if (nextItemInQueue) {
this.playLibraryItem({
libraryItemId: nextItemInQueue.libraryItemId,
episodeId: nextItemInQueue.episodeId || null,
queueItems: this.playerQueueItems
})
}
},
setPlaying(isPlaying) {
this.isPlaying = isPlaying
this.$store.commit('setIsPlaying', isPlaying)
@@ -316,6 +347,7 @@ export default {
}
},
sessionOpen(session) {
// For opening session on init (temporarily unused)
this.$store.commit('setMediaPlaying', {
libraryItem: session.libraryItem,
episodeId: session.episodeId
@@ -364,7 +396,11 @@ export default {
var episodeId = payload.episodeId || null
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
this.playerHandler.play()
if (payload.startTime !== null && !isNaN(payload.startTime)) {
this.seek(payload.startTime)
} else {
this.playerHandler.play()
}
return
}
@@ -375,9 +411,14 @@ export default {
if (!libraryItem) return
this.$store.commit('setMediaPlaying', {
libraryItem,
episodeId
episodeId,
queueItems: payload.queueItems || []
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
this.$nextTick(() => {
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
},
pauseItem() {
this.playerHandler.pause()
@@ -389,11 +430,13 @@ export default {
},
mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('playback-seek', this.seek)
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
},
beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('playback-seek', this.seek)
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
}

View File

@@ -93,12 +93,12 @@ export default {
return null
})
if (!response) {
this.$toast.error('Author not found')
this.$toast.error(`Author ${this.name} 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)')
if (response.author.imagePath) this.$toast.success(`Author ${response.author.name} was updated`)
else this.$toast.success(`Author ${response.author.name} was updated (no image found)`)
} else {
this.$toast.info('No updates were made for Author')
this.$toast.info(`No updates were made for Author ${response.author.name}`)
}
this.searching = false
},

View File

@@ -1,20 +1,25 @@
<template>
<div class="w-full border-b border-gray-700 pb-2">
<div v-if="book" class="w-full border-b border-gray-700 pb-2">
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
<div class="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 class="text-base">{{ book.title }}</h1>
<div class="flex-grow" />
<p>{{ book.publishedYear }}</p>
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
<div class="w-full bg-primary">
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
<div v-else class="w-12 h-12 md:w-20 md:h-20 bg-primary" />
</div>
<p class="text-gray-300 text-sm">{{ book.author }}</p>
</div>
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
<div class="flex items-center">
<h1 class="text-sm md:text-base">{{ book.title }}</h1>
<div class="flex-grow" />
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
</div>
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}</p>
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
<p class="leading-3 text-xs text-gray-400">
{{ series.series }}<span v-if="series.volumeNumber">&nbsp;#{{ series.volumeNumber }}</span>
{{ series.series }}<span v-if="series.sequence">&nbsp;#{{ series.sequence }}</span>
</p>
</div>
</div>
@@ -25,7 +30,7 @@
<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 v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
</div>
</div>
@@ -63,7 +68,7 @@ export default {
selectMatch() {
var book = { ...this.book }
book.cover = this.selectedCover
this.$emit('select', this.book)
this.$emit('select', book)
},
clickCover(cover) {
this.selectedCover = cover

View File

@@ -1,26 +1,18 @@
<template>
<div class="relative">
<div class="rounded-sm h-full relative" :style="{ padding: `0px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
<div class="rounded-sm h-full relative" :style="{ width: width + 'px', height: height + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
<covers-group-cover ref="groupcover" :id="seriesId" :name="groupName" :group-to="groupTo" :type="groupType" :book-items="bookItems" :width="coverWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
</div>
<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 z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
</div>
</nuxt-link>
</div>
<div v-if="!isCategorized" 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' }">{{ groupName }}</p>
</div>
</div>
</div>
</template>
@@ -31,11 +23,8 @@ export default {
type: Object,
default: () => null
},
width: {
type: Number,
default: 120
},
isCategorized: Boolean,
width: Number,
height: Number,
bookCoverAspectRatio: Number
},
data() {
@@ -43,23 +32,7 @@ export default {
isHovering: false
}
},
watch: {
width(newVal) {
this.$nextTick(() => {
if (this.$refs.groupcover) {
this.$refs.groupcover.init()
}
})
}
},
computed: {
seriesId() {
return this.groupEncode
},
labelFontSize() {
if (this.coverWidth < 160) return 0.75
return 0.875
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@@ -70,29 +43,11 @@ export default {
return this._group.type
},
groupTo() {
if (this.groupType === 'series') {
return `/library/${this.currentLibraryId}/series/${this._group.id}`
} else if (this.groupType === 'collection') {
return `/collection/${this._group.id}`
} else {
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
}
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
coverWidth() {
return this.width * 2
},
coverHeight() {
return this.width * this.bookCoverAspectRatio
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
paddingX() {
return 16 * this.sizeMultiplier
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
bookItems() {
return this._group.books || []

View File

@@ -31,7 +31,7 @@ export default {
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2

View File

@@ -37,9 +37,11 @@
<!-- 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>
<!-- Finished progress bar for collapsed series -->
<div v-else-if="booksInSeries && seriesIsFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Overlay is not shown if collapsing series in library -->
<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="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" 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>
@@ -66,6 +68,11 @@
</div>
</div>
<!-- Processing/loading spinner overlay -->
<div v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
<widgets-loading-spinner size="la-lg" />
</div>
<!-- Series name overlay -->
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
@@ -78,13 +85,17 @@
</div>
</ui-tooltip>
<div v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
</div>
<!-- Series sequence -->
<div v-if="seriesSequence && !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` }">
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
</div>
@@ -119,16 +130,16 @@ export default {
},
orderBy: String,
filterBy: String,
sortingIgnorePrefix: Boolean
sortingIgnorePrefix: Boolean,
continueListeningShelf: Boolean
},
data() {
return {
isHovering: false,
isMoreMenuOpen: false,
isProcessingReadUpdate: false,
processing: false,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false
@@ -173,7 +184,8 @@ export default {
return this.mediaType === 'podcast'
},
placeholderUrl() {
return '/book_placeholder.jpg'
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
},
bookCoverSrc() {
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
@@ -224,6 +236,10 @@ export default {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
},
libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
},
hasCover() {
return !!this.media.coverPath
},
@@ -249,14 +265,14 @@ export default {
},
displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
return this.mediaMetadata.titleIgnorePrefix
}
return this.title
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
},
displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author
if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || ''
}
@@ -264,6 +280,7 @@ export default {
return this.author
},
displaySortLine() {
if (this.collapsedSeries) return null
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
@@ -287,6 +304,12 @@ export default {
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
},
seriesIsFinished() {
return !this.libraryItemIdsInSeries.some((lid) => {
const progress = this.store.getters['user/getUserMediaProgress'](lid)
return !progress || !progress.isFinished
})
},
showError() {
if (this.recentEpisode) return false // Dont show podcast error on episode card
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
@@ -359,7 +382,7 @@ export default {
},
moreMenuItems() {
if (this.recentEpisode) {
return [
const items = [
{
func: 'editPodcast',
text: 'Edit Podcast'
@@ -369,6 +392,13 @@ export default {
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
}
]
if (this.continueListeningShelf) {
items.push({
func: 'removeFromContinueListening',
text: 'Remove from Continue Listening'
})
}
return items
}
var items = []
@@ -377,12 +407,14 @@ export default {
{
func: 'toggleFinished',
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
},
{
func: 'openCollections',
text: 'Add to Collection'
}
]
if (this.userCanUpdate) {
items.push({
func: 'openCollections',
text: 'Add to Collection'
})
}
}
if (this.userCanUpdate) {
items.push({
@@ -400,6 +432,18 @@ export default {
text: 'Re-Scan'
})
}
if (this.series && this.bookMount) {
items.push({
func: 'removeSeriesFromContinueListening',
text: 'Remove Series from Continue Series'
})
}
if (this.continueListeningShelf) {
items.push({
func: 'removeFromContinueListening',
text: 'Remove from Continue Listening'
})
}
return items
},
_socket() {
@@ -433,7 +477,7 @@ export default {
},
isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.TITLES
return this.bookshelfView === constants.BookshelfView.DETAIL
},
isAuthorBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
@@ -443,6 +487,10 @@ export default {
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
},
rssFeed() {
if (this.booksInSeries) return null
return this.store.getters['feeds/getFeedForItem'](this.libraryItemId)
}
},
methods: {
@@ -481,6 +529,7 @@ export default {
this.libraryItem = libraryItem
},
clickCard(e) {
if (this.processing) return
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
@@ -499,11 +548,25 @@ export default {
}
this.$emit('edit', this.libraryItem)
},
toggleFinished() {
toggleFinished(confirmed = false) {
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${this.displayTitle}" as finished?`,
callback: (confirmed) => {
if (confirmed) {
this.toggleFinished(true)
}
},
type: 'yesNo'
}
this.store.commit('globals/setConfirmPrompt', payload)
return
}
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
this.processing = true
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
@@ -513,12 +576,12 @@ export default {
axios
.$patch(apiEndpoint, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.processing = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.processing = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
@@ -526,11 +589,12 @@ export default {
this.$emit('editPodcast', this.libraryItem)
},
rescan() {
this.rescanning = true
this.$axios
if (this.processing) return
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
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}"`)
@@ -545,7 +609,9 @@ export default {
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
.finally(() => {
this.processing = false
})
},
showEditModalFiles() {
@@ -556,6 +622,40 @@ export default {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
removeSeriesFromContinueListening() {
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
.$get(`/api/me/series/${this.series.id}/remove-from-continue-listening`)
.then((data) => {
console.log('User updated', data)
})
.catch((error) => {
console.error('Failed to remove series from home', error)
this.$toast.error('Failed to update user')
})
.finally(() => {
this.processing = false
})
},
removeFromContinueListening() {
if (!this.userProgress) return
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
.$get(`/api/me/progress/${this.userProgress.id}/remove-from-continue-listening`)
.then((data) => {
console.log('User updated', data)
})
.catch((error) => {
console.error('Failed to hide item from home', error)
this.$toast.error('Failed to update user')
})
.finally(() => {
this.processing = false
})
},
openCollections() {
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowUserCollectionsModal', true)
@@ -619,16 +719,55 @@ export default {
console.log('Got library itemn', libraryItem)
this.store.commit('showEReader', libraryItem)
},
selectBtnClick() {
selectBtnClick(evt) {
if (this.processingBatch) return
this.selected = !this.selected
this.$emit('select', this.libraryItem)
this.$emit('select', { entity: this.libraryItem, shiftKey: evt.shiftKey })
},
play() {
async play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
const queueItems = []
// Podcast episode load queue items
if (this.recentEpisode) {
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
const fullLibraryItem = await axios.$get(`/api/items/${this.libraryItemId}`).catch((err) => {
console.error('Failed to fetch library item', err)
return null
})
this.processing = false
if (fullLibraryItem && fullLibraryItem.media.episodes) {
const episodes = fullLibraryItem.media.episodes || []
// Sort from least recent to most recent
episodes.sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
const episodeIndex = episodes.findIndex((ep) => ep.id === this.recentEpisode.id)
if (episodeIndex >= 0) {
for (let i = episodeIndex; i < episodes.length; i++) {
const episode = episodes[i]
const podcastProgress = this.store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
if (!podcastProgress || !podcastProgress.isFinished) {
queueItems.push({
libraryItemId: this.libraryItemId,
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})
}
}
}
}
}
eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.recentEpisode ? this.recentEpisode.id : null
episodeId: this.recentEpisode ? this.recentEpisode.id : null,
queueItems
})
},
mouseover() {

View File

@@ -4,7 +4,7 @@
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<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 v-show="isHovering && userCanUpdate" 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', 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>
@@ -30,7 +30,12 @@ export default {
bookshelfView: {
type: Number,
default: 0
}
},
collectionMount: {
type: Object,
default: () => null
},
isTag: Boolean
},
data() {
return {
@@ -63,7 +68,10 @@ export default {
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.TITLES
return this.bookshelfView == constants.BookshelfView.DETAIL
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
}
},
methods: {
@@ -99,6 +107,10 @@ export default {
}
}
},
mounted() {}
mounted() {
if (this.collectionMount) {
this.setEntity(this.collectionMount)
}
}
}
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
@@ -10,16 +10,17 @@
<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>
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(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>
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
</div>
</template>
@@ -39,7 +40,9 @@ export default {
seriesMount: {
type: Object,
default: () => null
}
},
sortingIgnorePrefix: Boolean,
orderBy: String
},
data() {
return {
@@ -51,6 +54,9 @@ export default {
}
},
computed: {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
@@ -65,12 +71,31 @@ export default {
title() {
return this.series ? this.series.name : ''
},
nameIgnorePrefix() {
return this.series ? this.series.nameIgnorePrefix : ''
},
displayTitle() {
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
return this.title
},
displaySortLine() {
if (this.orderBy === 'addedAt') {
// return this.addedAt
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
} else if (this.orderBy === 'totalDuration') {
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
}
return null
},
books() {
return this.series ? this.series.books || [] : []
},
addedAt() {
return this.series ? this.series.addedAt : 0
},
totalDuration() {
return this.series ? this.series.totalDuration : 0
},
seriesBookProgress() {
return this.books
.map((libraryItem) => {
@@ -99,7 +124,7 @@ export default {
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.TITLES
return this.bookshelfView == constants.BookshelfView.DETAIL
}
},
methods: {

View File

@@ -0,0 +1,174 @@
<template>
<div class="w-full border border-white border-opacity-10 rounded-xl p-4 my-2" :class="notification.enabled ? 'bg-primary bg-opacity-25' : 'bg-error bg-opacity-5'">
<div class="flex flex-wrap items-center">
<p class="text-base md:text-lg font-semibold pr-4">{{ eventName }}</p>
<div class="flex-grow" />
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="fireTestEventAndSucceed">Fire onTest Event</ui-btn>
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" color="red-600" @click.stop="fireTestEventAndFail">Fire & Fail</ui-btn>
<!-- <ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="rapidFireTestEvents">Rapid Fire</ui-btn> -->
<ui-btn v-else-if="notification.enabled" :loading="sendingTest" small class="mr-2" @click.stop="sendTestClick">Test</ui-btn>
<ui-btn v-else :loading="enabling" small color="success" class="mr-2" @click="enableNotification">Enable</ui-btn>
<ui-icon-btn :size="7" icon-font-size="1.1rem" icon="edit" class="mr-2" @click="editNotification" />
<ui-icon-btn bg-color="error" :size="7" icon-font-size="1.2rem" icon="delete" @click="deleteNotificationClick" />
</div>
<div class="pt-4">
<p class="text-gray-300 text-xs md:text-sm mb-2">{{ notification.urls.join(', ') }}</p>
<p v-if="lastFiredAt && lastAttemptFailed" class="text-red-300 text-xs">Last attempt failed {{ $dateDistanceFromNow(lastFiredAt) }} ({{ numConsecutiveFailedAttempts }} attempt{{ numConsecutiveFailedAttempts === 1 ? '' : 's' }})</p>
<p v-else-if="lastFiredAt" class="text-gray-400 text-xs">Last fired {{ $dateDistanceFromNow(lastFiredAt) }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
notification: {
type: Object,
default: () => {}
}
},
data() {
return {
sendingTest: false,
enabling: false,
deleting: false,
testing: false
}
},
computed: {
eventName() {
return this.notification ? this.notification.eventName : null
},
lastFiredAt() {
return this.notification ? this.notification.lastFiredAt : null
},
lastAttemptFailed() {
return this.notification ? this.notification.lastAttemptFailed : null
},
numConsecutiveFailedAttempts() {
return this.notification ? this.notification.numConsecutiveFailedAttempts : null
}
},
methods: {
// For testing using the onTest event
fireTestEventAndFail() {
this.fireTestEvent(true)
},
fireTestEventAndSucceed() {
this.fireTestEvent(false)
},
fireTestEvent(intentionallyFail = false) {
this.testing = true
this.$axios
.$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`)
.then(() => {
this.$toast.success('Triggered onTest Event')
})
.catch((error) => {
console.error('Failed', error)
const errorMsg = error.response ? error.response.data : null
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger onTest event')
})
.finally(() => {
this.testing = false
})
},
rapidFireTestEvents() {
this.testing = true
var numFired = 0
var interval = setInterval(() => {
this.fireTestEvent()
numFired++
if (numFired > 25) {
this.testing = false
clearInterval(interval)
}
}, 100)
},
// End testing functions
sendTestClick() {
const payload = {
message: `Trigger this notification with test data?`,
callback: (confirmed) => {
if (confirmed) {
this.sendTest()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
sendTest() {
this.sendingTest = true
this.$axios
.$get(`/api/notifications/${this.notification.id}/test`)
.then(() => {
this.$toast.success('Triggered test notification')
})
.catch((error) => {
console.error('Failed', error)
const errorMsg = error.response ? error.response.data : null
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger test notification')
})
.finally(() => {
this.sendingTest = false
})
},
enableNotification() {
this.enabling = true
const payload = {
id: this.notification.id,
enabled: true
}
this.$axios
.$patch(`/api/notifications/${this.notification.id}`, payload)
.then((updatedSettings) => {
this.$emit('update', updatedSettings)
this.$toast.success('Notification enabled')
})
.catch((error) => {
console.error('Failed to update notification', error)
this.$toast.error('Failed to update notification')
})
.finally(() => {
this.enabling = false
})
},
deleteNotificationClick() {
const payload = {
message: `Are you sure you want to delete this notification?`,
callback: (confirmed) => {
if (confirmed) {
this.deleteNotification()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteNotification() {
this.deleting = true
this.$axios
.$delete(`/api/notifications/${this.notification.id}`)
.then((updatedSettings) => {
this.$emit('update', updatedSettings)
this.$toast.success('Deleted notification')
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to delete notification')
})
.finally(() => {
this.deleting = false
})
},
editNotification() {
this.$emit('edit', this.notification)
}
},
mounted() {}
}
</script>

View File

@@ -24,7 +24,7 @@ export default {
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
name() {
return this.series.name

View File

@@ -1,8 +1,8 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full 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="block truncate text-xs">{{ selectedText }}</span>
</span>
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@@ -15,42 +15,12 @@
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
</div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons">arrow_right</span>
</div>
</li>
</template>
</ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons">arrow_left</span>
</div>
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate">Back</span>
</div>
</li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
<div class="flex items-center justify-center">
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div>
</li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
@@ -61,92 +31,15 @@
<script>
export default {
props: {
value: String
value: String,
items: {
type: Array,
default: () => []
}
},
data() {
return {
showMenu: false,
sublist: null,
bookItems: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Series',
value: 'series',
sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
},
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{
text: 'Language',
value: 'languages',
sublist: true
},
{
text: 'Progress',
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',
sublist: false
}
]
}
},
watch: {
showMenu(newVal) {
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
}
showMenu: false
}
},
computed: {
@@ -158,81 +51,10 @@ 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) {
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 ''
}
},
genres() {
return this.filterData.genres || []
},
tags() {
return this.filterData.tags || []
},
series() {
return this.filterData.series || []
},
authors() {
return this.filterData.authors || []
},
narrators() {
return this.filterData.narrators || []
},
languages() {
return this.filterData.languages || []
},
progress() {
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
},
missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
if (typeof item === 'string') {
return {
text: item,
value: this.$encode(item)
}
} else {
return {
text: item.name,
value: this.$encode(item.id)
}
}
})
const filter = this.items.find((i) => i.value === this.selected)
return filter ? filter.text : ''
},
filterData() {
return this.$store.state.libraries.filterData || {}
@@ -245,18 +67,9 @@ export default {
this.$nextTick(() => this.$emit('change', 'all'))
},
clickOutside() {
if (!this.selectedItemSublist) this.sublist = null
this.showMenu = false
},
clickedSublistOption(item) {
this.clickedOption({ value: `${this.sublist}.${item}` })
},
clickedOption(option) {
if (option.sublist) {
this.sublist = option.value
return
}
var val = option.value
if (this.selected === val) {
this.showMenu = false

View File

@@ -1,5 +1,5 @@
<template>
<div class="sm:w-80 w-full sm:ml-6 relative">
<div class="sm:w-80 w-full relative">
<form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>

View File

@@ -0,0 +1,314 @@
<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>
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-icons" style="font-size: 1.1rem">close</span>
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
</div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons">arrow_right</span>
</div>
</li>
</template>
</ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons">arrow_left</span>
</div>
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate">Back</span>
</div>
</li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
<div class="flex items-center justify-center">
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div>
</li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</div>
</div>
</template>
<script>
export default {
props: {
value: String,
isSeries: Boolean
},
data() {
return {
showMenu: false,
sublist: null,
seriesItems: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
},
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{
text: 'Language',
value: 'languages',
sublist: true
},
{
text: 'Series Progress',
value: 'progress',
sublist: true
}
],
bookItems: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Series',
value: 'series',
sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
},
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{
text: 'Language',
value: 'languages',
sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
},
{
text: 'Missing',
value: 'missing',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
},
{
text: 'RSS Feed Open',
value: 'feed-open',
sublist: false
}
],
podcastItems: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
}
]
}
},
watch: {
showMenu(newVal) {
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
}
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
selectItems() {
if (this.isSeries) return this.seriesItems
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) {
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 ''
}
},
genres() {
return this.filterData.genres || []
},
tags() {
return this.filterData.tags || []
},
series() {
return this.filterData.series || []
},
authors() {
return this.filterData.authors || []
},
narrators() {
return this.filterData.narrators || []
},
languages() {
return this.filterData.languages || []
},
progress() {
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
},
missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
if (typeof item === 'string') {
return {
text: item,
value: this.$encode(item)
}
} else {
return {
text: item.name,
value: this.$encode(item.id)
}
}
})
},
filterData() {
return this.$store.state.libraries.filterData || {}
}
},
methods: {
clearSelected() {
this.selected = 'all'
this.showMenu = false
this.$nextTick(() => this.$emit('change', 'all'))
},
clickOutside() {
if (!this.selectedItemSublist) this.sublist = null
this.showMenu = false
},
clickedSublistOption(item) {
this.clickedOption({ value: `${this.sublist}.${item}` })
},
clickedOption(option) {
if (option.sublist) {
this.sublist = option.value
return
}
var val = option.value
if (this.selected === val) {
this.showMenu = false
return
}
this.selected = val
this.showMenu = false
this.$nextTick(() => this.$emit('change', val))
}
}
}
</script>

View File

@@ -44,6 +44,10 @@ export default {
text: 'Author (Last, First)',
value: 'media.metadata.authorNameLF'
},
{
text: 'Published Year',
value: 'media.metadata.publishedYear'
},
{
text: 'Added At',
value: 'addedAt'

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full 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>
@@ -26,29 +26,15 @@
export default {
props: {
value: String,
descending: Boolean
descending: Boolean,
items: {
type: Array,
default: () => []
}
},
data() {
return {
showMenu: false,
items: [
{
text: 'Pub Date',
value: 'publishedAt'
},
{
text: 'Title',
value: 'title'
},
{
text: 'Season',
value: 'season'
},
{
text: 'Episode',
value: 'episode'
}
]
showMenu: false
}
},
computed: {

View File

@@ -37,6 +37,11 @@ export default {
return this.value
},
set(val) {
try {
localStorage.setItem("volume", val);
} catch(error) {
console.error('Failed to store volume', err)
}
this.$emit('input', val)
}
},
@@ -141,6 +146,10 @@ export default {
if (this.value === 0) {
this.isMute = true
}
const storageVolume = localStorage.getItem("volume")
if (storageVolume) {
this.volume = parseFloat(storageVolume)
}
},
beforeDestroy() {
window.removeEventListener('mousewheel', this.scroll)

View File

@@ -58,7 +58,7 @@ export default {
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 `http://localhost:3333${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
}
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
}

View File

@@ -94,7 +94,8 @@ export default {
return this.author
},
placeholderUrl() {
return '/book_placeholder.jpg'
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
},
fullCoverUrl() {
if (!this.libraryItem) return null
@@ -125,6 +126,9 @@ export default {
},
userToken() {
return this.$store.getters['user/getToken']
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
}
},
methods: {

View File

@@ -17,7 +17,6 @@ export default {
},
width: Number,
height: Number,
groupTo: String,
bookCoverAspectRatio: Number
},
data() {

View File

@@ -1,41 +0,0 @@
<template>
<div ref="container" @mouseover="mouseover" @mouseleave="mouseleave" class="relative">
<covers-book-cover :width="24" :audiobook="audiobook" />
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
isHovering: false
}
},
computed: {
placeholderUrl() {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
return this.$store.getters['globals/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
},
hasCover() {
return !!this.audiobook.book.cover
}
},
methods: {
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
}
},
mounted() {}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative">
<div class="relative rounded-sm" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative overflow-hidden">
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
@@ -17,6 +17,8 @@
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div>
</div>
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
</div>
</template>
@@ -29,7 +31,11 @@ export default {
default: 120
},
showOpenNewTab: Boolean,
bookCoverAspectRatio: Number
bookCoverAspectRatio: Number,
showResolution: {
type: Boolean,
default: true
}
},
data() {
return {
@@ -54,6 +60,9 @@ export default {
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
}
},
methods: {

View File

@@ -1,23 +0,0 @@
<template>
<svg fill="currentColor" class="h-full w-full p-px" viewBox="0 0 1978.03 2349.44">
<path
d="M2519.5,1438.39c-12.13-10.1-31-25-56.57-42.62V1197.31c0-505.94-410.15-916.09-916.1-916.09h0c-505.94,0-916.09,410.15-916.09,916.09v198.46c-25.57,17.66-44.44,32.52-56.57,42.62a45.45,45.45,0,0,0-16.35,34.95v237.74a45.45,45.45,0,0,0,16.35,35c28.28,23.54,93.18,72.92,194.22,123.55v23.11c0,62.32,40.21,112.85,89.8,112.85h0c49.59,0,89.8-50.53,89.8-112.85V1322.51c0-62.33-40.21-112.86-89.8-112.86h0c-47.51,0-86.4,46.38-89.58,105.07l-.22.11V1197.31c0-429.92,348.52-778.43,778.44-778.43h0c429.92,0,778.44,348.51,778.44,778.43v117.52l-.22-.11c-3.18-58.69-42.06-105.07-89.58-105.07h0c-49.59,0-89.79,50.53-89.79,112.86v570.18c0,62.32,40.2,112.85,89.79,112.85h0c49.6,0,89.8-50.53,89.8-112.85v-23.11c101.05-50.63,165.95-100,194.23-123.55a45.48,45.48,0,0,0,16.35-35V1473.34A45.48,45.48,0,0,0,2519.5,1438.39Z"
transform="translate(-557.82 -281.22)"
/>
<path d="M1227.4,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56A108.47,108.47,0,0,0,1227.4,998.08H1115.33a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1047.75,1289.38H1295v25.83H1047.75Z" transform="translate(-557.82 -281.22)" />
<path d="M1602.87,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1490.8a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1423.22,1289.38h247.22v25.83H1423.22Z" transform="translate(-557.82 -281.22)" />
<path d="M1978.34,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1866.27a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1798.69,1289.38h247.22v25.83H1798.69Z" transform="translate(-557.82 -281.22)" />
<rect x="180.05" y="2185.95" width="1617.93" height="163.49" rx="81.74" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,16 +0,0 @@
<template>
<svg fill="currentColor" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,16 +0,0 @@
<template>
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,16 +0,0 @@
<template>
<svg class="p-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,19 +0,0 @@
<template>
<svg class="p-px" viewBox="0 0 122.877 120.596">
<path
fill="currentColor"
d="M68.925,69.906v50.689H53.953V69.906c-4.918-2.662-8.259-7.867-8.259-13.854 c0-8.694,7.05-15.744,15.745-15.744c8.694,0,15.745,7.05,15.745,15.744C77.184,62.039,73.843,67.244,68.925,69.906L68.925,69.906z M39.32,11.165c2.916-1.438,4.111-4.969,2.673-7.882c-1.438-2.914-4.966-4.111-7.88-2.674C22.213,6.479,12.958,16.19,7.11,27.625 c-4.32,8.445-6.783,17.842-7.08,27.325c-0.299,9.563,1.587,19.223,5.973,28.114c5.401,10.953,14.558,20.695,28.039,27.592 c2.889,1.477,6.429,0.33,7.905-2.559c1.477-2.889,0.331-6.428-2.558-7.904c-11.037-5.645-18.486-13.525-22.833-22.334 c-3.506-7.111-5.014-14.857-4.774-22.539c0.243-7.757,2.256-15.442,5.79-22.348C22.304,23.721,29.76,15.879,39.32,11.165 L39.32,11.165z M88.765,0.608c-2.914-1.438-6.443-0.24-7.881,2.674c-1.438,2.914-0.242,6.445,2.674,7.882 c9.561,4.715,17.017,12.556,21.747,21.808c3.533,6.905,5.547,14.59,5.789,22.348c0.24,7.682-1.268,15.428-4.773,22.539 c-4.347,8.809-11.796,16.689-22.833,22.334c-2.889,1.477-4.034,5.016-2.558,7.904c1.476,2.889,5.016,4.035,7.905,2.559 c13.48-6.896,22.638-16.639,28.039-27.592c4.386-8.891,6.272-18.551,5.973-28.114c-0.297-9.483-2.76-18.88-7.079-27.325 C109.919,16.19,100.665,6.479,88.765,0.608L88.765,0.608z M82.791,26.505c-2.195-1.581-5.256-1.082-6.837,1.113 c-1.58,2.195-1.082,5.256,1.113,6.837c0.885,0.637,1.753,1.352,2.604,2.134c4.971,4.583,7.919,10.694,8.538,17.16 c0.626,6.524-1.111,13.437-5.518,19.552c-0.748,1.039-1.61,2.092-2.585,3.15c-1.835,1.992-1.708,5.098,0.287,6.932 c1.994,1.834,5.099,1.705,6.933-0.287c1.18-1.279,2.286-2.641,3.315-4.072c5.862-8.139,8.166-17.4,7.322-26.197 c-0.848-8.853-4.871-17.208-11.648-23.457C85.249,28.387,84.074,27.431,82.791,26.505L82.791,26.505z M45.81,34.458 c2.195-1.581,2.694-4.642,1.113-6.837c-1.581-2.195-4.642-2.694-6.837-1.114c-1.284,0.926-2.458,1.882-3.524,2.864 c-6.778,6.25-10.801,14.604-11.649,23.457c-0.844,8.796,1.46,18.06,7.323,26.199c1.031,1.43,2.136,2.791,3.315,4.07 c1.834,1.992,4.939,2.121,6.932,0.287c1.996-1.834,2.123-4.939,0.288-6.932c-0.975-1.059-1.837-2.111-2.585-3.15 c-4.406-6.115-6.144-13.027-5.518-19.551c0.619-6.465,3.567-12.577,8.538-17.16C44.058,35.81,44.926,35.095,45.81,34.458 L45.81,34.458z"
/>
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -146,7 +146,6 @@ export default {
watch: {
show: {
handler(newVal) {
console.log('accoutn modal show change', newVal)
if (newVal) {
this.init()
}
@@ -162,6 +161,9 @@ export default {
this.$emit('input', val)
}
},
user() {
return this.$store.state.user.user
},
title() {
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
},
@@ -250,6 +252,12 @@ export default {
this.$toast.error(`Failed to update account: ${data.error}`)
} else {
console.log('Account updated', data.user)
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
console.log('Current user token was updated')
this.$store.commit('user/setUserToken', data.user.token)
}
this.$toast.success('Account updated')
this.show = false
}
@@ -305,7 +313,6 @@ export default {
this.isNew = !this.account
if (this.account) {
console.log(this.account)
this.newUser = {
username: this.account.username,
password: this.account.password,

View File

@@ -0,0 +1,91 @@
<template>
<modals-modal v-model="show" name="backup-scheduler" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Set Backup Schedule</p>
</div>
</template>
<div v-if="show && newCronExpression" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
<div class="flex items-center justify-end">
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? 'Save Backup Schedule' : 'No update necessary' }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
cronExpression: {
type: String,
default: '* * * * *'
}
},
data() {
return {
processing: false,
newCronExpression: null,
isUpdated: false
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
expressionUpdated() {
this.isUpdated = this.newCronExpression !== this.cronExpression
},
init() {
this.newCronExpression = this.cronExpression
this.isUpdated = false
},
submit() {
// If custom expression input is focused then unfocus it instead of submitting
if (this.$refs.expressionBuilder && this.$refs.expressionBuilder.checkBlurExpressionInput) {
if (this.$refs.expressionBuilder.checkBlurExpressionInput()) {
return
}
}
this.processing = true
var updatePayload = {
backupSchedule: this.newCronExpression
}
this.$store
.dispatch('updateServerSettings', updatePayload)
.then((success) => {
console.log('Updated Server Settings', success)
this.processing = false
this.show = false
this.$emit('update:cronExpression', this.newCronExpression)
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.processing = false
})
}
},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -0,0 +1,141 @@
<template>
<modals-modal v-model="show" name="batchQuickMatch" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="show" class="w-full h-full">
<div class="py-4 px-4">
<h1 class="text-2xl">Quick Match {{ selectedBookIds.length }} Books</h1>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<div class="flex px-8 items-center py-2">
<p class="pr-4">Provider</p>
<ui-dropdown v-model="options.provider" :items="providers" small />
</div>
<p class="text-base px-8 py-2">Quick Match will attempt to add missing covers and metadata for the selected books. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.</p>
<div class="flex px-8 items-end py-2">
<ui-toggle-switch v-model="options.overrideCover"/>
<ui-tooltip :text="tooltips.updateCovers">
<p class="pl-4">
Update Covers
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex px-8 items-end py-2">
<ui-toggle-switch v-model="options.overrideDetails"/>
<ui-tooltip :text="tooltips.updateDetails">
<p class="pl-4">
Update Details
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="mt-4 py-4 border-b border-white border-opacity-10 text-white text-opacity-80 border-t border-white border-opacity-5">
<div class="flex items-center px-4">
<ui-btn type="button" @click="show = false">Cancel</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" @click="doBatchQuickMatch">Continue</ui-btn>
</div>
</div>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
processing: false,
lastUsedLibrary: undefined,
options: {
provider: undefined,
overrideDetails: true,
overrideCover: true,
overrideDefaults: true
},
tooltips: {
updateCovers: 'Allow overwriting of existing covers for the selected books when a match is located.',
updateDetails: 'Allow overwriting of existing details for the selected books when a match is located.'
}
}
},
watch: {
show: {
handler(newVal) {
this.init()
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showBatchQuickMatchModal
},
set(val) {
this.$store.commit('globals/setShowBatchQuickMatchModal', val)
}
},
title() {
return `${this.selectedBookIds.length} Items Selected`
},
showBatchQuickMatchModal() {
return this.$store.state.globals.showBatchQuickMatchModal
},
selectedBookIds() {
return this.$store.state.selectedLibraryItems || []
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
}
},
methods: {
init() {
// If we don't have a set provider (first open of dialog) or we've switched library, set
// the selected provider to the current library default provider
if (!this.options.provider || (this.options.lastUsedLibrary != this.currentLibraryId)) {
this.options.lastUsedLibrary = this.currentLibraryId
this.options.provider = this.libraryProvider
}
},
doBatchQuickMatch() {
if (!this.selectedBookIds.length) return
if (this.processing) return
this.processing = true
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/quickmatch`, {
options: this.options,
libraryItemIds: this.selectedBookIds
})
.then(() => {
this.$toast.info('Batch quick match of ' + this.selectedBookIds.length + ' books started!')
}).catch((error) => {
this.$toast.error('Batch quick match failed')
console.error('Failed to batch quick match', error)
}).finally(() => {
this.processing = false
this.$store.commit('setProcessingBatch', false)
this.show = false
})
}
},
mounted() {}
}
</script>

View File

@@ -1,6 +1,11 @@
<template>
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<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">Your Bookmarks</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="show" class="w-full h-full">
<template v-for="bookmark in bookmarks">
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
@@ -8,8 +13,8 @@
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
<p class="text-xl">No Bookmarks</p>
</div>
<div class="w-full h-px bg-white bg-opacity-10" />
<form @submit.prevent="submitCreateBookmark">
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
<div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400">
@@ -39,7 +44,8 @@ export default {
type: Number,
default: 0
},
libraryItemId: String
libraryItemId: String,
hideCreate: Boolean
},
data() {
return {

View File

@@ -20,7 +20,7 @@
</div>
</div>
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">Save</ui-btn>
</div>
@@ -75,7 +75,7 @@ export default {
}
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
collection() {
return this.$store.state.globals.selectedCollection || {}
@@ -85,6 +85,9 @@ export default {
},
books() {
return this.collection.books || []
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {

View File

@@ -8,10 +8,10 @@
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
<div class="flex">
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" label="Series Name" />
</div>
<div class="w-24 sm:w-28 md:w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" label="Sequence" />
</div>
</div>
<div class="flex justify-end mt-2 p-1">
@@ -59,9 +59,26 @@ export default {
set(val) {
this.$emit('input', val)
}
},
isNewSeries() {
if (!this.selectedSeries || !this.selectedSeries.id) return false
return this.selectedSeries.id.startsWith('new')
}
},
methods: {
setInputFocus() {
if (this.isNewSeries) {
// Focus on series input if new series
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.setFocus()
}
} else {
// Focus on sequence input if existing series
if (this.$refs.sequenceInput) {
this.$refs.sequenceInput.setFocus()
}
}
},
submitSeriesForm() {
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur()
@@ -89,15 +106,15 @@ export default {
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
this.$store.commit('setInnerModalOpen', true)
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.setInputFocus()
},
setHide() {
if (this.content) this.content.style.transform = 'scale(0)'
if (this.el) this.el.remove()
document.documentElement.classList.remove('modal-open')
this.$store.commit('setInnerModalOpen', false)
this.$eventBus.$off('modal-hotkey', this.hotkey)

View File

@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" name="listening-session-modal" :width="700" :height="'unset'">
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
@@ -96,6 +96,10 @@
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
</div>
</div>
<div class="flex items-center">
<ui-btn small color="error" @click.stop="deleteSessionClick">Delete</ui-btn>
</div>
</div>
</modals-modal>
</template>
@@ -110,7 +114,9 @@ export default {
}
},
data() {
return {}
return {
processing: false
}
},
computed: {
show: {
@@ -147,7 +153,37 @@ export default {
return 'Unknown'
}
},
methods: {},
methods: {
deleteSessionClick() {
const payload = {
message: `Are you sure you want to delete this session?`,
callback: (confirmed) => {
if (confirmed) {
this.deleteSession()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteSession() {
this.processing = true
this.$axios
.$delete(`/api/sessions/${this._session.id}`)
.then(() => {
this.processing = false
this.$toast.success('Session deleted successfully')
this.$emit('removedSession')
this.show = false
})
.catch((error) => {
this.processing = false
console.error('Failed to delete session', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to delete session')
})
}
},
mounted() {}
}
</script>

View File

@@ -50,7 +50,8 @@ export default {
return {
el: null,
content: null,
preventClickoutside: false
preventClickoutside: false,
isShowingPrompt: false
}
},
watch: {
@@ -93,7 +94,7 @@ export default {
this.show = false
},
clickBg(ev) {
if (!this.show) return
if (!this.show || this.isShowingPrompt) return
if (this.preventClickoutside) {
this.preventClickoutside = false
return
@@ -147,8 +148,16 @@ export default {
} else {
console.warn('Invalid modal init', this.name)
}
},
showingPrompt(isShowing) {
this.isShowingPrompt = isShowing
}
},
mounted() {}
mounted() {
this.$eventBus.$on('showing-prompt', this.showingPrompt)
},
beforeDestroy() {
this.$eventBus.$off('showing-prompt', this.showingPrompt)
}
}
</script>

View File

@@ -70,7 +70,7 @@ export default {
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem

View File

@@ -116,6 +116,9 @@ export default {
if (result.updated) {
this.$toast.success('Author updated')
this.show = false
} else if (result.merged) {
this.$toast.success('Author merged')
this.show = false
} else this.$toast.info('No updates were needed')
}
this.processing = false

View File

@@ -1,6 +1,5 @@
<template>
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-bg" :class="wrapperClass" @click="click" @mouseover="mouseover" @mouseleave="mouseleave">
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400">
{{ this.$secondsToTimestamp(bookmark.time) }}

View File

@@ -0,0 +1,73 @@
<template>
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Changelog</p>
</div>
</template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
<div class="custom-text" v-html="compiledMarkedown" />
</div>
</modals-modal>
</template>
<script>
import { marked } from '@/static/libs/marked/index.js'
export default {
props: {
value: Boolean,
changelog: String,
currentVersion: String
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
compiledMarkedown() {
return marked.parse(this.changelog, { gfm: true, breaks: true })
},
currentVersionNumber() {
return this.currentVersion
}
},
methods: {
init() {}
},
mounted() {}
}
</script>
<style scoped>
/*
1. we need to manually define styles to apply to the parsed markdown elements,
since we don't have access to the actual elements in this component
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
*/
.custom-text ::v-deep > h2 {
@apply text-lg font-bold;
}
.custom-text ::v-deep > h3 {
@apply text-lg font-bold;
}
.custom-text ::v-deep > ul {
@apply list-disc list-inside pb-4;
}
</style>

View File

@@ -46,12 +46,14 @@ export default {
{
id: 'chapters',
title: 'Chapters',
component: 'modals-item-tabs-chapters'
component: 'modals-item-tabs-chapters',
mediaType: 'book'
},
{
id: 'episodes',
title: 'Episodes',
component: 'modals-item-tabs-episodes'
component: 'modals-item-tabs-episodes',
mediaType: 'podcast'
},
{
id: 'files',
@@ -64,9 +66,18 @@ export default {
component: 'modals-item-tabs-match'
},
{
id: 'manage',
title: 'Manage',
component: 'modals-item-tabs-manage'
id: 'tools',
title: 'Tools',
component: 'modals-item-tabs-tools',
mediaType: 'book',
admin: true
},
{
id: 'schedule',
title: 'Schedule',
component: 'modals-item-tabs-schedule',
mediaType: 'podcast',
admin: true
}
]
}
@@ -120,16 +131,20 @@ export default {
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.id === 'manage' && (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.mediaType && this.mediaType !== tab.mediaType) return false
if (tab.admin && !this.userIsAdminOrUp) return false
if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
if (tab.id === 'tools' && this.isMissing) return false
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
if (tab.id === 'match' && this.userCanUpdate) return true
return false
})
@@ -190,7 +205,6 @@ export default {
if (prevBook) {
this.unregisterListeners()
this.libraryItem = prevBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', prevBook)
this.$nextTick(this.registerListeners)
} else {
@@ -210,7 +224,6 @@ export default {
if (nextBook) {
this.unregisterListeners()
this.libraryItem = nextBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', nextBook)
this.$nextTick(this.registerListeners)
} else {
@@ -218,8 +231,10 @@ export default {
}
},
selectTab(tab) {
if (this.selectedTab === tab) return
if (this.availableTabs.find((t) => t.id === tab)) {
this.selectedTab = tab
this.processing = false
}
},
libraryItemUpdated(expandedLibraryItem) {

View File

@@ -31,9 +31,9 @@
<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.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
</template>
@@ -58,7 +58,7 @@
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</template>
@@ -125,15 +125,12 @@ export default {
return this.$store.state.scanners.providers
},
searchTitleLabel() {
if (this.provider == 'audible') return 'Search Title or ASIN'
if (this.provider.startsWith('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
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
@@ -167,7 +164,7 @@ export default {
.filter((f) => f.fileType === 'image')
.map((file) => {
var _file = { ...file }
_file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
return _file
})
}

View File

@@ -12,11 +12,11 @@
<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-2 md:mr-4">
<ui-tooltip :disabled="!!quickMatching" :text="`Populate empty ${mediaType} details & cover with first ${mediaType} result from '${libraryProvider}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.`" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md:mr-4">
<ui-tooltip :disabled="!!libraryScan" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>

View File

@@ -2,8 +2,15 @@
<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="userIsAdminOrUp" class="flex items-end justify-end mb-4">
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" label="Max episodes" class="w-16 mr-2" input-class="h-10">
<div class="flex -mb-0.5">
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">Limit</p>
<ui-tooltip direction="top" text="Max # of episodes to download. Use 0 for unlimited.">
<span class="material-icons text-base">info_outlined</span>
</ui-tooltip>
</div>
</ui-text-input-with-label>
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
</div>
@@ -52,7 +59,8 @@ export default {
data() {
return {
checkingNewEpisodes: false,
lastEpisodeCheckInput: null
lastEpisodeCheckInput: null,
maxEpisodesToDownload: 3
}
},
watch: {
@@ -89,6 +97,16 @@ export default {
if (this.$refs.lastCheckInput) {
this.$refs.lastCheckInput.blur()
}
if (this.$refs.maxEpisodesInput) {
this.$refs.maxEpisodesInput.blur()
}
if (this.maxEpisodesToDownload < 0) {
this.maxEpisodesToDownload = 3
this.$toast.error('Invalid max episodes to download')
return
}
this.checkingNewEpisodes = true
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
@@ -102,7 +120,7 @@ export default {
}
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
.$get(`/api/podcasts/${this.libraryItemId}/checknew?limit=${this.maxEpisodesToDownload}`)
.then((response) => {
if (response.episodes && response.episodes.length) {
console.log('New episodes', response.episodes.length)

View File

@@ -1,267 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<!-- Merge to m4b -->
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
</div>
<div class="flex-grow" />
<div class="mt-2 md:mt-0">
<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>
<!-- Split to mp3 -->
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Split M4B to MP3's</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</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="true" @click="startAudiobookMerge">Not yet implemented</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>
<!-- Embed Metadata -->
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Embed Metadata</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
>Open Manager
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
</div>
</div>
</div>
<p v-if="showM4bDownload" 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 30 minutes.
</p>
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
<p v-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: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
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'
},
chapters() {
return this.media.chapters || []
},
showM4bDownload() {
if (!this.mediaTracks.length) return false
return !this.isSingleM4b
},
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
}
},
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,14 +1,14 @@
<template>
<div id="match-wrapper" class="w-full h-full overflow-hidden px-4 py-6 relative">
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
<div class="w-36 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div>
<div class="w-72 px-1">
<div class="flex-grow md: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">
<div v-show="provider != 'itunes'" class="w-60 md: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>
@@ -20,140 +20,145 @@
<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">
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4">
<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">
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-4">
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="clearSelectedMatch">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-3">Update Book Details</p>
</div>
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
<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 v-if="selectedMatchOrig.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly label="Cover" class="flex-grow mx-4" />
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
</a>
</div>
</div>
<div v-if="selectedMatch.title" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.title" />
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.subtitle" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.author" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" />
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</p>
</div>
</div>
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publisher" />
<div v-if="selectedMatchOrig.publisher" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.publishedYear" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.series" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<widgets-series-input-widget v-model="selectedMatch.series" />
<widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.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.genres" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.genres" />
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
<p v-if="mediaMetadata.genresList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genresList || '' }}</p>
<ui-multi-select v-model="selectedMatch.genres" :items="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genres.join(', ') }}</p>
</div>
</div>
<div v-if="selectedMatch.tags" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.tags" />
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" />
<p v-if="mediaMetadata.tagsList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.tagsList || '' }}</p>
<p v-if="media.tags" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ media.tags.join(', ') }}</p>
</div>
</div>
<div v-if="selectedMatch.language" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.language" />
<div v-if="selectedMatchOrig.language" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" />
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.isbn" />
<div v-if="selectedMatchOrig.isbn" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.asin" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.itunesId" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.feedUrl" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.itunesPageUrl" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
<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 v-if="selectedMatchOrig.releaseDate" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
<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>
@@ -187,6 +192,7 @@ export default {
searchResults: [],
hasSearched: false,
selectedMatch: null,
selectedMatchOrig: null,
selectedMatchUsage: {
title: true,
subtitle: true,
@@ -197,7 +203,6 @@ export default {
publisher: true,
publishedYear: true,
series: true,
volumeNumber: true,
genres: true,
tags: true,
language: true,
@@ -209,7 +214,8 @@ export default {
itunesId: true,
feedUrl: true,
releaseDate: true
}
},
selectAll: true
}
},
watch: {
@@ -234,26 +240,25 @@ export default {
return this.selectedMatch.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
name: se.series,
sequence: se.volumeNumber || ''
sequence: se.sequence || ''
}
})
},
set(val) {
console.log('set series items', val)
this.selectedMatch.series = val
}
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/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'
if (this.provider.startsWith('audible')) return 'Search Title or ASIN'
else if (this.provider == 'itunes') return 'Search Term'
return 'Search Title'
},
@@ -271,6 +276,14 @@ export default {
}
},
methods: {
selectAllToggled(val) {
for (const key in this.selectedMatchUsage) {
this.selectedMatchUsage[key] = val
}
},
checkboxToggled() {
this.selectAll = Object.values(this.selectedMatchUsage).findIndex((v) => v == false) < 0
},
persistProvider() {
try {
localStorage.setItem('book-provider', this.provider)
@@ -299,7 +312,7 @@ export default {
this.isProcessing = true
this.lastSearch = searchQuery
var searchEntity = this.isPodcast ? 'podcast' : 'books'
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`).catch((error) => {
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
console.error('Failed', error)
return []
})
@@ -323,7 +336,7 @@ export default {
this.hasSearched = true
},
init() {
this.selectedMatch = null
this.clearSelectedMatch()
this.selectedMatchUsage = {
title: true,
subtitle: true,
@@ -334,7 +347,6 @@ export default {
publisher: true,
publishedYear: true,
series: true,
volumeNumber: true,
genres: true,
tags: true,
language: true,
@@ -363,6 +375,10 @@ export default {
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
if (this.searchTitle) {
this.submitSearch()
}
},
selectMatch(match) {
if (match) {
@@ -373,37 +389,34 @@ export default {
match.series = match.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
name: se.series,
sequence: se.volumeNumber || ''
sequence: se.sequence || ''
}
})
}
}
if (match.genres && Array.isArray(match.genres)) {
match.genres = match.genres.join(',')
if (match.genres && !Array.isArray(match.genres)) {
// match.genres = match.genres.join(',')
match.genres = match.genres.split(',').map((g) => g.trim())
}
}
console.log('Select Match', match)
this.selectedMatch = match
this.selectedMatchOrig = JSON.parse(JSON.stringify(match))
},
buildMatchUpdatePayload() {
var updatePayload = {}
updatePayload.metadata = {}
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 seriesPayload = []
if (!Array.isArray(this.selectedMatch[key])) {
seriesPayload.push({
id: `new-${Math.floor(Math.random() * 10000)}`,
name: this.selectedMatch[key],
sequence: volumeNumber
})
console.error('Invalid series in selectedMatch', this.selectedMatch[key])
} else {
var seriesPayload = []
this.selectedMatch[key].forEach((seriesItem) =>
seriesPayload.push({
id: seriesItem.id,
@@ -411,9 +424,8 @@ export default {
sequence: seriesItem.sequence
})
)
updatePayload.metadata.series = seriesPayload
}
updatePayload.metadata.series = seriesPayload
} else if (key === 'author' && !this.isPodcast) {
var authors = this.selectedMatch[key]
if (!Array.isArray(authors)) {
@@ -430,7 +442,8 @@ export default {
} else if (key === 'narrator') {
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'genres') {
updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
updatePayload.metadata.genres = [...this.selectedMatch[key]]
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'itunesId') {
@@ -481,15 +494,19 @@ export default {
} else {
this.$toast.info('No detail updates were necessary')
}
this.selectedMatch = null
this.clearSelectedMatch()
this.$emit('selectTab', 'details')
} else {
this.$toast.error('Item Details Failed to Update')
}
} else {
this.selectedMatch = null
this.clearSelectedMatch()
}
this.isProcessing = false
},
clearSelectedMatch() {
this.selectedMatch = null
this.selectedMatchOrig = null
}
}
}
@@ -497,6 +514,11 @@ export default {
<style>
.matchListWrapper {
height: calc(100% - 80px);
height: calc(100% - 124px);
}
@media (min-width: 768px) {
.matchListWrapper {
height: calc(100% - 80px);
}
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="w-full h-full relative">
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6">
<template v-if="!feedUrl">
<widgets-alert type="warning" class="text-base mb-4">No RSS feed URL is set for this podcast</widgets-alert>
</template>
<template v-if="feedUrl || autoDownloadEpisodes">
<div class="flex items-center justify-between mb-4">
<p class="text-base md:text-xl font-semibold">Schedule Automatic Episode Downloads</p>
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
</div>
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" />
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download.">
<p class="pl-4 text-base">
Max episodes to keep
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded.">
<p class="pl-4 text-base">
Max new episodes to download per check
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoDownloadEpisodes" v-model="cronExpression" />
</template>
</div>
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
<div class="flex items-center px-2 md:px-4">
<div class="flex-grow" />
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? 'Save' : 'No update necessary' }}</ui-btn>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
enableAutoDownloadEpisodes: false,
cronExpression: null,
newMaxEpisodesToKeep: 0,
newMaxNewEpisodesToDownload: 0
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
feedUrl() {
return this.mediaMetadata.feedUrl
},
autoDownloadEpisodes() {
return !!this.media.autoDownloadEpisodes
},
autoDownloadSchedule() {
return this.media.autoDownloadSchedule
},
maxEpisodesToKeep() {
return this.media.maxEpisodesToKeep
},
maxNewEpisodesToDownload() {
return this.media.maxNewEpisodesToDownload
},
isUpdated() {
return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep) || this.maxNewEpisodesToDownload !== Number(this.newMaxNewEpisodesToDownload)
}
},
methods: {
updatedMaxEpisodesToKeep() {
if (isNaN(this.newMaxEpisodesToKeep) || this.newMaxEpisodesToKeep < 0) {
this.newMaxEpisodesToKeep = 0
} else {
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
}
},
updateMaxNewEpisodesToDownload() {
if (isNaN(this.newMaxNewEpisodesToDownload) || this.newMaxNewEpisodesToDownload < 0) {
this.newMaxNewEpisodesToDownload = 0
} else {
this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)
}
},
save() {
// If custom expression input is focused then unfocus it instead of submitting
if (this.$refs.cronExpressionBuilder && this.$refs.cronExpressionBuilder.checkBlurExpressionInput) {
if (this.$refs.cronExpressionBuilder.checkBlurExpressionInput()) {
return
}
}
if (this.$refs.maxEpisodesInput && this.$refs.maxEpisodesInput.isFocused) {
this.$refs.maxEpisodesInput.blur()
return
}
const updatePayload = {
autoDownloadEpisodes: this.enableAutoDownloadEpisodes
}
if (this.enableAutoDownloadEpisodes) {
updatePayload.autoDownloadSchedule = this.cronExpression
}
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
}
if (this.newMaxNewEpisodesToDownload !== this.maxNewEpisodesToDownload) {
updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload
}
this.updateDetails(updatePayload)
},
async updateDetails(updatePayload) {
this.isProcessing = true
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, 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')
return true
} else {
this.$toast.info('No updates were necessary')
}
}
return false
},
init() {
this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes
this.cronExpression = this.autoDownloadSchedule
this.newMaxEpisodesToKeep = this.maxEpisodesToKeep
this.newMaxNewEpisodesToDownload = this.maxNewEpisodesToDownload
}
},
mounted() {
this.init()
}
}
</script>
<style scoped>
#scheduleWrapper {
height: calc(100% - 80px);
max-height: calc(100% - 80px);
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-xl font-semibold mb-2">Audiobook File Management Tools</p>
<!-- Merge to m4b -->
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">Make M4B Audiobook File</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=m4b`" class="flex items-center"
>Open Manager
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
</div>
</div>
</div>
<!-- Split to mp3 -->
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Split M4B to MP3's</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :disabled="true">Not yet implemented</ui-btn>
</div>
</div>
</div>
<!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Embed Metadata</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=embed`" class="flex items-center"
>Open Manager
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
</div>
</div>
</div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks</p>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaTracks() {
return this.media.tracks || []
},
isSingleM4b() {
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
},
chapters() {
return this.media.chapters || []
},
showM4bDownload() {
if (!this.mediaTracks.length) return false
return !this.isSingleM4b
},
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="w-full h-full px-4 py-2 mb-4">
<div class="w-full h-full px-1 md:px-4 py-2 mb-4">
<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">

View File

@@ -2,7 +2,7 @@
<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>
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
@@ -11,10 +11,10 @@
</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="px-2 md: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" ref="tabComponent" :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="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
<div class="flex justify-end">
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
</div>
@@ -46,6 +46,11 @@ export default {
id: 'settings',
title: 'Settings',
component: 'modals-libraries-library-settings'
},
{
id: 'schedule',
title: 'Schedule',
component: 'modals-libraries-schedule-scan'
}
],
libraryCopy: null
@@ -84,6 +89,7 @@ export default {
},
updateLibrary(library) {
this.mapLibraryToCopy(library)
console.log('Updated library', this.libraryCopy)
},
getNewLibraryData() {
return {
@@ -93,9 +99,11 @@ export default {
icon: 'database',
mediaType: 'book',
settings: {
coverAspectRatio: this.$constants.BookCoverAspectRatio.SQUARE,
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false
skipMatchingMediaWithIsbn: false,
autoScanCronExpression: null
}
}
},
@@ -112,7 +120,9 @@ export default {
if (key === 'folders') {
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
} else if (key === 'settings') {
this.libraryCopy.settings = { ...library.settings }
for (const settingKey in library.settings) {
this.libraryCopy.settings[settingKey] = library.settings[settingKey]
}
} else {
this.libraryCopy[key] = library[key]
}
@@ -134,6 +144,13 @@ export default {
submit() {
if (!this.validate()) return
// If custom expression input is focused then unfocus it instead of submitting
if (this.$refs.tabComponent && this.$refs.tabComponent.checkBlurExpressionInput) {
if (this.$refs.tabComponent.checkBlurExpressionInput()) {
return
}
}
if (this.library) {
this.submitUpdateLibrary()
} else {

View File

@@ -29,9 +29,10 @@
<div v-else-if="loadingFolders" class="py-12 text-center">
<p>Loading folders...</p>
</div>
<div v-else class="py-12 text-center">
<div v-else class="py-12 text-center max-w-sm mx-auto">
<p class="text-lg mb-2">No Folders Available</p>
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
<p class="text-gray-300 mb-2">Note: folders already mapped will not be shown</p>
<p v-if="isDebian" class="text-red-400">Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.</p>
</div>
<div class="w-full py-2">
@@ -88,6 +89,12 @@ export default {
...d
}
})
},
isDebian() {
return this.Source == 'debian'
},
Source() {
return this.$store.state.Source
}
},
methods: {

View File

@@ -1,23 +1,32 @@
<template>
<div class="w-full h-full px-4 py-1 mb-4">
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
<ui-tooltip :text="tooltips.coverAspectRatio">
<p class="pl-4 text-base">
Use square book covers
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
<ui-toggle-switch v-else disabled :value="false" />
<p class="pl-4 text-lg">Disable folder watcher for library</p>
<p class="pl-4 text-base">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 v-if="mediaType == 'book'" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
<p class="pl-4 text-base">Skip matching books that already have an ASIN</p>
</div>
</div>
<div v-if="mediaType == 'book'" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
<p class="pl-4 text-base">Skip matching books that already have an ISBN</p>
</div>
</div>
</div>
@@ -35,9 +44,13 @@ export default {
data() {
return {
provider: null,
useSquareBookCovers: false,
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false
skipMatchingMediaWithIsbn: false,
tooltips: {
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
}
}
},
computed: {
@@ -59,6 +72,7 @@ export default {
getLibraryData() {
return {
settings: {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
@@ -69,6 +83,7 @@ export default {
this.$emit('update', this.getLibraryData())
},
init() {
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn

View File

@@ -0,0 +1,56 @@
<template>
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
<div class="flex items-center justify-between mb-4">
<p class="text-base md:text-xl font-semibold">Schedule Automatic Library Scans</p>
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
</div>
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
</div>
</template>
<script>
export default {
props: {
library: {
type: Object,
default: () => null
},
processing: Boolean
},
data() {
return {
cronExpression: null,
enableAutoScan: false
}
},
computed: {},
methods: {
checkBlurExpressionInput() {
// returns true if advanced cron input is focused
if (!this.$refs.cronExpressionBuilder) return false
return this.$refs.cronExpressionBuilder.checkBlurExpressionInput()
},
toggleEnableAutoScan(v) {
if (!v) this.updatedCron(null)
else if (!this.cronExpression) {
this.cronExpression = '0 0 * * 1'
this.updatedCron(this.cronExpression)
}
},
updatedCron(expression) {
this.$emit('update', {
settings: {
autoScanCronExpression: expression
}
})
},
init() {
this.cronExpression = this.library.settings.autoScanCronExpression
this.enableAutoScan = !!this.cronExpression
}
},
mounted() {
this.init()
}
}
</script>

View File

@@ -0,0 +1,188 @@
<template>
<modals-modal ref="modal" v-model="show" name="notification-edit" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full px-3 py-5 md:p-12">
<ui-dropdown v-model="newNotification.eventName" label="Notification Event" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
<ui-multi-select v-model="newNotification.urls" label="Apprise URL(s)" class="mb-2" />
<ui-text-input-with-label v-model="newNotification.titleTemplate" label="Title Template" class="mb-2" />
<ui-textarea-with-label v-model="newNotification.bodyTemplate" label="Body Template" :rows="4" class="mb-2" />
<p v-if="availableVariables" class="text-sm text-gray-300"><strong>Available variables:</strong> {{ availableVariables.join(', ') }}</p>
<div class="flex items-center pt-4">
<div class="flex items-center">
<ui-toggle-switch v-model="newNotification.enabled" />
<p class="text-lg pl-2">Enabled</p>
</div>
<div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn>
</div>
</div>
</div>
</form>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
notification: {
type: Object,
default: () => null
},
notificationData: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false,
newNotification: {},
isNew: true
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
notificationEvents() {
if (!this.notificationData) return []
return this.notificationData.events || []
},
eventOptions() {
return this.notificationEvents.map((e) => ({ value: e.name, text: e.name, subtext: e.description }))
},
selectedEventData() {
return this.notificationEvents.find((e) => e.name === this.newNotification.eventName)
},
showLibrarySelectInput() {
return this.selectedEventData && this.selectedEventData.requiresLibrary
},
title() {
return this.isNew ? 'Create Notification' : 'Update Notification'
},
availableVariables() {
return this.selectedEventData ? this.selectedEventData.variables || null : null
}
},
methods: {
eventOptionUpdated() {
if (!this.selectedEventData) return
this.newNotification.titleTemplate = this.selectedEventData.defaults.title || ''
this.newNotification.bodyTemplate = this.selectedEventData.defaults.body || ''
},
close() {
// Force close when navigating - used in UsersTable
if (this.$refs.modal) this.$refs.modal.setHide()
},
submitForm() {
if (!this.newNotification.urls.length) {
this.$toast.error('Must enter an Apprise URL')
return
}
if (this.isNew) {
this.submitCreate()
} else {
this.submitUpdate()
}
},
submitUpdate() {
this.processing = true
const payload = {
...this.newNotification
}
console.log('Sending update notification', payload)
this.$axios
.$patch(`/api/notifications/${payload.id}`, payload)
.then((updatedSettings) => {
this.$emit('update', updatedSettings)
this.$toast.success('Notification updated')
this.show = false
})
.catch((error) => {
console.error('Failed to update notification', error)
this.$toast.error('Failed to update notification')
})
.finally(() => {
this.processing = false
})
},
submitCreate() {
this.processing = true
const payload = {
...this.newNotification
}
console.log('Sending create notification', payload)
this.$axios
.$post('/api/notifications', payload)
.then((updatedSettings) => {
this.$emit('update', updatedSettings)
this.$toast.success('Notification created')
this.show = false
})
.catch((error) => {
console.error('Failed to create notification', error)
this.$toast.error('Failed to create notification')
})
.finally(() => {
this.processing = false
})
},
init() {
this.isNew = !this.notification
if (this.notification) {
this.newNotification = {
id: this.notification.id,
libraryId: this.notification.libraryId,
eventName: this.notification.eventName,
urls: [...this.notification.urls],
titleTemplate: this.notification.titleTemplate,
bodyTemplate: this.notification.bodyTemplate,
enabled: this.notification.enabled,
type: this.notification.type
}
} else {
this.newNotification = {
libraryId: null,
eventName: 'onTest',
urls: [],
titleTemplate: '',
bodyTemplate: '',
enabled: true,
type: null
}
this.eventOptionUpdated()
}
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div v-if="item" class="w-full flex items-center px-4 py-2" :class="wrapperClass" @mouseover="mouseover" @mouseleave="mouseleave">
<covers-preview-cover :src="coverUrl" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
<div class="flex-grow px-2 py-1 queue-item-row-content truncate">
<p class="text-gray-200 text-sm truncate">{{ title }}</p>
<p class="text-gray-300 text-sm">{{ subtitle }}</p>
<p v-if="caption" class="text-gray-400 text-xs">{{ caption }}</p>
</div>
<div class="w-28">
<p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">Streaming</p>
<div v-else-if="isHovering" class="flex items-center justify-end -mx-1">
<button class="outline-none mx-1 flex items-center" @click.stop="playClick">
<span class="material-icons text-success">play_arrow</span>
</button>
<button class="outline-none mx-1 flex items-center" @click.stop="removeClick">
<span class="material-icons text-error">close</span>
</button>
</div>
<p v-else class="text-gray-400 text-sm text-right">{{ durationPretty }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
item: {
type: Object,
default: () => {}
},
index: Number
},
data() {
return {
isHovering: false
}
},
computed: {
title() {
return this.item.title || ''
},
subtitle() {
return this.item.subtitle || ''
},
caption() {
return this.item.caption
},
libraryItemId() {
return this.item.libraryItemId
},
episodeId() {
return this.item.episodeId
},
coverPath() {
return this.item.coverPath
},
coverUrl() {
if (!this.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
duration() {
return this.item.duration
},
durationPretty() {
if (!this.duration) return 'N/A'
return this.$elapsedPretty(this.duration)
},
isOpenInPlayer() {
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)
},
wrapperClass() {
if (this.isOpenInPlayer) return 'bg-yellow-400 bg-opacity-10'
if (this.index % 2 === 0) return 'bg-gray-300 bg-opacity-5 hover:bg-opacity-10'
return 'bg-bg hover:bg-gray-300 hover:bg-opacity-10'
}
},
methods: {
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
playClick() {
this.$emit('play', this.item)
},
removeClick() {
this.$emit('remove', this.item)
}
},
mounted() {}
}
</script>
<style scoped>
.queue-item-row-content {
max-width: calc(100% - 48px - 128px);
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Player Queue</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4" style="max-height: 80vh">
<div v-if="show" class="w-full h-full">
<div class="pb-4 px-4 flex items-center">
<p class="text-base text-gray-200">Player Queue</p>
<p class="text-base text-gray-400 px-4">{{ playerQueueItems.length }} Items</p>
<div class="flex-grow" />
<ui-checkbox v-model="playerQueueAutoPlay" label="Auto Play" medium checkbox-bg="primary" border-color="gray-600" label-class="pl-2 mb-px" />
</div>
<modals-player-queue-item-row v-for="(item, index) in playerQueueItems" :key="index" :item="item" :index="index" @play="playItem" @remove="removeItem" />
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItemId: String
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
playerQueueAutoPlay: {
get() {
return this.$store.state.playerQueueAutoPlay
},
set(val) {
this.$store.commit('setPlayerQueueAutoPlay', val)
}
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
}
},
methods: {
playItem(item) {
this.$eventBus.$emit('play-item', {
libraryItemId: item.libraryItemId,
episodeId: item.episodeId || null,
queueItems: this.playerQueueItems
})
this.show = false
},
removeItem(item) {
const updatedQueue = this.playerQueueItems.filter((i) => {
if (!i.episodeId) return i.libraryItemId !== item.libraryItemId
return i.libraryItemId !== item.libraryItemId || i.episodeId !== item.episodeId
})
this.$store.commit('setPlayerQueueItems', updatedQueue)
}
}
}
</script>

View File

@@ -5,33 +5,14 @@
<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-y-auto" style="max-height: 80vh">
<div class="flex flex-wrap">
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
</div>
<div class="w-2/5 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 default-style">
<ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" />
</div>
</div>
<div class="flex justify-end pt-4">
<ui-btn @click="submit">Submit</ui-btn>
</div>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
<div ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div>
</modals-modal>
</template>
@@ -41,25 +22,19 @@ export default {
data() {
return {
processing: false,
newEpisode: {
season: null,
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()
}
selectedTab: 'details',
tabs: [
{
id: 'details',
title: 'Details',
component: 'modals-podcast-tabs-episode-details'
},
{
id: 'match',
title: 'Match',
component: 'modals-podcast-tabs-episode-match'
}
]
}
},
computed: {
@@ -77,67 +52,29 @@ export default {
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'
},
tabComponentName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
}
},
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.season = this.episode.season || ''
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)
})
selectTab(tab) {
this.selectedTab = tab
}
},
mounted() {}
}
</script>
<style scoped>
.tab {
height: 40px;
}
.tab.tab-selected {
height: 41px;
}
</style>

View File

@@ -11,11 +11,11 @@
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)"
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(index, episode)"
>
<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>
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
@@ -23,20 +23,13 @@
<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>
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
</div>
</div>
</modals-modal>
@@ -58,7 +51,8 @@ export default {
data() {
return {
processing: false,
selectedEpisodes: {}
selectedEpisodes: {},
selectAll: false
}
},
watch: {
@@ -78,22 +72,12 @@ export default {
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
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
@@ -115,8 +99,27 @@ export default {
}
},
methods: {
toggleSelectEpisode(index) {
toggleSelectAll(val) {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
else this.$set(this.selectedEpisodes, String(i), val)
}
},
checkSetIsSelectedAll() {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
this.selectAll = false
return
}
}
this.selectAll = true
},
toggleSelectEpisode(index, episode) {
if (this.itemEpisodeMap[episode.enclosure.url]) return
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
this.checkSetIsSelectedAll()
},
submit() {
var episodesToDownload = []
@@ -145,17 +148,15 @@ export default {
console.error('Failed to download episodes', error)
this.processing = false
this.$toast.error(errorMsg)
this.selectedEpisodes = {}
this.selectAll = false
})
},
init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
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)
}
}
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.selectAll = false
this.selectedEpisodes = {}
}
},
mounted() {}
@@ -170,4 +171,4 @@ export default {
#episodes-scroll {
max-height: calc(80vh - 200px);
}
</style>
</style>

View File

@@ -1,18 +1,18 @@
<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 class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
</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 ref="wrapper" id="podcast-wrapper" class="p-2 md:p-8 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
<div class="w-full">
<p class="text-lg font-semibold mb-2 px-2">Details</p>
<div v-if="podcast.imageUrl" class="p-1 w-full">
<div v-if="podcast.imageUrl" class="p-2 w-full">
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
</div>
<div class="flex">
<div class="flex flex-wrap">
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
</div>
@@ -20,7 +20,7 @@
<ui-text-input-with-label v-model="podcast.author" label="Author" />
</div>
</div>
<div class="flex">
<div class="flex flex-wrap">
<div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
</div>
@@ -31,19 +31,19 @@
<div class="p-2 w-full">
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
</div>
<div class="flex">
<div class="flex flex-wrap">
<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 />
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" input-class="h-10" readonly />
</div>
</div>
</div>
<div class="flex items-center py-4">
<div class="flex items-center py-4 px-2">
<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" />
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm md:text-base font-semibold" />
</div>
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
</div>

View File

@@ -7,16 +7,17 @@
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div class="mb-4">
<p class="text-lg text-gray-200 mb-4">
<p v-if="episode" class="text-lg text-gray-200 mb-4">
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
>?
</p>
<p v-else class="text-lg text-gray-200 mb-4">Are you sure you want to remove {{ episodes.length }} episodes?</p>
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
</div>
<div class="flex justify-between items-center pt-4">
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
<ui-btn @click="submit">{{ btnText }}</ui-btn>
</div>
</div>
</modals-modal>
@@ -30,9 +31,9 @@ export default {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
episodes: {
type: Array,
default: () => []
}
},
data() {
@@ -55,34 +56,49 @@ export default {
this.$emit('input', val)
}
},
episode() {
if (this.episodes.length === 1) return this.episodes[0]
return null
},
title() {
if (this.episodes.length > 1) return `Remove ${this.episodes.length} episodes`
return 'Remove Episode'
},
episodeId() {
return this.episode ? this.episode.id : null
btnText() {
if (this.episodes.length > 1) return this.hardDeleteFile ? `Delete ${this.episodes.length} episodes` : `Remove ${this.episodes.length} episodes`
return this.hardDeleteFile ? 'Delete episode' : 'Remove episode'
},
episodeTitle() {
return this.episode ? this.episode.title : null
}
},
methods: {
submit() {
async submit() {
this.processing = true
var queryString = this.hardDeleteFile ? '?hard=1' : ''
this.$axios
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
.then(() => {
for (const episode of this.episodes) {
const success = await this.$axios
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${episode.id}${queryString}`)
.then(() => true)
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to remove episode'
console.error('Failed to remove episode', error)
this.$toast.error(errorMsg)
return false
})
if (!success) {
this.processing = false
this.$toast.success('Podcast episode removed')
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
console.error('Failed update episode', error)
this.processing = false
this.$toast.error(errorMsg)
})
this.$emit('clearSelected')
return
}
}
this.processing = false
this.$toast.success(`${this.episodes.length} episode${this.episodes.length > 1 ? 's' : ''} removed`)
this.show = false
this.$emit('clearSelected')
}
},
mounted() {}

View File

@@ -66,7 +66,7 @@ export default {
return this.mediaMetadata.author
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
}
},
methods: {},

View File

@@ -0,0 +1,149 @@
<template>
<div>
<div class="flex flex-wrap">
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
</div>
<div class="w-2/5 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 default-style">
<ui-rich-text-editor label="Description" v-model="newEpisode.description" />
</div>
</div>
<div class="flex items-center justify-end pt-4">
<ui-btn @click="submit">Submit</ui-btn>
</div>
<div v-if="enclosureUrl" class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
</div>
<div v-else class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
newEpisode: {
season: null,
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: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
episodeId() {
return this.episode ? this.episode.id : null
},
enclosure() {
return this.episode ? this.episode.enclosure || {} : {}
},
enclosureUrl() {
return this.enclosure.url
}
},
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.season = this.episode.season || ''
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.isProcessing = true
this.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
.then(() => {
this.isProcessing = false
this.$toast.success('Podcast episode updated')
this.$emit('close')
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
console.error('Failed update episode', error)
this.isProcessing = false
this.$toast.error(errorMsg)
})
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,156 @@
<template>
<div style="min-height: 200px">
<template v-if="!podcastFeedUrl">
<div class="py-8">
<widgets-alert type="error">Podcast has no RSS feed url to use for matching</widgets-alert>
</div>
</template>
<template v-else>
<form @submit.prevent="submitForm">
<div class="flex mb-2">
<ui-text-input-with-label v-model="episodeTitle" :disabled="isProcessing" label="Episode Title" class="pr-1" />
<ui-btn class="mt-5 ml-1" :loading="isProcessing" type="submit">Search</ui-btn>
</div>
</form>
<div v-if="!isProcessing && searchedTitle && !episodesFound.length" class="w-full py-8">
<p class="text-center text-lg">No episode matches found</p>
</div>
<div v-for="(episode, index) in episodesFound" :key="index" class="w-full py-4 border-b border-white border-opacity-5 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer px-2" @click.stop="selectEpisode(episode)">
<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-400">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
</div>
</template>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
episodeTitle: '',
searchedTitle: '',
episodesFound: []
}
},
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
episodeId() {
return this.episode ? this.episode.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
podcastFeedUrl() {
return this.mediaMetadata.feedUrl
}
},
methods: {
getUpdatePayload(episodeData) {
var updatePayload = {}
for (const key in episodeData) {
if (key === 'enclosure') {
if (!this.episode.enclosure || JSON.stringify(this.episode.enclosure) !== JSON.stringify(episodeData.enclosure)) {
updatePayload[key] = {
...episodeData.enclosure
}
}
} else if (episodeData[key] != this.episode[key]) {
updatePayload[key] = episodeData[key]
}
}
return updatePayload
},
selectEpisode(episode) {
const episodeData = {
title: episode.title || '',
subtitle: episode.subtitle || '',
description: episode.description || '',
enclosure: episode.enclosure || null,
episode: episode.episode || '',
episodeType: episode.episodeType || '',
season: episode.season || '',
pubDate: episode.pubDate || '',
publishedAt: episode.publishedAt
}
const updatePayload = this.getUpdatePayload(episodeData)
if (!Object.keys(updatePayload).length) {
return this.$toast.info('No updates are necessary')
}
console.log('Episode update payload', updatePayload)
this.isProcessing = true
this.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatePayload)
.then(() => {
this.isProcessing = false
this.$toast.success('Podcast episode updated')
this.$emit('selectTab', 'details')
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
console.error('Failed update episode', error)
this.isProcessing = false
this.$toast.error(errorMsg)
})
},
submitForm() {
if (!this.episodeTitle || !this.episodeTitle.length) {
this.$toast.error('Must enter an episode title')
return
}
this.searchedTitle = this.episodeTitle
this.isProcessing = true
this.$axios
.$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${this.$encodeUriPath(this.episodeTitle)}`)
.then((results) => {
this.episodesFound = results.episodes.map((ep) => ep.episode)
console.log('Episodes found', this.episodesFound)
this.isProcessing = false
})
.catch((error) => {
console.error('Failed to search for episode', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to search for episode')
this.isProcessing = false
})
},
init() {
this.searchedTitle = null
this.episodesFound = []
this.episodeTitle = this.episode ? this.episode.title || '' : ''
}
},
mounted() {}
}
</script>

View File

@@ -117,7 +117,7 @@ export default {
serverAddress: window.origin,
slug: this.newFeedSlug
}
if (this.$isDev) payload.serverAddress = 'http://localhost:3333'
if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
console.log('Payload', payload)
this.$axios

View File

@@ -27,6 +27,10 @@
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
</div>
</ui-tooltip>
<button v-if="playerQueueItems.length" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
<span class="material-icons text-2xl sm:text-3xl">playlist_play</span>
</button>
</div>
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
@@ -138,6 +142,9 @@ export default {
hasNextChapter() {
if (!this.chapters.length) return false
return this.currentChapterIndex < this.chapters.length - 1
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
}
},
methods: {
@@ -226,6 +233,13 @@ export default {
})
this.updateTimestamp()
},
checkUpdateChapterTrack() {
// Changing media in player may not have chapters
if (!this.chapters.length && this.useChapterTrack) {
this.useChapterTrack = false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
}
},
seek(time) {
this.$emit('seek', time)
},
@@ -286,7 +300,10 @@ export default {
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate)
},

View File

@@ -0,0 +1,117 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-60 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-lg mb-8 mt-2 px-1" v-html="message" />
<div class="flex px-1 items-center">
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">Cancel</ui-btn>
<div class="flex-grow" />
<ui-btn v-if="isYesNo" color="success" @click="confirm">Yes</ui-btn>
<ui-btn v-else color="primary" @click="confirm">Ok</ui-btn>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {},
data() {
return {
el: null,
content: null
}
},
watch: {
show(newVal) {
if (newVal) {
this.setShow()
} else {
this.setHide()
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showConfirmPrompt
},
set(val) {
this.$store.commit('globals/setShowConfirmPrompt', val)
}
},
confirmPromptOptions() {
return this.$store.state.globals.confirmPromptOptions || {}
},
message() {
return this.confirmPromptOptions.message || ''
},
callback() {
return this.confirmPromptOptions.callback
},
type() {
return this.confirmPromptOptions.type || 'ok'
},
persistent() {
return !!this.confirmPromptOptions.persistent
},
isYesNo() {
return this.type === 'yesNo'
},
modalHeight() {
return 'unset'
},
modalWidth() {
return '500px'
}
},
methods: {
clickedOutside(evt) {
if (!this.show) return
if (evt) {
evt.stopPropagation()
evt.preventDefault()
}
if (this.persistent) return
if (this.callback) this.callback(false)
this.show = false
},
nevermind() {
if (this.callback) this.callback(false)
this.show = false
},
confirm() {
if (this.callback) this.callback(true)
this.show = false
},
setShow() {
this.$eventBus.$emit('showing-prompt', true)
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
},
setHide() {
this.$eventBus.$emit('showing-prompt', false)
this.content.style.transform = 'scale(0)'
this.el.remove()
}
},
mounted() {
this.el = this.$refs.wrapper
this.content = this.$refs.content
this.content.style.transform = 'scale(0)'
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
this.el.style.opacity = 1
this.el.remove()
},
beforeDestroy() {
if (this.show) {
this.$eventBus.$emit('showing-prompt', false)
}
}
}
</script>

View File

@@ -65,12 +65,10 @@ export default {
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
},
setHide() {
this.content.style.transform = 'scale(0)'
this.el.remove()
document.documentElement.classList.remove('modal-open')
}
},
mounted() {

View File

@@ -53,6 +53,7 @@
<script>
import Path from 'path'
import { Archive } from 'libarchive.js/main.js'
import { CompressedFile } from 'libarchive.js/src/compressed-file'
Archive.init({
workerUrl: '/libarchive/worker-bundle.js'
@@ -150,7 +151,11 @@ export default {
responseType: 'blob'
})
const archive = await Archive.open(buff)
this.filesObject = await archive.getFilesObject()
const originalFilesObject = await archive.getFilesObject()
// to support images in subfolders we need to flatten the object
// ref: https://github.com/advplyr/audiobookshelf/issues/811
this.filesObject = this.flattenFilesObject(originalFilesObject)
console.log('Extracted files object', this.filesObject)
var filenames = Object.keys(this.filesObject)
this.parseFilenames(filenames)
@@ -168,6 +173,26 @@ export default {
this.loading = false
}
},
flattenFilesObject(filesObject) {
const flattenObject = (obj, prefix = '') => {
var _obj = {}
for (const key in obj) {
const newKey = prefix ? prefix + '/' + key : key
if (obj[key] instanceof CompressedFile) {
_obj[newKey] = obj[key]
} else if (!key.startsWith('_') && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
_obj = {
..._obj,
...flattenObject(obj[key], newKey)
}
} else {
_obj[newKey] = obj[key]
}
}
return _obj
}
return flattenObject(filesObject)
},
async extractXmlFile(filename) {
console.log('extracting xml filename', filename)
try {
@@ -187,7 +212,7 @@ export default {
},
parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,4}/g)
var numbersinpath = basename.match(/\d{1,5}/g)
if (!numbersinpath || !numbersinpath.length) {
return {
index: -1,

View File

@@ -96,7 +96,9 @@ export default {
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}`
const relRelPath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
return `/ebook/${this.libraryId}/${this.folderId}/${relRelPath}`
},
userToken() {
return this.$store.getters['user/getToken']

View File

@@ -21,7 +21,7 @@
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
<td>
<div class="w-full flex flex-row items-center justify-center">
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">Restore</ui-btn>
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
<ui-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">
@@ -45,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. 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">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 backed 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>

View File

@@ -24,10 +24,10 @@
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
@@ -57,6 +57,9 @@ export default {
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
metadata() {
return this.media.metadata || {}
},
chapters() {
return this.media.chapters || []
},
@@ -67,6 +70,30 @@ export default {
methods: {
clickBar() {
this.expanded = !this.expanded
},
goToTimestamp(time) {
if (this.$store.getters['getIsMediaStreaming'](this.libraryItemId)) {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: null,
startTime: time
})
} else {
const payload = {
message: `Start playback for "${this.metadata.title}" at ${this.$secondsToTimestamp(time)}?`,
callback: (confirmed) => {
if (confirmed) {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: null,
startTime: time
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}
}
},
mounted() {}

View File

@@ -1,12 +1,13 @@
<template>
<div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 bg-primary">
<p>Collection List</p>
<div class="w-6 h-6 bg-white bg-opacity-10 flex items-center justify-center rounded-full ml-2">
<p class="font-mono text-sm">{{ books.length }}</p>
<div class="w-full h-14 flex items-center px-4 md:px-6 py-2 bg-primary">
<p class="pr-4">Collection List</p>
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ books.length }}</span>
</div>
<div class="flex-grow" />
<!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> -->
<p v-if="totalDuration" class="text-sm text-gray-200">{{ 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">
@@ -51,11 +52,18 @@ export default {
}
},
computed: {
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
totalDuration() {
var _total = 0
this.books.forEach((book) => {
_total += book.media.duration
})
return _total
},
totalDurationPretty() {
return this.$elapsedPrettyExtended(this.totalDuration)
}
},
methods: {

View File

@@ -34,7 +34,7 @@
</div>
</td>
<td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>

View File

@@ -24,6 +24,14 @@
<th class="text-left w-20">Size</th>
<th class="text-left w-20">Duration</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
<th v-if="showExperimentalFeatures" class="text-center w-20">
<div class="flex items-center">
<p>Tone</p>
<ui-tooltip text="Experimental feature for testing Tone library metadata scan results. Results logged in browser console." class="ml-2 w-2" direction="left">
<span class="material-icons-outlined text-sm">information</span>
</ui-tooltip>
</div>
</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
@@ -38,7 +46,10 @@
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text pt-1">download</span></a>
</td>
<td v-if="showExperimentalFeatures" class="text-center">
<ui-icon-btn borderless :loading="toneProbing" icon="search" @click="toneProbe(track.index)" />
</td>
</tr>
</template>
@@ -65,7 +76,8 @@ export default {
data() {
return {
showTracks: false,
showFullPath: false
showFullPath: false,
toneProbing: false
}
},
computed: {
@@ -77,11 +89,35 @@ export default {
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
clickBar() {
this.showTracks = !this.showTracks
},
toneProbe(index) {
this.toneProbing = true
this.$axios
.$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`)
.then((data) => {
console.log('Tone probe data', data)
if (data.error) {
this.$toast.error('Tone probe error: ' + data.error)
} else {
this.$toast.success('Tone probe successful! Check browser console')
}
})
.catch((error) => {
console.error('Failed to tone probe', error)
this.$toast.error('Tone probe failed')
})
.finally(() => {
this.toneProbing = false
})
}
},
mounted() {}

View File

@@ -164,8 +164,8 @@ export default {
}
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('user_added', this.addUpdateUser)
this.$root.socket.off('user_updated', this.addUpdateUser)
this.$root.socket.off('user_removed', this.userRemoved)
}
}
@@ -208,6 +208,6 @@ export default {
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #272727
background-color: #272727;
}
</style>

View File

@@ -1,12 +1,12 @@
<template>
<div class="w-full px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
<div v-if="book" class="flex h-20">
<div class="w-16 max-w-16 h-full">
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
<div v-if="book" class="flex h-16 md:h-20">
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-xl">menu</span>
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
</div>
</div>
<div class="h-full relative" :style="{ width: coverWidth + 'px' }">
<div class="h-full relative flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<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">
@@ -14,28 +14,30 @@
</div>
</div>
</div>
<div class="flex-grow max-w-md h-full px-2 flex items-center">
<div class="truncate px-1">
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
<div class="flex-grow overflow-hidden max-w-48 md:max-w-md h-full flex items-center px-2 md:px-3">
<div>
<div class="truncate max-w-48 md:max-w-md">
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<template v-for="(author, index) in bookAuthors">
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template>
</div>
<p class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p>
</div>
</div>
<div class="w-20 flex items-center">
<p class="font-mono text-sm">{{ bookDuration }}</p>
</div>
<!-- <div class="w-12 flex items-center justify-center">
<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="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : translateDistance">
<div class="flex h-full items-center">
<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'">
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
</div>
<div class="mx-1">
<div v-if="userCanDelete" class="mx-1">
<ui-icon-btn icon="close" borderless @click="removeClick" />
</div>
</div>
@@ -71,6 +73,11 @@ export default {
}
},
computed: {
translateDistance() {
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
return '-translate-x-24'
},
media() {
return this.book.media || {}
},
@@ -83,11 +90,11 @@ export default {
bookTitle() {
return this.mediaMetadata.title || ''
},
bookAuthor() {
return (this.mediaMetadata.authors || []).map((au) => au.name).join(', ')
bookAuthors() {
return this.mediaMetadata.authors || []
},
bookDuration() {
return this.$secondsToTimestamp(this.media.duration)
return this.$elapsedPretty(this.media.duration)
},
isMissing() {
return this.book.isMissing
@@ -107,9 +114,18 @@ export default {
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
coverSize() {
return this.$store.state.globals.isMobile ? 30 : 50
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
return 50
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {

View File

@@ -6,7 +6,7 @@
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies">
<div :key="library.id" class="item">
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
<widgets-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
<ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
@@ -23,7 +23,7 @@
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<span class="material-icons text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
<!-- For mobile -->
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
@@ -105,7 +105,7 @@ export default {
},
matchAll() {
this.$axios
.$post(`/api/libraries/${this.library.id}/matchall`)
.$get(`/api/libraries/${this.library.id}/matchall`)
.then(() => {
console.log('Starting scan for matches')
})

View File

@@ -1,6 +1,6 @@
<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 cursor-pointer" @click="$emit('view', episode)">
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode">
<div class="flex-grow px-2">
<p class="text-sm font-semibold">
{{ title }}
@@ -8,6 +8,12 @@
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
<div class="flex justify-between pt-2 max-w-xl">
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
</div>
<div class="flex items-center pt-2">
<button 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 focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
@@ -17,20 +23,18 @@
<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.season" class="px-4 text-sm text-gray-300">Season #{{ episode.season }}</p>
<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>
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
</div>
</div>
<div class="w-24 min-w-24" />
<div v-if="isHovering || isSelected || selectionMode" class="hidden md:block w-12 min-w-12" />
</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 v-if="isSelected || selectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !selectionMode ? 'translate-x-24' : '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" />
<ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" />
</div>
</div>
</div>
@@ -46,13 +50,15 @@ export default {
episode: {
type: Object,
default: () => {}
}
},
selectionMode: Boolean
},
data() {
return {
isProcessingReadUpdate: false,
processingRemove: false,
isHovering: false
isHovering: false,
isSelected: false
}
},
computed: {
@@ -78,7 +84,7 @@ export default {
return this.$secondsToTimestamp(this.episode.duration)
},
isStreaming() {
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
},
streamIsPlaying() {
return this.$store.state.streamIsPlaying && this.isStreaming
@@ -104,8 +110,17 @@ export default {
}
},
methods: {
clickedEpisode() {
this.$emit('view', this.episode)
},
clickedSelectionBg() {
this.isSelected = !this.isSelected
this.selectedUpdated(this.isSelected)
},
selectedUpdated(value) {
this.$emit('selected', { isSelected: value, episode: this.episode })
},
mouseover() {
// if (this.isDragging) return
this.isHovering = true
},
mouseleave() {
@@ -118,13 +133,24 @@ export default {
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
})
this.$emit('play', this.episode)
}
},
toggleFinished() {
toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${this.title}" as finished?`,
callback: (confirmed) => {
if (confirmed) {
this.toggleFinished(true)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
return
}
var updatePayload = {
isFinished: !this.userIsFinished
}

View File

@@ -3,14 +3,24 @@
<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" />
<template v-if="isSelectionMode">
<ui-tooltip :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
</ui-tooltip>
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">Remove {{ selectedEpisodes.length }} episode{{ selectedEpisodes.length > 1 ? 's' : '' }}</ui-btn>
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">Cancel</ui-btn>
</template>
<template v-else>
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-32 md:w-36 h-9 ml-1 sm:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-32 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
</template>
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
<template v-for="episode in episodesSorted">
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" />
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" />
</template>
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
</div>
</template>
@@ -25,10 +35,50 @@ export default {
data() {
return {
episodesCopy: [],
filterKey: 'incomplete',
sortKey: 'publishedAt',
sortDesc: true,
selectedEpisode: null,
showPodcastRemoveModal: false
showPodcastRemoveModal: false,
selectedEpisodes: [],
episodesToRemove: [],
processing: false,
sortItems: [
{
text: 'Pub Date',
value: 'publishedAt'
},
{
text: 'Title',
value: 'title'
},
{
text: 'Season',
value: 'season'
},
{
text: 'Episode',
value: 'episode'
}
],
filterItems: [
{
value: 'all',
text: 'Show All'
},
{
value: 'incomplete',
text: 'Incomplete'
},
{
value: 'complete',
text: 'Complete'
},
{
value: 'in_progress',
text: 'In Progress'
}
]
}
},
watch: {
@@ -37,6 +87,9 @@ export default {
}
},
computed: {
isSelectionMode() {
return this.selectedEpisodes.length > 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
@@ -50,17 +103,106 @@ export default {
return this.media.episodes || []
},
episodesSorted() {
return 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' })
return this.episodesCopy
.filter((ep) => {
if (this.filterKey === 'all') return true
const episodeProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, ep.id)
if (this.filterKey === 'incomplete') return !episodeProgress || !episodeProgress.isFinished
if (this.filterKey === 'complete') return episodeProgress && episodeProgress.isFinished
return episodeProgress && !episodeProgress.isFinished
})
.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' })
})
},
selectedIsFinished() {
// Find an item that is not finished, if none then all items finished
return !this.selectedEpisodes.find((episode) => {
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress || !itemProgress.isFinished
})
}
},
methods: {
toggleBatchFinished() {
this.processing = true
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedEpisodes.map((episode) => {
return {
libraryItemId: this.libraryItem.id,
episodeId: episode.id,
isFinished: newIsFinished
}
})
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Batch update success!')
this.processing = false
this.clearSelected()
})
.catch((error) => {
this.$toast.error('Batch update failed')
console.error('Failed to batch update read/not read', error)
this.processing = false
})
},
removeEpisodeModalToggled(val) {
if (!val) this.episodesToRemove = []
},
clearSelected() {
const episodeRows = this.$refs.episodeRow
if (episodeRows && episodeRows.length) {
for (const epRow of episodeRows) {
if (epRow) epRow.isSelected = false
}
}
this.selectedEpisodes = []
},
removeSelectedEpisodes() {
this.episodesToRemove = this.selectedEpisodes
this.showPodcastRemoveModal = true
},
episodeSelected({ isSelected, episode }) {
if (isSelected) {
this.selectedEpisodes.push(episode)
} else {
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
}
},
playEpisode(episode) {
const queueItems = []
const episodesInListeningOrder = this.episodesCopy.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
const episodeIndex = episodesInListeningOrder.findIndex((e) => e.id === episode.id)
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
const episode = episodesInListeningOrder[i]
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
if (!podcastProgress || !podcastProgress.isFinished) {
queueItems.push({
libraryItemId: this.libraryItem.id,
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})
}
}
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id,
episodeId: episode.id,
queueItems
})
},
removeEpisode(episode) {
this.selectedEpisode = episode
this.episodesToRemove = [episode]
this.showPodcastRemoveModal = true
},
editEpisode(episode) {

View File

@@ -14,6 +14,7 @@ export default {
value: Boolean,
label: String,
small: Boolean,
medium: Boolean,
checkboxBg: {
type: String,
default: 'white'
@@ -47,19 +48,23 @@ export default {
wrapperClass() {
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
if (this.small) classes.push('w-4 h-4')
else if (this.medium) classes.push('w-5 h-5')
else classes.push('w-6 h-6')
return classes.join(' ')
},
labelClassname() {
if (this.labelClass) return this.labelClass
var classes = ['pl-1']
if (this.small) classes.push('text-xs md:text-sm')
var classes = []
if (this.small) classes.push('text-xs md:text-sm pl-1')
else if (this.medium) classes.push('text-base md:text-lg pl-2')
else classes.push('pl-2')
return classes.join(' ')
},
svgClass() {
var classes = [`text-${this.checkColor}`]
if (this.small) classes.push('w-3 h-3')
else if (this.medium) classes.push('w-3.5 h-3.5')
else classes.push('w-4 h-4')
return classes.join(' ')

View File

@@ -3,7 +3,9 @@
<p class="text-sm font-semibold px-1" :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 class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
<span v-if="selectedSubtext">:&nbsp;</span>
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons">expand_more</span>
@@ -12,10 +14,12 @@
<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="item in items">
<template v-for="item in itemsToShow">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
<span v-if="item.subtext">:&nbsp;</span>
<span v-if="item.subtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ item.subtext }}</span>
</div>
</li>
</template>
@@ -58,12 +62,26 @@ export default {
this.$emit('input', val)
}
},
itemsToShow() {
return this.items.map((i) => {
if (typeof i === 'string') {
return {
text: i,
value: i
}
}
return i
})
},
selectedItem() {
return this.items.find((i) => i.value === this.selected)
return this.itemsToShow.find((i) => i.value === this.selected)
},
selectedText() {
return this.selectedItem ? this.selectedItem.text : ''
},
selectedSubtext() {
return this.selectedItem ? this.selectedItem.subtext : ''
},
buttonClass() {
var classes = []
if (this.small) classes.push('h-9')

View File

@@ -54,7 +54,7 @@ export default {
return
}
e.preventDefault()
this.$emit('click')
this.$emit('click', e)
e.stopPropagation()
}
},

View File

@@ -89,6 +89,9 @@ export default {
// this.currentSearch = this.textInput
}, 100)
},
setFocus() {
if (this.$refs.input && this.editable) this.$refs.input.focus()
},
inputFocus() {
this.isFocused = true
},

View File

@@ -1,18 +1,18 @@
<template>
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-36 relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="flex items-center justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
<span class="hidden sm:block">{{ currentLibrary.name }}</span>
</span>
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<div class="flex items-center justify-center sm:justify-start">
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
</div>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
<div class="flex items-center px-3">
<widgets-library-icon :icon="library.icon" class="mr-2" />
<li :key="library.id" class="text-gray-400 hover:text-white select-none relative py-2 cursor-pointer hover:bg-black-400" role="option" @click="selectLibrary(library)">
<div class="flex items-center px-2">
<ui-library-icon :icon="library.icon" class="mr-1.5" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
</div>
</li>

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