Compare commits

...

1635 Commits

Author SHA1 Message Date
advplyr
b4dc1c1f03 Merge branch 'master' into sqlite 2023-03-22 12:18:02 -05:00
advplyr
7181df0479 Fix:Patreon episodes with variable query strings #1622 2023-03-21 17:59:37 -05:00
advplyr
633e83a4ab Update libraryItem model to include libraryId 2023-03-21 17:06:08 -05:00
advplyr
d745e6b656 Merge branch 'master' into sqlite 2023-03-20 16:03:07 -05:00
advplyr
59b5f8cbbe Merge pull request #1624 from maltejur/master
Truncate long title in stream container
2023-03-20 16:01:23 -05:00
advplyr
b62e88c4ed Update routes/controllers/db structure 2023-03-20 15:52:33 -05:00
advplyr
d6108a0722 Merge pull request #1621 from burghy86/patch-9
Update it.json
2023-03-20 15:49:31 -05:00
advplyr
1af7e59d88 Merge pull request #1618 from fidoriel/transcode-continue-bug
Fix transcoded streams fail to continue listening
2023-03-20 15:49:11 -05:00
advplyr
7b425e9a9d Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:42 -05:00
advplyr
596a03900b Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:36 -05:00
advplyr
b283644d95 Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:31 -05:00
Malte Jürgens
808690c137 truncate long title in stream container 2023-03-20 21:28:15 +01:00
burghy86
136c347586 Update it.json
fix new line
2023-03-20 12:20:02 +01:00
advplyr
258b9ec54e Update library item example route 2023-03-19 17:41:27 -05:00
fidoriel
e81238038e m3u8url 2023-03-19 22:26:36 +00:00
fidoriel
fcf6964d7d hlsurl 2023-03-19 21:41:49 +00:00
advplyr
54ca58e610 Update model casing & associations 2023-03-19 15:19:22 -05:00
advplyr
2131a65299 Merge branch 'master' into sqlite 2023-03-19 09:21:00 -05:00
advplyr
bd75ad4576 Version bump 2.2.17 2023-03-18 17:44:45 -05:00
advplyr
f970d8e539 Merge pull request #1517 from mfcar/addEpisodeFilter
Add episodes search
2023-03-18 17:25:53 -05:00
advplyr
c49010b4e1 Merge master 2023-03-18 17:26:11 -05:00
advplyr
243bc7b0d0 Complete migration file 2023-03-18 16:56:57 -05:00
advplyr
b8de041497 Update migration file to use bulkCreate 2023-03-17 18:04:39 -05:00
advplyr
8287822354 Merge branch 'master' into sqlite 2023-03-17 17:08:11 -05:00
advplyr
146093d81e Add:Support for .awb AMR-WB audio file #1565 2023-03-17 16:52:07 -05:00
advplyr
11ccbf1913 Merge pull request #1609 from Linden-Ryuujin/feature/semicolonSeperators
Support for scanning semicolon seperated author and narator lists.
2023-03-16 17:06:22 -05:00
Linden Ryuujin
a4a334a18a Support for scanning semicolon seperated author and narator lists. 2023-03-16 21:44:03 +00:00
advplyr
387a37e4da Fix:Download podcast episodes that are not mp3 #1513 2023-03-15 18:04:31 -05:00
advplyr
0f83a292f6 Migration test force re-create tables 2023-03-15 17:50:47 -05:00
advplyr
c738e35a8c Starting db migration file 2023-03-15 17:42:35 -05:00
advplyr
b2e1e24ca5 Merge branch 'master' into sqlite 2023-03-14 16:20:08 -05:00
advplyr
ebad304aa9 Remove filePerms log 2023-03-14 15:38:53 -05:00
advplyr
8b557a0cb9 Fix:Private Patreon feed URLs getting encoded twice #1600 2023-03-14 15:38:19 -05:00
advplyr
40b808e73d Update:Use title ID3 tag on tracks when setting chapters and prefer audio metadata setting is enabled #679 2023-03-13 17:56:16 -05:00
advplyr
a8b57a1ce9 Cleanup rebuild tracks/set chapters 2023-03-13 17:45:44 -05:00
advplyr
c7f457da3e Feed and Setting models 2023-03-13 17:13:31 -05:00
advplyr
bed3758268 PlaybackSession, Playlist, PlaylistMediaItem, Device data models 2023-03-12 17:55:12 -05:00
advplyr
a1a923df94 Merge branch 'master' into sqlite 2023-03-12 16:15:59 -05:00
advplyr
35315843f2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-03-12 15:52:55 -05:00
advplyr
27b9d3b94f Update:Add support for MKA audio files #1597 2023-03-12 15:52:49 -05:00
advplyr
bbf324ea83 Adding more models 2023-03-12 14:51:45 -05:00
advplyr
0010ac5a40 Merge pull request #1599 from Nab0y/master
Update Russian localization
2023-03-11 13:58:42 -06:00
Dmitry Naboychenko
884808f34e Update russian localization 2023-03-11 22:40:24 +03:00
advplyr
adc4309951 Merge branch 'master' into sqlite 2023-03-11 11:54:34 -06:00
advplyr
f75ed07497 Update readme screenshot 2023-03-10 16:23:07 -06:00
advplyr
b707d6f3c9 Update Dockerfile run command to use node index.js 2023-03-09 11:47:48 -06:00
advplyr
b8ab72a141 Sequelize and sqlite init with test user model 2023-03-08 12:33:52 -06:00
advplyr
a2d4a4a906 Update:Home page episodes dont show a number if no episode number 2023-03-08 08:30:54 -06:00
advplyr
434d743d99 Clean translation files alphabetized 2023-03-07 10:28:10 -06:00
advplyr
30f16b05fe Merge pull request #1586 from Machou/master
fr_FR update
2023-03-07 10:13:07 -06:00
Machou
92a88f4416 Update fr.json 2023-03-07 17:05:51 +01:00
advplyr
5c9c122af2 Update:Config side nav line height and padding for cleaner wrapping 2023-03-07 09:42:33 -06:00
advplyr
620d5ce578 Add:Spanish language option 2023-03-07 09:37:55 -06:00
advplyr
363e1cee4b Merge pull request #1587 from apineiro97/master
Spanish Translation
2023-03-07 09:30:16 -06:00
advplyr
93f576772a Merge pull request #1585 from springsunx/patch-1
Update zh-cn.json
2023-03-07 09:27:52 -06:00
Machou
d4612bae92 Update fr.json 2023-03-07 06:48:24 +01:00
Arturo Pineiro
e01af27008 LabelSettingsOverdriveMediaMarkersHelp 2023-03-06 21:41:02 -08:00
Arturo Pineiro
657fe0a650 Spanish translation 2023-03-06 21:36:38 -08:00
Arturo Pineiro
9a6ec5548e fix missing translations 2023-03-06 21:33:51 -08:00
Arturo Pineiro
0807509ea7 added "ButtonDownloadQueue": "Queue", 2023-03-06 21:28:09 -08:00
Arturo Pineiro
d9d1c4e360 Added Spanish translation 2023-03-06 21:18:59 -08:00
blok
2135e5b066 fr_FR update 2023-03-07 05:54:20 +01:00
blok
b69eb10ae0 fr_FR update 2023-03-07 05:36:53 +01:00
SunX
e1512b6f54 Update zh-cn.json 2023-03-07 11:07:01 +08:00
Machou
1b8e8215d6 Update fr.json 2023-03-07 03:35:21 +01:00
advplyr
9b44e36e7b Version bump 2.2.16 2023-03-05 16:28:45 -06:00
advplyr
db1ca08c2e Update scanner logs to show inode value on path changes and missing items #1447 2023-03-05 15:38:21 -06:00
advplyr
557d3243c3 Fix:Series & collection rss feeds repeating first book #1531 2023-03-05 15:26:18 -06:00
advplyr
785942b94f Update:Series books page fallback to sort by title/collapsed series name when no sequence #1503 2023-03-05 14:48:20 -06:00
advplyr
3df7caa838 Fix:OPF parser crash when no narrators #1578 2023-03-05 12:40:21 -06:00
advplyr
aef2c52630 Merge pull request #1581 from mfcar/improvePodcastEditing
Improve podcast editing
2023-03-05 12:28:12 -06:00
advplyr
dccad3055b Remove library item listener from edit episode modal 2023-03-05 12:28:20 -06:00
advplyr
c629923a80 Merge pull request #1562 from mfcar/addNextScheduleInfo
Improve dates, times and schedule backup info
2023-03-05 11:44:59 -06:00
advplyr
b4f1fd5b25 Remove currently from date/time setting 2023-03-05 11:38:07 -06:00
advplyr
267897ce74 Merge pull request #1559 from mfcar/addDownloadQueue
Add download queue page
2023-03-05 10:48:25 -06:00
advplyr
022bf9d0ef Show current episode download on init and download queue page updates 2023-03-05 10:35:34 -06:00
mfcar
61c759e0c4 Add tasks queue dropdown 2023-03-05 11:15:36 +00:00
mfcar
cfb3ce0c60 Merge branch 'master' into addDownloadQueue 2023-03-04 22:00:18 +00:00
mfcar
72396c5a98 Add Prev/Next buttons on podcast editing 2023-03-04 19:04:55 +00:00
mfcar
12f231b886 Add save action without closing the modal 2023-03-04 16:44:52 +00:00
mfcar
6aeed24296 Update example label 2023-03-04 11:51:53 +00:00
mfcar
d8b6e09bc0 Merge branch 'master' into addNextScheduleInfo 2023-03-04 11:09:35 +00:00
advplyr
d95975cade Fix:Series page progress filter #1577 2023-03-03 17:35:14 -06:00
mfcar
c4208a4690 package-lock.json lacking 2023-02-28 17:07:18 +00:00
mfcar
7c7a6df6e4 Using cron-parse lib to parse the cron expression. Cron-parse can handle with more scenarios. 2023-02-28 17:04:46 +00:00
advplyr
791c058ef8 Merge pull request #1563 from mfcar/improvePodcastSearch
Improve podcast search
2023-02-27 16:42:37 -06:00
advplyr
c847aea0a4 Merge pull request #1556 from Weldawadyathink/public_rss_feeds
Fix incorrect tags when blocking public feeds
2023-02-27 16:40:18 -06:00
mfcar
e56164aa5a Add a new date format 2023-02-27 20:31:38 +00:00
mfcar
cfb5e909a9 Improve podcast search 2023-02-27 18:22:17 +00:00
mfcar
071444a9e7 Improve dates, times and schedule backup info 2023-02-27 18:04:26 +00:00
mfcar
34ac972130 Add download queue 2023-02-27 02:56:07 +00:00
advplyr
97b5cf04f5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-02-25 15:05:49 -06:00
advplyr
0d50d730d9 Update:Html sanitizer to allow br tag 2023-02-25 15:05:44 -06:00
Spenser Bushey
3a7fd0bcc9 Fix incorrect tags when blocking public feeds 2023-02-25 09:00:26 -08:00
advplyr
f0edea5d52 Merge pull request #1553 from Smoukus/fix-german-typo
fix german typo
2023-02-25 08:59:05 -06:00
advplyr
9c6b07df99 Merge pull request #1554 from mfcar/blockRssFeed
Add rss feed configuration
2023-02-25 08:56:32 -06:00
advplyr
caacf461ab Open rss feed metadataDetails optional 2023-02-25 08:53:09 -06:00
mfcar
5bdbc75522 Fix typo 2023-02-25 13:32:08 +00:00
mfcar
0d3e6b1d0a Add rss details configuration 2023-02-25 13:20:26 +00:00
Smoukus
a122e25cba fix german typo 2023-02-25 11:57:07 +01:00
advplyr
d7b287bfed Merge pull request #1551 from mfcar/mf/alreadyInYourLibraryIndicator
Improve explicit label and add a AlreadyInYourLibrary indicator
2023-02-24 17:57:44 -06:00
advplyr
ba4f585318 Update client/pages/library/_library/podcast/search.vue 2023-02-24 17:57:25 -06:00
mfcar
3f859723a6 Typo 2023-02-24 23:45:06 +00:00
mfcar
c820d0e62b Fix truncate hiding explicit icon 2023-02-24 23:36:15 +00:00
mfcar
7a47032a96 Improve explicit label and add a AlreadyInYourLibrary indicator 2023-02-24 23:31:16 +00:00
advplyr
2db4dd6a40 Merge pull request #1539 from Linden-Ryuujin/feature/coverImage
Prefer cover images called cover
2023-02-23 17:55:05 -06:00
advplyr
f58e2b6dce Update cover image set on first scan 2023-02-23 17:55:11 -06:00
advplyr
859a53e79a Merge pull request #1536 from mfcar/addSeasonInfo
Adding podcast type, season and episode info to the feed
2023-02-23 17:39:46 -06:00
mfcar
ad0edc6329 Fix merge conflicts and add language information on the feed rss 2023-02-23 00:33:04 +00:00
Linden Ryuujin
002fb7a35e When setting the cover image prefer images called "cover", otherwise fallback to original behaviour of first in the list. 2023-02-23 00:09:05 +00:00
mfcar
cc62a20a5d Merge branch 'master' into addSeasonInfo
# Conflicts:
#	client/components/modals/podcast/NewModal.vue
2023-02-23 00:06:21 +00:00
advplyr
ec7e965dfa Merge pull request #1534 from mfcar/fixExplicitInfo
Fixed explicit/language info import and added Explicit indicator
2023-02-22 17:36:59 -06:00
advplyr
9c3f5406a9 Update client/components/modals/podcast/NewModal.vue 2023-02-22 17:36:42 -06:00
mfcar
f4ec6948d2 Add dropown 2023-02-22 19:18:42 +00:00
mfcar
9a51c3be0f Add dropdown to the episode type 2023-02-22 18:48:36 +00:00
mfcar
b1ee54522a Add support to podcast type 2023-02-22 18:22:52 +00:00
mfcar
c14d13440f Add explicit info 2023-02-22 12:48:12 +00:00
advplyr
8c84640484 Merge pull request #1530 from mfcar/fixingScheduleModal
Fixed schedule info when using Prev/Next button
2023-02-21 16:00:13 -06:00
advplyr
0d8917ced6 Update client/components/widgets/CronExpressionBuilder.vue 2023-02-21 16:00:01 -06:00
mfcar
a006eb489d Fix schedule modal info 2023-02-21 21:40:15 +00:00
advplyr
f2941e04d3 Merge pull request #1529 from tomazed/translation-fr
update fr locale
2023-02-21 14:51:38 -06:00
advplyr
2728546660 Merge pull request #1528 from Hallo951/master
Update de.json
2023-02-21 14:51:19 -06:00
mfcar
eeb7c80518 Add translation strings and change the input type to search 2023-02-21 19:30:42 +00:00
Tomazed
c8c40360ad update HeaderStatsLargestItems 2023-02-21 12:19:31 +01:00
Hallo951
79ab656217 Update de.json
Update german language
2023-02-21 10:14:49 +01:00
advplyr
5c250da388 Merge pull request #1518 from mfcar/addSizeStats
Add largest item stats
2023-02-20 17:41:20 -06:00
advplyr
505e0eb3a2 Update translations 2023-02-20 17:41:26 -06:00
advplyr
388444e51f Merge pull request #1515 from dwtong/encode-podcast-url
Encode podcast url when downloading episode
2023-02-20 17:26:33 -06:00
mfcar
08d7a9aa14 Add size stats 2023-02-19 21:39:28 +00:00
mfcar
f650ae7f18 Add episode filter on the episodes list 2023-02-19 20:48:39 +00:00
mfcar
6d138ae905 Add episode filter 2023-02-19 20:07:32 +00:00
Dan Tong
956678c08c Encode podcast url when downloading episode 2023-02-18 14:21:45 +13:00
advplyr
911c854365 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-02-16 18:01:31 -06:00
advplyr
3c5dc17e3c Fix:Replace unicode x in playback speed control with regular x #1508 2023-02-16 18:01:25 -06:00
advplyr
e709cc4cb1 Merge pull request #1468 from lkiesow/integration-test
Integration Test
2023-02-16 17:51:36 -06:00
advplyr
da7825e3e3 Merge pull request #1505 from p-rintz/master
Add library tags variable to podcast notifications
2023-02-15 15:58:59 -06:00
advplyr
4039dc7968 Podcast episode download notification adding variables for mediaTags, podcastAuthor, podcastDescription, podcastGenres, episodeTitle, episodeSubtitle, episodeDescription 2023-02-15 15:57:04 -06:00
Philipp Rintz
e345c4cc9e Correct the libraryTags variable 2023-02-15 00:00:34 +01:00
Philipp Rintz
a08cfa436e Fix code formatting 2023-02-14 16:51:20 +01:00
Philipp Rintz
7207efb4da Add library tags variable to podcast notifications 2023-02-14 16:41:58 +01:00
advplyr
481611ff33 Merge pull request #1500 from Machou/patch-1
Update fr.json
2023-02-12 07:59:41 -06:00
Machou
b67cd37a38 Update fr.json 2023-02-12 07:44:08 +01:00
advplyr
6e8547f0c8 Version bump 2.2.15 2023-02-11 16:30:06 -06:00
advplyr
96930d7ecc Fix:Scrollable config side nav and mobile ui 2023-02-11 16:19:04 -06:00
advplyr
23f2c8a251 Fix:Replacing item cover remove old covers case insensitive #1391 2023-02-11 15:56:18 -06:00
advplyr
c5372d1405 Fix:Series,Collection,Playlist title scaling #1440 2023-02-11 15:51:23 -06:00
advplyr
dcfbed5f30 Update:Add inode value to log #1447 2023-02-11 15:39:34 -06:00
advplyr
895ab8d18a Fix:Audio track hover timestamp bubble z-index 2023-02-11 15:29:39 -06:00
advplyr
8b5d05739f Fix:Adding new podcast when folder already exists #1462 2023-02-11 15:25:54 -06:00
advplyr
a8f6202302 Remove Gentium Book font, reduce appbar icon and title font size 2023-02-11 15:02:56 -06:00
advplyr
5d40fdf277 Merge pull request #1487 from Nab0y/master
FantLab.ru BookFinder
2023-02-11 14:29:38 -06:00
advplyr
f35c96e118 FantLab minor refactor 2023-02-11 14:25:25 -06:00
advplyr
8f8d6f81ab Fix:Upload API endpoint crashing on invalid request with no files #1473 2023-02-10 17:25:19 -06:00
advplyr
e195eec1c5 Fix:OPF parser supporting attributes on tags #1478 2023-02-10 17:22:23 -06:00
advplyr
33846e46fa Fix:Handle podcast RSS feeds with iso-8859-1 encoding #1489 2023-02-10 17:07:25 -06:00
advplyr
2ad03bcb9a Fix:Bad backup files and backing up playlists, feeds #1485 2023-02-10 15:33:42 -06:00
Dmitry
371cd3b2e5 Update server/providers/FantLab.js
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2023-02-09 23:09:44 +03:00
Dmitry Naboychenko
b9307143bd FantLab match provider fixes after code review 2023-02-08 22:32:27 +03:00
Dmitry
36e44e902a Merge branch 'advplyr:master' into master 2023-02-08 17:31:19 +03:00
advplyr
4529fc0124 Merge pull request #1484 from magnww/fix_oom_crash
Reduce memory usage when scanning large folders
2023-02-07 16:59:33 -06:00
gefan
ba07761de3 Revert "kill zombie processes to reduce memory usage"
This reverts commit 19e39f6321.
2023-02-07 12:33:33 +08:00
Dmitry
3b7ce69327 Merge branch 'advplyr:master' into master 2023-02-07 00:25:45 +03:00
Dmitry Naboychenko
cf927f61a0 Add FantLab.ru Book Finder 2023-02-07 00:25:18 +03:00
gefan
61c32d99e7 scan media files in batches 2023-02-07 00:18:57 +08:00
gefan
19e39f6321 kill zombie processes to reduce memory usage 2023-02-07 00:18:48 +08:00
advplyr
f9e6655359 Update:API endpoint for syncing multiple local sessions. New API endpoint to get current user. Deprecate /me/sync-local-progress endpoint 2023-02-05 16:52:17 -06:00
advplyr
debf0f495d Fix:OPML upload path separator #1476 2023-02-04 13:34:50 -06:00
advplyr
3383ec2046 Add logs to playback session manager 2023-02-04 13:23:13 -06:00
advplyr
b957e1a36b Update:API endpoints for library and library item scan updated to POST requests 2023-02-03 17:50:42 -06:00
advplyr
c93f17051a Fix:Event sent when changing languages to rehydrate page 2023-02-03 14:50:48 -06:00
advplyr
5983f0262f Merge pull request #1472 from Nab0y/master
Russian localization
2023-02-03 14:49:14 -06:00
Dmitry
96a8e74d38 Merge branch 'advplyr:master' into master 2023-02-03 23:09:09 +03:00
Dmitry Naboychenko
6f3d488c3d Add russian localization 2023-02-03 23:08:38 +03:00
advplyr
74dcc4f9e4 Merge pull request #1470 from tomazed/patch-localization
Patch localization Item Metadata Utils Header
2023-02-03 04:27:41 -06:00
Tomazed
8c4d3b93c8 revert formatting 2023-02-03 11:25:02 +01:00
Tomazed
c411cf04cc ItemMetaDataUtils Header localized 2023-02-03 11:16:45 +01:00
advplyr
d1b25da408 Merge pull request #1469 from yuuzhan/adding-tags-to-metadata.abs
Adding tags to metadata.abs
2023-02-02 17:19:30 -06:00
advplyr
08f765fa51 Update parsing and using tags from abmetadata file 2023-02-02 17:13:22 -06:00
advplyr
337cf90c4b Add debug logs to playback sessions 2023-02-02 16:24:34 -06:00
advplyr
17b930e13d Merge pull request #1463 from Machou/patch-1
Update fr.json
2023-02-02 16:17:23 -06:00
yuuzhan
639b600570 Updated parseAndCheckForUpdates to pass in LibraryItem instead of Metadata Object 2023-02-02 12:47:12 -05:00
yuuzhan
7a751b8f91 Updated function parseAndCheckForUpdates to pass Library Item rather then just the metadata object 2023-02-02 12:46:22 -05:00
yuuzhan
68621e0c07 Update abmetadataGenerator.js 2023-02-02 12:43:48 -05:00
Lars Kiesow
d2512d324a Integration Test
This patch adds a minimal integration test building Audiobookshelf as a
binary, running it and checking if the server is available on each push
and pull request.

We can easily extend this with a Selenium or Playwright test later, but
it should already alert us about problems in the build pipeline without
the need for any developer to take a look at the new patches.
2023-02-02 00:48:09 +01:00
advplyr
5abb02e93a Version bump 2.2.14 2023-02-01 16:17:36 -06:00
advplyr
573079c5a1 Fix:Downgrade to axios 0.27.2 for pkg #1466 2023-02-01 15:58:58 -06:00
advplyr
5bde320ac7 Update:Remove X-Powered-By express response headers 2023-02-01 14:34:01 -06:00
Machou
5f63d97e59 Update fr.json 2023-02-01 03:40:41 +01:00
Machou
bf2fe3faea Update fr.json
fr_FR update
fr_FR reworked
fr_FR cleaned
2023-02-01 03:37:53 +01:00
advplyr
ea60f19e7a Version bump 2.2.13 2023-01-31 16:35:49 -06:00
advplyr
cefc75a4ed Fix:Edit library modal blur inputs on submit #1427 2023-01-31 16:04:30 -06:00
advplyr
d8753aafb9 Fix:Series collapse series filter out empty sequences #1450 2023-01-31 15:53:04 -06:00
advplyr
ba5ad228cc Merge pull request #1456 from jramer/master
Fixes m4b chapters only taken from first file.
2023-01-30 17:53:48 -06:00
advplyr
0203f9cc1b Update server/objects/mediaTypes/Book.js 2023-01-30 17:50:50 -06:00
advplyr
4770be5a39 Update server/objects/mediaTypes/Book.js 2023-01-30 17:50:45 -06:00
advplyr
1bac395bed Merge pull request #1453 from lkiesow/bookshelf-view
One Default Bookshelf View
2023-01-30 17:29:44 -06:00
advplyr
e818f270cd Merge pull request #1457 from tomazed/LocalizedDateFns
Localized date fns
2023-01-30 08:44:06 -06:00
advplyr
c4e2726622 Update client/plugins/i18n.js 2023-01-30 08:42:56 -06:00
Tomazed
74d8a09f31 Merge branch 'master' into LocalizedDateFns 2023-01-30 15:22:59 +01:00
Tomazed
618338165e Removed unused strings from translations 2023-01-30 15:17:42 +01:00
Tomazed
db494001a2 Import all locales from date-fns/locale 2023-01-30 15:17:04 +01:00
Tomazed
a67213fb7b Simplified languageCodeMap maintainability 2023-01-30 15:09:11 +01:00
Joakim Ramer
5d96b2cc6e Logs correctly and simplifies for single audio file. 2023-01-30 12:56:22 +01:00
Joakim Ramer
72d0b097ab Reverts change of file ending 2023-01-30 12:48:50 +01:00
Joakim Ramer
36d2957fb4 Adds check for duplicated chapter data 2023-01-30 12:46:41 +01:00
Tomazed
b5de517aad Generate days from date-fns locale 2023-01-30 01:49:30 +01:00
Tomazed
41db0e3bb1 Use Date-Fns locale to generate month and day labels 2023-01-30 01:25:35 +01:00
Tomazed
e8d582269b localized date-fns library 2023-01-30 00:52:28 +01:00
Joakim Ramer
80ef8ee890 Fixes m4b chapters only taken from first file. 2023-01-30 00:42:02 +01:00
Lars Kiesow
a65859f575 One Default Bookshelf View
This patch updates the default value of the home page bookshelf view so
that it is identical to the default library view. Having different
styles by default seems odd. I picked the non-wooden view as default
based on the recent discussion on Matrx/Discord that that one looks more
modern.
2023-01-28 23:17:31 +01:00
advplyr
5724887785 Merge pull request #1451 from springsunx/patch-1
Update zh-cn.json
2023-01-28 15:04:21 -06:00
advplyr
8908aa7a82 Fix:Podcast RSS feeds update on new/updated episodes #1435 2023-01-28 14:58:10 -06:00
advplyr
f83dd29213 Update:syncLocalMediaProgress API response payload 2023-01-28 14:46:01 -06:00
SunX
99d90778f4 Update zh-cn.json 2023-01-28 20:36:16 +08:00
advplyr
49279430fc Add:Recommended book home page shelf 2023-01-27 17:59:06 -06:00
advplyr
030c20b12e Merge pull request #1438 from tomazed/LocalizedStatsPage
Localized stats page
2023-01-24 17:30:32 -06:00
Tomazed
5e943ef152 Fix Localized Days in Your Stats page 2023-01-24 19:19:42 +01:00
Tomazed
4ae057f626 Fix Localized Months displaying in heatmap 2023-01-24 18:03:32 +01:00
advplyr
9ebe4b55dd Merge pull request #1431 from lkiesow/x-accel
Implement X-Accel Redirect
2023-01-23 17:27:23 -06:00
advplyr
2f7403adec Merge pull request #1432 from jmt-gh/issue_1410
Align "Series Name" and "Sequence" inputs on Series Editor modal
2023-01-23 17:19:50 -06:00
jmt-gh
2777b496ad change the label to be a label instead of a p 2023-01-22 20:44:39 -08:00
advplyr
f7a3dbf209 Fix:Embed metadata tool embeds cover image 2023-01-22 18:02:57 -06:00
advplyr
d900093976 Fix:Make m4b embed cover image 2023-01-22 18:00:35 -06:00
Lars Kiesow
08250e266e Implement X-Accel Redirect
This patch implements [X-Accel](https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/)
redirect headers as an optional way for offloading static file delivery
from Express to Nginx, which is far better optimized for static file
delivery.

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

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

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

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

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

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

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

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

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

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

That's all.

Impact
------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This fixes  #1382
2023-01-10 21:50:33 +01:00
Hallo951
33ae5445be Update 2023-01-10 10:06:57 +01:00
advplyr
5ed06871b6 Merge pull request #1381 from tomazed/translation-fr
Update fr.json => ButtonEdit
2023-01-09 18:18:41 -06:00
Tomazed
e98eb8f1eb Update fr.json => ButtonEdit 2023-01-09 22:31:24 +01:00
advplyr
ebedaeb3b0 Version bump 2.2.12 2023-01-08 10:48:25 -06:00
advplyr
62aec63d1d Fix:Backups to not backup temp db files 2023-01-08 09:59:24 -06:00
advplyr
3c25e87e8d Update:Cleanup audio player 2023-01-08 09:38:37 -06:00
advplyr
08d16ce7c2 Silence remove invalid sessions debug log 2023-01-08 09:15:11 -06:00
advplyr
2cb3808326 Fix:Loading backups catching failed backups 2023-01-08 09:11:55 -06:00
advplyr
bdb6f0c0aa Update:Sync session API endpoint to not respond with a payload 2023-01-07 17:33:05 -06:00
advplyr
5255bf13cc Update:Libraries table using context menu instead of hover buttons. Cleanup mobile view #1342 2023-01-07 17:14:55 -06:00
advplyr
3588e1e8d3 Update:Handle badly formatted series sequence from Audible #1339 2023-01-07 16:33:20 -06:00
advplyr
8fa8360e99 Update:Manual match tab prefer using ASIN with audible providers #1352 2023-01-07 16:22:59 -06:00
advplyr
b305cfd268 Update max playback speed to 10x 2023-01-07 16:18:52 -06:00
advplyr
ff10287d05 Fix:Force AAC when transcoding ALAC audio file streams #1372 2023-01-07 15:58:57 -06:00
advplyr
7a7708403f Update:Item metadata utils tag and genre loading indicator visible in viewport #1346 2023-01-07 15:44:59 -06:00
advplyr
ddabd0ee75 Update:Library folder browser to not save folders from request #1371 2023-01-07 15:31:51 -06:00
advplyr
5a26704c32 Add:Option to disable backup of audio files in embed metadata tool #1370 2023-01-07 15:16:52 -06:00
advplyr
7ccf36a896 Merge pull request #1374 from lkiesow/gentium-book-basic
Update Gentium Book Basic Font
2023-01-07 13:35:06 -06:00
Lars Kiesow
e9a84dd7dd Update Gentium Book Basic Font
This patch updates the Gentium Book Basic font file [1]. While I
couldn't get any client to use the previous file, it doesn't seem to be
a problem with this file and now the text is being rendered correctly.

[1] https://gwfh.mranftl.com/fonts/gentium-book-basic?subsets=latin
2023-01-07 20:25:11 +01:00
advplyr
b00510855e Fix:Gentium Book Basic font 2023-01-07 13:06:44 -06:00
advplyr
2cd9079692 Add MusicBrainz provider 2023-01-07 13:05:33 -06:00
advplyr
3e4b1652fc Fix disc/track metadata mapping 2023-01-06 17:39:15 -06:00
advplyr
878330b4fb Fix filePathToPOSIX used in scan, updates for music track page 2023-01-06 17:10:55 -06:00
advplyr
9a85ad1f6b Fix:Check if Windows before cleaning file path for POSIX separators #1254 2023-01-05 17:45:27 -06:00
advplyr
f76f9c7f84 Merge pull request #1367 from lkiesow/log-source
Add Source to Logging
2023-01-05 16:51:37 -06:00
advplyr
3426832f2b Fix for windows, update regex to only include line number, move to end of log 2023-01-05 16:44:34 -06:00
Lars Kiesow
10fd51498c Add Source to Logging
The Audiobookshelf logs sometimes contain information about the source
of the log statement, but sometimes they don't This really depends on
developers adding these information to the log messages.

But even then, the information is usually just a hint about the module
logging this, like `[Db]` or [Watcher]`, and finding the exact line can
be hard.

This patch automatically adds the source of the log statement to the
logs. This means if someone calls `Logger.info(…)` in line `22` of
`foo.js`, the log statement will contain this file and line:

```
[2023-01-05 19:04:12[ (LogManager.js:85:18) DEBUG: Daily Log file found 2023-01-05.txt
[2023-01-05 19:04:12] (LogManager.js:59:12)  INFO: [LogManager] Init current daily log filename: 2023-01-05.txt
```

This should make it much easier to identify the code where the log
statement originated from.

Long-term, this also means that we can probably remove the manually set
identifiers contained in the log messages, like the `[LogManager]` in
the example above.
2023-01-05 19:13:31 +01:00
advplyr
49c581ed35 Add:Podcast option to quick match all unmatched episodes 2023-01-04 18:13:46 -06:00
Lars Kiesow
f095d89980 Update Server Libraries
This patch updates the server libraries to their latest state.
2023-01-05 00:46:23 +01:00
advplyr
1609f1a499 Add:Global library search also searches on podcast episode titles #1363 2023-01-04 17:43:15 -06:00
advplyr
88bd51e2da Fix:Update authors in different order #1361 2023-01-04 17:21:25 -06:00
advplyr
74388fe0b9 Fix:Series sequence parsed from metadata.abs allow non-numerical characters #1128 #1360 2023-01-04 15:55:02 -06:00
advplyr
7f5356100d Bookshelf updates for music tracks 2023-01-03 18:00:01 -06:00
advplyr
84d2d00a30 Merge pull request #1353 from tomazed/translation-fr
Update fr.json
2023-01-03 15:37:30 -06:00
Tomazed
31dddfbb60 Update fr.json 2023-01-03 10:53:27 +01:00
advplyr
d6da161b13 Music albums grouping and page 2023-01-02 18:02:04 -06:00
advplyr
9de7be1cb4 Update scanner, music meta tags and fix issue with force update 2023-01-02 16:35:39 -06:00
advplyr
5410aae8fc Remove old scanner setting from ServerSettings 2023-01-02 12:07:26 -06:00
advplyr
86bf6bfc62 Remove scannerMaxThreads from ServerSettings 2023-01-02 12:05:58 -06:00
advplyr
0807146aab Cleanup scanner 2023-01-02 12:05:07 -06:00
advplyr
591d8a8ab1 Add:OPF file pulls ASIN and subtitle #1330 2023-01-02 10:47:13 -06:00
advplyr
b1d4e28027 Merge pull request #1350 from lkiesow/settings-menu
Fix Hidden Settings Menu
2023-01-02 10:40:24 -06:00
advplyr
44363f05ac Start of new epub reader 2023-01-01 18:09:00 -06:00
Lars Kiesow
452af43916 Fix Hidden Settings Menu
This patch fixes several problems of the settings menu related to
display on mobile devices or small(ish) windows:

- The `isMobileLandscape` is now calculated correctly. Previously, this
  was set to `true` if a device was in portrait mode.

- Showing the button to collapse the settings menu and making the menu
  collapsible now use the same mechanism. Previously, it could happen
  that the menu was opened and not fixed, but no button to close it
  again was shown.

- The icons fore opening and closing the settings menu are now both
  arrows, indicating that their functionality is reversed.

- The button to open the menu now always has the string “Settings”,
  instead of using the name of the current page. The current page hader
  is listed below that anyway and this is the action component to open
  the settings menu after all.

This fixes #1334
2023-01-01 19:49:43 +01:00
advplyr
70ba2f7850 Add:RSS feed for series & cleanup empty series from db #1265 2022-12-31 16:58:19 -06:00
advplyr
a364fe5031 Merge RSS feed modals into a universal one 2022-12-31 15:26:37 -06:00
advplyr
ca6765c8e7 Add translations for series #1166 2022-12-31 15:04:37 -06:00
advplyr
6bfa281dc5 Update:Series page toolbar add context menu and confirm dialog for marking series as finished 2022-12-31 14:56:18 -06:00
advplyr
d8ee61bfab Update:Personalized API endpoint include query string to add rssFeed to entities 2022-12-31 14:31:38 -06:00
advplyr
c6763dee2d Remove invalid RSS feeds on init and remove feeds when associated entity is removed 2022-12-31 14:08:34 -06:00
advplyr
0e6b0d3eff Update:Remove RSS feeds from login response payload and include feeds from library items request 2022-12-31 10:59:12 -06:00
advplyr
8bbfee334c Update:Show RSS feed icon on collection card & update API endpoint for fetching collections 2022-12-31 10:33:38 -06:00
advplyr
f806e4cce3 Merge pull request #1343 from lkiesow/a11y-main-settings
Accessibility Improvements for Main Settings
2022-12-31 09:30:48 -06:00
advplyr
209ba308bd Merge branch 'master' into a11y-main-settings 2022-12-31 08:43:26 -06:00
advplyr
4cd9088a66 Add translations for aria labels #1166 2022-12-30 16:27:21 -06:00
advplyr
ac5e2e5c73 Merge pull request #1341 from lkiesow/a11y-user-settings
Fix keyboard navigation in user settings
2022-12-30 16:26:07 -06:00
Lars Kiesow
f1329d2847 Accessibility Improvements for Main Settings
This patch fixes some accessibility problems on the main settings page.
Most notably, it makes sure that the different options have labels which
are picked up by screen readers.

As a more generic addition, this also makes sure that the dropdown
component will always have a proper label constructed, explaining what
the dropdown is for and what its current value is.
2022-12-30 19:14:04 +01:00
advplyr
27faefc64d Merge pull request #1338 from naleo/master
Fix incorrect series and seriespart tag codes, they were swapped
2022-12-29 18:05:41 -06:00
advplyr
0fa7e61dc1 Merge pull request #1336 from lkiesow/user-settings-screenreader
Make User Settings Accessible via Screen Reader
2022-12-29 18:03:40 -06:00
advplyr
5a3f14ae51 Remove extra space from label 2022-12-29 18:03:05 -06:00
Lars Kiesow
4e61185136 Fix keyboard navigation in user settings
This patch makes sure that the option in the user settings are
accessible via keyboard navigation and that the labels, if users use a
screen reader, actually make sense.

This patch introduces new strings which need to be translated. Although
I did already provide a German translation.
2022-12-29 21:36:42 +01:00
Naleo
6ee06d5dae Fix incorrent series and seriespart tag codes, they were swapped 2022-12-29 08:41:46 -10:00
Lars Kiesow
2c344a0bc0 Make User Settings Accessible via Screen Reader
This patch should fix most of the problems for users trying to access
the user settings via screen reader. It makes sure user interface
elements can be reached via keyboard and provides proper labels, roles
and values so you not only can interact with elements but also know what
you are actually changing.

While not focused on other views, this should also already fix a number
of accessibility issues with other settings pages.
2022-12-29 05:00:40 +01:00
advplyr
315c83e4c3 RSS feed for collection to update when any item in the collection is updated #606 2022-12-28 18:08:03 -06:00
advplyr
9e4bc582cb Merge pull request #1335 from lkiesow/keyboard-navigation-libraries
Fix keyboard navigation in library selection
2022-12-28 17:18:35 -06:00
Lars Kiesow
fc6aa1f91f Fix keyboard navigation in library selection
This patch fixes the keyboard navigation in the library selection of the
main app bar. Without this patch, no options are selectable via keyboard
and selecting an option and hitting return has no effect.
2022-12-29 00:09:22 +01:00
advplyr
d4bea34423 Merge pull request #1333 from lkiesow/keynoard-navigation-border
Highlight items when navigating via keyboard
2022-12-28 17:01:17 -06:00
advplyr
a551a2d288 Merge pull request #1332 from lkiesow/home-img-alt
Text description of home link
2022-12-28 16:40:21 -06:00
Lars Kiesow
4b0c59b174 Highlight items when navigating via keyboard
This patch highlights items in the app bar if a user uses the keyboard
to navigate in audiobookshelf. This ensures that users actually know
which item they have selected.

This also modifies the text for the library selector, so that users
which are using a screen reader understand that it is a selector for
libraries and not only a button related to the current library.
2022-12-28 22:59:27 +01:00
Lars Kiesow
a0840d2a08 Text description of home link
This patch adds the missing alt attribute to the image linking the home
page of audiobookshelf. This allows screen readers to explain to users
where this link leads to.
2022-12-28 22:55:11 +01:00
advplyr
308ccf470f Add:Open RSS feed for collection #606 #1265 2022-12-27 18:03:31 -06:00
advplyr
4021b6eca1 Merge pull request #1320 from lkiesow/undefined-default
Fix undefined string assignment
2022-12-27 15:37:31 -06:00
advplyr
061695f922 Add:API endpoint for opening RSS feed for collection #606 #1265 2022-12-26 17:48:39 -06:00
advplyr
e803dcd325 Update:RSS feed API routes 2022-12-26 16:58:36 -06:00
Lars Kiesow
128796bd36 Fix undefined string assignment
Assigning something to `process.env.profile`, Node stringifies the value. This
means that assigning `undefined` to an environment variable in Node will result
in it holding the string `undefined`.

This means, for example, that `module.exports.FFPROBE_PATH || 'ffprobe'` in
`server/libs/nodeFfprobe/index.js` will actually result in the string
`undefined`.

This patch fixes several such assignments in the `index.js`, potentially
causing problems in the development mode.
2022-12-26 23:55:14 +01:00
advplyr
775dedc338 Cleanup and remove more vars 2022-12-26 16:08:53 -06:00
advplyr
45c9038954 Fix:Manually updating author image path & realtime update author image #1317 2022-12-26 15:45:42 -06:00
advplyr
8acf962864 Update:Remove relImagePath from Author entity 2022-12-26 15:29:45 -06:00
advplyr
c3fc38639e Merge pull request #1297 from Eschguy/master
Add Caddyfile example to readme
2022-12-25 16:24:57 -06:00
Austin Eschweiler
b60b75c8da Update readme.md 2022-12-25 11:32:12 -06:00
advplyr
0f7edec73b Add note in readme about subfolder 2022-12-24 13:32:45 -06:00
advplyr
321277826f Readme updates 2022-12-24 11:32:40 -06:00
advplyr
6e752af2c0 Update readme with new docs 2022-12-24 11:29:58 -06:00
advplyr
0717ae39db Fix music fine file with inode 2022-12-24 11:12:39 -06:00
advplyr
7bc5902ea8 Merge pull request #1312 from Machou/master
Update fr.json
2022-12-24 06:58:04 -06:00
Machou
a28e1ed5e0 Update fr.json 2022-12-24 07:49:22 +01:00
advplyr
43d9e129a6 Merge pull request #1310 from k9withabone/socket-fixes
Socket fixes
2022-12-23 07:47:32 -06:00
advplyr
b516019ddd Merge branch 'socket-fixes' of https://github.com/k9withabone/audiobookshelf into socket-fixes 2022-12-23 07:34:15 -06:00
advplyr
e4c20d677c Update server/controllers/SeriesController.js
Co-authored-by: Paul Nettleton <paulnett7@hotmail.com>
2022-12-23 07:33:33 -06:00
advplyr
33e183b802 Merge branch 'master' into socket-fixes 2022-12-23 07:27:14 -06:00
advplyr
b884f8fe11 Laying the groundwork for music media type #964 2022-12-22 16:38:55 -06:00
Paul Nettleton
2cba83f1dd Server socket event fixes 2022-12-22 16:26:11 -06:00
Paul Nettleton
a9ee9031c3 Add rss feed minified 2022-12-22 16:04:42 -06:00
advplyr
c3717f6979 Merge pull request #1306 from burghy86/patch-7
Update it.json
2022-12-21 07:22:43 -06:00
advplyr
657d4dd705 Update:Trim whitespace from audio file meta tag values #1305 2022-12-21 07:13:28 -06:00
burghy86
17356ffd79 Update it.json
fix
2022-12-21 10:46:21 +01:00
advplyr
c4be75b5bd Fix:Backups cron scheduler modal #1304 2022-12-20 12:35:31 -06:00
advplyr
57422d0759 Fix:PWA manifest add PNG icon for desktop browsers #1300 2022-12-20 11:57:52 -06:00
advplyr
d2454201b4 Merge pull request #1302 from Hallo951/master
Update de.json
2022-12-20 09:43:43 -06:00
Hallo951
3a92a69693 Update de.json
- minor fixes
- Item translated with medium or media
2022-12-20 09:36:50 +01:00
Austin Eschweiler
d733c9ccc6 Add Caddyfile example to readme
An example Caddyfile based on what I use
2022-12-19 18:10:55 -06:00
advplyr
3e15e09c07 Fix:Get libraries endpoint #1296 2022-12-19 17:46:32 -06:00
advplyr
0592a41d4f Version bump 2.2.11 2022-12-19 17:16:58 -06:00
advplyr
c32e33f804 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-19 17:16:48 -06:00
advplyr
616ffb8f79 Add:M4b tool configurable options bitrate/channels/codec #1029 #1257 2022-12-19 17:13:04 -06:00
advplyr
bc771a3a44 Delete DownloadManager.js 2022-12-19 16:20:18 -06:00
advplyr
539d1a2d4f Merge pull request #1294 from tomazed/translation-fr
Update fr.json regarding new Metadata strings
2022-12-19 16:16:56 -06:00
advplyr
4d8cea0bb4 Merge pull request #1293 from springsunx/patch-1
Update zh-cn.json
2022-12-19 16:16:37 -06:00
advplyr
8b46262e93 Merge pull request #1292 from Hallo951/master
Update de.json
2022-12-19 16:16:17 -06:00
advplyr
eb9a077520 Fix scroll listener for multi select inputs 2022-12-19 16:10:45 -06:00
advplyr
3d3a224402 Fix:Edit modal dropdown menus hidden #1295 2022-12-19 15:32:17 -06:00
advplyr
e1397a6dda Update:Author cover image API endpoint to get raw cover image #1291 2022-12-19 15:06:43 -06:00
advplyr
8f49aae979 Fix:Adding podcast and filename sanitize func #1290 2022-12-19 15:02:31 -06:00
Tomazed
c0a13f01d4 Update fr.json regarding new Metadata strings 2022-12-19 16:39:46 +01:00
SunX
efcebc616c Update zh-cn.json 2022-12-19 22:08:54 +08:00
Hallo951
902867c3bc Update de.json 2022-12-19 09:02:17 +01:00
advplyr
b7abd372e4 Version bump 2.2.10 2022-12-18 18:38:00 -06:00
advplyr
147ffc0210 Fix:Cover size widget behind home page arrow #1288 2022-12-18 18:37:03 -06:00
advplyr
1b2ccb6cee Fix:Series inner input behind details modal #1289 2022-12-18 18:35:05 -06:00
advplyr
c58a6b9047 Version bump 2.2.9 2022-12-18 15:50:47 -06:00
advplyr
b787fb18f3 Merge pull request #1251 from lkiesow/PermissionsStartOnly
No PermissionsStartOnly=true
2022-12-18 15:50:10 -06:00
advplyr
17cce9c914 Merge pull request #1287 from lkiesow/subpath-detection
Fix Sub-path Detection
2022-12-18 15:48:28 -06:00
Lars Kiesow
90299e348c Fix Sub-path Detection
If the scanner detects new files with a path containing part of the name
of an already existing library item, the new item will incorrectly be
detected as being a parent directory of the already existing item and
the import will be aborted.

You can follow these steps to reproduce the issue:

```
❯ mkdir audiobooks/author/

❯ mv title\ 10 audiobooks/author
[2022-12-18 22:14:12] DEBUG: [Watcher] File Added /home/lars/dev/audiobookshelf/audiobooks/author/title 10/dictaphone.mp3
[2022-12-18 22:14:16] DEBUG: [DB] Library Items inserted 1

❯ mv title\ 1 audiobooks/author
[2022-12-18 22:15:03] DEBUG: [Watcher] File Added /home/lars/dev/audiobookshelf/audiobooks/author/title 1/dictaphone.mp3
[2022-12-18 22:15:07]  WARN: [Scanner] Files were modified in a parent directory of a library item "title 10" - ignoring
```

Since `'title 10'.startsWith('title 1')` is `true`, the current code
makes this false assumption.

This patch fixes the issue by requiring a path separator to be part of
the matching path. This should ensure that only true parent directories
are detected.

This patch requires audiobookshelf to always use Unix file separators.
But that shouldn't be a problem since audiobookshelf always seems to use
these kinds of separators. Even on Windows.
2022-12-18 22:23:50 +01:00
advplyr
fe25a1bc54 Update item metadata pages sort 2022-12-18 15:16:32 -06:00
advplyr
edbe1851b5 Add translation strings for item metadata utils #1166 2022-12-18 15:11:48 -06:00
advplyr
ad6c5a4f00 Merge pull request #1286 from tomazed/translation-fr
Update fr.json with new strings from d7cc8a0
2022-12-18 14:54:08 -06:00
advplyr
4971787482 Add:Manage genres #1163 2022-12-18 14:52:53 -06:00
Tomazed
56d2ec9c22 Update fr.json with new strings from d7cc8a052a 2022-12-18 21:37:47 +01:00
advplyr
106ddc9541 Fix scan log path #1285 2022-12-18 14:26:15 -06:00
advplyr
4d93e39fa9 Add:Item metadata utils config page for managing tags #1163 2022-12-18 14:17:52 -06:00
advplyr
54b41b15c2 Merge pull request #1282 from lkiesow/google-books-https
Use HTTPS for Google Books Images
2022-12-17 17:59:44 -06:00
advplyr
54ca42a903 Update:Bookshelf view title sign width 2022-12-17 17:50:16 -06:00
advplyr
d7cc8a052a New translation strings for collections/playlist #1166 2022-12-17 17:47:35 -06:00
advplyr
5165f11460 Add:Create playlist from a collection #1226 2022-12-17 17:31:19 -06:00
Lars Kiesow
b47ce4fb24 Use HTTPS for Google Books Images
The API for Google Books will return HTTP image URLs when matiching any
books using it as a search provider. In a secure environment, this
causes browser warnings.

All Google image links support HTTPS and we can safely switch to HTTOS
to avoid these warnings.
2022-12-18 00:18:11 +01:00
advplyr
9b1f7f566f Fix:On bookshelf view show series name placard on shelf #1239 2022-12-17 16:36:41 -06:00
advplyr
10295b000a Update:Remove HOST default to allow for ipv6 #1256 2022-12-17 15:55:53 -06:00
advplyr
c06d734d5e Update:Persist series sort/filter options #1272 2022-12-17 15:10:25 -06:00
advplyr
49a69193d8 Comments where user settings needs to be removed 2022-12-17 14:52:10 -06:00
advplyr
7852804a9c Update:Remove call to server for user settings, user settings stored locally 2022-12-17 14:50:01 -06:00
advplyr
415dda37a4 Update:Match tab persist selected details to use #1276 2022-12-17 10:27:27 -06:00
advplyr
179d339afd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-16 17:58:42 -06:00
advplyr
858c1a7353 Update:Series inner input modal update button Save to Submit #1277 2022-12-16 17:57:46 -06:00
advplyr
0b42b81558 Update:Author modal Submit button to Save #1280 2022-12-16 17:54:00 -06:00
advplyr
f9678dec2f Merge pull request #1275 from tomazed/translation-fr
Update fr.json for batch update
2022-12-15 17:58:17 -06:00
advplyr
82642b295c Merge pull request #1271 from tomazed/localization-update
Missing Localization in Appbar.vue
2022-12-15 17:57:52 -06:00
advplyr
ba3d84a924 Update client/components/app/Appbar.vue 2022-12-15 17:57:42 -06:00
advplyr
96e2f934a3 Merge pull request #1270 from Hallo951/master
Update de.json
2022-12-15 17:56:53 -06:00
advplyr
a68ade2b3d Update:Select largest cover image from Google Books provider #1244 2022-12-15 17:54:02 -06:00
advplyr
4fcdeda447 Add:Book library filter for missing cover image #1243 2022-12-15 17:46:27 -06:00
advplyr
dc03835742 Update:Trim whitespace from chapter titles in chapter editor #1248 2022-12-15 17:40:34 -06:00
advplyr
50430e6b27 Update:Audiobook RSS feed track episode pub dates #1253 2022-12-15 17:36:29 -06:00
advplyr
d130dd6d5e Fix:Setting file ownership for /config and /metadata/logs #584 2022-12-15 17:30:45 -06:00
advplyr
793cc989de Fix:Overflowing edit library folders #1266 2022-12-15 16:51:37 -06:00
Tomazed
27d8c4d67c Update fr.json for batch update 2022-12-15 23:19:46 +01:00
Tomazed
48f493a9f5 Missing Localization in Appbar.vue 2022-12-15 17:50:13 +01:00
Hallo951
04992ee3fb Update de.json 2022-12-15 16:36:28 +01:00
advplyr
4d8e2a1279 Update:Max filename to 255 bytes in utf-16 #1261 2022-12-13 17:46:18 -06:00
advplyr
2af7b6b6f1 Add translation strings for batch update page #1166 2022-12-13 16:59:46 -06:00
advplyr
e59351566d Add:Batch append details #848 2022-12-13 16:28:05 -06:00
advplyr
05d10b73c3 Merge pull request #1231 from k9withabone/server/respond-with-objects
Server respond with objects
2022-12-12 17:53:57 -06:00
advplyr
41e192c6a5 Update more vars 2022-12-12 17:52:20 -06:00
advplyr
ea42ab7624 Update get all users route 2022-12-12 17:48:57 -06:00
advplyr
2d9035d90b Update get tags route and revert podcast/books search route 2022-12-12 17:45:51 -06:00
advplyr
0ae853c119 Update library items batch get route 2022-12-12 17:36:53 -06:00
advplyr
3c0fdff7b4 Update libraries reorder and get all authors routes 2022-12-12 17:33:59 -06:00
advplyr
eede2bbd46 Update for filesystem and libraries api update and revert personalized shelves route 2022-12-12 17:29:56 -06:00
advplyr
5c31687a0f Merge branch 'master' into server/respond-with-objects 2022-12-12 17:20:14 -06:00
advplyr
6b654d3c2d Update:Starting session for finished item sets the user start time back to 0 2022-12-12 17:18:56 -06:00
Lars Kiesow
91cbe45839 No PermissionsStartOnly=true
This patch removes `PermissionsStartOnly=true` from the systemd unit
file used for packaging. This shouldn't be necessary for any commands
run by the unit.
2022-12-06 00:52:23 +01:00
advplyr
7883d4a97f Merge pull request #1249 from lkiesow/tooltips
Add Missing Tooltips
2022-12-05 17:13:14 -06:00
advplyr
9f4547cff8 Update client/components/app/Appbar.vue 2022-12-05 17:13:03 -06:00
advplyr
a98106593d Update client/components/app/Appbar.vue 2022-12-05 17:12:58 -06:00
advplyr
c625b3f08c Update client/components/app/Appbar.vue 2022-12-05 17:12:53 -06:00
advplyr
9e7f09c21b Merge pull request #1245 from burghy86/patch-6
Update it.json
2022-12-05 17:03:19 -06:00
Lars Kiesow
616caecdf1 Add Missing Tooltips
This patch adds a few more missing tooltips to the user interface.
2022-12-05 23:16:27 +01:00
burghy86
cee19c5128 Update it.json
fix and add
2022-12-05 16:50:16 +01:00
advplyr
67db41a525 Update:Get item cover API endpoint to allow for returning the raw cover image 2022-12-04 16:23:15 -06:00
advplyr
3ea3e55d17 Fix:Typo in library settings 2022-12-03 17:50:54 -06:00
advplyr
4959a28485 Update:Playlists cover size 2022-12-03 15:44:53 -06:00
advplyr
6d2482a98e Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-01 18:08:57 -06:00
advplyr
4b23b842bb Version bump 2.2.8 2022-12-01 18:08:52 -06:00
advplyr
07bebc8808 Merge pull request #1238 from Hallo951/master
Update german language
2022-12-01 18:06:04 -06:00
advplyr
027d7f7a5b Fix:Chapter editor show save button when applying lookup data #1237 2022-12-01 17:42:02 -06:00
advplyr
6baa0fa047 Fix:Multi-select library items using shift key #1236 2022-12-01 17:39:23 -06:00
advplyr
8425fac543 Update PWA workbox 2022-12-01 17:26:22 -06:00
Hallo951
7b2ac7b9e9 Update german language 2022-12-01 11:27:06 +01:00
advplyr
bf071be247 Version bump 2.2.7 2022-11-30 17:43:13 -06:00
advplyr
6c05a0af8a Merge pull request #1234 from tomazed/master
[Update] client/strings/fr.json
2022-11-30 17:33:59 -06:00
advplyr
0e292c64c4 Update:Only emit library socket events to users with access to lib 2022-11-30 17:32:59 -06:00
advplyr
725f8eecdb Fix:Batch selecting ebooks showing play button in appbar #1235 2022-11-30 17:09:00 -06:00
Tomazed
521a673094 [Update] client/strings/fr.json 2022-11-30 23:20:29 +01:00
advplyr
d917f0e37d Fix:Ebook reader for ebooks in root folder #1232 2022-11-30 16:15:25 -06:00
advplyr
7ed5b1744f Var cleanup 2022-11-29 18:03:50 -06:00
Paul Nettleton
c9ab2a242d Update MiscController.js to respond with objects
Changes:
- `getAllTags` (GET /api/tags)
2022-11-29 12:26:59 -06:00
Paul Nettleton
13532cba14 Update SearchController.js to respond with objects
Changes:
- `findCovers` (GET /api/search/covers)
- `findBooks` (GET /api/search/books)
- `findPodcasts` (GET /api/search/podcast)
2022-11-29 12:23:02 -06:00
Paul Nettleton
3fb2bd3362 Update SeriesController.js to respond with objects
Changes:
- `search` (GET /api/series/search)
2022-11-29 12:08:40 -06:00
Paul Nettleton
e80c3a1c5a Update AuthorController.js to respond with objects
Changes:
- `search` (GET /api/authors/search)
2022-11-29 12:04:45 -06:00
Paul Nettleton
e04d26307e Update FileSystemController.js to respond with objects
Changes:
- `getPaths` (GET /api/filesystem)
2022-11-29 11:55:22 -06:00
Paul Nettleton
b8f74e1c98 Update CollectionController.js to respond with objects
Changes:
- `findAll` (GET /api/collections)
2022-11-29 11:48:21 -06:00
Paul Nettleton
0851050392 Update UserController.js to respond with objects
Changes:
- `findAll` (GET /api/users)
2022-11-29 11:43:39 -06:00
Paul Nettleton
b84882d9d1 Update LibraryItemController.js to respond with objects
Changes:
- `batchGet` (POST /api/items/batch/get)
2022-11-29 11:37:45 -06:00
Paul Nettleton
cd37a7618e Update LibraryController.js to respond with objects
Changes:
- `findAll` (GET /api/libraries)
- `getLibraryUserPersonalizedOptimal` (GET /api/libraries/<ID>/personalized)
- `getAuthors` (GET /api/libraries/<ID>/authors)
- `reorder` (POST /api/libraries/order)
2022-11-29 11:30:25 -06:00
advplyr
64a7cfac3b Merge pull request #1230 from springsunx/patch-1
Update zh-cn.json
2022-11-29 07:57:28 -06:00
SunX
1ee7ba54f8 Update zh-cn.json 2022-11-29 21:45:15 +08:00
advplyr
6bb18f8800 Fix:Purge cache buttons on config page for mobile screens #1228 2022-11-28 17:55:52 -06:00
advplyr
b26b854963 Translation strings for other langs #1166 2022-11-28 17:52:36 -06:00
advplyr
7d58361ced Update:Chapter editor add reset button, cleanup ui, add translation strings #1166 2022-11-28 17:49:58 -06:00
advplyr
a3723f3d06 Update:New translation strings for chapter editor #1166 2022-11-28 17:00:06 -06:00
advplyr
78d1cd0cfb Add:Chapter editor button to set chapters using audio tracks #1229 2022-11-28 16:55:13 -06:00
advplyr
d41366a417 Fix:Playlist API endpoint permissions 2022-11-28 16:29:04 -06:00
advplyr
a2347150a2 Fix:PWA manifest start_url 2022-11-28 16:26:26 -06:00
advplyr
d33f23dede Merge pull request #1225 from springsunx/patch-1
Update zh-cn.json
2022-11-28 09:23:22 -06:00
SunX
cfca2be1b2 Update zh-cn.json 2022-11-28 18:14:08 +08:00
advplyr
73f07c1392 Update:More translation strings for library filters #1166 2022-11-27 17:55:25 -06:00
advplyr
4541e9ddc3 Fix:Library filters when using other language #1166 2022-11-27 17:54:40 -06:00
advplyr
972271a1a9 Add:Library filter for single & multi-track audiobooks #1213 2022-11-27 17:42:02 -06:00
advplyr
e97d92a8ac Fix:Copy to clipboard 2022-11-27 17:10:06 -06:00
advplyr
9a73e352d1 Version bump 2.2.6 2022-11-27 16:07:26 -06:00
advplyr
08f09f81fa Fix:Updating authors image 2022-11-27 15:35:47 -06:00
advplyr
c72609013c Update:i18n strings for playlists 2022-11-27 15:14:28 -06:00
advplyr
29a6434fdc Playlist and collections cleanup 2022-11-27 15:12:55 -06:00
advplyr
eb2ea9950a Remove playlists for user when removing user 2022-11-27 14:54:17 -06:00
advplyr
e307ded192 Remove item from playlist when removing item, update PlaylistController socket events to emit to playlist userId 2022-11-27 14:49:21 -06:00
advplyr
2d6c997b38 Cleanup collections add/create modal 2022-11-27 14:39:29 -06:00
advplyr
232a80a848 Fix playlist cover for single item playlsit 2022-11-27 14:35:55 -06:00
advplyr
083f8faa46 Update:Fetch library API to return numUserPlaylists, only display playlists in siderail if user has playlists 2022-11-27 14:34:27 -06:00
advplyr
0fcf978ffe Add /playlist/:id to dynamic routes 2022-11-27 14:23:28 -06:00
advplyr
c1360267c6 Fix:Collection page set library on refresh 2022-11-27 14:22:46 -06:00
advplyr
084bea6b15 Update:Collections i18n strings 2022-11-27 14:19:22 -06:00
advplyr
2032dd88ba Hide add to playlist buttons for ebooks 2022-11-27 14:16:22 -06:00
advplyr
b11b1be432 Cleanup playlist cover component 2022-11-27 14:13:31 -06:00
advplyr
b743b34fab Update playlist cover to square of 4 items 2022-11-27 14:11:17 -06:00
advplyr
950d10091d Add playlists to bookshelf item context menu 2022-11-27 13:44:54 -06:00
advplyr
af0e02b9a2 Update lazybookshelf playlist socket events 2022-11-27 13:38:08 -06:00
advplyr
1332147c4a Update playlist icons 2022-11-27 13:34:50 -06:00
advplyr
f07cb1e7a3 Fix player UI icon buttons from pr #1187 2022-11-27 12:36:10 -06:00
advplyr
53dbdd115f Update:Playlists for podcasts 2022-11-27 12:33:38 -06:00
advplyr
a217ed5574 Update:Handle edit playlist item 2022-11-27 12:17:58 -06:00
advplyr
531f947754 Update:Remove playlist if all items are removed 2022-11-27 12:04:49 -06:00
advplyr
c957e9483e Update:Playlist edit modal 2022-11-27 11:53:48 -06:00
advplyr
623a706555 Update:Create playlist items table 2022-11-26 17:58:52 -06:00
advplyr
7e171576e0 Update:Add libraries playlists API endpoint, add lazy playlists card 2022-11-26 17:24:46 -06:00
advplyr
0979b3e03d Update:Playlist cover & json expanded 2022-11-26 16:45:54 -06:00
advplyr
1131bfa751 Update:Creating user playlists modal 2022-11-26 16:25:14 -06:00
advplyr
f9b87b94bf Add:Playlist API endpoints 2022-11-26 15:14:45 -06:00
advplyr
59ed2ec87f Merge branch 'master' into playlists 2022-11-26 12:53:30 -06:00
advplyr
7b0b79e3a1 Merge pull request #1221 from jmt-gh/settings_page_component
Implement a Settings Content component
2022-11-26 11:07:09 -06:00
advplyr
53f73e1201 Settings content updates 2022-11-26 11:08:09 -06:00
jmt-gh
c62a1dfff0 Convert Backup page to use new settings component
This commit moves the Backup page to use the new settings component.

As part of this, I had to do a little bit of modification for some of
the strings, as I cleaned up the way the strings are laid out on the
page.
2022-11-25 21:12:23 -08:00
jmt-gh
61f8055493 convert notifications page to use new settings component 2022-11-25 21:12:12 -08:00
jmt-gh
000d7fd249 convert library stats page to use new settings component 2022-11-25 21:11:45 -08:00
jmt-gh
087de03a1f convert log page to use new settings component 2022-11-25 21:11:32 -08:00
jmt-gh
a3ca6159fb convert stats page to use new settings component 2022-11-25 21:11:17 -08:00
jmt-gh
5de6ee136a Convert Users settings page to use new component
This commit moves the Users settings page to use the new Settings
Content component. Similar to the Libraries page, this one is already a
component. I mimiced the behavior of the existing libraries component to
get the same functionality needed here
2022-11-25 21:10:05 -08:00
jmt-gh
d5a19f2b42 Convert Library settings page to use new component
This commit moves the library settings page over to use the new
component. This page is 1 of 2 that actually has a component for itself,
so it was mostly just modifying that existing component and wrapping it
2022-11-25 21:08:54 -08:00
jmt-gh
e3ec5dd506 convert sessions page to use settings content component 2022-11-25 21:08:10 -08:00
jmt-gh
762748225d convert settings page to use settings content component 2022-11-25 21:07:41 -08:00
jmt-gh
4db34e0c56 Add new settings content component
This commit adds a new settings conent component. This is a container
component for the settings pages, so that they all get the same
formatting by default. It handles the header text, description text, and
any "add new" plus button as needed.
2022-11-25 21:06:18 -08:00
advplyr
fb078d05bc Merge pull request #1218 from jmt-gh/fix_notifications_ui
Update notification settings UI to match other pages
2022-11-25 14:01:00 -06:00
jmt-gh
f59edffa43 update notification settings to match other pages 2022-11-25 11:04:11 -08:00
advplyr
7aa0ddb71f Merge branch 'master' into playlists 2022-11-25 08:09:46 -06:00
advplyr
a0a6256c7a Merge pull request #1212 from Weldawadyathink/master
Improve title naming for single file audiobooks when opening RSS feed
2022-11-25 06:21:55 -06:00
advplyr
df7e331605 Update server/objects/FeedEpisode.js 2022-11-25 06:21:50 -06:00
Spenser Bushey
8c23704e17 Merge branch 'advplyr:master' into master 2022-11-24 23:12:55 -08:00
Spenser Bushey
12abb1731c Single file audiobook rss feed naming logic moved to FeedEpisode.js 2022-11-24 23:10:20 -08:00
advplyr
180293ebc1 Update:Cleanup socket usage & add func for emitting events to admin users 2022-11-24 16:35:26 -06:00
advplyr
e2af33e136 Update:Refactor socket connection management into SocketAuthority 2022-11-24 15:53:58 -06:00
advplyr
42e68edc65 Fix:Users table activity & cleanup 2022-11-24 14:44:09 -06:00
advplyr
47e732c213 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-24 13:51:53 -06:00
advplyr
77a86d92f4 Update:Socket event for getting online users & test event for messaging all online users 2022-11-24 13:51:41 -06:00
advplyr
64a8a046c1 Update:Backups API endpoints, add get all backups route, update socket init event payload 2022-11-24 13:14:29 -06:00
Spenser Bushey
1f02cbddd3 Merge branch 'advplyr:master' into master 2022-11-23 22:37:02 -08:00
Spenser Bushey
5e7bca02b3 RSS feeds for single file audiobooks now use book title 2022-11-23 22:36:07 -08:00
advplyr
097f9549b1 Merge pull request #1211 from lkiesow/started-at
Fix startedAt in progress API
2022-11-23 17:37:40 -06:00
Lars Kiesow
45434b16e0 Fix startedAt in progress API
If no progress had been set before, setting `startedAt` did not work and
it would always been set to `finishedAt` or `Date.now()`. Settings this
if any progress had already been recorded did work.

This fixes the problem so that setting `startedAt` it properly works in
both cases.
2022-11-24 00:16:20 +01:00
advplyr
6af5ac2be1 Merge pull request #1208 from konradorlinski/polish-translation
Add more polish translation
2022-11-23 16:08:20 -06:00
advplyr
34ff7efa27 Merge pull request #1205 from lkiesow/api-start-end-date
Allow specifying start and end of progress via API
2022-11-23 16:07:53 -06:00
ko
8f4391003f Add more polish translation 2022-11-23 18:29:35 +01:00
advplyr
ecefb30f3d Merge pull request #1206 from lkiesow/400-bad-request
Respond with bad request to unvalid request data
2022-11-23 07:27:18 -06:00
Lars Kiesow
a8162b57ba Respond with bad request to unvalid request data
This patch updates the batch progress update endpoint to respond with a
`400 Bad Request` instead of a `500 Internal Server Error` if a user
sends an invalid request with no body. This is a user error after all.

```
❯ curl -i -X PATCH \
  'http://127.0.0.1:3333/api/me/progress/batch/update' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5Q_MoRptP0oI' \
  -H 'Content-Type: application/json'
HTTP/1.1 400 Bad Request
…

Missing request payload
```
2022-11-23 02:15:36 +01:00
Lars Kiesow
b0edac4234 Allow specifying start and end of progress via API
This patch is a minor extension to the update progress and batch update
progress API and allows you to specify `finishedAt` and `startedAt` when
updating a progress.

If not specified, both values are still automatically set to the current
time. If just `finishedAt` is specified, `startedAt` is set to the same
value.

Example API request:

```
❯ curl -i -X PATCH \
  'http://127.0.0.1:3333/api/me/progress/li_ywupqxw5d22adcadpa' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJyb290IiwidXNlcm5hbWUiOiJyb290IiwiaWF0IjoxNjY4OTYxNjAxfQ._NbilCoFy_hfoqy7uvbV4E_0X6qgLYapQ_MoRptP0oI' \
  -H 'Content-Type: application/json' \
  --data-raw '{"isFinished":true, "finishedAt": 1668556852000, "startedAt": 1668056852000}'
```
2022-11-23 01:32:52 +01:00
advplyr
98c4045a71 Setting up Playlist model 2022-11-22 18:08:11 -06:00
advplyr
24e90e2ead Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-22 16:58:05 -06:00
advplyr
145e0217b6 Update:Media session show next/prev track buttons #1201 2022-11-22 16:57:18 -06:00
advplyr
e5925fb1b6 Create config.yml 2022-11-22 16:27:59 -06:00
advplyr
9e416d02bd Merge pull request #1200 from springsunx/patch-1
Update _id.vue
2022-11-22 04:33:56 -06:00
SunX
82b7068130 Update _id.vue 2022-11-22 12:51:37 +08:00
advplyr
579ee36857 Merge pull request #1197 from Hallo951/patch-1
Update de.json
2022-11-21 10:08:57 -06:00
advplyr
4f2d7a519d Update client/strings/de.json 2022-11-21 10:08:51 -06:00
advplyr
a3642d92c5 Update client/strings/de.json 2022-11-21 10:08:45 -06:00
advplyr
224f36164f Update client/strings/de.json 2022-11-21 10:08:39 -06:00
Hallo951
638c220ae8 Update de.json 2022-11-21 16:27:41 +01:00
advplyr
51070b3e7b Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-21 07:52:38 -06:00
advplyr
0aa2723063 Update:API status codes and update server settings response payload 2022-11-21 07:52:33 -06:00
advplyr
1af66c8e8b Merge pull request #1196 from tomazed/master
Update fr.json
2022-11-21 07:18:51 -06:00
advplyr
7df8795d52 Fix:Icon sizes 2022-11-21 07:18:10 -06:00
Tomazed
a0e9ae7092 Merge branch 'advplyr:master' into master 2022-11-21 13:54:48 +01:00
Tomazed
0f0d8e317a Update fr.json 2022-11-21 13:54:05 +01:00
advplyr
3d5ca7d5c4 Merge pull request #1192 from lkiesow/update-edit-author-modal
Update Author Modal on Changes
2022-11-21 06:43:32 -06:00
advplyr
e33104fa2b Merge pull request #1193 from lkiesow/no-feed-no-error
No feed log level
2022-11-21 06:40:38 -06:00
advplyr
a2f1723642 Update log level for RSS feed requests 2022-11-21 06:39:32 -06:00
advplyr
93357cf280 Merge pull request #1195 from springsunx/patch-1
Update zh-cn.json
2022-11-21 06:28:53 -06:00
advplyr
767427c787 Merge pull request #1194 from burghy86/patch-5
Update it.json
2022-11-21 06:27:52 -06:00
SunX
9377631896 Update zh-cn.json 2022-11-21 19:01:34 +08:00
burghy86
d08af094b8 Update it.json
fix and add new string
2022-11-21 11:40:40 +01:00
Lars Kiesow
c307b1e6fb No feed log level
This patch drops the log level for logging requests to non-existing
feeds from error to debug. The reasoning behind this is that this is a
client error and a client error is returned and can be handled by the
client.

Otherwise anyone can easily spam the logs with error messages by just
requesting non-existing feeds.
2022-11-21 01:54:25 +01:00
Lars Kiesow
d387d5b758 Update Author Modal on Changes
If you are on the home page and open the edit author modal, you can
automatically update all data by clicking “Quick Match” or you can
remove a set image by clicking 🗑.

Both options will update the actual data, but not the data in the open
modal. This means that, for example, a picture is still shown in the
modal after deleting it. That's confusing.

This patch fixes the bug and makes sure the modal is updated if the data
is updated.
2022-11-21 01:48:19 +01:00
advplyr
c285dd666d Fix:Australian audible TLD #1191 2022-11-20 17:17:25 -06:00
advplyr
b37b382ea7 Update:More translation strings #1103 #1166 2022-11-20 17:11:51 -06:00
advplyr
a2cd755ffa Merge pull request #1188 from lkiesow/a11y-tooltip
Make Tooltips Accessible
2022-11-20 16:47:27 -06:00
advplyr
340aedfe13 Merge pull request #1187 from lkiesow/tooltip
Add a few tooltips
2022-11-20 16:44:02 -06:00
advplyr
6fafa7a75e Update other string files with new strings #1103 2022-11-20 16:45:11 -06:00
advplyr
03df5aaf42 Update client/strings/en-us.json 2022-11-20 16:41:51 -06:00
advplyr
6d84db08a8 Update client/strings/en-us.json 2022-11-20 16:41:47 -06:00
advplyr
1a5e0d2a5e Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-20 16:12:38 -06:00
advplyr
70d887bada Update:API status codes and default provider for findCovers route 2022-11-20 16:12:30 -06:00
Lars Kiesow
ee0ac00f80 Make Tooltips Accessible
When using accessibility tools like screen magnifiers, dynamic screen
content can be quite problematic. In particular content, which only
appears if you interact with elements somewhere else on the screen. That
is the case, for example, with the current implementation of tooltips
used by audiobookshelf.

This patch provides a slight adjustment, keeping the tooltips open if
you hover over them. This allows users to have better access to the
content.
2022-11-20 20:02:31 +01:00
Lars Kiesow
fdfb07ff2c Add a few tooltips
Starting to use audiobookshelf, the function of some buttons weren't
very clear to me and while some buttons have tooltips, others have not.

This patch adds some additional tooltips to the user interface,
further explaining some of the functionality.
2022-11-20 18:50:34 +01:00
advplyr
b648155170 Merge pull request #1185 from springsunx/patch-1
Update zh-cn.json
2022-11-20 08:01:25 -06:00
SunX
59dc5299b1 Update zh-cn.json 2022-11-20 21:30:55 +08:00
advplyr
357a63a4d9 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-19 17:57:34 -06:00
advplyr
94912c7542 Update:Enable PWA workbox #354 2022-11-19 17:57:26 -06:00
advplyr
fae182b328 Merge pull request #1182 from tomazed/master
Update fr.json from new strings in  advplyr/audiobookshelf@d24ed98bcd
2022-11-19 15:41:36 -06:00
Tomazed
9ba2f3e33a Merge branch 'advplyr:master' into master 2022-11-19 22:40:29 +01:00
advplyr
5ca2bc5d64 Version bump v2.2.5 2022-11-19 14:50:57 -06:00
Tomazed
442687b198 Fix: Incorrect translation in fr.json 2022-11-19 21:49:27 +01:00
Tomazed
7e400d3e9c Update fr.json from new strings in advplyr/audiobookshelf@d24ed98bcd 2022-11-19 21:48:17 +01:00
advplyr
e3ba739db5 Update:Encode & embed metadata API endpoints, separate cache & search endpoints into controllers 2022-11-19 13:28:06 -06:00
advplyr
cd92a22f4d Update:Account button icon size 2022-11-19 12:09:05 -06:00
advplyr
d24ed98bcd Update:Add translation strings for other languages #1103 #1166 2022-11-19 11:44:54 -06:00
advplyr
bcd224f534 Update:Add translation strings for bookshelf #1103 #1166 2022-11-19 11:44:08 -06:00
advplyr
1a93103e50 Update:Remove limit for batch editing #1170 2022-11-19 11:27:08 -06:00
advplyr
45ccf9d4be Update:Hide bookshelf toolbar inputs when batch selecting 2022-11-19 11:24:21 -06:00
advplyr
052a8307b3 Merge pull request #1181 from burghy86/patch-4
Update it.json
2022-11-19 11:10:40 -06:00
burghy86
a0c0b9ea76 Update it.json
fix with the new it.json file
2022-11-19 17:47:52 +01:00
advplyr
7485cf1a26 Add:Batch select audiobook play button, item page mobile screen size cleanup 2022-11-19 10:20:10 -06:00
advplyr
8931702f1b Update:Filter submenu translations & new translation string #1103 #1166 2022-11-19 09:17:41 -06:00
advplyr
00fae3eb16 Merge pull request #1180 from tomazed/master
Update fr.json
2022-11-19 09:06:50 -06:00
advplyr
003e8e17be Merge pull request #1177 from springsunx/patch-1
Update zh-cn.json
2022-11-19 09:06:37 -06:00
Tomazed
edd9443d51 Update fr.json 2022-11-19 15:59:38 +01:00
SunX
b93a4c6792 Update zh-cn.json 2022-11-19 18:24:57 +08:00
advplyr
30cf144090 Merge pull request #1176 from tomazed/master
Fix: French translations regarding issue #1166
2022-11-18 17:27:39 -06:00
advplyr
f17abef20a Update:Add more translation strings for sort/filter menus #1103 #1166 2022-11-18 16:59:11 -06:00
Tomazed
937438800e Fix: French translations regarding issue #1166 2022-11-18 23:38:25 +01:00
advplyr
892fb6410c Update:Add client ip address in server log for failed auth attempts #1172 2022-11-17 18:04:11 -06:00
advplyr
7008267e42 Fix:Mobile series books sortBy filter #1152 2022-11-17 17:09:27 -06:00
advplyr
2e5e02472c Update:Playback session sync local status codes 2022-11-17 17:00:37 -06:00
advplyr
f9d37228cf Merge pull request #1171 from springsunx/patch-1
Update zh-cn.json
2022-11-17 15:48:01 -06:00
SunX
f48d52a489 Update zh-cn.json 2022-11-17 10:23:13 +08:00
advplyr
7d8c8fa5bb Update:Navigation for mobile screen include authors page and podcast latest page 2022-11-16 17:23:18 -06:00
advplyr
96a739e22d Fix:Removing all sessions from last page of sessions table #1168 2022-11-16 16:28:46 -06:00
advplyr
c3ec036009 Update:New strings for translation #1103 #1166 2022-11-16 16:11:06 -06:00
advplyr
c7794e00f6 Update:Author image from cache API status codes 2022-11-16 15:32:32 -06:00
advplyr
3316394f5c Add:Button on series books page to re-add series to continue listening #1159 2022-11-15 17:20:57 -06:00
advplyr
c5d66989a6 Update:Bookshelf toolbar for series page on mobile 2022-11-15 17:05:03 -06:00
advplyr
e6b886a511 Merge pull request #1156 from tomazed/master
Add French Translation
2022-11-15 16:49:57 -06:00
Tomazed
9bdfb05ea6 Fixed typo in ButtonRemoveSeriesFromContinueSeries 2022-11-15 10:08:23 +01:00
Tomazed
52d02b32f7 update French translation with "LabelStatsOverallDays" and "LabelStatsOverallHours" 2022-11-15 09:55:56 +01:00
Tomazed
adff5a7705 Merge branch 'advplyr:master' into master 2022-11-15 09:51:06 +01:00
advplyr
60fb4090ff Merge pull request #1155 from springsunx/patch-1
Update zh-cn.json
2022-11-14 18:04:34 -06:00
SunX
dd28be0113 Update zh-cn.json 2022-11-15 08:02:26 +08:00
advplyr
5a60bb8267 Update:Stats translation for Overall Days/Hours 2022-11-14 17:55:45 -06:00
Tomazed
2749b710e6 Add French Translation 2022-11-14 17:45:26 +01:00
SunX
55ddcde631 Update zh-cn.json
fix translation errors
2022-11-14 20:45:00 +08:00
advplyr
4d2bcfd167 Fix:Revert calculating total entities 2022-11-13 16:46:43 -06:00
advplyr
1fe4cffd3b Version bump 2.2.4 2022-11-13 14:13:12 -06:00
advplyr
8f83752abc Fix:Get library items endpoint limit & total entities count 2022-11-13 13:25:20 -06:00
advplyr
31be2ba4fb Update:User getMostRecentItemProgress method to support podcast episode progress 2022-11-13 09:03:16 -06:00
advplyr
dc156a2eac Update:api/users/online API endpoint unauth status code 2022-11-13 08:26:32 -06:00
advplyr
42050a5f17 Fix:User toJSONForPublic method 2022-11-13 08:25:51 -06:00
advplyr
bcc7fcb645 Add:Polish translations, update translation json files with new strings, fix side rail buttons to center and wrap long text #1103 2022-11-13 08:15:41 -06:00
advplyr
d96f427b83 Merge pull request #1149 from konradorlinski/polish-translation
Update polish translation
2022-11-13 07:57:41 -06:00
konradorlinski
bba8d0a46f Update polish translation 2022-11-13 12:53:31 +01:00
advplyr
a07a69e7de Version bump 2.2.3 2022-11-12 17:22:16 -06:00
advplyr
cbc2f64e2e Add:Croatian language #1103 2022-11-12 16:55:42 -06:00
advplyr
ef622108c9 Merge pull request #1147 from Smoukus/croatian-translation
add Croatian translation
2022-11-12 16:49:00 -06:00
advplyr
78559520ab Add:Player queue for audiobooks #1077 2022-11-12 16:48:35 -06:00
Smoukus
61a8f31802 add croatian translation 2022-11-12 23:45:40 +01:00
advplyr
3357ccfaf3 Add:Buttons to add/remove podcast episodes from player queue 2022-11-12 15:41:41 -06:00
advplyr
92e3e0ef6e Update collection id prefix 2022-11-12 14:31:45 -06:00
advplyr
ed76f51f4b Update:Service worker icons 2022-11-12 10:03:41 -06:00
advplyr
7d569e1e3e Update:Some incorrect status codes returned from API 2022-11-12 09:36:00 -06:00
advplyr
16cf5b5616 Add:Italian language selection #1103 2022-11-12 08:07:53 -06:00
advplyr
b260bcaeb1 Merge pull request #1144 from austinphilp/fix-listening-sessions-count-bug
Fix listening sessions count bug
2022-11-12 08:02:07 -06:00
advplyr
3ffc481a54 Fix users latest session computed property 2022-11-12 08:03:13 -06:00
advplyr
b9b38d82f2 Merge pull request #1146 from burghy86/patch-2
Update it.json
2022-11-12 07:52:24 -06:00
advplyr
9635d72cef Update client/strings/it.json 2022-11-12 07:52:20 -06:00
burghy86
288edae3d1 Update it.json
surely there are various things to fix. I'll take the time next week to correct everything properly
2022-11-12 14:36:25 +01:00
advplyr
ec90aafed1 Merge pull request #1145 from Smoukus/german-translation
fix minor typos
2022-11-12 04:31:58 -06:00
Smoukus
c023678c11 fix minor typos 2022-11-12 11:18:24 +01:00
advplyr
cada1a6857 Update:Add Deutsch language to dropdown #1103 2022-11-11 17:57:02 -06:00
advplyr
5eac2a91fb Merge pull request #1143 from Hallo951/master
german translation
2022-11-11 17:48:21 -06:00
Austin Philp
eb295453fc Cleanup 2022-11-11 15:47:20 -08:00
advplyr
28feed6ea2 Fix:Remove collections when removing library 2022-11-11 17:44:19 -06:00
Austin Philp
c6dc4054be Use total from listening-sessions endpoint to display total sessions 2022-11-11 15:41:50 -08:00
advplyr
6f901defd6 Fix:Show only collections for selected library #1130 2022-11-11 17:28:05 -06:00
advplyr
4cbc8676c6 Update:Rename UserCollections to Collections 2022-11-11 17:13:10 -06:00
Hallo951
0d587b6aae Übersetzung_final 2022-11-12 00:08:47 +01:00
Hallo951
a47bf7a835 Übersetzung_v4 2022-11-11 14:33:25 +01:00
Hallo951
fce9e72851 Übersetzung_v3 2022-11-11 14:22:55 +01:00
Hallo951
6357fb26bf Übersetzung_v2 2022-11-11 12:49:00 +01:00
Hallo951
d2aabde8fe Merge branch 'advplyr:master' into master 2022-11-11 08:30:54 +01:00
advplyr
fdf67e17a0 Add:API endpoint to get users online and open listening sessions #1125 2022-11-10 17:42:20 -06:00
advplyr
abb4137d4c Fix:Set library item updatedAt when scan has updates, fixes updating an open RSS feed #1131 2022-11-10 17:25:17 -06:00
advplyr
a237058e30 Merge pull request #1134 from springsunx/patch-1
change language name and fix translation errors
2022-11-10 16:46:53 -06:00
SunX
06851f50f4 Update zh-cn.json 2022-11-10 21:46:05 +08:00
SunX
54c1a49e1e Update zh-cn.json 2022-11-10 20:14:05 +08:00
SunX
12e47fb034 Update zh-cn.json 2022-11-10 20:10:34 +08:00
SunX
c91897ae99 Update zh-cn.json
Fix translation errors.
2022-11-10 19:51:54 +08:00
SunX
26f4479859 Update i18n.js
change langeage name
2022-11-10 10:46:31 +08:00
advplyr
c33314edfb Add:Language select in account page #1103 2022-11-09 18:00:20 -06:00
advplyr
b083f6ab96 Fix:Podcast quick match genres 2022-11-09 16:50:26 -06:00
advplyr
8d5e08b76a Merge pull request #1132 from springsunx/patch-1
Update zh-cn.json
2022-11-09 16:03:04 -06:00
Hallo951
a7019e2f11 Übersetzung_v1 2022-11-09 11:03:16 +01:00
Hallo951
a7163f7a00 Merge branch 'advplyr:master' into master 2022-11-09 09:50:09 +01:00
SunX
a1f758cd7b Update zh-cn.json 2022-11-09 13:58:06 +08:00
advplyr
946e4f39cc merge translations 2022-11-08 18:11:03 -06:00
advplyr
6e064eeafb Add:Server setting for default language #1103 2022-11-08 18:09:07 -06:00
advplyr
400e34a4c7 Update:More localization strings #1103 2022-11-08 17:10:08 -06:00
Hallo951
780a0a9dd6 Übersetzung_v1 2022-11-08 22:20:10 +01:00
advplyr
c1b3d7779b Fix:Multi-select and shift select 2022-11-08 08:38:42 -06:00
advplyr
2662b3ec49 Update:More localization strings #1103 2022-11-08 08:37:39 -06:00
advplyr
042a175d16 Merge pull request #1119 from burghy86/patch-1
Update it.json
2022-11-07 18:28:11 -06:00
advplyr
5e50ac91ff Merge pull request #1121 from ruoti/add-series-ranges
Fixing range generation in series labels
2022-11-07 18:27:26 -06:00
advplyr
faac6f677a Update:More localization strings #1103 2022-11-07 18:27:17 -06:00
advplyr
46d02744a1 Merge pull request #1120 from springsunx/patch-1
Update zh-ch.json
2022-11-07 18:27:08 -06:00
advplyr
d7e61c3aba Merge pull request #1124 from konradorlinski/pol-patch
Update polish translation
2022-11-07 18:26:44 -06:00
Konrad
c23c51eb78 Update polish translation 2022-11-07 21:29:56 +01:00
burghy86
270b2bb826 Update it.json 2022-11-07 16:53:56 +01:00
Scott Ruoti
0643116e9b Fixing range generation in series labels 2022-11-07 09:24:48 -05:00
SunX
03ea055299 Update zh-ch.json 2022-11-07 22:22:55 +08:00
SunX
da12f94be4 Update zh-ch.json 2022-11-07 21:25:39 +08:00
burghy86
64d196c347 Update it.json 2022-11-07 14:06:05 +01:00
burghy86
c8d3e0c912 Update it.json
i have traslate all item. but I see on my  portal that on the home page there are other objects to be translated.
will you add them soon? I mean the "Continue Listening", "Continue Series", "Recently Added" on the homepage, Listen Again, Newest Authors, collapse series, You haven't made any collections yet.
 in option zone not found: Filter by User, backup page, notification page, and your stat page
2022-11-07 14:00:28 +01:00
advplyr
eb463a2958 Add:Start of localization i18n #1103 2022-11-06 17:56:44 -06:00
advplyr
3282ac67e4 Fix:Podcast pubDate parsing #1116 2022-11-06 15:43:17 -06:00
advplyr
8319891c96 Merge pull request #1105 from ruoti/collapseseries-patch
Patching handling of titles with multiple series
2022-11-06 10:55:38 -06:00
advplyr
24d97d17ba Add collapseBookSeries to default settings 2022-11-06 10:55:44 -06:00
advplyr
7425622d93 Merge branch 'master' into collapseseries-patch 2022-11-06 10:52:22 -06:00
advplyr
5050de3a17 Update deploy-linux script 2022-11-06 10:24:59 -06:00
Scott Ruoti
b1111912f7 Added sorting by sequence for series and collapsing series in series view 2022-11-05 20:30:13 -04:00
Scott Ruoti
c1035d97e8 Show book sequences for collapsed series when filtering by series 2022-11-05 20:01:01 -04:00
Scott Ruoti
b322d0207b Fixed sorting to be more consistent for multiple series (and generally) 2022-11-05 20:01:01 -04:00
Scott Ruoti
d64932dad7 Fixes bug when titles are in multiple series being collapsed 2022-11-05 20:01:01 -04:00
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
advplyr
95ebe0f087 Version bump 2.0.23 2022-07-02 19:15:23 -05:00
advplyr
0a6aa43b07 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-07-02 09:10:52 -05:00
advplyr
806a8cf659 Update:Library config page for mobile #753 and style updates 2022-07-02 09:10:47 -05:00
advplyr
1a32fbfeec Merge pull request #795 from jmt-gh/issue_794
Fix issue with unecessary empty search during match (Issue #794)
2022-06-30 15:48:07 -05:00
jmt-gh
67396c16dd formatting update 2022-06-29 19:25:59 -07:00
jmt-gh
b0684b6f1b Await the responses from googlebooks and itunes 2022-06-29 19:19:58 -07:00
jmt-gh
661778c02c Await the response from audible for book search by ASIN 2022-06-29 19:18:00 -07:00
advplyr
5c4241aefe Merge pull request #792 from jmt-gh/issue_760
Fix truncation on your stats page (Issue #760)
2022-06-29 19:24:11 -05:00
jmt-gh
3f6bc90824 remove truncation from number column 2022-06-29 08:25:12 -07:00
advplyr
4ade6e04a8 Merge pull request #791 from alexmaras/master
fix: disable workbox to prevent failure with service worker
2022-06-29 08:23:07 -05:00
Alex Maras
49d0835236 fix: disable workbox to prevent failure with service worker 2022-06-29 21:11:03 +08:00
advplyr
d90bd92bcc Fix:Item edit modal for mobile landscape #754 2022-06-28 18:29:11 -05:00
advplyr
41c016b8c7 Update:Match card show series and series sequence if available #762 2022-06-28 17:32:46 -05:00
advplyr
5b4d3f71f9 Update:Global library search strips periods, commas and other characters when matching #750 2022-06-26 15:46:16 -05:00
advplyr
256a9322ef Fix:Mobile toolbar for podcasts and add collections for books #693 2022-06-26 11:34:58 -05:00
advplyr
793f82e445 Update:Edit modal for mobile screen sizes and update tailwind 2022-06-26 11:15:19 -05:00
advplyr
ab6da3914b Merge pull request #777 from alexmaras/fix/chapter-seek-bar
Fix/chapter seek bar
2022-06-25 11:03:23 -05:00
advplyr
0b53f0ebf3 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-25 11:01:06 -05:00
advplyr
76d668514e Fix:Mark as not finished when duration is not set in media progress #771 2022-06-25 11:01:01 -05:00
Alex Maras
3c347bef7d fix: better variable naming 2022-06-26 00:00:52 +08:00
Alex Maras
e837e5f780 fix: reuse existing variable 2022-06-25 23:58:55 +08:00
Alex Maras
26348ccc74 Merge remote-tracking branch 'fork/master' into fix/chapter-seek-bar 2022-06-25 23:56:42 +08:00
Alex Maras
729a756e21 fix: use total time for chapter name resolution when in chapter track mode 2022-06-25 23:53:40 +08:00
advplyr
4dbddcf179 Merge pull request #776 from alexmaras/fix/chapter-seek-bar
fix: use chapter duration when seeking on track bar
2022-06-25 10:40:55 -05:00
Alex Maras
f2fff34d4d fix: use chapter start as a base for the seek time if seeking within chapters 2022-06-25 23:37:18 +08:00
advplyr
59c5e2c1d9 Allow custom headers in requests 2022-06-25 10:36:37 -05:00
Alex Maras
067006f406 fix: use chapter duration when seeking on track bar 2022-06-25 23:24:40 +08:00
advplyr
93d82b973e Merge pull request #770 from jmt-gh/relative_chapter_times
Show chapter duration in the "now playing" chapter modal (Issue #767)
2022-06-23 17:51:56 -05:00
advplyr
a9a3423b58 Update chapter modal for mobile 2022-06-23 17:50:25 -05:00
advplyr
f4ee215ad8 Update chapters modal truncate long chapter titles and show chapter duration 2022-06-23 17:36:55 -05:00
jmt-gh
48431b1c35 add support for showing chapter duration in chapters modal 2022-06-22 18:58:57 -07:00
advplyr
ce961f90ba Merge pull request #759 from jmt-gh/update_stats_uis
Update Library Stats and Your Stats UIs to match other Settings UIs
2022-06-22 17:50:05 -05:00
advplyr
916d2f6bb3 Merge pull request #758 from jmt-gh/update_settings
Update Settings page UI
2022-06-22 17:48:06 -05:00
advplyr
01e7098f00 Updates to setting formatting and copy 2022-06-22 17:47:21 -05:00
advplyr
e02fbac4cd Merge pull request #757 from jmt-gh/update_users_table
Update UsersTable styling to match other tables
2022-06-22 17:30:46 -05:00
advplyr
a8fce32e70 Merge pull request #749 from jmt-gh/add_reorder_icons_to_libraries
Add reorder icon to libraries table
2022-06-20 10:27:06 -05:00
jmt-gh)
d0637c1e3d update library and your stat UIs to match 2022-06-19 19:25:44 -07:00
jmt-gh)
f6702d299d update html formatting 2022-06-19 18:08:04 -07:00
jmt-gh)
033b7ece28 update text formatting 2022-06-19 17:46:38 -07:00
jmt-gh)
5f5dce6d53 initial Settings update 2022-06-19 17:31:52 -07:00
jmt-gh)
82c5c7518b remove unecessary css styling 2022-06-19 17:13:24 -07:00
jmt-gh)
7a60ffb3c4 update UsersTable styling 2022-06-19 17:10:15 -07:00
advplyr
2795f657b5 Merge pull request #755 from jmt-gh/update_sessions_table
Update Sessions page to have a matching "settings UI"
2022-06-19 18:28:35 -05:00
advplyr
9ef5b5830e Merge pull request #752 from jmt-gh/issue_702
Move matching toasts to top right (Issue #702)
2022-06-19 18:26:16 -05:00
advplyr
879adfa633 Remove last bottom-center for toast 2022-06-19 18:25:59 -05:00
advplyr
b12a344776 Fix:Chromecast button on mobile screen sizes #756 2022-06-19 15:43:45 -05:00
jmt-gh
50b1098797 add back in empty state 2022-06-19 10:07:09 -07:00
jmt-gh
fdfaa7eba4 unify on 'Listening Sessions' 2022-06-19 10:01:06 -07:00
jmt-gh
5525587513 update sessions table to match other settings tables UI 2022-06-19 09:54:22 -07:00
jmt-gh
1f20ed7640 Move matching toasts to top right 2022-06-19 09:31:51 -07:00
advplyr
f741064843 Merge pull request #748 from jmt-gh/update_log_page
Update Log page to have a matching "settings UI"
2022-06-19 10:07:56 -05:00
advplyr
d5138e4c0a Merge pull request #747 from jmt-gh/fix_remove_button_padding
Fix padding on "Remove All Library Items" button
2022-06-19 10:06:26 -05:00
advplyr
42a30c33db Merge pull request #746 from jmt-gh/update_placard_size
Fix placard sizes so "Continue Listening" fits
2022-06-19 10:06:04 -05:00
advplyr
e5d978f8e8 Merge pull request #744 from jmt-gh/issue_741
Add toggle for switching between Chapter and Book Duration in player (issue #741)
2022-06-19 10:05:06 -05:00
advplyr
ccc82520a9 Update chapter track progress bar, timestamps, hide chapter ticks. Update mobile responsiveness for player 2022-06-19 10:04:15 -05:00
jmt-gh)
22acf52a26 remove unecessary space 2022-06-19 02:06:15 -07:00
jmt-gh)
2ccd2786f4 add reorder icon to the library items 2022-06-19 02:03:05 -07:00
jmt-gh
0028136935 update height of content to optimize screen space 2022-06-19 01:53:23 -07:00
jmt-gh
0edc46b771 update log page to have a matching UI 2022-06-19 01:46:42 -07:00
jmt-gh
2261f3d1c3 fix right padding on remove all items button 2022-06-19 01:09:02 -07:00
jmt-gh
5c0e792782 fix placard size so continue listening fits 2022-06-19 01:03:56 -07:00
jmt-gh
644882e04f add support for swapping progress bar between current chapter duration and book duration 2022-06-18 23:55:34 -07:00
advplyr
67f51c6de9 Version bump 2.0.22 2022-06-18 18:43:19 -05:00
advplyr
0c8fd6ab0e Update:Uploader to treat audio files as separate audiobooks if uploading only audio files #670 2022-06-18 16:44:20 -05:00
advplyr
5452a57a14 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-18 13:11:20 -05:00
advplyr
19f020e7a6 Fix:Open playback session on init #743 2022-06-18 13:11:15 -05:00
advplyr
825641f2a9 Merge pull request #742 from mrdth/feature/fetch-author-image
Fetch author photo from external URL
2022-06-18 12:10:53 -05:00
advplyr
35ab4cb2fe Update photo url input to photo path/url to be consistent with item covers 2022-06-18 12:05:30 -05:00
advplyr
fd13607d89 Update:Logs to use server local timezone instead of UTC #656 2022-06-18 11:34:28 -05:00
mrdth
f79b4d44b9 Fetch author photo from external URL
Add a new text field 'Photo URL' on the author edit modal, if there
is no existing image for an author.

When submitted, the image is saved from the URL provided
2022-06-18 17:04:38 +01:00
advplyr
91e30a6e84 Merge pull request #736 from cassieesposito/dateFormat
Date format
2022-06-18 10:34:32 -05:00
advplyr
8ab0f164a6 Update server settings ui and add display settings section 2022-06-18 10:32:12 -05:00
advplyr
578bb03404 Merge pull request #730 from jmt-gh/issue_719
Properly support multiple narrators when Matching (#719)
2022-06-18 09:32:46 -05:00
advplyr
06582b5371 Merge pull request #739 from jmt-gh/mobile_responsive_search
Show search bar on mobile responsive views (fix #715)
2022-06-18 09:30:17 -05:00
Cassie Esposito
7f6baf35b7 Merge branch 'advplyr:master' into dateFormat 2022-06-17 20:58:05 -07:00
advplyr
6227d0baa1 Update 2022-06-17 19:09:03 -05:00
advplyr
e334b585be Libraries dropdown updates for mobile 2022-06-17 16:50:46 -05:00
advplyr
c83b3f19f7 Merge pull request #735 from samosbor/master
Update BookFinder.js: Retry Search with Clean Title and Author if No Books Found
2022-06-17 15:46:57 -05:00
Sam
d31ec055f9 check if cleantitle and cleanauthor are different 2022-06-17 10:31:31 -07:00
jmt-gh
38c259a45e initial commit for responsive search bar on mobile 2022-06-17 07:48:42 -07:00
Cassie Esposito
b2ee24de98 Applied dateFormat setting to alternative bookshelf view 2022-06-17 01:35:24 -07:00
Cassie Esposito
9ba0e52bb7 Added an option to change date format 2022-06-17 01:26:10 -07:00
Sam
edc712e6f6 Merge branch 'master' of https://github.com/samosbor/audiobookshelf 2022-06-17 01:14:19 -07:00
Sam
485888b2d9 list includes syntax 2022-06-16 22:37:36 -07:00
advplyr
c2e90d4d83 Merge pull request #734 from jmt-gh/add_local_docker_build_commands
Add npm commands to build local docker containers
2022-06-16 17:09:48 -05:00
advplyr
f5d89b8f52 Merge pull request #733 from jmt-gh/multiline_pills
Update pills so that they handle multi-line properly
2022-06-16 17:08:59 -05:00
advplyr
378b40790a Merge branch 'master' into multiline_pills 2022-06-16 17:08:47 -05:00
advplyr
be3d38392d Merge pull request #732 from jmt-gh/update_pills
Update details "pills" so they don't overlap when they flow to new lines
2022-06-16 17:07:21 -05:00
Sam Osborne
27fef50983 retry search with clean title author 2022-06-16 13:48:37 -07:00
jmt-gh
167df85c1e add npm commands to build local docker containers 2022-06-15 22:23:36 -07:00
jmt-gh
009e16c9a4 add break-all for multiline support for long entries 2022-06-15 22:12:56 -07:00
jmt-gh
b40cc767b2 add margin on y so pills don't touch when they overflow 2022-06-15 20:49:27 -07:00
jmt-gh
4f5f2d32be Properly support multiple narrators when Matching
This commit adds proper support for multiple narrators when matching. We
were accidentally missing a split in the same way we handle it for
genres and tags
2022-06-15 20:23:56 -07:00
advplyr
66be3e0281 Update:Clear global search input when clicking dropdown item #723 2022-06-15 12:35:50 -05:00
advplyr
987f188f00 Merge pull request #720 from jmt-gh/fix_formatting
Fix indents from 4 spaces to 2 in parseOverdriveMediaMarkers.js
2022-06-14 13:28:11 -05:00
jmt-gh
daca2bdf2a fix spacing from 4 to 2 2022-06-13 22:23:02 -07:00
advplyr
8894f52439 Merge pull request #716 from jmt-gh/abs_overdrive
Add support for leveraging chapter data directly from Overdrive mp3s during scanning
2022-06-13 18:25:44 -05:00
jmt-gh
863f81e55a remove logger 2022-06-12 20:43:20 -07:00
jmt-gh
d03d3735e5 Add chapter end time support 2022-06-12 20:28:09 -07:00
advplyr
3bb2df6e12 Fix:Abs metadata parser to allow second equals sign in value #634 2022-06-12 17:47:14 -05:00
advplyr
80c9efc618 Update:Make m4b timeout to 30 mins 2022-06-12 15:59:36 -05:00
jmt-gh
3279901ab0 remove clientside changes 2022-06-12 12:24:29 -07:00
jmt-gh
d43d351721 remove loggers 2022-06-12 02:03:26 -07:00
jmt-gh
8210eba439 clean up loggers 2022-06-12 01:57:00 -07:00
jmt-gh
cbd7294b0b add getter to libraryscan.js for overdrivemediamarker 2022-06-12 01:54:58 -07:00
jmt-gh
6064e8af87 fix using OMMs with regular scan option 2022-06-12 01:46:50 -07:00
jmt-gh
8754f0c25f Cleaning up server code
Doing some literal cleaning
2022-06-12 01:38:09 -07:00
jmt-gh
f31700f668 update comments 2022-06-12 00:54:34 -07:00
jmt-gh
9877b139f6 add new parser for overdrive media markers 2022-06-12 00:53:56 -07:00
jmt-gh
5643c846ee Fix bug for certain scan types
Needed to look in to scanOptions to access the properties I wanted.
It's..... unclear to me if this needs to be done for those other ones as
well. I think so?
2022-06-12 00:47:54 -07:00
jmt-gh
5a071babe9 added library/ to .gitignore 2022-06-12 00:22:04 -07:00
jmt-gh
3d85d0bce6 add settings page setting 2022-06-12 00:21:37 -07:00
jmt-gh
68afc2c718 Add support for various scan types
This commit adds support for the various scan types, and ensures that we
only run Overdrive parsing on files that can actually support it
2022-06-11 23:56:36 -07:00
jmt-gh
b3d9323f66 Initial commit for server side approach
This is the first commit for bringing this over to the server side.

It works! Right now it fails if the autoscanner or or the manual
individual book scanner try to do it's thing. I'll need to update those
2022-06-11 23:17:22 -07:00
jmt-gh
effc63755b remove unused console.log 2022-06-11 11:07:49 -07:00
jmt-gh
b90934a72a remove unused modal 2022-06-11 11:06:19 -07:00
jmt-gh
e01748eb2f Full support for generating/applying chapter data
This commit adds the rest of the support for actually being able to use
Overdrive MediaMarkers.

Shoutout to benonymity's project OverdriveChapterize, where I was able
to port over the logic to actually do the timestamp conversions

https://github.com/benonymity/OverdriveChapterizer/blob/main/chapters.py

I still need to do a lot of cleanup of the actual code, and finish the UI.
2022-06-11 10:57:17 -07:00
jmt-gh
430fbf5e46 Parse out mediamarkers from all files
This commit updates the logic in
generateChaptersFromOverdriveMediaMarkers to create a single array of
objects that holds all of the clean MediaMarker data. it still needs to
be conveted to the NewChapters format.

I should also rename this function to "cleanChaptersFromOMM", but I"ll
do that later
2022-06-11 09:34:22 -07:00
jmt-gh
27e6b9ce0d DRAFT client support for overdrive media markers
Initial client side support. Still a good amount to do. Specifically
around actually parsing out all of the media markers, and generating a
single chapter object that can be applied
2022-06-11 02:01:37 -07:00
jmt-gh
fc614b9833 Add support for overdrive media marker file tag
This commit adds serverside support for grabbing the
overdrive_media_marker file tag that exists on mp3 files from overdrive
2022-06-11 02:00:07 -07:00
advplyr
a97c102369 Version bump 2.0.21 2022-06-09 18:24:03 -05:00
advplyr
745a491f90 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-09 17:56:22 -05:00
advplyr
b2880ab0a9 Fix:Match tab #708 2022-06-09 17:56:16 -05:00
advplyr
f916454c55 Merge pull request #707 from jmt-gh/fix_library_scanning
Fix library scanning failing
2022-06-09 04:54:08 -05:00
jmt-gh
701b8ea12e Fix bug with library scanning introduced in #697
Looks like #697 missed a reference update that caused scanning libraries
to fail. This fixes that
2022-06-08 19:15:35 -07:00
advplyr
2079942ccd Merge pull request #697 from jvanbruegge/patch-1
Use `show` and `episode_id` tags for audiobook series
2022-06-08 16:10:28 -05:00
advplyr
140b718592 Merge pull request #699 from jmt-gh/698_metadata_downloads_not_created
Update some instances of mkdir to ensureDir (#698)
2022-06-08 16:05:19 -05:00
Jan van Brügge
2de8c72131 Allow show and episode_id tags for audiobook series
FFmpeg only supports a very limited number of tags for m4b files (see https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata) by default. `series` and `series-part` are only possible by enabling custom tags with `-movflags use_metadata_tags`. To work around that, `show` and `episode_id` are added as second option.
2022-06-08 11:11:17 +02:00
advplyr
089d4b5cee Update:Remove fast-sort dependency 2022-06-07 20:22:23 -05:00
advplyr
e06a015d6e Update:Remove proper-lockfile dependency 2022-06-07 20:15:00 -05:00
advplyr
b7e546f2f5 Update:Remove node-cron dependency 2022-06-07 20:04:51 -05:00
advplyr
26ef275ab4 Update:Remove image-type dependency 2022-06-07 19:53:05 -05:00
advplyr
416db7c981 Update:Remove read-chunk dependency 2022-06-07 19:44:38 -05:00
advplyr
78079b2e60 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-07 19:25:28 -05:00
advplyr
03bffb725a Update:Remove rss feed dependencies add node-xml lib 2022-06-07 19:25:14 -05:00
advplyr
46fc89e247 Update:Cache feed xml 2022-06-07 18:37:37 -05:00
advplyr
fbbceaa642 Add:Persist RSS feeds in db #696, Update:RSS feed data model 2022-06-07 18:29:43 -05:00
jmt-gh
f5aae25cc8 remove random character 2022-06-06 18:52:08 -07:00
jmt-gh
8d03943acb remove extra debug log 2022-06-06 18:51:49 -07:00
jmt-gh
853513b926 update approach for ensuring download directory always exists 2022-06-06 18:51:08 -07:00
jmt-gh
c606a41314 update Cache folder creation to leverage ensureDirs 2022-06-06 08:18:15 -07:00
jmt-gh
35f29ca22b Use ensureDir instead of mkdir to fix 698
This commit updates the mkdir for creating the download location to
ensureDir, which is an alias for mkdirs and mkdirp, meaning they will
create the entire path of the directory if it does not exist.

https://github.com/jprichardson/node-fs-extra/blob/master/docs/ensureDir.md
2022-06-06 08:12:58 -07:00
advplyr
ac00f3ebe7 Merge pull request #692 from cassieesposito/getBookDataFromDir-refactor
Fixed bugs that caused getSequence to run twice and broke year recognition
2022-06-05 18:02:39 -05:00
Cassie Esposito
6846de98f8 Fixed bugs that caused getSequence to run twice and broke year recognition 2022-06-05 15:52:18 -07:00
advplyr
881baa818d Fix:Progress filter 2022-06-05 15:26:27 -05:00
advplyr
b671145e73 Merge pull request #682 from jmt-gh/update_cover_on_merge
Support embedding cover art metadata in the embed metadata tool
2022-06-05 13:04:12 -05:00
jmt-gh
8809c7b900 Handle null and delete cover cases
This commit adds in supporting if a cover path is null. If this is the
case, we completely remove the video stream from the file, as the user
either:

a) uploaded a file with no video stream (so removing it is a no-op)
b) removed the cover in ABS, so we should respect that on merge
2022-06-05 10:36:42 -07:00
advplyr
ae8f3aa918 Version bump 2.0.20 2022-06-05 10:59:01 -05:00
advplyr
5d4047c171 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-05 10:16:19 -05:00
advplyr
6f80591afd Fix:Switching to next track pausing player #685 2022-06-05 10:06:07 -05:00
jmt-gh
9b6fa8fe8c Merge branch 'advplyr:master' into update_cover_on_merge 2022-06-04 19:00:41 -07:00
jmt-gh
d6c02ebb2c Support embedding cover art metadata
Added support for chapter metadata in #678, but completely missed that
coverart wasn't getting embedded in the embed metadata tool. This commit
adds that in
2022-06-04 18:56:55 -07:00
advplyr
788d867ec3 Merge pull request #681 from jmt-gh/m4b_no_coverart
Fix cover art not being generated for M4B export
2022-06-04 20:56:04 -05:00
jmt-gh
3bc3914fd9 Fix cover art not being generated for m4b export
This commit fixes an issue where cover art was not being generated
properly when creating an M4B audiobook.

More context can be found in discord:
https://discord.com/channels/942908292873723984/981321213882282035/982777444631195681
2022-06-04 17:50:26 -07:00
advplyr
3d821dacb7 Fix:Sessions table cleanup 2022-06-04 15:51:00 -05:00
advplyr
e0546c6164 Version bump 2.0.19 2022-06-04 14:42:36 -05:00
advplyr
be7ccfb209 Merge pull request #678 from jmt-gh/issue_676_chapter_metadata
Support embedding updated chapter metadata (issue #676)
2022-06-04 14:02:44 -05:00
advplyr
938a8c6f80 Fix:Casing typo in LibraryItem 2022-06-04 13:00:51 -05:00
advplyr
5cd343cb01 Add:All listening sessions config page 2022-06-04 12:44:42 -05:00
jmt-gh
ab0094a53b Support embedding updated chapter metadata (676)
This commit resolves issue #676. The embed metadata tool was missing the
flag that tells ffmpeg to not only update the "top" metadata, but also
the chapter metadata.
2022-06-04 10:17:42 -07:00
advplyr
2d5e4ebcf0 Add:Audio player next/prev chapter buttons 2022-06-04 12:07:38 -05:00
advplyr
3171ce5aba Update:Paginated listening sessions 2022-06-04 10:52:37 -05:00
advplyr
0e1692d26b Fix:Matching authors with multiple authors split by comma #667 2022-06-03 19:21:31 -05:00
advplyr
e8cd18eac2 Add:Alert when progress is not syncing 2022-06-03 19:11:13 -05:00
advplyr
bf928692d5 Update:API route for getting playback session and getting media progress 2022-06-03 18:59:42 -05:00
advplyr
792490b629 Merge pull request #664 from bskrtich/docker_updates
feat: Updates to docker file and gh action
2022-06-03 05:02:11 -05:00
advplyr
0d1ff35c5e Add:Not Finished progress filter #650 2022-06-02 18:20:18 -05:00
advplyr
67e02fddbd Comment out expand on player ui 2022-06-02 17:54:07 -05:00
advplyr
09beb6a2ae Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-02 16:32:42 -05:00
advplyr
1bd657f07d Fix:Mark as finished once media has ended #635 2022-06-02 16:31:52 -05:00
advplyr
2dba17a7ae Merge pull request #651 from selfhost-alt/handle-another-backup-parse-error
Gracefully handle unexpected end of file when listing backup files
2022-06-02 07:26:42 -05:00
Brandon Skrtich
4900649908 feat: Updates to docker file and gh action
* Clean up Dockerfile
* Add health check to Dockerfile
* Update gh action versions
2022-06-02 05:55:01 +00:00
advplyr
c3b33ea37a Fix:Sanitize filename to remove line breaks and check filename length is not too long #663 2022-06-01 20:14:10 -05:00
advplyr
36bd6e649a Fix:Remove podcast episode to also remove library file #636 2022-06-01 17:45:52 -05:00
advplyr
4621c78573 Update:Show version number in bottom of siderail #660 and save previous version data to continue showing if update is available 2022-06-01 17:15:13 -05:00
advplyr
c88bbf1ce4 Fix:Authors landing page available on refresh #659 2022-06-01 16:29:29 -05:00
advplyr
d37b25a6f6 Update audio player to player ui and separate out components 2022-05-31 20:13:46 -05:00
advplyr
792268f5ee Merge branch 'master' into video 2022-05-31 18:53:30 -05:00
advplyr
5f2d6f4d5e Add:Support for wav #652 2022-05-31 18:45:40 -05:00
Selfhost Alt
1350a91fba Handle another type of corrupted backup file 2022-05-30 23:53:00 -07:00
advplyr
acf22ca4fa Testing video media type 2022-05-30 19:26:53 -05:00
advplyr
705aac40d7 Remove experimental set bookshelf texture 2022-05-30 09:58:02 -05:00
advplyr
7456052620 Fix:Match update cover image #648 2022-05-30 09:52:42 -05:00
advplyr
6cd4ec7fce Version bump v2.0.18 2022-05-29 13:18:31 -05:00
advplyr
93b8e11378 Fix:Mark media as finished if less than 5 seconds remain on a sync and call progress sync again when last track ends #635 2022-05-29 12:55:14 -05:00
advplyr
6161daeef0 Fix:OPML file upload reset 2022-05-29 12:22:16 -05:00
advplyr
cfcd351570 Add:Match All Authors button #642 2022-05-29 12:15:39 -05:00
advplyr
514893646a Add:OPML Upload for bulk adding podcasts #588 2022-05-29 11:46:45 -05:00
advplyr
e5469cc0f8 Update:Podcast library items do not show incomplete error when it doesnt have audio files #636 2022-05-29 07:25:30 -05:00
advplyr
ec6e70725c Fix:Include Watcher as lib with no dependencies and fix tiny-readdir bug #610 2022-05-28 20:01:20 -05:00
advplyr
160dac109d Add:User permission restrict explicit content #637 2022-05-28 16:53:03 -05:00
advplyr
6be741045f Merge pull request #622 from kaldigo/master
Updated matching
2022-05-28 15:56:27 -05:00
advplyr
f41d6d5c77 Update multi-series edit for match and make into separate component with inner modal 2022-05-28 15:54:04 -05:00
advplyr
a5dacd7821 Merge master 2022-05-28 13:58:52 -05:00
advplyr
8b12508b0c Add:Rich text editor for podcast episode description 2022-05-28 13:36:58 -05:00
advplyr
a394f38fe9 Add:Full podcast episode description parsed and viewable in modal #492 2022-05-28 11:38:51 -05:00
advplyr
c4bfa266b0 Add:HTML sanitizer lib to support html in podcasts and replace strip html lib 2022-05-27 19:41:40 -05:00
advplyr
96232676cb Fix:Save RSS feed url passed in by user instead of using the RSS feed returned from the request #634 2022-05-27 17:50:56 -05:00
advplyr
b2aab06e01 Add:Listening session modal with all details 2022-05-27 17:39:24 -05:00
advplyr
f002532c1e Add:User listening sessions page, Update:Listening sessions to save media times and device info 2022-05-26 19:09:46 -05:00
advplyr
54663f0f01 Fix:Listening stats on users page and user listening-sessions api endpoint 2022-05-26 15:10:12 -05:00
advplyr
d8df9a9dff Update dockerfile for generating client 2022-05-25 10:26:21 -05:00
advplyr
68efd30a54 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-24 21:44:15 -05:00
advplyr
27407d49dd Version bump 2.0.17 2022-05-24 21:44:11 -05:00
advplyr
97d4330cda Merge pull request #632 from cassieesposito/getBookDataFromDir-refactor
URGENT: Fix to serious bug
2022-05-24 21:33:40 -05:00
Cassie Esposito
3153bdc5bb Fixed bug that caused scanner to fail to get title when subtitle parsing is off, refactored possibly confusing variable declarations. 2022-05-24 19:04:51 -07:00
Cassie Esposito
31fd75a895 Merge branch 'getBookDataFromDir-refactor' of github.com:cassieesposito/audiobookshelf into getBookDataFromDir-refactor 2022-05-24 18:43:20 -07:00
Cassie Esposito
b22173a631 Undoing changes caused by linter run amok 2022-05-24 17:30:16 -07:00
advplyr
d2e012d7b1 Version bump 2.0.16 2022-05-24 19:19:16 -05:00
advplyr
d4fe0be386 Merge pull request #631 from cassieesposito/getBookDataFromDir-refactor
Get book data from dir refactor
2022-05-24 19:17:42 -05:00
Cassie Esposito
6d947bbc29 Converted indentation from 4 spaces to 2 2022-05-24 17:06:44 -07:00
advplyr
5187d0e55f Add:Option to hard delete podcast episode from file system #488 2022-05-24 18:38:25 -05:00
Cassie Esposito
c6253e4fd4 Merge branch 'getBookDataFromDir-refactor' of github.com:cassieesposito/audiobookshelf into getBookDataFromDir-refactor 2022-05-24 16:26:59 -07:00
Cassie Esposito
1ab933c8b0 Refactored getSequence. Slight behavior changes introduced.
All components of the bottom level directory
except volume which can no longer use '-' for separation, but 'Vol 4 Title' is still valid
and '4. Title' or 'Vol 4.' are also now valid.
2022-05-24 16:24:10 -07:00
Cassie Esposito
e2e5dd372a Merge branch 'master' into getBookDataFromDir-refactor 2022-05-24 12:56:10 -07:00
Kaldigo
aeb87c81a1 Fix missed preferMatchedMetadata rename 2022-05-24 01:29:43 +01:00
advplyr
3e98b6f749 Update:Remove manual sorting of podcast episodes and default to sort by published at 2022-05-23 19:28:00 -05:00
advplyr
3c465994fe Fix:Hide remove icon from author images with no image 2022-05-23 19:12:40 -05:00
advplyr
6cfe583535 Fix:Static router for downloading single file library items #627 2022-05-23 18:31:11 -05:00
advplyr
0ad7a98fc7 Add:Support for single book files to be detected by Watcher #610, Fix:Single media file in library folder root is only supported for books not podcasts 2022-05-23 18:15:15 -05:00
Kaldigo
ce88ebb55b Removed response groups in Audible query and limited to 10 results 2022-05-23 22:48:11 +01:00
Kaldigo
c7e3f08d39 Merge branch 'advplyr:master' into master 2022-05-23 22:46:17 +01:00
Kaldigo
d15264832d Updated matching with latest changes, Added override toggle for quickmatch, added asin and isbn to quickmatch query, updated audible provider to use audnexus 2022-05-23 03:56:51 +01:00
advplyr
a8d5b543d7 Update:Parsing sequence from folder will strip leading zeros #562 2022-05-22 19:17:21 -05:00
advplyr
f2e16017f6 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-22 08:14:42 -05:00
advplyr
4d227cbade Add copy to clipboard fallback 2022-05-22 08:05:39 -05:00
advplyr
15a85299b9 Merge pull request #612 from cassieesposito/scan-for-narrator
Scan for narrator
2022-05-21 12:04:06 -05:00
advplyr
d22e9e32ed Remove dev dependency from package.json 2022-05-21 11:36:08 -05:00
advplyr
8beac53f5f Update:Send source back with auth request 2022-05-21 11:21:03 -05:00
Cassie Esposito
cbad435690 Merge branch 'advplyr:master' into scan-for-narrator 2022-05-21 08:07:42 -07:00
Cassie Esposito
169b637720 Removed dependency erroniously added by IDE 2022-05-21 08:06:06 -07:00
advplyr
f083d4b5f6 Update dockerfile failing with dev dependency 2022-05-20 18:15:54 -05:00
Cassie Esposito
3451a312e9 Merge branch 'advplyr:master' into getBookDataFromDir-refactor 2022-05-20 15:45:10 -07:00
Cassie Esposito
927c1a3514 Merge branch 'advplyr:master' into scan-for-narrator 2022-05-20 15:40:26 -07:00
advplyr
dabcad5ebd Update experimental e-reader alert 2022-05-20 16:47:00 -05:00
advplyr
796602d1b2 Add:Enable e-reader server setting to allow all users to access experimental e-reader #614 2022-05-20 16:34:51 -05:00
advplyr
302870a101 Fix:Continue series shelf show next book in series #608 2022-05-20 15:55:03 -05:00
advplyr
3954aa1963 Merge pull request #611 from cassieesposito/npm-watch
Added support for npm run watch
2022-05-20 09:31:37 -05:00
Cassie Esposito
2d8c840ad6 Cleaned up function getSequence, became more forgiving of whitespace around metadata elements 2022-05-20 01:03:36 -07:00
Cassie Esposito
f1f02b185e Cleaned function getPublishedYear 2022-05-19 22:55:00 -07:00
Cassie Esposito
13d21e90f8 Cleaned function getSubtitle 2022-05-19 22:31:55 -07:00
Cassie Esposito
dd664da871 Separated individual element parsing functions out of function getBookDataFromDir 2022-05-19 22:10:53 -07:00
Cassie Esposito
6ff66370fe Use {} instead of [] for narrators tag. Removed logging left over from debugging. 2022-05-19 21:07:04 -07:00
Cassie Esposito
23904d57ad Narrator data is sucessfully saved from folder name. 2022-05-19 20:59:59 -07:00
Cassie Esposito
efdb43e2d2 Merge branch 'advplyr:master' into scan-for-narrator 2022-05-19 19:45:35 -07:00
Cassie Esposito
67523095d6 Narrators successfully isolated from path. Next steps: parse multiple narrators and save to disk 2022-05-19 19:42:45 -07:00
Cassie Esposito
e2d869bb19 Added support for npm run watch to automatically reset server during development. 2022-05-19 19:10:51 -07:00
advplyr
d38e9499db Version bump 2.0.15 2022-05-19 19:58:09 -05:00
advplyr
c7429efe95 Update:Debian install script to create directories if they dont exist and set ownership #503 2022-05-19 19:51:27 -05:00
advplyr
b925dbcc95 Fix:Updating library folder paths will only set file permissions if it needed to create the folder #529 2022-05-19 19:00:34 -05:00
advplyr
2a235b8324 Add:RSS feeds for audiobooks #606 2022-05-19 18:51:58 -05:00
advplyr
06cc2a1b21 Fix:Increase width of MoreMenu since Mark as Not Finished was wrapping 2022-05-19 18:11:02 -05:00
advplyr
4bcca97b1f Update:Home page continue series shelf to use first unplayed book (instead of next book after most recently played) #608 2022-05-19 18:09:26 -05:00
advplyr
313b9026f1 Add:Authors page links to filter by author books and link to series page #609 2022-05-19 17:42:34 -05:00
advplyr
139ee013a7 Add:Parsing tags from OPF metadata #602, Update:OPF parser to check for prefix on package/metadata/meta objects 2022-05-18 19:25:18 -05:00
advplyr
7e5ab477b2 Update:Persist scroll position for bookshelves #604 2022-05-18 18:37:38 -05:00
advplyr
eba37c46cb Update:Confirmation when clicking force re-scan #591 2022-05-18 16:36:54 -05:00
advplyr
228d9cc301 Fix:Library scan toasts 2022-05-18 16:33:24 -05:00
advplyr
85946dd1d5 Update build-win add GZip 2022-05-17 19:57:50 -05:00
advplyr
b40598593d Merge pull request #593 from BCNelson/master
Add devcontainer
2022-05-17 19:18:47 -05:00
advplyr
e918a46d09 Fix:Siderail margin on mobile and tablets 2022-05-15 15:51:30 -05:00
advplyr
8061ee29d5 Add:Media session controls and metadata 2022-05-15 15:48:41 -05:00
Bradley Nelson
e15e04f085 Add dev container 2022-05-15 14:24:24 -06:00
advplyr
958d68ffa9 Update readme remove default username and password 2022-05-15 13:47:53 -05:00
advplyr
c8a743ccc1 Version bump 2.0.14 2022-05-15 12:53:41 -05:00
advplyr
09dc95f560 Fix:Create cache dirs on server init 2022-05-15 11:19:04 -05:00
advplyr
853858825b Fix:File permissions on cache dirs and cache images, Fix:Db delete read stream closing before write stream resulting in deletes sometimes not happening 2022-05-15 09:51:08 -05:00
advplyr
c962090c3a Update:No longer creating initial root user and initial library, add init root user page, web app works with no libraries 2022-05-14 17:23:22 -05:00
advplyr
63a8e2433e Fix:Manage and chapters item page available on refresh 2022-05-14 13:08:56 -05:00
advplyr
f78d287b59 Update:Matching authors uses the ASIN if set #572, Fix:Purge author image cache when updating author 2022-05-13 18:11:54 -05:00
advplyr
eaa383b6d8 Update:Show siderail on all pages not just library pages 2022-05-13 17:40:43 -05:00
advplyr
113026ce13 Fix:Sanitize new podcast folder names and ensure feedUrl is in feed metadata #589 2022-05-13 17:13:58 -05:00
advplyr
578a946ca5 Fix:Update changes to filterdata (authors, narrators, genres, tags, languages, series) 2022-05-13 16:51:54 -05:00
advplyr
f31306eda0 Fix:Realtime updates on book cards when changing series sequence #590 2022-05-13 16:26:34 -05:00
advplyr
c62b716a2c Fix:Duplicate keys on episode slider 2022-05-13 10:39:33 -05:00
advplyr
97ed20c683 Merge pull request #586 from jflattery/main
Fix for unsorted feeds, update npm, add docker tag
2022-05-12 17:36:51 -05:00
jflattery
d5c46dcbfb Dedupe packages 2022-05-12 21:14:36 +00:00
jflattery
30934edd57 Add tag
Addressing issues found on discord where users are not getting latest image
2022-05-12 21:01:47 +00:00
jflattery
d06fd1a1b1 Update npm packages 2022-05-12 20:58:30 +00:00
jflattery
6bb36381f1 Fix for unsorted feeds
Fix for Podcasts such as Beers with Talos who don't publish their feed in a chronological order
2022-05-12 20:26:21 +00:00
advplyr
a1331fb3f8 Version bump 2.0.13 2022-05-11 18:57:09 -05:00
advplyr
17d15144eb Update:Podcast new episode check cronjob to use last episode pub date if exists otherwise fallback to using last check date 2022-05-11 18:55:19 -05:00
advplyr
74d26eece4 Add:Re-broadcast podcast with RSS feed no longer experimental #553, Update:Podcast RSS feed modal warnings and note text 2022-05-11 18:34:17 -05:00
advplyr
474a7d08d0 Fix:Watcher & scanner on folder renames to check inode value and update existing library item paths 2022-05-11 18:18:54 -05:00
advplyr
639c930779 Fix:Remove all button when not viewing issues page #585 2022-05-11 17:39:15 -05:00
advplyr
c6323f8ad9 Fix:Local playback session store date/dayOfWeek string to be used in stats 2022-05-11 17:35:04 -05:00
advplyr
caea6c6371 Update:Show seconds in elapsedPretty 2022-05-11 17:20:32 -05:00
advplyr
d285845e04 Fix:Crash when mobile sends invalid library item to sync with session 2022-05-11 17:07:41 -05:00
advplyr
5a6867e98a Add:Listening sessions calendar heat map 2022-05-11 16:27:40 -05:00
advplyr
621444114f Fix:Modal click outside srcElement null 2022-05-10 19:30:17 -05:00
advplyr
5591704aad Fix:Uploads page set folder path on load #581 2022-05-10 17:49:25 -05:00
advplyr
cc1181b301 Add:Chapter editor, lookup chapters via audnexus, chapters table on audiobook landing page #435 2022-05-10 17:03:41 -05:00
advplyr
095f49824e Version bump 2.0.12 2022-05-09 18:45:33 -05:00
advplyr
b330030f50 Fix:Ignore file metadata updates to metadata.abs files 2022-05-09 18:40:54 -05:00
advplyr
a7d422e23f Add:Alternate view for home page, series and collections without wood texture #424 2022-05-09 18:23:23 -05:00
advplyr
f51a31c8ca Update:Remove back arrow in appbar 2022-05-09 15:12:55 -05:00
advplyr
290340a385 Fix:Rescan filter out items not updated #577 2022-05-09 07:23:29 -05:00
advplyr
0137f6dfeb Update:Show publishedYear below book cards instead of author name 2022-05-08 18:54:41 -05:00
advplyr
7f27eabf3e Update:Authors page check user can access library items and can edit 2022-05-08 18:48:57 -05:00
advplyr
4f7588c87d Update:Author names to link to authors page 2022-05-08 18:43:24 -05:00
advplyr
a19b6370c4 Fix:getBookCoverAspectRatio 2022-05-08 18:40:43 -05:00
advplyr
fbd7ae10d1 Add:Authors landing page #187 2022-05-08 18:21:46 -05:00
advplyr
f94c706fc8 Update:Item edit modal add Save and Save & Close buttons 2022-05-08 15:25:33 -05:00
advplyr
9de4b1069a Fix:Item edit modal scrollable and overflowing #574 2022-05-08 14:52:58 -05:00
advplyr
8fbe3c3884 Remove unnecessary background-image #569 2022-05-07 20:21:08 -05:00
advplyr
abf9120363 Fix:Hide book library settings for podcast libraries #573 2022-05-07 20:10:23 -05:00
advplyr
69f250cba5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-07 20:01:33 -05:00
advplyr
2103edfcdc Update:Max levenshtein distance for matching author names to 3 #572 2022-05-07 20:01:29 -05:00
advplyr
02ba147bd4 Merge pull request #571 from jflattery/main
fix repo
2022-05-07 11:50:27 -05:00
jflattery
230b548921 fix repo 2022-05-07 16:09:59 +00:00
advplyr
f34ebdc016 Version bump 2.0.11 2022-05-05 18:50:15 -05:00
advplyr
69ad651671 Fix:Context menu on library page 2022-05-05 18:12:27 -05:00
advplyr
edc919b3f5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-05 18:07:50 -05:00
advplyr
c8c7a9ece5 Merge pull request #561 from jflattery/main
Add support for seasonal podcasts
2022-05-05 18:06:52 -05:00
advplyr
8702ac1ccf Fix:Manage tracks page 2022-05-05 18:04:17 -05:00
advplyr
33833e0a36 Update:Host fonts locally #563 2022-05-05 18:02:42 -05:00
jflattery
6b98baafdf Resolve @advplyr's feedback
Add 'itunes' tag to 'season' and fix display formating
2022-05-05 13:38:00 +00:00
jflattery
cc285bb685 Add support for seasonal podcasts
Podcasts such as [Command Line Heroes](https://podcasts.apple.com/us/podcast/command-line-heroes/id1319947289) have multiple seasons in which each has it's own , . This seaks to add support for such podcast series.
2022-05-04 14:14:09 +00:00
advplyr
ef0243f1d7 Version bump 2.0.10 2022-05-03 19:35:30 -05:00
advplyr
7a7d53f92e Update:Close author modal on update 2022-05-03 19:33:00 -05:00
advplyr
2e070227ab Update:Give full permissions to admin users except updating root or viewing root api token #137 2022-05-03 19:16:16 -05:00
advplyr
195a30096f Update:Experimental RSS feed setting custom slugs with default to library item id #553 2022-05-03 18:52:34 -05:00
advplyr
55c40658f2 Add:Sort by duration for audiobooks and sort by number of episodes for podcasts #558 2022-05-03 17:50:19 -05:00
advplyr
db48a486e5 Fix:Drag and drop upload limits to 100 items per folder #560 2022-05-03 17:41:49 -05:00
advplyr
d869a9836e Add:More menu for podcast episode cards with Mark as Finished and Edit Podcast #559 2022-05-03 17:21:22 -05:00
advplyr
55680cbc98 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-03 16:30:54 -05:00
advplyr
9b7e6a6058 Fix:Linux build script to use node16 2022-05-03 16:30:49 -05:00
advplyr
a482e5d316 Merge pull request #555 from selfhost-alt/handle-corrupted-backups
Handle corrupted backups gracefully and continue loading other backups
2022-05-03 06:48:08 -05:00
Selfhost Alt
5ac342defd Handle corrupted backups gracefully and continue loading other backups 2022-05-02 22:47:16 -07:00
advplyr
944a5b3e92 Version bump v2.0.9 2022-05-02 19:04:57 -05:00
advplyr
9b9de84740 Add:Experimental embed audio metadata page 2022-05-02 18:48:00 -05:00
advplyr
2746e61cb3 Fix:Authors card hide edit & search icon for users without edit permission #549 2022-05-02 17:32:52 -05:00
advplyr
7f1d797fb2 Update:Submit edit details closes modal 2022-05-02 17:31:02 -05:00
advplyr
2059c9f14a Fix:Podcast RSS feed require fs 2022-05-02 17:21:16 -05:00
advplyr
0e16a9c8de Update:Many more debug logs for auto-download podcasts, add timeout for feed request, use anonymous function in cron job 2022-05-02 17:17:26 -05:00
advplyr
b6a33bf7bb Merge pull request #551 from jflattery/main
docker compose and run changes
2022-05-02 16:58:03 -05:00
jflattery
ce88ac9f33 Revert "add version number"
This reverts commit d4cd8c6db9.
2022-05-02 21:48:28 +00:00
advplyr
678dceefed Add:Experimental generate podcast RSS feed #553 2022-05-02 16:42:30 -05:00
advplyr
8b38dda229 Add:experimental generate podcast feed for testing 2022-05-02 14:41:59 -05:00
advplyr
7373c7159b Add additional logs during podcast episode checks and allow up to 3 failed feed requests 2022-05-01 19:54:33 -05:00
advplyr
e34a39dde4 Update:Edit modal merge tab to manage 2022-05-01 19:39:52 -05:00
jflattery
d4cd8c6db9 add version number 2022-05-02 00:38:24 +00:00
jflattery
9e93a3c7e6 align docker compose with run 2022-05-02 00:09:47 +00:00
advplyr
4a8bcc90ea Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-01 18:33:51 -05:00
advplyr
84c12a6e7e Add:Experimental embed metadata in audio files #141 2022-05-01 18:33:46 -05:00
advplyr
2a513ac8b8 Merge pull request #550 from mediacowboy/master
Docker Compose Update Instructions
2022-05-01 16:37:48 -05:00
MediaCowboy
97687c96cd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-01 15:57:03 -05:00
MediaCowboy
a42c13aec2 Docker Compose Update 2022-05-01 15:56:57 -05:00
advplyr
5f0f8b92d1 Fix:Continue series home page shelf to check for finished books in series #545 2022-05-01 15:31:07 -05:00
advplyr
78ca6aa679 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-01 15:12:26 -05:00
advplyr
22e3d4a150 Fix:Account tags accessible #542 2022-05-01 15:12:21 -05:00
advplyr
e3fba1fb2b Merge pull request #548 from BeastleeUK/patch-1
Temp Fix for Unknown Error in App with Traefik
2022-05-01 13:46:20 -05:00
BeastleeUK
4d95250990 Temp Fix for Unknown Error in App with Traefik 2022-05-01 19:44:30 +01:00
advplyr
4776368501 Update docker-build.yml 2022-05-01 12:51:20 -05:00
advplyr
8b0ed2bf29 Update:readme ubuntu install section to point to website install docs 2022-05-01 12:40:28 -05:00
advplyr
54389e3c25 Version bump v2.0.8 2022-04-30 13:19:07 -05:00
advplyr
bf0da1c6ec Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-30 12:33:54 -05:00
advplyr
591a866f8c Fix:Removing section from upload page #530 2022-04-30 12:31:58 -05:00
advplyr
fc8473ed84 Add:Putting back in the Continue Series shelf on the home page #541 2022-04-30 12:24:48 -05:00
advplyr
b19442e440 Remove old home page personalized API route 2022-04-30 11:36:05 -05:00
advplyr
7a51e0693d Merge pull request #534 from cassieesposito/tooltips_for_appbar
Added tooltips missing from Appbar buttons
2022-04-30 11:33:22 -05:00
Cassie Esposito
21785c8e72 Merge branch 'advplyr:master' into tooltips_for_appbar 2022-04-30 09:27:48 -07:00
Cassie Esposito
bdf6ccbd2d Removed duplicate conditional from line 62 of client/components/app/Appbar.vue 2022-04-30 09:21:27 -07:00
advplyr
ceb163570f Fix:Set next accessible library when currently selected library is removed 2022-04-29 18:57:46 -05:00
advplyr
049ae73d74 Update:Guest user accounts cannot change the account password #537 2022-04-29 18:38:13 -05:00
advplyr
729fdd5c9f Update:User type admin permissions to create podcasts and download episodes #507 2022-04-29 18:29:40 -05:00
advplyr
4dac8ac16c Fix:Account type select dropdown & add root user change password button 2022-04-29 18:19:04 -05:00
advplyr
220bbc3d2d Fix:Series covers on home page not spread out correctly #505, Update:Server settings are now returned with auth requests 2022-04-29 17:43:46 -05:00
advplyr
c2a4b32192 Fix:Series on search page not directing to series page #533 2022-04-29 17:12:02 -05:00
advplyr
09d0d47549 Fix:Setting user can access all libraries/tags 2022-04-29 16:50:06 -05:00
advplyr
4185807da4 Add:Check for new episodes manual check and update last check time, Update:Adding new podcasts and downloading podcast episodes restricted to admin users 2022-04-29 16:42:40 -05:00
advplyr
8abda14e0f Version bump v2.0.7 2022-04-29 13:16:29 -05:00
advplyr
619e5c0895 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-29 13:14:19 -05:00
advplyr
3a2594cde9 Version bump v2.0.6 2022-04-29 13:13:54 -05:00
advplyr
5cca2d0155 Update docker-build.yml 2022-04-29 13:01:12 -05:00
advplyr
a467637cb5 Version bump v2.0.5 2022-04-29 12:59:35 -05:00
advplyr
1a23001955 Update version check to use releases from gh api instead of tags, add 5 minute buffer between checking for new releases 2022-04-29 12:20:51 -05:00
advplyr
8942dca31d Update docker-build workflow 2022-04-29 09:48:00 -05:00
advplyr
2a919012b6 Version bump 2.0.4 2022-04-28 18:43:00 -05:00
advplyr
40b342498f Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-28 18:40:34 -05:00
advplyr
e220b2818a Add docker-build workflow 2022-04-28 18:40:29 -05:00
Cassie Esposito
620bf7990f Added tooltips for edit, delete, and deselect all buttons to client/components/app/Appbar.vue 2022-04-28 15:44:07 -07:00
advplyr
0df36d2609 Merge pull request #523 from mediacowboy/patch-1
Update readme.md
2022-04-28 17:43:50 -05:00
MediaCowboy
adfe50a841 Update readme.md
Updated the pull command to reflect the new docker repo.
2022-04-27 22:26:44 -05:00
advplyr
35925ddc1b Merge pull request #522 from selfhost-alt/skip-matching-identified-media
Add options to skip matching media items if they already have an ASIN/ISBN
2022-04-27 20:14:04 -05:00
advplyr
33dfb764fa Add:Support for openaudible folder structure (subject to change), add support for treating single audio files in the root directory as library items #401 2022-04-27 19:42:34 -05:00
advplyr
49bef2c641 Fix:Uploader removing single item from parsed upload items #530 2022-04-27 18:08:07 -05:00
advplyr
ac58536501 Fix:Drag n drop folder upload 2022-04-27 18:03:00 -05:00
advplyr
c344555be3 Fix:default user settings for orderBy and default to sort ascending for titles and authors #515 2022-04-27 17:20:44 -05:00
MediaCowboy
645bcc53c6 Update readme.md
Removed the --rm from the docker install command and added Docker Update section
2022-04-26 21:28:24 -05:00
Selfhost Alt
84dd06dfc4 Add options to skip matching media items if they already have an ASIN/ISBN 2022-04-26 17:36:29 -07:00
advplyr
0a73dd6437 Add:Ability to ignore directories by putting a file named .ignore inside dir #516 2022-04-26 19:11:32 -05:00
advplyr
2cc055a1ad Fix:checkbox default check color add to tailwind safelist #521 2022-04-26 18:14:11 -05:00
advplyr
d8ec3bd218 Merge pull request #512 from selfhost-alt/log-empty-folder-path-on-scan
Log full path when warning about empty root
2022-04-25 19:14:54 -05:00
advplyr
d189ec74c9 Update item api endpoint to include user media progress with item if using query string include=progress and optionally episode=episodeid - for mobile app downloads 2022-04-25 19:03:26 -05:00
advplyr
4291769b93 Fix:Filter checks on server to check for mediaType 2022-04-25 17:36:18 -05:00
Selfhost Alt
22900a3f67 Log full path when warning about empty root 2022-04-25 15:28:03 -07:00
advplyr
7fa08449de Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-25 16:39:02 -05:00
advplyr
4f7203fccb Update docker template 2022-04-25 16:38:57 -05:00
advplyr
0eea766931 Merge pull request #509 from jflattery/patch-1
Change default to ghcr
2022-04-25 14:58:18 -05:00
advplyr
5c054aef90 Merge pull request #508 from jflattery/patch-2
Change default to ghcr
2022-04-25 14:57:54 -05:00
Jim Flattery
a1674d5da1 Change default to ghcr 2022-04-25 15:45:08 -04:00
Jim Flattery
91597a5454 Change default to ghcr 2022-04-25 15:43:58 -04:00
advplyr
11354a3e3f Version bump 2.0.3 2022-04-24 19:18:43 -05:00
advplyr
dcd4f69383 Fix: set downloaded/uploaded cover owner and permissions and if creating intitial config/metadata directories at startup then set owner of those #394 2022-04-24 19:12:00 -05:00
advplyr
e253939c1e Fix: upload page files table selectable filename, size and type #406 2022-04-24 18:55:26 -05:00
advplyr
f25ce1c0e7 Fix: overlapping text on collections book table #410 2022-04-24 18:51:11 -05:00
advplyr
7717e57c16 Fix: add extra check for valid names and valid author name #502 2022-04-24 18:41:47 -05:00
advplyr
2e28c9b06d Add: button on issues page to remove all library items with issues #476 2022-04-24 18:25:33 -05:00
advplyr
4bc7cd2045 Fix: show books with invalid audio files and add error icon on book items #491 2022-04-24 18:05:15 -05:00
advplyr
5389115120 Add: Button on series page to mark all series as finished #452 2022-04-24 17:46:21 -05:00
advplyr
6e99cf6570 Fix: filter sort authors and series, authors page sort alphabetical #497 2022-04-24 17:15:41 -05:00
advplyr
21bdd9f9ec Fix set invalid flag to false when adding first episode to an empty podcast library item, dont show podcast errors on episode cards 2022-04-24 17:03:43 -05:00
advplyr
e3ae3f7e6a Update personalized api endpoint to new optimal function that only loops through library items once 2022-04-24 16:56:30 -05:00
advplyr
74bf917150 Update readme 2022-04-24 11:14:30 -05:00
advplyr
5666b263f5 Readme updates and banner update to represent podcasts 2022-04-24 11:11:49 -05:00
advplyr
fc8fec62a0 Version bump 2.0.2 2022-04-23 19:41:35 -05:00
advplyr
034d858f18 Change new podcast modal to remove episode download list #494, Fix error when importing many episodes (set max size to 5MB) #493, show podcast episodes downloading and in queue on podcast landing page 2022-04-23 19:41:06 -05:00
advplyr
ebc9e1a888 Fix batch mark as finished and clear selection #490 2022-04-23 17:17:05 -05:00
advplyr
c5a9c2bf5a Merge pull request #489 from selfhost-alt/configurable-backup-size
Make maximum backup size configurable
2022-04-23 17:06:59 -05:00
advplyr
3dbce8fd71 Fix:Persist playback rate #419 2022-04-23 16:51:13 -05:00
advplyr
b2d299dba6 Remove open playback sessions for user when starting a new playback session 2022-04-23 16:18:34 -05:00
Selfhost Alt
cb5d9a8287 Add explicit byte conversion variable to make code more self-documenting 2022-04-23 10:26:37 -07:00
Selfhost Alt
f9530897c0 Add tooltip to explain the max backup size 2022-04-23 10:23:01 -07:00
Selfhost Alt
7c7e8285a4 Make maximum backup size configurable 2022-04-23 10:19:31 -07:00
advplyr
7b3f9a1e0c Add bulkInsertEntities to db to handle migrating large collections 2022-04-23 06:25:16 -05:00
advplyr
399e0ea0bc Merge pull request #486 from selfhost-alt/quickmatch-updates-media-descriptions
Set description when quick matching media
2022-04-23 06:00:59 -05:00
advplyr
a47b0bce57 Merge pull request #485 from selfhost-alt/fix-scan-error
Update folder update logic to use new media path name
2022-04-23 05:59:10 -05:00
Selfhost Alt
4b60b4f73e Set description when quick matching media 2022-04-22 23:19:46 -07:00
Selfhost Alt
d88b20addd Update folder update logic to use new media path name 2022-04-22 22:29:38 -07:00
advplyr
5d12cc3f23 Podcast home page shelves for currently listening episodes, newest episodes. Podcast episode card 2022-04-22 19:31:11 -05:00
advplyr
84fb7ce8b3 Merge pull request #484 from benonymity/search_fix
Fix libraryItem ID reference in global search
2022-04-22 18:03:56 -05:00
benonymity
243cc672f7 Fix libraryItem in global search, same fix as app 2022-04-22 18:58:43 -04:00
advplyr
663546dd77 Fix edit modal registering/unregistering library item listeners #483 2022-04-22 17:42:49 -05:00
advplyr
1b79b3f42d Add secondary sort by series sort title when sorting by author #274 2022-04-22 17:11:03 -05:00
advplyr
d4525ad5ca Version bump 2.0.1 and Fix db function validation 2022-04-22 12:44:24 -05:00
advplyr
dc9c307663 Fix user tags issue 2022-04-22 05:00:52 -05:00
advplyr
554e9ec238 Remove download button form item landing page 2022-04-22 04:53:09 -05:00
advplyr
2276228531 Fix user permissions restricted by tag #421 2022-04-21 19:29:15 -05:00
advplyr
6f7d2ef4cd Merge pull request #477 from jflattery/master
remove redunant line
2022-04-21 18:52:53 -05:00
advplyr
ad3fbe7abf Add back in m4b merge downloader in experimental #478 2022-04-21 18:52:28 -05:00
jflattery
c58110c7b7 remove redunant line 2022-04-21 18:08:45 +00:00
advplyr
f781fa9e6b Add green finished line for series #454 2022-04-21 08:55:29 -05:00
advplyr
7f3543400a Add realtime updates to collections bookshelf 2022-04-21 08:30:44 -05:00
advplyr
1ff5637c1b Fix user issue sending POST requests to play endpoints #473 2022-04-21 07:24:54 -05:00
advplyr
f2d9de5a5f Library stats page links to genres, authors, items #453, use overall days when hours > 10000 2022-04-20 18:43:39 -05:00
advplyr
8be3bebee8 Fix showing series on book landing page 2022-04-20 18:20:31 -05:00
advplyr
ef88972b25 Fix total listening time stats check for strings, remove from experimental since listening sessions are created for all playbacks 2022-04-20 18:16:27 -05:00
advplyr
35f3b5863f Add library match all back updated to support v2 models 2022-04-20 18:05:09 -05:00
advplyr
ff294867f8 Fix library folder check if folder exists and if not then attempt to create folder and set permissions, fix library folder check for changes before saving 2022-04-20 17:49:34 -05:00
advplyr
1c6cd7499b Remove old cover method make sure cover filename is an actual image 2022-04-20 17:34:20 -05:00
advplyr
ce35ae6b03 Merge pull request #469 from jflattery/master
Increase readability of logs
2022-04-20 16:38:50 -05:00
jflattery
28c99cf17f Increase readability of logs
Add podcast title to log output when autodownload fails
2022-04-20 17:35:15 +00:00
advplyr
584e754eae Remove db log from testing 2022-04-20 08:38:24 -05:00
advplyr
68cf748e77 Fix previous version check for db migration to v2 2022-04-20 08:31:57 -05:00
advplyr
9b8f53caf6 abmetadata generator fixes 2022-04-20 07:41:45 -05:00
advplyr
fdf332937f Remove match books on library item temporarily until implemented 2022-04-19 21:49:12 -05:00
advplyr
182545a729 Fix ebook scan 2022-04-19 21:10:24 -05:00
advplyr
e83df2bf4b Update migration version 2022-04-19 20:55:40 -05:00
advplyr
10299e3037 Merge pull request #465 from selfhost-alt/filter-by-missing-fields
Proposal: Add a filter for media that is missing specific fields
2022-04-19 05:02:12 -05:00
advplyr
6a43672973 Merge pull request #464 from selfhost-alt/include-filter-name-in-ui
Include the type of filter being applied in the UI
2022-04-19 04:59:35 -05:00
Selfhost Alt
02bf55b401 Add a filter for media that is missing specific fields 2022-04-18 21:47:03 -07:00
Selfhost Alt
f0615c2971 Include the type of filter being applied in the UI 2022-04-18 21:20:32 -07:00
advplyr
7ef44eb75b Fix episode sort by publishedAt instead of pubDate 2022-04-18 18:09:23 -05:00
advplyr
044804115b Version bump 2.0.0 2022-04-18 08:10:55 -05:00
advplyr
3b941d59a3 Merge pull request #463 from selfhost-alt/strict-asin-check
Update Audible scraper to be more strict about what it considers an ASIN and a valid ASIN query response
2022-04-18 07:06:55 -05:00
advplyr
d69f6020c6 Fix podcast episode playback session duration, use podcast episode plaintext description 2022-04-17 17:52:06 -05:00
Selfhost Alt
2fc60e4e9c Handle an undefined publisher_summary when querying Audible 2022-04-16 14:57:36 -07:00
Selfhost Alt
cdcfd01da2 Only consider an Audible ASIN query successful if the response contains an author 2022-04-16 11:55:58 -07:00
Selfhost Alt
d6c5b6e8c6 Implement a stricter check for possible ASIN values in titles 2022-04-16 10:40:10 -07:00
advplyr
5d305c96ad Add support for WMA and AIFF audio files #449, add remove orphan streams, clean up audio mime type logic 2022-04-16 12:37:10 -05:00
advplyr
6d823f4e42 Podcast episode audio file to always use index 1 2022-04-15 20:49:13 -05:00
advplyr
bd5e865a11 Merge pull request #461 from rasmuslos/master
Convert timeListened to float
2022-04-15 07:58:59 -05:00
Rasmus Krämer
cd274e0844 Merge branch 'master' of https://github.com/rasmuslos/audiobookshelf 2022-04-15 12:59:45 +02:00
Rasmus Krämer
e9249430c3 Parse current time as float 2022-04-15 12:59:42 +02:00
Rasmus Krämer
cd5e5099f2 Merge branch 'advplyr:master' into master 2022-04-15 12:22:16 +02:00
Rasmus Krämer
09dd90e3fc Convert timeListened to int 2022-04-15 12:22:00 +02:00
advplyr
a62f7a4861 Update uploader to support podcast folder structure 2022-04-14 18:24:24 -05:00
advplyr
5a26b01ffb Add LibrarySettings and update edit library modal to include settings tab 2022-04-14 17:15:52 -05:00
advplyr
cbde451120 Add redirects for media types on unsupported pages 2022-04-14 12:57:34 -05:00
advplyr
8bbeae4873 Fix check podcast episodes cronjob 2022-04-14 10:15:42 -05:00
advplyr
05dff2583a Backups to store server version in zip details and check and show alert for old backups created before version 2.0.0 2022-04-13 18:51:06 -05:00
advplyr
79a82df914 Remove NFO metadata and save metadata button 2022-04-13 18:23:44 -05:00
advplyr
3f6ed6dbf9 Add Podcast match tab and find covers 2022-04-13 18:13:39 -05:00
advplyr
4edba20e9e Update podcast search page to support manually entering podcast RSS feed 2022-04-13 16:55:48 -05:00
advplyr
2c6e1cc2b5 Merge pull request #459 from jflattery/master
podcast episode number & accessibility improvements
2022-04-13 16:06:45 -05:00
jflattery
e1af25d9d8 Accessible tweaks 2022-04-13 20:17:00 +00:00
jflattery
9b30a8ff4b Accessibility Labels: User Account Icon 2022-04-13 19:14:44 +00:00
jflattery
b1a9de819e Improve Accessibility: Zoom Labels 2022-04-13 19:10:03 +00:00
Jim Flattery
68da974c12 Merge branch 'advplyr:master' into master 2022-04-13 11:01:47 -04:00
jflattery
8c47ccb651 Add episode number
Add episode number to list group view
2022-04-13 15:00:20 +00:00
advplyr
d544ecc657 Merge pull request #458 from jflattery/master
Make sort column title more clear & add ep#
2022-04-13 08:40:13 -05:00
jflattery
9f69a8ace3 Make sort column title more clear & add ep# 2022-04-13 13:29:31 +00:00
advplyr
a90cfc4d04 Fix experimental e-reader with new data model 2022-04-13 08:26:43 -05:00
advplyr
88354de495 Fix abmetadata chapter parser 2022-04-13 07:57:21 -05:00
advplyr
5b02c5185f Fix fs error library item 2022-04-13 04:55:39 -05:00
advplyr
1152e5513e Add podcast episode sorting and saving sort order 2022-04-12 18:07:13 -05:00
advplyr
8ce9b55969 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-12 17:32:59 -05:00
advplyr
ccf08e9e80 Merge pull request #457 from jflattery/master
Update and dedupe packages
2022-04-12 17:32:53 -05:00
advplyr
b0b1d2707d Add podcast episode date picker for pubDate 2022-04-12 17:32:27 -05:00
advplyr
469278cd1e Fix:Global search support podcasts 2022-04-12 16:54:52 -05:00
advplyr
10d9e11387 Update abmetadata file for new data model, add chapter and description section parser 2022-04-12 16:05:16 -05:00
jflattery
5328f4cddb Dedupe packages 2022-04-12 18:03:43 +00:00
jflattery
4154022ad1 Update Packages 2022-04-12 18:00:00 +00:00
advplyr
642e9787c0 Merge pull request #456 from rasmuslos/master
Fixed "select all" button
2022-04-12 07:09:15 -05:00
Rasmus Krämer
da2e65c042 Merge branch 'master' of https://github.com/rasmuslos/audiobookshelf 2022-04-12 14:05:28 +02:00
Rasmus Krämer
ab895fa8ed Filter all episodes when selecting all 2022-04-12 14:05:24 +02:00
Rasmus Krämer
f5e892b862 allow connections from the mobile app while running in dev env 2022-04-12 13:57:45 +02:00
advplyr
ac097862fc Update sorting and filtering for podcasts, add title ignore prefix to podcast metadata, check user permissions for podcast episode row UI 2022-04-11 19:42:09 -05:00
advplyr
23cc6bb210 Add published at to podcast episode row #428, Fix podcast select episodes, fix save order of podcast episode, fix remove podcast episode 2022-04-10 11:01:50 -05:00
advplyr
c60807f998 Removing remaining legacy objects, remove njodb error for fileExists 2022-04-10 10:05:05 -05:00
advplyr
99e2ea228d Update chromecast with new data model 2022-04-10 06:02:53 -05:00
advplyr
8df05896b5 Fix remove media progress use libraryItemId 2022-04-09 20:30:18 -05:00
advplyr
174dac8fd4 Add collapse series, add filter by series include sequence and sort, show number of episodes on podcast card 2022-04-09 19:44:46 -05:00
advplyr
2a386ca2a9 Add sync local media progress routes for offline mobile playback session support 2022-04-09 17:56:51 -05:00
advplyr
fc228013d3 Merge pull request #448 from rasmuslos/bulk-download
Added select all option to the episode selector
2022-04-09 05:36:06 -05:00
advplyr
64b824ef6b Merge pull request #445 from rasmuslos/token-env
Only fall back to the default secret when no env var is provided
2022-04-09 05:33:10 -05:00
Rasmus Krämer
96cd91a385 Added select all episodes option to episode feed 2022-04-09 11:44:31 +02:00
Rasmus Krämer
5c91c1e2c7 Added select all option to the episode selector 2022-04-09 10:25:24 +02:00
Rasmus Krämer
2df5ab0dde Only fall back to the default secret when no is provided 2022-04-09 09:25:13 +02:00
advplyr
baf738f5ba Fix updating media progress object id 2022-04-08 19:27:35 -05:00
advplyr
3a7cafbb95 Update media progress object to use unique id for podcast episodes 2022-04-08 19:19:47 -05:00
advplyr
3276b04256 Fix authors filter query string 2022-04-08 18:34:30 -05:00
advplyr
ac3fa31d1e Update Podcast Episode add libraryItemId, expanded returns audioTrack object 2022-04-05 19:40:40 -05:00
advplyr
6e5e638076 Update Book.js to return array of AudioTrack objects on json expand 2022-04-03 16:01:59 -05:00
advplyr
609bf4309f Merge pull request #439 from Albuca/patch-1
Change 'Current' to 'Currently'
2022-04-02 18:17:30 -05:00
Albuca
66b5c14c6b Change 'Current' to 'Currently'
Nitpicking verbiage tbh. Reference: https://github.com/advplyr/audiobookshelf/issues/431
2022-04-02 17:37:44 -05:00
advplyr
e4936ed522 Add chapters to playback session 2022-04-02 11:41:17 -05:00
advplyr
c201e2aa98 Add mediaPlayer to playback session 2022-04-02 11:19:57 -05:00
advplyr
3d3f20296c Add displayTitle and displayAuthor to playback session 2022-04-02 10:26:42 -05:00
advplyr
9ae71615bc Add:Match tab show current value next to new match value #431 2022-03-31 17:10:02 -05:00
advplyr
292840a0e3 Update njodb path and add proper-lockfile package 2022-03-31 16:34:24 -05:00
advplyr
84e6e6fdbe Include njodb statically & fix write stream issue 2022-03-31 16:32:50 -05:00
advplyr
cfe27dff80 Add:Server setting to set custom sorting prefixes to ignore #358 2022-03-31 15:07:50 -05:00
advplyr
c75895d711 Fix:Podcast scanner get embedded cover art 2022-03-28 20:23:16 -05:00
advplyr
c0ff28ffff Add recent series and authors bookshelf rows on home 2022-03-27 16:16:08 -05:00
advplyr
58dfa65660 Fix update podcast episode api route; 2022-03-27 15:46:57 -05:00
advplyr
3f8e685d64 Podcasts add get episode feed and download, add edit podcast episode modal 2022-03-27 15:37:04 -05:00
advplyr
08e1782253 Fix use first accessible library depending on display order, default library id checked on server when authenticating 2022-03-27 09:45:28 -05:00
advplyr
0dd219f303 Add podcast episode auto download new episodes cron 2022-03-26 19:58:59 -05:00
advplyr
d5e96a3422 Fix podcast re-scan, fix more menu item 2022-03-26 19:00:55 -05:00
advplyr
03bfecefee Podcast episode playing fix title and author 2022-03-26 18:30:58 -05:00
advplyr
12027b9a76 Podcast episode player fixes, episode table ui updates 2022-03-26 18:23:33 -05:00
advplyr
0e665e2091 Add playing podcast episodes, episode progress, podcast page, podcast home page shelves 2022-03-26 17:41:26 -05:00
advplyr
e32d05ea27 Podcast library item card, edit details, batch edit 2022-03-26 15:23:25 -05:00
advplyr
5446aea910 Add Scanner support for podcasts 2022-03-26 14:29:49 -05:00
advplyr
86e7c7fc33 Merge pull request #426 from jflattery/master
Upgrade Node to v16 and update packages
2022-03-26 12:51:51 -05:00
advplyr
173b72c3b5 Add:Purge cache promp alert 2022-03-26 12:08:05 -05:00
advplyr
3150822117 New data model removing media entity for books 2022-03-26 11:59:34 -05:00
jflattery
9a96d17a30 Update NPM Packages
Update all NPM packages addressing several CVEs
2022-03-25 22:14:02 +00:00
jflattery
c98409b9ae Address three CVEs
Addresses CVE-2021-3749 (HIGH), CVE-2022-0155 (HIGH), and CVE-2022-0536 (MEDIUM).
2022-03-24 17:34:34 +00:00
jflattery
0e3640c246 Upgrade Node to v16
As Node.JS v12 is EOL in April 2022, project should move to a newer version.
2022-03-24 15:38:02 +00:00
RailRoad
e030b59bae Address CVE-2022-21676
Upgraded socket.io to 4.4.1 to address uncaught Exception in older version of engine.io
2022-03-24 15:19:48 +00:00
advplyr
920ca683b9 Podcast episode downloader, update podcast data model 2022-03-21 19:24:38 -05:00
advplyr
28d76d21f1 Add expand library item authors to /items/:id route 2022-03-21 05:08:33 -05:00
advplyr
e1e6b46456 Create podcast manager and re-organize managers 2022-03-20 16:41:06 -05:00
advplyr
122f2a2556 New data model fix collections page & table 2022-03-20 16:16:39 -05:00
advplyr
27f1bd90f9 Add:Restrict user permissions by tag 2022-03-20 06:29:08 -05:00
advplyr
f8d0384155 Migration change metadata folder from /books to /items, podcast data model updates, add podcast routes 2022-03-19 10:13:10 -05:00
advplyr
43bbfbfee3 Fix library check path and set provider, update podcast model and UI 2022-03-19 06:41:54 -05:00
advplyr
deadc63dbb Add podcast add modal 2022-03-18 19:16:54 -05:00
advplyr
a9b9e23f46 Library update migrate to use book mediaType, disable editing mediaType, set icon instead of media category 2022-03-18 17:09:17 -05:00
advplyr
6a06ba4327 Fix player content url, update user progress object include media entity id, update reset progress route 2022-03-18 15:31:46 -05:00
advplyr
3d2bbc7719 Fix bug with creating new series & authors on scan 2022-03-18 14:08:57 -05:00
advplyr
c9ea5dd2d7 New data model backups and move backups to API endpoints 2022-03-18 13:44:29 -05:00
advplyr
eea3e2583c New data model fix library stats 2022-03-18 12:37:47 -05:00
advplyr
57399bb79e Clean up ApiRouter adding MiscController, move upload and scan to api endpoints 2022-03-18 11:51:55 -05:00
advplyr
69fcb103e4 Fix:Updating author name to update author name on each library item 2022-03-18 09:38:36 -05:00
advplyr
f00b120e96 New data model scanner update and change scan chunks to be based on total file size 2022-03-18 09:16:10 -05:00
advplyr
14a8f84446 New data model update bookmarks and bookmark routes to use API 2022-03-17 20:28:04 -05:00
advplyr
099ae7c776 New data model play media entity, PlaybackSessionManager 2022-03-17 19:10:47 -05:00
advplyr
1cf9e85272 New data model update MeController user progress routes 2022-03-17 13:33:22 -05:00
advplyr
c4eeb1cfb7 New data model Book media type contains Audiobooks updates 2022-03-17 12:25:12 -05:00
advplyr
1dde02b170 Add user API token with copy to clipboard 2022-03-17 09:28:31 -05:00
advplyr
08e648a3bc Fix db migration 2022-03-17 09:07:02 -05:00
advplyr
755e70b4a9 Fix db migration 2022-03-17 09:04:10 -05:00
advplyr
5ff4cd2c0b Merge pull request #423 from Quietus/configurablehost
Allowed the configuration of a "HOST" parameter to enable ipv6 support.
2022-03-17 08:18:55 -05:00
advplyr
e36c31c5e7 Add HOST config for docker and debian 2022-03-17 08:18:39 -05:00
Quietus
d561a48229 Allowed the configuration of a "HOST" parameter to enable ipv6 support. 2022-03-17 11:06:52 +00:00
advplyr
5243a225e8 Update sample book library item 2022-03-16 19:22:16 -05:00
advplyr
4fe60465e5 New data model change of Book media type to include array of Audiobook and Ebook objects 2022-03-16 19:15:25 -05:00
advplyr
0af6ad63c1 New data model start of PlaybackSessionManager to replace StreamManager, remove podcast & ip npm package 2022-03-15 19:28:54 -05:00
advplyr
68b13ae45f New data model migration for users, bookmarks and playback sessions 2022-03-15 18:57:15 -05:00
advplyr
4c2ad3ede5 Add author edit modal & remove from experimental 2022-03-14 18:53:49 -05:00
advplyr
deea6702f0 Change Library object use mediaCategory, allow adding new manual folder path, validate folder paths, fix Watcher re-init after folder path updates 2022-03-14 09:56:24 -05:00
advplyr
7348432594 New data model update for Match tab 2022-03-14 08:12:28 -05:00
advplyr
7d66f1eec9 New data model edit tracks page, match, quick match, clean out old files 2022-03-13 19:34:31 -05:00
advplyr
be1e1e7ba0 New data model update stats page and routes, update users page 2022-03-13 17:33:50 -05:00
advplyr
4bdef893af New data model batch routes and batch editor 2022-03-13 17:10:48 -05:00
advplyr
6597fca576 New data model fix scan for creating series/authors and mapping ebooks 2022-03-13 13:47:36 -05:00
advplyr
ea9ec13845 New data model for global search input and search page 2022-03-13 12:39:12 -05:00
advplyr
30f15d3575 Add:Authors page match authors and display author image 2022-03-13 10:35:35 -05:00
advplyr
dad12537b6 New data model authors routes 2022-03-13 06:42:43 -05:00
advplyr
65df377a49 New model update audio player, stream, collections 2022-03-12 19:59:35 -06:00
advplyr
2d19208340 New model updates for series, collections, authors routes 2022-03-12 18:50:31 -06:00
advplyr
73257188f6 New data model save covers, scanner, new api routes 2022-03-12 17:45:32 -06:00
advplyr
5f4e5cd3d8 New model update details, author and series inputs with create new, compare & copy utils 2022-03-11 19:46:32 -06:00
advplyr
f2be3bc95e Add multi select dropdown with query from server 2022-03-10 19:13:19 -06:00
advplyr
2a30cc428f New api routes, updating web client pages, audiobooks to libraryItem migration 2022-03-10 18:45:02 -06:00
advplyr
b97ed953f7 Add db migration file to change audiobooks to library items with new data model 2022-03-09 19:23:17 -06:00
advplyr
65793f7109 Start of new data model 2022-03-08 19:31:44 -06:00
advplyr
2b7f53b0a7 Add:Support for book folders with CD# subfolders #393 2022-03-07 16:22:20 -06:00
advplyr
c6eb1096e8 Add:Podcast search page 2022-03-06 19:02:06 -06:00
advplyr
a907c88f66 Add:iTunes search api metadata provider #381 2022-03-06 17:26:35 -06:00
advplyr
43f48b65f8 Add:Podcast iTunes search api and iTunes provider 2022-03-06 16:32:04 -06:00
advplyr
2a4cbd48b8 Remove old API routes 2022-03-06 09:51:56 -06:00
advplyr
b6e4f3a8c5 Add:Podcast RSS feed parser 2022-03-05 18:54:24 -06:00
advplyr
83976b5549 Fix:Encode filename for audio player direct plays 2022-03-05 17:28:15 -06:00
advplyr
9f29b245d7 Version bump 1.7.2 2022-03-05 15:46:06 -06:00
advplyr
ecf62c5443 Add:Experimental direct play support 2022-03-05 15:37:30 -06:00
advplyr
eb82d9c300 Add:Sleep timer #165 2022-03-05 12:30:46 -06:00
advplyr
45582343b8 Fix:Backups table UI 2022-03-05 10:23:30 -06:00
advplyr
6a4d3a55b1 Fix:User last activity 2022-03-05 10:10:42 -06:00
advplyr
95bacce5e5 Add:Start/complete date on audiobook page #383, Fix local hls stream url 2022-03-04 18:44:20 -06:00
advplyr
408775a25a Add:Library media type selection and library icons 2022-03-03 19:03:34 -06:00
advplyr
d3a8ecc8d1 Remove unnecessary env 2022-03-01 17:59:47 -06:00
advplyr
ecc425d89d Fix:Remove epub dependency from server 2022-02-27 19:03:38 -06:00
advplyr
289b3e9f94 Version bump 1.7.1 2022-02-27 19:02:07 -06:00
advplyr
74a68a4557 Add:Check metadata.abs file modified date to rescan 2022-02-27 18:58:23 -06:00
advplyr
42604331ff Add:Sort by file birthtime and modified timestamps #215 2022-02-27 18:18:38 -06:00
advplyr
428a515c6a Add:mtime,ctime,birthtime to audiobook folder and files 2022-02-27 18:07:36 -06:00
advplyr
c81b12f459 Add:Book folder name starting with number and has series folder use as volume number 2022-02-27 16:52:01 -06:00
advplyr
779d22bf55 Add:Parse abmetadata metadata.abs file, save metadata.abs on all audiobook insert/update 2022-02-27 16:14:57 -06:00
advplyr
295c6b0c74 Add:Generate book metadata file when book details are changed,Add:Server setting for storing book metadata in book folder 2022-02-27 14:28:18 -06:00
advplyr
aa50cc2d81 Change:Main dir paths moved to global vars, server settings stored in globals vars 2022-02-27 13:47:52 -06:00
advplyr
eb109c398f Change:Server setting for coverDestination to storeCoverWithBook boolean, Add:abmetadata generator 2022-02-27 12:47:56 -06:00
advplyr
4e7d2ddc58 Fix:Quick match requiring cover 2022-02-27 11:03:10 -06:00
advplyr
a5b10aac96 Readme update features 2022-02-27 11:02:33 -06:00
advplyr
fcbccaeb4e Fix:Drag n drop uploader 2022-02-26 18:28:09 -06:00
813 changed files with 126898 additions and 23148 deletions

4
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get install ffmpeg gnupg2 -y
ENV NODE_ENV=development

View File

@@ -0,0 +1,12 @@
{
"build": { "dockerfile": "Dockerfile" },
"mounts": [
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
],
"features": {
"fish": "latest"
},
"extensions": [
"eamodio.gitlens"
]
}

View File

@@ -12,4 +12,5 @@ dev.js
test/
/client/.nuxt/
/client/dist/
/dist/
/dist/
/deploy/

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

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/pJsjuNCKRq
about: Ask questions, get help troubleshooting, and join the Abs community here.
- name: Matrix
url: https://matrix.to/#/#audiobookshelf:matrix.org
about: Ask questions, get help troubleshooting, and join the Abs community here.

82
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
---
name: Build and Push Docker Image
on:
# Allows you to run workflow manually from Actions tab
workflow_dispatch:
inputs:
tags:
description: 'Docker Tag'
required: true
default: 'latest'
push:
branches: [main,master]
tags:
- 'v*.*.*'
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
- index.js
- package.json
jobs:
build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-20.04
steps:
- name: Check out
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
tags: |
type=edge,branch=master
type=semver,pattern={{version}}
- name: Setup QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to Dockerhub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to ghcr
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Build image
uses: docker/build-push-action@v3
with:
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

44
.github/workflows/integration-test.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Integration Test
on:
pull_request:
push:
branches-ignore:
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
jobs:
build:
name: build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: setup nade
uses: actions/setup-node@v3
with:
node-version: 16
- name: install pkg
run: npm install -g pkg
- name: get client dependencies
working-directory: client
run: npm ci
- name: build client
working-directory: client
run: npm run generate
- name: get server dependencies
run: npm ci --only=production
- name: build binary
run: pkg -t node18-linux-x64 -o audiobookshelf .
- name: run audiobookshelf
run: |
./audiobookshelf &
sleep 5
- name: test if server is available
run: curl -sf http://127.0.0.1:3333 | grep Audiobookshelf

5
.gitignore vendored
View File

@@ -4,11 +4,14 @@ node_modules/
/config/
/audiobooks/
/audiobooks2/
/podcasts/
/media/
/metadata/
test/
/client/.nuxt/
/client/dist/
/dist/
/deploy/
sw.*
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

@@ -1,19 +1,37 @@
### STAGE 0: Build client ###
FROM node:12-alpine AS build
FROM node:16-alpine AS build
WORKDIR /client
COPY /client /client
RUN npm install
RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###
FROM node:12-alpine
RUN apk update && apk add --no-cache --update ffmpeg
FROM sandreas/tone:v0.1.2 AS tone
FROM node:16-alpine
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
curl \
tzdata \
ffmpeg \
make \
python3 \
g++
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist
COPY index.js index.js
COPY package-lock.json package-lock.json
COPY package.json package.json
COPY index.js package* /
COPY server server
RUN npm ci --production
RUN npm ci --only=production
RUN apk del make python3 g++
EXPOSE 80
CMD ["npm", "start"]
HEALTHCHECK \
--interval=30s \
--timeout=3s \
--start-period=10s \
CMD curl -f http://127.0.0.1/healthcheck || exit 1
CMD ["node", "index.js"]

View File

@@ -2,49 +2,11 @@
set -e
set -o pipefail
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
ABS_LOG_DIR="/var/log/audiobookshelf"
declare -r init_type='auto'
declare -ri no_rebuild='0'
add_user() {
: "${1:?'User was not defined'}"
declare -r user="$1"
declare -r uid="$2"
if [ -z "$uid" ]; then
declare -r uid_flags=""
else
declare -r uid_flags="--uid $uid"
fi
declare -r group="${3:-$user}"
declare -r descr="${4:-No description}"
declare -r shell="${5:-/bin/false}"
if ! getent passwd | grep -q "^$user:"; then
echo "Creating system user: $user in $group with $descr and shell $shell"
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
fi
}
add_group() {
: "${1:?'Group was not defined'}"
declare -r group="$1"
declare -r gid="$2"
if [ -z "$gid" ]; then
declare -r gid_flags=""
else
declare -r gid_flags="--gid $gid"
fi
if ! getent group | grep -q "^$group:" ; then
echo "Creating system group: $group"
groupadd $gid_flags --system $group
fi
}
start_service () {
: "${1:?'Service name was not defined'}"
declare -r service_name="$1"
@@ -76,13 +38,10 @@ start_service () {
fi
}
add_group 'audiobookshelf' ''
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
mkdir -p '/var/log/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
# Create log directory if not there and set ownership
if [ ! -d "$ABS_LOG_DIR" ]; then
mkdir -p "$ABS_LOG_DIR"
chown -R 'audiobookshelf:audiobookshelf' "$ABS_LOG_DIR"
fi
start_service 'audiobookshelf'

View File

@@ -2,21 +2,60 @@
set -e
set -o pipefail
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
DEFAULT_PORT=7331
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
CONFIG_PATH="/etc/default/audiobookshelf"
DEFAULT_PORT=13378
DEFAULT_HOST="0.0.0.0"
add_user() {
: "${1:?'User was not defined'}"
declare -r user="$1"
declare -r uid="$2"
if [ -z "$uid" ]; then
declare -r uid_flags=""
else
declare -r uid_flags="--uid $uid"
fi
declare -r group="${3:-$user}"
declare -r descr="${4:-No description}"
declare -r shell="${5:-/bin/false}"
if ! getent passwd | grep -q "^$user:"; then
echo "Creating system user: $user in $group with $descr and shell $shell"
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
fi
}
add_group() {
: "${1:?'Group was not defined'}"
declare -r group="$1"
declare -r gid="$2"
if [ -z "$gid" ]; then
declare -r gid_flags=""
else
declare -r gid_flags="--gid $gid"
fi
if ! getent group | grep -q "^$group:" ; then
echo "Creating system group: $group"
groupadd $gid_flags --system $group
fi
}
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 "WARNING: can't access working directory ($FFMPEG_INSTALL_DIR) creating it" >&2
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
mkdir "$FFMPEG_INSTALL_DIR"
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
cd "$FFMPEG_INSTALL_DIR"
fi
@@ -24,88 +63,44 @@ 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
should_build_config() {
if [ -f "$CONFIG_PATH" ]; then
echo "You already have a config file. Do you want to use it?"
options=("Yes" "No")
select yn in "${options[@]}"
do
case $yn in
"Yes")
false; return
;;
"No")
true; return
;;
esac
done
else
echo "No existing config found in $CONFIG_PATH"
true; return
fi
}
setup_config_interactive() {
if should_build_config; then
echo "Okay, let's setup a new config."
AUDIOBOOK_PATH=""
read -p "
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
if [[ -z "$AUDIOBOOK_PATH" ]]; then
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
fi
DATA_PATH=""
read -p "
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
if [[ -z "$DATA_PATH" ]]; then
DATA_PATH="$DEFAULT_DATA_PATH"
fi
PORT=""
read -p "
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
if [[ -z "$PORT" ]]; then
PORT="$DEFAULT_PORT"
fi
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
METADATA_PATH=$DATA_PATH/metadata
CONFIG_PATH=$DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$PORT"
echo "$config_text"
echo "$config_text" > /etc/default/audiobookshelf;
echo "Config created"
fi
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
# Create directory and set permissions
echo "Creating default data dir at $DEFAULT_DATA_DIR"
mkdir "$DEFAULT_DATA_DIR"
chown -R 'audiobookshelf:audiobookshelf' "$DEFAULT_DATA_DIR"
fi
echo "Creating default config."
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
CONFIG_PATH=$DEFAULT_DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$DEFAULT_PORT"
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
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"
@@ -115,6 +110,10 @@ setup_config() {
fi
}
add_group 'audiobookshelf' ''
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
setup_config
install_ffmpeg

View File

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

View File

@@ -48,7 +48,7 @@ Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control;
# Package debian
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
fakeroot dpkg-deb --build dist/debian

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

@@ -1,6 +1,8 @@
@import './fonts.css';
@import './transitions.css';
@import './draggable.css';
@import './defaultStyles.css';
@import './absicons.css';
:root {
--bookshelf-texture-img: url(/textures/wood_default.jpg);
@@ -12,18 +14,34 @@
height: calc(100% - 64px);
max-height: calc(100% - 64px);
}
.page.streaming {
height: calc(100% - 64px - 165px);
max-height: calc(100% - 64px - 165px);
}
#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 {
/* Sidebar width + scrollbar width */
width: calc(100vw - 88px);
}
@media (max-width: 768px) {
#bookshelf {
height: calc(100% - 80px);
}
.bookshelf-row {
width: 100vw;
}
}
#page-wrapper {
@@ -34,36 +52,25 @@
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar:horizontal {
height: 8px;
}
/* ::-webkit-scrollbar:horizontal { */
/* height: 16px; */
/* height: 24px;
} */
/* Track */
::-webkit-scrollbar-track {
background-color: rgba(0,0,0,0);
background-color: rgba(0, 0, 0, 0);
}
/* ::-webkit-scrollbar-track:horizontal { */
/* background: rgb(149, 119, 90); */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
box-shadow: 2px 14px 8px #111111aa;
} */
/* Handle */
::-webkit-scrollbar-thumb {
background: #855620;
background: #855620;
border-radius: 4px;
}
/* ::-webkit-scrollbar-thumb:horizontal { */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* box-shadow: 2px 14px 8px #111111aa;
border-radius: 4px;
} */
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #704922;
background: #704922;
}
.no-scroll::-webkit-scrollbar {
@@ -71,6 +78,13 @@
opacity: 0;
}
.no-scroll {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
/* Chrome, Safari, Edge, Opera */
.no-spinner::-webkit-outer-spin-button,
.no-spinner::-webkit-inner-spin-button {
@@ -89,18 +103,23 @@ input[type=number] {
width: 100%;
border: 1px solid #474747;
}
.tracksTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.tracksTable tr {
background-color: #373838;
}
.tracksTable tr:hover {
background-color: #474747;
}
.tracksTable td {
padding: 4px 8px;
}
.tracksTable th {
padding: 4px 8px;
font-size: 0.75rem;
@@ -113,13 +132,22 @@ input[type=number] {
border-right: 6px solid transparent;
border-top: 6px solid white;
}
.arrow-down-small {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
}
.triangle-right {
width: 0;
height: 0;
border-left: 8px solid transparent;
border-bottom: 8px solid transparent;
border-top: 8px solid rgb(34,127,35);
border-right: 8px solid rgb(34,127,35);
border-top: 8px solid rgb(34, 127, 35);
border-right: 8px solid rgb(34, 127, 35);
}
.icon-text {
@@ -149,6 +177,7 @@ input[type=number] {
.box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
.shadow-height {
height: calc(100% - 4px);
}
@@ -165,9 +194,9 @@ input[type=number] {
Bookshelf Label
*/
.categoryPlacard {
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
letter-spacing: 1px;
}
.shinyBlack {
background-color: #2d3436;
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
@@ -187,3 +216,32 @@ Bookshelf Label
opacity: 1;
filter: blur(20px);
}
.episode-subtitle {
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-height: 16px;
/* fallback */
max-height: 32px;
/* fallback */
-webkit-line-clamp: 2;
/* number of lines to show */
-webkit-box-orient: vertical;
}
/* 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

@@ -0,0 +1,55 @@
/*
This is for setting regular html styles for places where embedding HTML will be
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
*/
.default-style p {
display: block;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
}
.default-style a {
text-decoration: none;
color: #5985ff;
}
.default-style ul {
display: block;
list-style: circle;
list-style-type: disc;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 40px;
}
.default-style ol {
display: block;
list-style: decimal;
list-style-type: decimal;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 40px;
}
.default-style li {
display: list-item;
text-align: -webkit-match-parent;
}
.default-style li::marker {
unicode-bidi: isolate;
font-variant-numeric: tabular-nums;
text-transform: none;
text-indent: 0px !important;
text-align: start !important;
text-align-last: start !important;
}

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

@@ -1,15 +1,15 @@
@font-face {
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 {
@@ -23,12 +23,13 @@
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
.material-icons:not([class*="text-"]) {
font-size: 1.5rem;
}
.material-icons-outlined {
font-family: 'Material Icons Outlined';
font-weight: normal;
@@ -40,28 +41,279 @@
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
.material-icons-outlined:not([class*="text-"]) {
font-size: 1.5rem;
}
/* cyrillic-ext */
@font-face {
font-family: 'Gentium Book Basic';
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-weight: 300;
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
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;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
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;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
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;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
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;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
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;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
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;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
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;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
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;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
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;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
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;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
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;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
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;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
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;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
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;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
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;
}
/* cyrillic-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
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;
}
/* cyrillic */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
/* latin-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
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;
}
/* latin */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
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

@@ -20,42 +20,67 @@
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
.slide-enter-to, .slide-leave {
.slide-enter-to,
.slide-leave {
max-height: 600px;
overflow: hidden;
}
.slide-enter, .slide-leave-to {
.slide-enter,
.slide-leave-to {
overflow: hidden;
max-height: 0;
}
.menu-enter, .menu-leave-active {
.menu-enter,
.menu-leave-active {
transform: translateY(-15px);
}
.menu-enter-active {
transition: all 0.2s;
}
.menu-leave-active {
transition: all 0.1s;
}
.menu-enter,
.menu-leave-active {
opacity: 0;
}
.menux-enter, .menux-leave-active {
.menux-enter,
.menux-leave-active {
transform: translateX(15px);
}
.menux-enter-active {
transition: all 0.2s;
}
.menux-leave-active {
transition: all 0.1s;
}
.menux-enter,
.menux-leave-active {
opacity: 0;
}
.list-complete-item {
transition: all 0.8s ease;
}
.list-complete-enter-from,
.list-complete-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-complete-leave-active {
position: absolute;
}

563
client/assets/trix.css Normal file
View File

@@ -0,0 +1,563 @@
@charset "UTF-8";
/*
Trix 1.3.1
Copyright © 2020 Basecamp, LLC
http://trix-editor.org/*/
trix-editor {
border: 1px solid rgb(75, 85, 99);
border-radius: 3px;
background: rgb(35, 35, 35);
margin: 0;
padding: 0.4em 0.6em;
min-height: 5em;
outline: none;
}
trix-toolbar * {
box-sizing: border-box;
}
trix-toolbar .trix-button-row {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
overflow-x: auto;
}
trix-toolbar .trix-button-group {
display: flex;
margin-bottom: 10px;
border: 1px solid rgb(75, 85, 99);
border-top-color: rgb(75, 85, 99);
border-bottom-color: rgb(75, 85, 99);
border-radius: 3px;
}
trix-toolbar .trix-button-group:not(:first-child) {
margin-left: 1.5vw;
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button-group:not(:first-child) {
margin-left: 0;
}
}
trix-toolbar .trix-button-group-spacer {
flex-grow: 1;
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button-group-spacer {
display: none;
}
}
trix-toolbar .trix-button {
position: relative;
float: left;
color: rgba(0, 0, 0, 0.6);
font-size: 0.75em;
font-weight: 600;
white-space: nowrap;
padding: 0 0.5em;
margin: 0;
outline: none;
border: none;
border-radius: 0;
background: transparent;
}
trix-toolbar .trix-button:not(:first-child) {
border-left: 1px solid rgb(75, 85, 99);
}
trix-toolbar .trix-button.trix-active {
background: #bbb;
color: black;
}
trix-toolbar .trix-button:not(:disabled) {
cursor: pointer;
background: rgb(35, 35, 35);
}
trix-toolbar .trix-button:disabled {
color: rgba(0, 0, 0, 0.25);
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button {
letter-spacing: -0.01em;
padding: 0 0.3em;
}
}
trix-toolbar .trix-button--icon {
font-size: inherit;
width: 2.6em;
height: 1.6em;
max-width: calc(0.8em + 4vw);
text-indent: -9999px;
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button--icon {
height: 2em;
max-width: calc(0.8em + 3.5vw);
}
}
trix-toolbar .trix-button--icon::before {
display: inline-block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.6;
content: "";
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: invert(100%);
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button--icon::before {
right: 6%;
left: 6%;
}
}
trix-toolbar .trix-button--icon.trix-active::before {
opacity: 1;
}
trix-toolbar .trix-button--icon:disabled::before {
opacity: 0.125;
}
trix-toolbar .trix-button--icon-attach::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M16.5%206v11.5a4%204%200%201%201-8%200V5a2.5%202.5%200%200%201%205%200v10.5a1%201%200%201%201-2%200V6H10v9.5a2.5%202.5%200%200%200%205%200V5a4%204%200%201%200-8%200v12.5a5.5%205.5%200%200%200%2011%200V6h-1.5z%22%2F%3E%3C%2Fsvg%3E);
top: 8%;
bottom: 4%;
}
trix-toolbar .trix-button--icon-bold::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M15.6%2011.8c1-.7%201.6-1.8%201.6-2.8a4%204%200%200%200-4-4H7v14h7c2.1%200%203.7-1.7%203.7-3.8%200-1.5-.8-2.8-2.1-3.4zM10%207.5h3a1.5%201.5%200%201%201%200%203h-3v-3zm3.5%209H10v-3h3.5a1.5%201.5%200%201%201%200%203z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-italic::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M10%205v3h2.2l-3.4%208H6v3h8v-3h-2.2l3.4-8H18V5h-8z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-link::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M9.88%2013.7a4.3%204.3%200%200%201%200-6.07l3.37-3.37a4.26%204.26%200%200%201%206.07%200%204.3%204.3%200%200%201%200%206.06l-1.96%201.72a.91.91%200%201%201-1.3-1.3l1.97-1.71a2.46%202.46%200%200%200-3.48-3.48l-3.38%203.37a2.46%202.46%200%200%200%200%203.48.91.91%200%201%201-1.3%201.3z%22%2F%3E%3Cpath%20d%3D%22M4.25%2019.46a4.3%204.3%200%200%201%200-6.07l1.93-1.9a.91.91%200%201%201%201.3%201.3l-1.93%201.9a2.46%202.46%200%200%200%203.48%203.48l3.37-3.38c.96-.96.96-2.52%200-3.48a.91.91%200%201%201%201.3-1.3%204.3%204.3%200%200%201%200%206.07l-3.38%203.38a4.26%204.26%200%200%201-6.07%200z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-strike::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.73%2014l.28.14c.26.15.45.3.57.44.12.14.18.3.18.5%200%20.3-.15.56-.44.75-.3.2-.76.3-1.39.3A13.52%2013.52%200%200%201%207%2014.95v3.37a10.64%2010.64%200%200%200%204.84.88c1.26%200%202.35-.19%203.28-.56.93-.37%201.64-.9%202.14-1.57s.74-1.45.74-2.32c0-.26-.02-.51-.06-.75h-5.21zm-5.5-4c-.08-.34-.12-.7-.12-1.1%200-1.29.52-2.3%201.58-3.02%201.05-.72%202.5-1.08%204.34-1.08%201.62%200%203.28.34%204.97%201l-1.3%202.93c-1.47-.6-2.73-.9-3.8-.9-.55%200-.96.08-1.2.26-.26.17-.38.38-.38.64%200%20.27.16.52.48.74.17.12.53.3%201.05.53H7.23zM3%2013h18v-2H3v2z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-quote::before {
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M6%2017h3l2-4V7H5v6h3zm8%200h3l2-4V7h-6v6h3z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-heading-1::before {
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12%209v3H9v7H6v-7H3V9h9zM8%204h14v3h-6v12h-3V7H8V4z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-code::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.2%2012L15%2015.2l1.4%201.4L21%2012l-4.6-4.6L15%208.8l3.2%203.2zM5.8%2012L9%208.8%207.6%207.4%203%2012l4.6%204.6L9%2015.2%205.8%2012z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-bullet-list::before {
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%204a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm4%203h14v-2H8v2zm0-6h14v-2H8v2zm0-8v2h14V5H8z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-number-list::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M2%2017h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1%203h1.8L2%2013.1v.9h3v-1H3.2L5%2010.9V10H2v1zm5-6v2h14V5H7zm0%2014h14v-2H7v2zm0-6h14v-2H7v2z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-undo::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.5%208c-2.6%200-5%201-6.9%202.6L2%207v9h9l-3.6-3.6A8%208%200%200%201%2020%2016l2.4-.8a10.5%2010.5%200%200%200-10-7.2z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-redo::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.4%2010.6a10.5%2010.5%200%200%200-16.9%204.6L4%2016a8%208%200%200%201%2012.7-3.6L13%2016h9V7l-3.6%203.6z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-8.3-.3l2.8%202.9L6%2014.2%204%2012l2-2-1.4-1.5L1%2012l.7.7zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-increase-nesting-level::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-6.9-1L1%2014.2l1.4%201.4L6%2012l-.7-.7-2.8-2.8L1%209.9%203.1%2012zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-dialogs {
position: relative;
}
trix-toolbar .trix-dialog {
position: absolute;
top: 0;
left: 0;
right: 0;
font-size: 0.75em;
padding: 15px 10px;
background: rgb(48, 48, 48);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border: 1px solid rgb(112, 112, 112);
border-radius: 5px;
z-index: 5;
}
trix-toolbar .trix-input--dialog {
font-size: inherit;
font-weight: normal;
padding: 0.5em 0.8em;
margin: 0 10px 0 0;
border-radius: 3px;
border: 1px solid #bbb;
background-color: rgb(95, 95, 95);
box-shadow: none;
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
}
trix-toolbar .trix-input--dialog.validate:invalid {
box-shadow: #F00 0px 0px 1.5px 1px;
}
trix-toolbar .trix-button--dialog {
font-size: inherit;
padding: 0.5em;
border-bottom: none;
color: #eee;
}
trix-toolbar .trix-dialog--link {
max-width: 600px;
}
trix-toolbar .trix-dialog__link-fields {
display: flex;
align-items: baseline;
}
trix-toolbar .trix-dialog__link-fields .trix-input {
flex: 1;
}
trix-toolbar .trix-dialog__link-fields .trix-button-group {
flex: 0 0 content;
margin: 0;
}
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
trix-editor [data-trix-mutable]::-moz-selection,
trix-editor [data-trix-cursor-target]::-moz-selection,
trix-editor [data-trix-mutable] ::-moz-selection {
background: none;
}
trix-editor [data-trix-mutable]::selection,
trix-editor [data-trix-cursor-target]::selection,
trix-editor [data-trix-mutable] ::selection {
background: none;
}
trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection {
background: highlight;
}
trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection {
background: highlight;
}
trix-editor [data-trix-mutable].attachment.attachment--file {
box-shadow: 0 0 0 2px highlight;
border-color: transparent;
}
trix-editor [data-trix-mutable].attachment img {
box-shadow: 0 0 0 2px highlight;
}
trix-editor .attachment {
position: relative;
}
trix-editor .attachment:hover {
cursor: default;
}
trix-editor .attachment--preview .attachment__caption:hover {
cursor: text;
}
trix-editor .attachment__progress {
position: absolute;
z-index: 1;
height: 20px;
top: calc(50% - 10px);
left: 5%;
width: 90%;
opacity: 0.9;
transition: opacity 200ms ease-in;
}
trix-editor .attachment__progress[value="100"] {
opacity: 0;
}
trix-editor .attachment__caption-editor {
display: inline-block;
width: 100%;
margin: 0;
padding: 0;
font-size: inherit;
font-family: inherit;
line-height: inherit;
color: inherit;
text-align: center;
vertical-align: top;
border: none;
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
}
trix-editor .attachment__toolbar {
position: absolute;
z-index: 1;
top: -0.9em;
left: 0;
width: 100%;
text-align: center;
}
trix-editor .trix-button-group {
display: inline-flex;
}
trix-editor .trix-button {
position: relative;
float: left;
color: #666;
white-space: nowrap;
font-size: 80%;
padding: 0 0.8em;
margin: 0;
outline: none;
border: none;
border-radius: 0;
background: transparent;
}
trix-editor .trix-button:not(:first-child) {
border-left: 1px solid #ccc;
}
trix-editor .trix-button.trix-active {
background: #cbeefa;
}
trix-editor .trix-button:not(:disabled) {
cursor: pointer;
}
trix-editor .trix-button--remove {
text-indent: -9999px;
display: inline-block;
padding: 0;
outline: none;
width: 1.8em;
height: 1.8em;
line-height: 1.8em;
border-radius: 50%;
background-color: #fff;
border: 2px solid highlight;
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25);
}
trix-editor .trix-button--remove::before {
display: inline-block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.7;
content: "";
background-image: url(data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.4L17.6%205%2012%2010.6%206.4%205%205%206.4l5.6%205.6L5%2017.6%206.4%2019l5.6-5.6%205.6%205.6%201.4-1.4-5.6-5.6z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E);
background-position: center;
background-repeat: no-repeat;
background-size: 90%;
}
trix-editor .trix-button--remove:hover {
border-color: #333;
}
trix-editor .trix-button--remove:hover::before {
opacity: 1;
}
trix-editor .attachment__metadata-container {
position: relative;
}
trix-editor .attachment__metadata {
position: absolute;
left: 50%;
top: 2em;
transform: translate(-50%, 0);
max-width: 90%;
padding: 0.1em 0.6em;
font-size: 0.8em;
color: #fff;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 3px;
}
trix-editor .attachment__metadata .attachment__name {
display: inline-block;
max-width: 100%;
vertical-align: bottom;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
trix-editor .attachment__metadata .attachment__size {
margin-left: 0.2em;
white-space: nowrap;
}
.trix-content {
line-height: 1.5;
}
.trix-content * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.trix-content h1 {
font-size: 1.2em;
line-height: 1.2;
}
.trix-content blockquote {
border: 0 solid #ccc;
border-left-width: 0.3em;
margin-left: 0.3em;
padding-left: 0.6em;
}
.trix-content [dir=rtl] blockquote,
.trix-content blockquote[dir=rtl] {
border-width: 0;
border-right-width: 0.3em;
margin-right: 0.3em;
padding-right: 0.6em;
}
.trix-content li {
margin-left: 1em;
}
.trix-content [dir=rtl] li {
margin-right: 1em;
}
.trix-content pre {
display: inline-block;
width: 100%;
vertical-align: top;
font-family: monospace;
font-size: 0.9em;
padding: 0.5em;
white-space: pre;
background-color: #eee;
overflow-x: auto;
}
.trix-content img {
max-width: 100%;
height: auto;
}
.trix-content .attachment {
display: inline-block;
position: relative;
max-width: 100%;
}
.trix-content .attachment a {
color: inherit;
text-decoration: none;
}
.trix-content .attachment a:hover,
.trix-content .attachment a:visited:hover {
color: inherit;
}
.trix-content .attachment__caption {
text-align: center;
}
.trix-content .attachment__caption .attachment__name+.attachment__size::before {
content: ' · ';
}
.trix-content .attachment--preview {
width: 100%;
text-align: center;
}
.trix-content .attachment--preview .attachment__caption {
color: #666;
font-size: 0.9em;
line-height: 1.2;
}
.trix-content .attachment--file {
color: #333;
line-height: 1;
margin: 0 2px 2px 2px;
padding: 0.4em 1em;
border: 1px solid #bbb;
border-radius: 5px;
}
.trix-content .attachment-gallery {
display: flex;
flex-wrap: wrap;
position: relative;
}
.trix-content .attachment-gallery .attachment {
flex: 1 0 33%;
padding: 0 0.5em;
max-width: 33%;
}
.trix-content .attachment-gallery.attachment-gallery--2 .attachment,
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
flex-basis: 50%;
max-width: 50%;
}

View File

@@ -1,401 +0,0 @@
<template>
<div class="w-full -mt-6">
<div class="w-full relative mb-1">
<div v-if="chapters.length" class="hidden md:flex absolute right-20 top-0 bottom-0 h-full items-end">
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
</div>
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-32' : 'right-20'">
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showBookmarks">
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div>
</div>
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" />
</div>
<div class="flex pb-4 md:pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-3xl">replay_10</span>
</div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span>
</div>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template>
<template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
<span class="material-icons">autorenew</span>
</div>
</template>
<div class="flex-grow" />
</div>
</div>
<div class="relative">
<!-- Track -->
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" />
</div>
</div>
</div>
<div class="flex">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" />
<p class="text-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p>
<div class="flex-grow" />
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div>
</template>
<script>
export default {
props: {
loading: Boolean,
paused: Boolean,
chapters: {
type: Array,
default: () => []
},
bookmarks: {
type: Array,
default: () => []
}
},
data() {
return {
volume: 1,
playbackRate: 1,
trackWidth: 0,
playedTrackWidth: 0,
bufferTrackWidth: 0,
readyTrackWidth: 0,
audioEl: null,
seekLoading: false,
showChaptersModal: false,
currentTime: 0,
trackOffsetLeft: 16, // Track is 16px from edge
duration: 0
}
},
computed: {
token() {
return this.$store.getters['user/getToken']
},
timeRemaining() {
return (this.duration - this.currentTime) / this.playbackRate
},
timeRemainingPretty() {
if (this.timeRemaining < 0) {
console.warn('Time remaining < 0', this.duration, this.currentTime, this.timeRemaining)
return this.$secondsToTimestamp(this.timeRemaining * -1)
}
return '-' + this.$secondsToTimestamp(this.timeRemaining)
},
progressPercent() {
if (!this.duration) return 0
return Math.round((100 * this.currentTime) / this.duration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
},
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
},
currentChapterName() {
return this.currentChapter ? this.currentChapter.title : ''
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
setDuration(duration) {
this.duration = duration
},
setCurrentTime(time) {
this.currentTime = time
this.updateTimestamp()
this.updatePlayedTrack()
},
playPause() {
this.$emit('playPause')
},
jumpBackward() {
this.$emit('jumpBackward')
},
jumpForward() {
this.$emit('jumpForward')
},
increaseVolume() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.setVolume(this.volume)
},
decreaseVolume() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.setVolume(this.volume)
},
setVolume(volume) {
this.$emit('setVolume', volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
},
setPlaybackRate(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
},
selectChapter(chapter) {
this.seek(chapter.start)
this.showChaptersModal = false
},
seek(time) {
this.$emit('seek', time)
},
playbackRateUpdated(playbackRate) {
this.setPlaybackRate(playbackRate)
},
playbackRateChanged(playbackRate) {
this.setPlaybackRate(playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.duration
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
var posLeft = offsetX - width / 2
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
posLeft = window.innerWidth - width - this.trackOffsetLeft
} else if (posLeft < -this.trackOffsetLeft) {
posLeft = -this.trackOffsetLeft
}
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampArrow) {
var width = this.$refs.hoverTimestampArrow.clientWidth
var posLeft = offsetX - width / 2
this.$refs.hoverTimestampArrow.style.opacity = 1
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
}
},
mouseleaveTrack() {
if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0
}
if (this.$refs.hoverTimestampArrow) {
this.$refs.hoverTimestampArrow.style.opacity = 0
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0
}
},
restart() {
this.seek(0)
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
},
setChunksReady(chunks, numSegments) {
var largestSeg = 0
for (let i = 0; i < chunks.length; i++) {
var chunk = chunks[i]
if (typeof chunk === 'string') {
var chunkRange = chunk.split('-').map((c) => Number(c))
if (chunkRange.length < 2) continue
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
} else if (chunk > largestSeg) {
largestSeg = chunk
}
}
var percentageReady = largestSeg / numSegments
var widthReady = Math.round(this.trackWidth * percentageReady)
if (this.readyTrackWidth === widthReady) return
this.readyTrackWidth = widthReady
this.$refs.readyTrack.style.width = widthReady + 'px'
},
updateTimestamp() {
var ts = this.$refs.currentTimestamp
if (!ts) {
console.error('No timestamp el')
return
}
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
ts.innerText = currTimeClean
},
updatePlayedTrack() {
var perc = this.currentTime / this.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
setBufferTime(bufferTime) {
if (!this.audioEl) {
return
}
var bufferlen = (bufferTime / this.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
},
showChapters() {
if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal
},
showBookmarks() {
this.$emit('showBookmarks', this.currentTime)
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.$emit('setPlaybackRate', this.playbackRate)
this.setTrackWidth()
},
setTrackWidth() {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
},
settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.setPlaybackRate(settings.playbackRate)
}
},
closePlayer() {
if (this.loading) return
this.$emit('close')
},
hotkey(action) {
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
},
windowResize() {
this.setTrackWidth()
}
},
mounted() {
window.addEventListener('resize', this.windowResize)
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey)
},
beforeDestroy() {
window.removeEventListener('resize', this.windowResize)
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey)
}
}
</script>
<style>
.loadingTrack {
animation-name: loadingTrack;
animation-duration: 1s;
animation-iteration-count: infinite;
}
@keyframes loadingTrack {
0% {
left: -25%;
}
100% {
left: 100%;
}
}
</style>

View File

@@ -1,63 +1,83 @@
<template>
<div class="w-full h-16 bg-primary relative">
<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 id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center">
<img v-if="!showBack" src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
<span class="material-icons text-4xl text-white">arrow_back</span>
</a>
<h1 class="text-2xl font-book mr-6 hidden lg:block">audiobookshelf</h1>
<nuxt-link to="/">
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
</nuxt-link>
<ui-libraries-dropdown />
<nuxt-link to="/">
<h1 class="text-xl 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>
<controls-global-search class="hidden md:block" />
<ui-libraries-dropdown class="mr-2" />
<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>
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
</ui-tooltip>
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer">
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
<google-cast-launcher></google-cast-launcher>
</div>
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons">equalizer</span>
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons">upload</span>
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons">settings</span>
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="items-center hidden md:flex">
<span class="block truncate">{{ username }}</span>
</span>
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span>
<span class="material-icons text-xl text-gray-100">person</span>
</span>
</nuxt-link>
</div>
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
<div class="flex-grow" />
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }}
</ui-btn>
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
</ui-tooltip>
<ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom">
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip>
<ui-tooltip v-if="userCanUpdate && isBookLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip>
<template v-if="userCanUpdate && numAudiobooksSelected < 50">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
<template v-if="userCanUpdate">
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</ui-tooltip>
</template>
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
</ui-tooltip>
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</ui-tooltip>
</div>
</div>
</div>
@@ -67,9 +87,7 @@
export default {
data() {
return {
processingBatchDelete: false,
totalEntities: 0,
isAllSelected: false
totalEntities: 0
}
},
computed: {
@@ -79,32 +97,38 @@ export default {
libraryName() {
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
},
libraryMediaType() {
return this.currentLibrary ? this.currentLibrary.mediaType : null
},
isPodcastLibrary() {
return this.libraryMediaType === 'podcast'
},
isBookLibrary() {
return this.libraryMediaType === 'book'
},
isHome() {
return this.$route.name === 'library-library'
},
showBack() {
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
},
user() {
return this.$store.state.user.user
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
username() {
return this.user ? this.user.username : 'err'
},
numAudiobooksSelected() {
return this.selectedAudiobooks.length
numMediaItemsSelected() {
return this.selectedMediaItems.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems
},
userAudiobooks() {
return this.$store.state.user.user.audiobooks || {}
selectedMediaItemsArePlayable() {
return !this.selectedMediaItems.some((i) => !i.hasTracks)
},
selectedSeries() {
return this.$store.state.audiobooks.selectedSeries
userMediaProgress() {
return this.$store.state.user.user.mediaProgress || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
@@ -115,11 +139,11 @@ export default {
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
selectedIsRead() {
// Find an audiobook that is not read, if none then all audiobooks read
return !this.selectedAudiobooks.find((ab) => {
var userAb = this.userAudiobooks[ab]
return !userAb || !userAb.isRead
selectedIsFinished() {
// Find an item that is not finished, if none then all items finished
return !this.selectedMediaItems.find((item) => {
const itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === item.id)
return !itemProgress || !itemProgress.isFinished
})
},
processingBatch() {
@@ -139,37 +163,72 @@ export default {
}
},
methods: {
toggleBookshelfTexture() {
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
},
async back() {
var popped = await this.$store.dispatch('popRoute')
if (popped) this.$store.commit('setIsRoutingBack', true)
var backTo = popped || '/'
this.$router.push(backTo)
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)
const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
const libraryItems = await this.$axios
.$post(`/api/items/batch/get`, { libraryItemIds })
.then((res) => res.libraryItems)
.catch((error) => {
const errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
this.$toast.error(errorMsg)
return []
})
if (!libraryItems.length) {
this.$store.commit('setProcessingBatch', false)
return
}
const queueItems = []
libraryItems.forEach((item) => {
let subtitle = ''
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
queueItems.push({
libraryItemId: item.id,
libraryId: item.libraryId,
episodeId: null,
title: item.media.metadata.title,
subtitle,
caption: '',
duration: item.media.duration || null,
coverPath: item.media.coverPath || null
})
})
this.$eventBus.$emit('play-item', {
libraryItemId: queueItems[0].libraryItemId,
queueItems
})
this.$store.commit('setProcessingBatch', false)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedAudiobooks', [])
this.$eventBus.$emit('bookshelf-clear-selection')
this.isAllSelected = false
if (this.processingBatch) return
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
},
toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
var newIsRead = !this.selectedIsRead
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
const newIsFinished = !this.selectedIsFinished
const updateProgressPayloads = this.selectedMediaItems.map((item) => {
return {
audiobookId: ab,
isRead: newIsRead
libraryItemId: item.id,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/audiobook/batch/update`, updateProgressPayloads)
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
this.$eventBus.$emit('bookshelf-clear-selection')
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error('Batch update failed')
@@ -178,26 +237,23 @@ export default {
})
},
batchDeleteClick() {
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
if (confirm(confirmMsg)) {
this.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/books/batch/delete`, {
audiobookIds: this.selectedAudiobooks
.$post(`/api/items/batch/delete`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success!')
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
this.$eventBus.$emit('bookshelf-clear-selection')
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error('Batch delete failed')
console.error('Failed to batch delete', error)
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
})
}
@@ -206,10 +262,13 @@ export default {
this.$router.push('/batch')
},
batchAddToCollectionClick() {
this.$store.commit('globals/setShowBatchUserCollectionsModal', true)
this.$store.commit('globals/setShowBatchCollectionsModal', true)
},
setBookshelfTotalEntities(totalEntities) {
this.totalEntities = totalEntities
},
batchAutoMatchClick() {
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
}
},
mounted() {
@@ -225,4 +284,4 @@ export default {
#appbar {
box-shadow: 0px 5px 5px #11111155;
}
</style>
</style>

View File

@@ -1,127 +0,0 @@
<template>
<div class="outer-container">
<!-- absolute positioned container -->
<div class="inner-container">
<div class="relative h-10">
<div class="table-header" id="headerdiv">
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
<thead>
<tr>
<th class="header-cell min-w-12 max-w-12"></th>
<th class="header-cell min-w-6 max-w-6"></th>
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
<th class="header-cell min-w-24 max-w-24 px-2"></th>
</tr>
</thead>
</table>
</div>
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
</div>
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
<tbody>
<template v-for="book in books">
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
books: {
type: Array,
default: () => []
}
},
data() {
return {
isScrollable: false
}
},
computed: {},
methods: {
checkIsScrolled() {
if (!this.$refs.tableBody) return
this.isScrollable = this.$refs.tableBody.scrollTop > 0
},
tableScrolled() {
this.checkIsScrolled()
},
editBook(book) {
var bookIds = this.books.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', book)
}
},
mounted() {
this.checkIsScrolled()
},
beforeDestroy() {}
}
</script>
<style>
.outer-container {
position: absolute;
top: 0;
left: 0;
overflow: visible;
height: calc(100% - 50px);
width: calc(100% - 10px);
margin: 10px;
}
.inner-container {
width: 100%;
height: 100%;
position: relative;
}
.table-header {
float: left;
overflow: hidden;
width: 100%;
}
.header-shadow {
box-shadow: 3px 8px 3px #11111155;
}
.table-body {
float: left;
height: 100%;
width: inherit;
overflow-y: scroll;
padding-right: 0px;
}
.header-cell {
background-color: #22222288;
padding: 0px 4px;
text-align: left;
height: 40px;
font-size: 0.9rem;
font-weight: semi-bold;
}
.body-cell {
text-align: left;
font-size: 0.9rem;
}
.book-row {
background-color: #22222288;
}
.book-row:nth-child(odd) {
background-color: #333;
}
.book-row.selected {
background-color: rgba(0, 255, 0, 0.05);
}
</style>

View File

@@ -1,179 +0,0 @@
<template>
<tr class="book-row" :class="selected ? 'selected' : ''">
<td class="body-cell min-w-12 max-w-12">
<div class="flex justify-center">
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
</div>
</td>
<td class="body-cell min-w-6 max-w-6">
<covers-hover-book-cover :audiobook="book" />
</td>
<td class="body-cell min-w-64 max-w-64 px-2">
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
<p class="truncate">
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
</p>
</nuxt-link>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ book.book.authorFL }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ seriesText }}</p>
</td>
<td class="body-cell min-w-24 max-w-24 px-2">
<p class="truncate">{{ book.book.publishYear }}</p>
</td>
<td class="body-cell min-w-80 max-w-80 px-2">
<p class="truncate">{{ book.book.description }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ book.book.narrator }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ genresText }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ tagsText }}</p>
</td>
<td class="body-cell min-w-24 max-w-24 px-2">
<div class="flex">
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
</div>
</td>
</tr>
</template>
<script>
export default {
props: {
book: {
type: Object,
default: () => {}
},
userAudiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
isProcessingReadUpdate: false
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
audiobookId() {
return this.book.id
},
selected: {
get() {
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
},
set(val) {
if (this.processingBatch) return
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
}
},
processingBatch() {
return this.$store.state.processingBatch
},
bookObj() {
return this.book.book || {}
},
series() {
return this.bookObj.series || null
},
volumeNumber() {
return this.bookObj.volumeNumber || null
},
seriesText() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
genresText() {
if (!this.bookObj.genres) return ''
return this.bookObj.genres.join(', ')
},
tagsText() {
return (this.book.tags || []).join(', ')
},
isMissing() {
return this.book.isMissing
},
isInvalid() {
return this.book.isInvalid
},
numEbooks() {
return this.book.numEbooks
},
numTracks() {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
showReadButton() {
return this.showExperimentalFeatures && this.numEbooks
},
showPlayButton() {
return !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
methods: {
selectBtnClick() {
if (this.processingBatch) return
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
},
openEbook() {
this.$store.commit('showEReader', this.book)
},
downloadClick() {
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
},
toggleRead() {
var updatePayload = {
isRead: !this.userIsRead
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
},
startStream() {
this.$eventBus.$emit('play-audiobook', this.book.id)
},
editClick() {
this.$emit('edit', this.book)
}
},
mounted() {}
}
</script>

View File

@@ -1,22 +1,39 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
</div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
<div v-if="loaded && !shelves.length && isRootUser" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
<div class="flex">
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div>
</div>
<div v-else class="w-full flex flex-col items-center">
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
<p class="text-center text-xl py-4">No results for query</p>
</div>
<!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
<template v-for="(shelf, index) in shelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-item-slider>
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</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">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-series-slider>
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-authors-slider>
</template>
</div>
<!-- Regular bookshelf view -->
<div v-else class="w-full">
<template v-for="(shelf, index) in shelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
</template>
</div>
</div>
@@ -37,12 +54,13 @@ export default {
keywordFilterTimeout: null,
scannerParseSubtitle: false,
wrapperClientWidth: 0,
shelves: []
shelves: [],
lastItemIndexSelected: -1
}
},
computed: {
isRootUser() {
return this.$store.getters['user/getIsRoot']
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
@@ -50,28 +68,93 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
isAlternativeBookshelfView() {
return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.DETAIL
},
bookCoverWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
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
},
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems || []
}
},
methods: {
showBookshelfTextureModal() {
this.$store.commit('globals/setShowBookshelfTextureModal', true)
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.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf
} else {
this.lastItemIndexSelected = -1
}
if (shiftKey && lastLastItemIndexSelected >= 0) {
let loopStart = indexOf
let loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected
loopEnd = indexOf
}
const flattenedEntitiesArray = []
this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities))
let 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.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
isSelecting = true
break
}
}
}
if (isSelecting) this.lastItemIndexSelected = indexOf
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = flattenedEntitiesArray[i]
if (thisEntity) {
const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else {
console.error('Invalid entity index', i)
}
}
} else {
const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
}
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', entity)
})
},
async init() {
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
@@ -84,8 +167,8 @@ export default {
this.loaded = true
},
async fetchCategories() {
var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`)
const categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
.then((data) => {
return data
})
@@ -93,57 +176,75 @@ 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() {
var shelves = []
if (this.results.audiobooks) {
if (this.results.books && this.results.books.length) {
shelves.push({
id: 'audiobooks',
id: 'books',
label: 'Books',
type: 'books',
entities: this.results.audiobooks.map((ab) => ab.audiobook)
labelStringKey: 'LabelBooks',
type: 'book',
entities: this.results.books.map((res) => res.libraryItem)
})
}
if (this.results.series) {
if (this.results.podcasts && this.results.podcasts.length) {
shelves.push({
id: 'podcasts',
label: 'Podcasts',
labelStringKey: 'LabelPodcasts',
type: 'podcast',
entities: this.results.podcasts.map((res) => res.libraryItem)
})
}
if (this.results.series && this.results.series.length) {
shelves.push({
id: 'series',
label: 'Series',
labelStringKey: 'LabelSeries',
type: 'series',
entities: this.results.series.map((seriesObj) => {
return {
name: seriesObj.series,
books: seriesObj.audiobooks,
...seriesObj.series,
books: seriesObj.books,
type: 'series'
}
})
})
}
if (this.results.tags) {
if (this.results.tags && this.results.tags.length) {
shelves.push({
id: 'tags',
label: 'Tags',
labelStringKey: 'LabelTags',
type: 'tags',
entities: this.results.tags.map((tagObj) => {
return {
name: tagObj.tag,
books: tagObj.audiobooks,
name: tagObj.name,
books: tagObj.books || [],
type: 'tags'
}
})
})
}
if (this.results.authors) {
if (this.results.authors && this.results.authors.length) {
shelves.push({
id: 'authors',
label: 'Authors',
labelStringKey: 'LabelAuthors',
type: 'authors',
entities: this.results.authors.map((a) => {
return {
id: a.author,
name: a.author,
numBooks: a.numBooks,
...a,
type: 'author'
}
})
@@ -151,89 +252,168 @@ export default {
}
this.shelves = shelves
},
settingsUpdated(settings) {},
scan() {
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
},
audiobookAdded(audiobook) {
console.log('Audiobook added', audiobook)
// TODO: Check if audiobook would be on this shelf
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
if (!this.search) {
this.fetchCategories()
}
},
audiobookUpdated(audiobook) {
console.log('Audiobook updated', audiobook)
libraryItemUpdated(libraryItem) {
console.log('libraryItem updated', libraryItem)
this.shelves.forEach((shelf) => {
if (shelf.type === 'books') {
if (shelf.type == 'book' || shelf.type == 'podcast') {
shelf.entities = shelf.entities.map((ent) => {
if (ent.id === audiobook.id) {
return audiobook
if (ent.id === libraryItem.id) {
return libraryItem
}
return ent
})
} else if (shelf.type === 'series') {
shelf.entities.forEach((ent) => {
ent.books = ent.books.map((book) => {
if (book.id === audiobook.id) return audiobook
if (book.id === libraryItem.id) return libraryItem
return book
})
})
}
})
},
removeBookFromShelf(audiobook) {
removeBookFromShelf(libraryItem) {
this.shelves.forEach((shelf) => {
if (shelf.type === 'books') {
if (shelf.type == 'book' || shelf.type == 'podcast') {
shelf.entities = shelf.entities.filter((ent) => {
return ent.id !== audiobook.id
return ent.id !== libraryItem.id
})
} else if (shelf.type === 'series') {
shelf.entities.forEach((ent) => {
ent.books = ent.books.filter((book) => {
return book.id !== audiobook.id
return book.id !== libraryItem.id
})
})
}
})
},
audiobookRemoved(audiobook) {
this.removeBookFromShelf(audiobook)
libraryItemRemoved(libraryItem) {
this.removeBookFromShelf(libraryItem)
},
audiobooksAdded(audiobooks) {
console.log('audiobooks added', audiobooks)
libraryItemsAdded(libraryItems) {
console.log('libraryItems added', libraryItems)
// TODO: Check if audiobook would be on this shelf
if (!this.search) {
this.fetchCategories()
}
},
audiobooksUpdated(audiobooks) {
audiobooks.forEach((ab) => {
this.audiobookUpdated(ab)
libraryItemsUpdated(items) {
items.forEach((li) => {
this.libraryItemUpdated(li)
})
},
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') {
shelf.entities = shelf.entities.map((ent) => {
if (ent.id === author.id) {
return {
...ent,
...author
}
}
return ent
})
}
})
},
authorRemoved(author) {
this.shelves.forEach((shelf) => {
if (shelf.type == 'authors') {
shelf.entities = shelf.entities.filter((ent) => ent.id != author.id)
}
})
},
initListeners() {
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
if (this.$root.socket) {
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
this.$root.socket.on('audiobook_added', this.audiobookAdded)
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
this.$root.socket.on('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)
this.$root.socket.on('item_added', this.libraryItemAdded)
this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('items_added', this.libraryItemsAdded)
} else {
console.error('Error socket not initialized')
}
},
removeListeners() {
this.$store.commit('user/removeSettingsListener', 'bookshelf')
if (this.$root.socket) {
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
this.$root.socket.off('audiobook_added', this.audiobookAdded)
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
this.$root.socket.off('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)
this.$root.socket.off('item_added', this.libraryItemAdded)
this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('items_added', this.libraryItemsAdded)
} else {
console.error('Error socket not initialized')
}

View File

@@ -1,10 +1,29 @@
<template>
<div class="relative">
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
<div class="w-full h-full pt-6">
<div v-if="shelf.type === 'books'" class="flex items-center">
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities">
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectBook" @edit="editBook" />
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :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"
: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">
@@ -14,24 +33,20 @@
</div>
<div v-if="shelf.type === 'tags'" class="flex items-center">
<template v-for="entity in shelf.entities">
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
<cards-group-card is-categorized :width="bookCoverWidth" :group="entity" :book-cover-aspect-ratio="bookCoverAspectRatio" @hook:updated="updatedBookCard" />
</nuxt-link>
<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">
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.name)}`">
<cards-author-card :width="bookCoverWidth" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
</nuxt-link>
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
</template>
</div>
</div>
</div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 22px">
<div class="absolute text-center categoryPlacard transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<p class="transform text-sm">{{ shelf.label }}</p>
<p class="transform text-sm">{{ $strings[shelf.labelStringKey] }}</p>
</div>
</div>
@@ -56,7 +71,8 @@ export default {
},
sizeMultiplier: Number,
bookCoverWidth: Number,
bookCoverAspectRatio: Number
bookCoverAspectRatio: Number,
continueListeningShelf: Boolean
},
data() {
return {
@@ -67,11 +83,6 @@ export default {
updateTimer: null
}
},
watch: {
isSelectionMode(newVal) {
this.updateSelectionMode(newVal)
}
},
computed: {
bookCoverHeight() {
return this.bookCoverWidth * this.bookCoverAspectRatio
@@ -79,9 +90,6 @@ export default {
shelfHeight() {
return this.bookCoverHeight + 48
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
paddingLeft() {
if (window.innerWidth < 768) return 1
return 2.5
@@ -90,29 +98,51 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected'] > 0
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
}
},
methods: {
editBook(audiobook) {
var bookIds = this.shelf.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', audiobook)
clearSelectedEntities() {
this.updateSelectionMode(false)
},
editAuthor(author) {
this.$store.commit('globals/showEditAuthorModal', author)
},
editItem(libraryItem) {
var itemIds = this.shelf.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModal', libraryItem)
},
editEpisode({ libraryItem, episode }) {
this.$store.commit('setSelectedLibraryItem', libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
updateSelectionMode(val) {
var selectedAudiobooks = this.$store.state.selectedAudiobooks
if (this.shelf.type === 'books') {
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-book-${ent.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedAudiobooks.includes(ent.id)
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
})
} else if (this.shelf.type === 'episode') {
this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
})
}
},
selectBook(audiobook) {
this.$store.commit('toggleAudiobookSelected', audiobook.id)
selectItem(payload) {
this.$emit('selectEntity', payload)
},
itemSelectedEvt() {
this.updateSelectionMode(this.isSelectionMode)
},
scrolled() {
clearTimeout(this.scrollTimer)
@@ -156,6 +186,14 @@ export default {
this.canScrollLeft = false
}
}
},
mounted() {
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
},
beforeDestroy() {
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
}
}
</script>
@@ -163,25 +201,13 @@ export default {
<style>
.categorizedBookshelfRow {
scroll-behavior: smooth;
width: calc(100vw - 80px);
/* background-color: rgb(214, 116, 36); */
background-image: var(--bookshelf-texture-img);
/* background-position: center; */
/* background-size: contain; */
background-repeat: repeat-x;
}
@media (max-width: 768px) {
.categorizedBookshelfRow {
width: 100vw;
}
}
.bookshelfDividerCategorized {
background: rgb(149, 119, 90);
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
box-shadow: 2px 14px 8px #111111aa;
}

View File

@@ -1,52 +1,98 @@
<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'">
<p>Home</p>
<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 v-if="isHomePage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonHome }}</p>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</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'">
<p>Library</p>
<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 v-if="isLibraryPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonLibrary }}</p>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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" />
</svg>
</nuxt-link>
<nuxt-link :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'">
<p>Series</p>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :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 v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :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 v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
<span v-else class="material-icons-outlined text-lg">collections_bookmark</span>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
/>
</svg>
</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'">
<p class="text-sm">{{ $strings.ButtonSearch }}</p>
</nuxt-link>
</div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
<template v-if="page !== 'search' && !isHome">
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
<div v-else class="items-center hidden md:flex">
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-2xl text-white">west</span>
</div>
<p class="pl-4 font-book text-lg">
{{ selectedSeries }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span>
</div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page -->
<template v-if="selectedSeries">
<p class="pl-2 text-base md:text-lg">
{{ seriesName }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span>
</div>
<div class="flex-grow" />
<ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" />
<!-- RSS feed -->
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="success" outlined @click="showOpenSeriesRSSFeed" />
</ui-tooltip>
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
</template>
<!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
<div class="flex-grow hidden sm:inline-block" />
<ui-checkbox v-show="showSortFilters" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<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> -->
<!-- collapse series checkbox -->
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<!-- library filter select -->
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
<!-- library sort select -->
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
<!-- series filter select -->
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
<!-- series sort select -->
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
<!-- issues page remove all button -->
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
</template>
<!-- search page -->
<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>
<p>{{ $strings.MessageSearchResultsFor }} "{{ searchQuery }}"</p>
<div class="flex-grow" />
</template>
<!-- authors page -->
<template v-else-if="page === 'authors'">
<div class="flex-grow" />
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
</template>
</div>
</div>
</template>
@@ -56,57 +102,293 @@ export default {
props: {
page: String,
isHome: Boolean,
selectedSeries: String,
selectedSeries: {
type: Object,
default: () => null
},
searchQuery: String,
viewMode: String
authors: {
type: Array,
default: () => []
}
},
data() {
return {
settings: {},
hasInit: false,
totalEntities: 0,
keywordFilter: null,
keywordTimeout: null
processingSeries: false,
processingIssues: false,
processingAuthors: false
}
},
computed: {
isGridMode() {
return this.viewMode === 'grid'
seriesContextMenuItems() {
if (!this.selectedSeries) return []
const items = [
{
text: this.isSeriesFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished,
action: 'mark-series-finished'
}
]
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
action: 'open-rss-feed'
})
}
if (this.isSeriesRemovedFromContinueListening) {
items.push({
text: 'Re-Add Series to Continue Listening',
action: 're-add-to-continue-listening'
})
}
return items
},
showSortFilters() {
seriesSortItems() {
return [
{
text: this.$strings.LabelName,
value: 'name'
},
{
text: this.$strings.LabelNumberOfBooks,
value: 'numBooks'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelTotalDuration,
value: 'totalDuration'
}
]
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isLibraryPage() {
return this.page === ''
},
isSeriesPage() {
return this.page === 'series'
},
isCollectionsPage() {
return this.page === 'collections'
},
isPlaylistsPage() {
return this.page === 'playlists'
},
isHomePage() {
return this.$route.name === 'library-library'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
},
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
},
isAlbumsPage() {
return this.page === 'albums'
},
numShowing() {
return this.totalEntities
},
entityName() {
if (!this.page) return 'Books'
if (this.page === 'series') return 'Series'
if (this.page === 'collections') return 'Collections'
if (this.isAlbumsPage) return 'Albums'
if (this.isMusicLibrary) return 'Tracks'
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
if (!this.page) return this.$strings.LabelBooks
if (this.isSeriesPage) return this.$strings.LabelSeries
if (this.isCollectionsPage) return this.$strings.LabelCollections
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
return ''
},
paramId() {
return this.$route.params ? this.$route.params.id || '' : ''
seriesId() {
return this.selectedSeries ? this.selectedSeries.id : null
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
seriesName() {
return this.selectedSeries ? this.selectedSeries.name : null
},
homePage() {
return this.$route.name === 'library-library'
seriesProgress() {
return this.selectedSeries ? this.selectedSeries.progress : null
},
libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id'
seriesRssFeed() {
return this.selectedSeries ? this.selectedSeries.rssFeed : null
},
showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
seriesLibraryItemIds() {
if (!this.seriesProgress) return []
return this.seriesProgress.libraryItemIds || []
},
isBatchSelecting() {
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
},
isSeriesFinished() {
return this.seriesProgress && !!this.seriesProgress.isFinished
},
isSeriesRemovedFromContinueListening() {
if (!this.seriesId) return false
return this.$store.getters['user/getIsSeriesRemovedFromContinueListening'](this.seriesId)
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
}
},
methods: {
searchBackArrow() {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
seriesContextMenuAction(action) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
} else if (action === 're-add-to-continue-listening') {
if (this.processingSeries) {
console.warn('Already processing series')
return
}
this.reAddSeriesToContinueListening()
} else if (action === 'mark-series-finished') {
if (this.processingSeries) {
console.warn('Already processing series')
return
}
this.markSeriesFinished()
}
},
seriesBackArrow() {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
showOpenSeriesRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.selectedSeries.id,
name: this.selectedSeries.name,
type: 'series',
feed: this.selectedSeries.rssFeed
})
},
reAddSeriesToContinueListening() {
this.processingSeries = true
this.$axios
.$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)
.then(() => {
this.$toast.success('Series re-added to continue listening')
})
.catch((error) => {
console.error('Failed to re-add series to continue listening', error)
this.$toast.error('Failed to re-add series to continue listening')
})
.finally(() => {
this.processingSeries = false
})
},
async matchAllAuthors() {
this.processingAuthors = true
for (const author of this.authors) {
const payload = {}
if (author.asin) payload.asin = author.asin
else payload.q = author.name
console.log('Payload', payload, 'author', author)
this.$eventBus.$emit(`searching-author-${author.id}`, true)
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
console.error(`Author ${author.name} not found`)
this.$toast.error(`Author ${author.name} not found`)
} else if (response.updated) {
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
else console.log(`Author ${response.author.name} was updated (no image found)`)
} else {
console.log(`No updates were made for Author ${response.author.name}`)
}
this.$eventBus.$emit(`searching-author-${author.id}`, false)
}
this.processingAuthors = false
},
removeAllIssues() {
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
this.processingIssues = true
this.$axios
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
.then(() => {
this.$toast.success('Removed library items with issues')
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
})
.catch((error) => {
console.error('Failed to remove library items with issues', error)
this.$toast.error('Failed to remove library items with issues')
})
.finally(() => {
this.processingIssues = false
})
}
},
markSeriesFinished() {
const newIsFinished = !this.isSeriesFinished
const payload = {
message: newIsFinished ? this.$strings.MessageConfirmMarkSeriesFinished : this.$strings.MessageConfirmMarkSeriesNotFinished,
callback: (confirmed) => {
if (confirmed) {
this.processingSeries = true
const updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
libraryItemId: lid,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)
this.selectedSeries.progress.isFinished = newIsFinished
})
.catch((error) => {
this.$toast.error(this.$strings.ToastSeriesUpdateFailed)
console.error('Failed to batch update read/not read', error)
})
.finally(() => {
this.processingSeries = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
updateOrder() {
this.saveSettings()
@@ -114,9 +396,18 @@ export default {
updateFilter() {
this.saveSettings()
},
updateSeriesSort() {
this.saveSettings()
},
updateSeriesFilter() {
this.saveSettings()
},
updateCollapseSeries() {
this.saveSettings()
},
updateCollapseBookSeries() {
this.saveSettings()
},
saveSettings() {
this.$store.dispatch('user/updateUserSettings', this.settings)
},
@@ -131,24 +422,31 @@ export default {
setBookshelfTotalEntities(totalEntities) {
this.totalEntities = totalEntities
},
keywordFilterInput() {
clearTimeout(this.keywordTimeout)
this.keywordTimeout = setTimeout(() => {
this.keywordUpdated(this.keywordFilter)
}, 1000)
rssFeedOpen(data) {
if (data.entityId === this.seriesId) {
console.log('RSS Feed Opened', data)
this.selectedSeries.rssFeed = data
}
},
keywordUpdated() {
this.$eventBus.$emit('bookshelf-keyword-filter', this.keywordFilter)
rssFeedClosed(data) {
if (data.entityId === this.seriesId) {
console.log('RSS Feed Closed', data)
this.selectedSeries.rssFeed = null
}
}
},
mounted() {
this.init()
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
}
}
</script>
@@ -158,4 +456,4 @@ export default {
#toolbar {
box-shadow: 0px 8px 6px #111111aa;
}
</style>
</style>

View File

@@ -1,17 +1,25 @@
<template>
<div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside">
<div class="md:hidden flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span>
<div>
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p class="leading-4">{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
</div>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
<div class="flex justify-between">
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamAudiobook && isMobileLandscape ? '300px' : '65px' }">
<p class="font-mono text-sm">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
<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>
</div>
</template>
@@ -22,65 +30,93 @@ export default {
isOpen: Boolean
},
data() {
return {}
return {
showChangelogModal: false
}
},
computed: {
userIsRoot() {
return this.$store.getters['user/getIsRoot']
Source() {
return this.$store.state.Source
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
configRoutes() {
if (!this.userIsRoot) {
if (!this.userIsAdminOrUp) {
return [
{
id: 'config-stats',
title: 'Your Stats',
title: this.$strings.HeaderYourStats,
path: '/config/stats'
}
]
}
return [
const configRoutes = [
{
id: 'config',
title: 'Settings',
title: this.$strings.HeaderSettings,
path: '/config'
},
{
id: 'config-libraries',
title: 'Libraries',
title: this.$strings.HeaderLibraries,
path: '/config/libraries'
},
{
id: 'config-users',
title: 'Users',
title: this.$strings.HeaderUsers,
path: '/config/users'
},
{
id: 'config-sessions',
title: this.$strings.HeaderListeningSessions,
path: '/config/sessions'
},
{
id: 'config-backups',
title: 'Backups',
title: this.$strings.HeaderBackups,
path: '/config/backups'
},
{
id: 'config-log',
title: 'Log',
title: this.$strings.HeaderLogs,
path: '/config/log'
},
{
id: 'config-library-stats',
title: 'Library Stats',
path: '/config/library-stats'
id: 'config-notifications',
title: this.$strings.HeaderNotifications,
path: '/config/notifications'
},
{
id: 'config-stats',
title: 'Your Stats',
path: '/config/stats'
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,
path: '/config/item-metadata-utils'
}
]
if (this.currentLibraryId) {
configRoutes.push({
id: 'config-library-stats',
title: this.$strings.HeaderLibraryStats,
path: '/config/library-stats'
})
configRoutes.push({
id: 'config-stats',
title: this.$strings.HeaderYourStats,
path: '/config/stats'
})
}
return configRoutes
},
wrapperClass() {
var classes = []
if (this.drawerOpen) classes.push('translate-x-0')
else classes.push('-translate-x-44')
if (this.isMobile) classes.push('z-50')
if (this.isMobilePortrait) classes.push('z-50')
else classes.push('z-40')
return classes.join(' ')
},
@@ -90,9 +126,11 @@ export default {
isMobileLandscape() {
return this.$store.state.globals.isMobileLandscape
},
isMobilePortrait() {
return this.$store.state.globals.isMobilePortrait
},
drawerOpen() {
if (this.isMobile) return this.isOpen
return true
return !this.isMobilePortrait || this.isOpen
},
routeName() {
return this.$route.name
@@ -109,11 +147,17 @@ export default {
githubTagUrl() {
return this.versionData.githubTagUrl
},
streamAudiobook() {
return this.$store.state.streamAudiobook
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()

View File

@@ -6,27 +6,22 @@
</div>
</template>
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
<div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
<div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
</div>
</div>
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
<p class="text-xl text-center">{{ emptyMessage }}</p>
<div class="flex justify-center mt-2">
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
<!-- Clear filter only available on Library bookshelf -->
<div v-if="entityName === 'items'" class="flex justify-center mt-2">
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
</div>
</div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
<p class="text-sm py-0.5">Texture</p>
</div>
</div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
</div>
</template>
@@ -41,6 +36,7 @@ export default {
mixins: [bookshelfCardsHelpers],
data() {
return {
routeFullPath: null,
initialized: false,
bookshelfHeight: 0,
bookshelfWidth: 0,
@@ -60,13 +56,13 @@ export default {
totalShelves: 0,
bookshelfMarginLeft: 0,
isSelectionMode: false,
isSelectAll: false,
currentSFQueryString: null,
pendingReset: false,
keywordFilter: null,
currScrollTop: 0,
resizeTimeout: null,
mountWindowWidth: 0
mountWindowWidth: 0,
lastItemIndexSelected: -1
}
},
watch: {
@@ -79,22 +75,42 @@ export default {
}
},
computed: {
isRootUser() {
return this.$store.getters['user/getIsRoot']
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() {
return this.libraryMediaType === 'podcast'
},
emptyMessage() {
if (this.page === 'series') return `You have no series`
if (this.page === 'collections') return "You haven't made any collections yet"
if (this.hasFilter) return `No Results for filter "${this.filterValue}"`
return 'No results'
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
if (this.hasFilter) {
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
return this.$getString('MessageBookshelfNoResultsForFilter', [this.filterName, this.filterValue])
}
return this.$strings.MessageNoResults
},
entityName() {
if (!this.page) return 'books'
if (!this.page) return 'items'
return this.page
},
seriesSortBy() {
return this.$store.getters['user/getUserSetting']('seriesSortBy')
},
seriesSortDesc() {
return this.$store.getters['user/getUserSetting']('seriesSortDesc')
},
seriesFilterBy() {
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
@@ -107,24 +123,23 @@ export default {
collapseSeries() {
return this.$store.getters['user/getUserSetting']('collapseSeries')
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
collapseBookSeries() {
return this.$store.getters['user/getUserSetting']('collapseBookSeries')
},
bookshelfView() {
return this.$store.getters['getServerSetting']('bookshelfView')
coverAspectRatio() {
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'
@@ -143,16 +158,16 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
isEntityBook() {
return this.entityName === 'series-books' || this.entityName === 'books'
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
bookWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
const coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
return coverSize
},
bookHeight() {
if (this.isCoverSquareAspectRatio) return this.bookWidth
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
return this.bookWidth * 1.6
},
shelfPadding() {
@@ -176,47 +191,106 @@ export default {
return 6
},
shelfHeight() {
if (this.isAlternativeBookshelfView) return this.entityHeight + 80 * this.sizeMultiplier
if (this.isAlternativeBookshelfView) {
const isItemEntity = this.entityName === 'series-books' || this.entityName === 'items'
const extraTitleSpace = isItemEntity ? 80 : this.entityName === 'albums' ? 60 : 40
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
}
return this.entityHeight + 40
},
totalEntityCardWidth() {
// Includes margin
return this.entityWidth + 24
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks || []
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems || []
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize
}
},
methods: {
showBookshelfTextureModal() {
this.$store.commit('globals/setShowBookshelfTextureModal', true)
},
clearFilter() {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
},
editEntity(entity) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
var bookIds = this.entities.map((e) => e.id)
if (this.entityName === 'items' || this.entityName === 'series-books') {
const bookIds = this.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', entity)
} else if (this.entityName === 'collections') {
this.$store.commit('globals/setEditCollection', entity)
} else if (this.entityName === 'playlists') {
this.$store.commit('globals/setEditPlaylist', entity)
}
},
clearSelectedEntities() {
this.updateBookSelectionMode(false)
this.isSelectionMode = false
this.isSelectAll = false
},
selectEntity(entity) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
this.$store.commit('toggleAudiobookSelected', entity.id)
selectEntity(entity, shiftKey) {
if (this.entityName === 'items' || this.entityName === 'series-books') {
const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf
} else {
this.lastItemIndexSelected = -1
}
var newIsSelectionMode = !!this.selectedAudiobooks.length
if (shiftKey && lastLastItemIndexSelected >= 0) {
let loopStart = indexOf
let loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected
loopEnd = indexOf
}
let 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.selectedMediaItems.some((i) => i.id === 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
const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else {
console.error('Invalid entity index', i)
}
}
} else {
const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
}
const newIsSelectionMode = !!this.selectedMediaItems.length
if (this.isSelectionMode !== newIsSelectionMode) {
this.isSelectionMode = newIsSelectionMode
this.updateBookSelectionMode(newIsSelectionMode)
@@ -229,9 +303,12 @@ export default {
this.entityComponentRefs[key].setSelectionMode(isSelectionMode)
}
}
if (!isSelectionMode) {
this.lastItemIndexSelected = -1
}
},
async fetchEntites(page = 0) {
var startIndex = page * this.booksPerFetch
const startIndex = page * this.booksPerFetch
this.isFetchingEntities = true
@@ -239,11 +316,11 @@ export default {
this.currentSFQueryString = this.buildSearchParams()
}
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `books/all` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error)
return null
})
@@ -260,16 +337,17 @@ export default {
this.totalEntities = payload.total
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
this.entities = new Array(this.totalEntities)
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
}
for (let i = 0; i < payload.results.length; i++) {
var index = i + startIndex
const index = i + startIndex
this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index])
}
}
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
}
},
loadPage(page) {
@@ -300,11 +378,11 @@ export default {
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
if (!this.pagesLoaded[firstBookPage]) {
console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
// console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
this.loadPage(firstBookPage)
}
if (!this.pagesLoaded[lastBookPage]) {
console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
this.loadPage(lastBookPage)
}
@@ -332,7 +410,6 @@ export default {
this.totalEntities = 0
this.currentPage = 0
this.isSelectionMode = false
this.isSelectAll = false
this.initialized = false
this.initSizeData()
@@ -368,15 +445,20 @@ 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') {
searchParams.set('filter', `series.${this.seriesId}`)
searchParams.set('sort', 'book.volumeNumber')
searchParams.set('desc', 0)
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)}`)
if (this.collapseBookSeries) {
searchParams.set('collapseseries', 1)
}
} else {
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
@@ -385,15 +467,13 @@ export default {
searchParams.set('sort', this.orderBy)
searchParams.set('desc', this.orderDesc ? 1 : 0)
}
if (this.collapseSeries) {
if (this.collapseSeries && !this.isPodcast) {
searchParams.set('collapseseries', 1)
}
}
return searchParams.toString()
},
checkUpdateSearchParams() {
if (this.page === 'series-books') return false
var newSearchParams = this.buildSearchParams()
var currentQueryString = window.location.search
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
@@ -404,13 +484,21 @@ export default {
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl)
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
return true
}
return false
},
settingsUpdated(settings) {
seriesSortUpdated() {
var wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) {
this.resetEntities()
}
},
settingsUpdated(settings) {
const wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) {
this.resetEntities()
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
@@ -420,49 +508,100 @@ export default {
scroll(e) {
if (!e || !e.target) return
var { scrollTop } = e.target
// clearTimeout(this.scrollTimeout)
// this.scrollTimeout = setTimeout(() => {
this.handleScroll(scrollTop)
// }, 250)
},
audiobookAdded(audiobook) {
console.log('Audiobook added', audiobook)
libraryItemAdded(libraryItem) {
console.log('libraryItem added', libraryItem)
// TODO: Check if audiobook would be on this shelf
this.resetEntities()
},
audiobookUpdated(audiobook) {
console.log('Audiobook updated', audiobook)
if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
libraryItemUpdated(libraryItem) {
console.log('Item updated', libraryItem)
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities[indexOf] = audiobook
this.entities[indexOf] = libraryItem
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(audiobook)
this.entityComponentRefs[indexOf].setEntity(libraryItem)
}
}
}
},
audiobookRemoved(audiobook) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
libraryItemRemoved(libraryItem) {
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== audiobook.id)
this.totalEntities = this.entities.length
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
this.totalEntities--
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.remountEntities()
this.executeRebuild()
}
}
},
audiobooksAdded(audiobooks) {
console.log('audiobooks added', audiobooks)
libraryItemsAdded(libraryItems) {
console.log('items added', libraryItems)
// TODO: Check if audiobook would be on this shelf
this.resetEntities()
},
audiobooksUpdated(audiobooks) {
audiobooks.forEach((ab) => {
this.audiobookUpdated(ab)
libraryItemsUpdated(libraryItems) {
libraryItems.forEach((ab) => {
this.libraryItemUpdated(ab)
})
},
collectionAdded(collection) {
if (this.entityName !== 'collections') return
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
this.resetEntities()
},
collectionUpdated(collection) {
if (this.entityName !== 'collections') return
console.log(`[LazyBookshelf] collectionUpdated ${collection.id}`, collection)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
if (indexOf >= 0) {
this.entities[indexOf] = collection
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(collection)
}
}
},
collectionRemoved(collection) {
if (this.entityName !== 'collections') return
console.log(`[LazyBookshelf] collectionRemoved ${collection.id}`, collection)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== collection.id)
this.totalEntities--
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.executeRebuild()
}
},
playlistAdded(playlist) {
if (this.entityName !== 'playlists') return
console.log(`[LazyBookshelf] playlistAdded ${playlist.id}`, playlist)
this.resetEntities()
},
playlistUpdated(playlist) {
if (this.entityName !== 'playlists') return
console.log(`[LazyBookshelf] playlistUpdated ${playlist.id}`, playlist)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
if (indexOf >= 0) {
this.entities[indexOf] = playlist
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(playlist)
}
}
},
playlistRemoved(playlist) {
if (this.entityName !== 'playlists') return
console.log(`[LazyBookshelf] playlistRemoved ${playlist.id}`, playlist)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== playlist.id)
this.totalEntities--
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.executeRebuild()
}
},
initSizeData(_bookshelf) {
var bookshelf = _bookshelf || document.getElementById('bookshelf')
if (!bookshelf) {
@@ -494,6 +633,15 @@ export default {
await this.fetchEntites(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
// Set last scroll position for this bookshelf page
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
if (path === this.routeFullPath) {
// Exact path match with query so use scroll position
window.bookshelf.scrollTop = scrollTop
}
}
},
executeRebuild() {
clearTimeout(this.resizeTimeout)
@@ -519,17 +667,22 @@ export default {
}
})
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
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 })
this.$eventBus.$on('user-settings', this.settingsUpdated)
if (this.$root.socket) {
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
this.$root.socket.on('audiobook_added', this.audiobookAdded)
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('item_added', this.libraryItemAdded)
this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('items_added', this.libraryItemsAdded)
this.$root.socket.on('collection_added', this.collectionAdded)
this.$root.socket.on('collection_updated', this.collectionUpdated)
this.$root.socket.on('collection_removed', this.collectionRemoved)
this.$root.socket.on('playlist_added', this.playlistAdded)
this.$root.socket.on('playlist_updated', this.playlistUpdated)
this.$root.socket.on('playlist_removed', this.playlistRemoved)
} else {
console.error('Bookshelf - Socket not initialized')
}
@@ -540,17 +693,23 @@ export default {
if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll)
}
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$off('socket_init', this.socketInit)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('socket_init', this.socketInit)
this.$eventBus.$off('user-settings', this.settingsUpdated)
if (this.$root.socket) {
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
this.$root.socket.off('audiobook_added', this.audiobookAdded)
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('item_added', this.libraryItemAdded)
this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('items_added', this.libraryItemsAdded)
this.$root.socket.off('collection_added', this.collectionAdded)
this.$root.socket.off('collection_updated', this.collectionUpdated)
this.$root.socket.off('collection_removed', this.collectionRemoved)
this.$root.socket.off('playlist_added', this.playlistAdded)
this.$root.socket.off('playlist_updated', this.playlistUpdated)
this.$root.socket.off('playlist_removed', this.playlistRemoved)
} else {
console.error('Bookshelf - Socket not initialized')
}
@@ -563,13 +722,25 @@ export default {
}
},
scan() {
this.$root.socket.emit('scan', this.currentLibraryId)
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
}
},
mounted() {
this.initListeners()
this.routeFullPath = window.location.pathname + (window.location.search || '')
},
updated() {
this.routeFullPath = window.location.pathname + (window.location.search || '')
setTimeout(() => {
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
@@ -580,6 +751,11 @@ export default {
beforeDestroy() {
this.destroyEntityComponents()
this.removeListeners()
// Set bookshelf scroll position for specific bookshelf page and query
if (window.bookshelf) {
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })
}
}
}
</script>
@@ -588,6 +764,7 @@ export default {
.bookshelfRow {
background-image: var(--bookshelf-texture-img);
}
.bookshelfDivider {
background: rgb(149, 119, 90);
background: var(--bookshelf-divider-bg);

View File

@@ -0,0 +1,48 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">{{ headerText }}</h1>
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
<button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
</div>
</div>
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
<slot></slot>
</div>
</template>
<script>
export default {
props: {
headerText: String,
description: String,
note: String,
showAddButton: Boolean
},
methods: {
clicked() {
this.$emit('clicked')
}
}
}
</script>
<style>
#settings-description a {
color: rgb(96 165 250);
}
#settings-description a:hover {
color: rgb(147 197 253);
text-decoration-line: underline;
}
#settings-description code {
font-size: 0.875rem;
border-radius: 6px;
background-color: rgb(82, 82, 82);
color: white;
padding: 2px 4px;
}
</style>

View File

@@ -1,45 +1,55 @@
<template>
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px">
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<nuxt-link :to="`/library/${currentLibraryId}`" 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="homePage ? '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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.9rem">Home</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">format_list_bulleted</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<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" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.9rem">Library</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.9rem">Series</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined">collections_bookmark</span>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
<p class="font-book pt-1.5" style="font-size: 0.9rem">Collections</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showExperimentalFeatures" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path
fill="currentColor"
@@ -47,15 +57,47 @@
/>
</svg>
<p class="font-book pt-1.5" style="font-size: 0.9rem">Authors</p>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="abs-icons icon-podcast text-xl"></span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-xl">album</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">file_download</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
<span class="material-icons text-2xl">warning</span>
<p class="font-book pt-1.5" style="font-size: 1rem">Issues</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
@@ -63,46 +105,39 @@
</div>
</nuxt-link>
<!-- <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<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="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>
<p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p>
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> -->
<!-- <nuxt-link to="/library/tags" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'tags' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p>
<div v-show="paramId === 'tags'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> -->
<!-- <nuxt-link to="/library/authors" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'authors' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p>
<div v-show="paramId === 'authors'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> -->
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
</div>
</template>
<script>
export default {
data() {
return {}
return {
showChangelogModal: false
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
Source() {
return this.$store.state.Source
},
isMobileLandscape() {
return this.$store.state.globals.isMobileLandscape
},
isShowingBookshelfToolbar() {
if (!this.$route.name) return false
return this.$route.name.startsWith('library')
},
offsetTop() {
return 64
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
paramId() {
return this.$route.params ? this.$route.params.id || '' : ''
@@ -110,6 +145,30 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
},
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
isMusicAlbumsPage() {
return this.paramId === 'albums'
},
homePage() {
return this.$route.name === 'library-library'
},
@@ -119,21 +178,49 @@ export default {
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
},
isPlaylistsPage() {
return this.paramId === 'playlists'
},
libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id'
},
showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
showingIssues() {
if (!this.$route.query) return false
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
},
numIssues() {
return this.$store.state.libraries.issues || 0
},
versionData() {
return this.$store.state.versionData || {}
},
hasUpdate() {
return !!this.versionData.hasUpdate
},
githubTagUrl() {
return this.versionData.githubTagUrl
},
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
showPlaylists() {
return this.$store.state.libraries.numUserPlaylists > 0
}
},
methods: {
clickChangelog() {
this.showChangelogModal = true
}
},
methods: {},
mounted() {}
}
</script>
</script>

View File

@@ -1,33 +1,63 @@
<template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-4 pt-2">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :audiobook="streamAudiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
<div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</nuxt-link>
<div class="flex items-start pl-24 mb-6 md:mb-0">
<div>
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div class="min-w-0">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
{{ title }}
</nuxt-link>
<div class="text-gray-400 flex items-center">
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p v-if="authorFL" class="pl-1.5 text-sm sm:text-base">
<nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
<div class="flex items-center">
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</div>
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
</div>
</div>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-xs">schedule</span>
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
</div>
</div>
<div class="flex-grow" />
<span class="material-icons p-4 cursor-pointer" @click="closePlayer">close</span>
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
</ui-tooltip>
</div>
<player-ui
ref="audioPlayer"
:chapters="chapters"
:paused="!isPlaying"
:loading="playerLoading"
:bookmarks="bookmarks"
:sleep-timer-set="sleepTimerSet"
:sleep-timer-remaining="sleepTimerRemaining"
:is-podcast="isPodcast"
@playPause="playPause"
@jumpForward="jumpForward"
@jumpBackward="jumpBackward"
@setVolume="setVolume"
@setPlaybackRate="setPlaybackRate"
@seek="seek"
@close="closePlayer"
@showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
/>
<audio-player ref="audioPlayer" :chapters="chapters" :paused="!isPlaying" :loading="playerLoading" :bookmarks="bookmarks" @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" @close="closePlayer" @showBookmarks="showBookmarks" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @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>
@@ -41,76 +71,177 @@ export default {
totalDuration: 0,
showBookmarksModal: false,
bookmarkCurrentTime: 0,
bookmarkAudiobookId: null,
playerLoading: false,
isPlaying: false,
currentTime: 0
currentTime: 0,
showSleepTimerModal: false,
showPlayerQueueItemsModal: false,
sleepTimerSet: false,
sleepTimerTime: 0,
sleepTimerRemaining: 0,
sleepTimer: null,
displayTitle: null,
initialPlaybackRate: 1,
syncFailedToast: null
}
},
computed: {
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
isSquareCover() {
return this.coverAspectRatio === 1
},
isMobile() {
return this.$store.state.globals.isMobile
},
bookCoverWidth() {
return 88
},
bookCoverPosTop() {
if (this.bookCoverAspectRatio === 1) return -10
return -64
if (this.isMobile) return 64 / this.coverAspectRatio
return 77 / this.coverAspectRatio
},
cover() {
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover
if (this.media.coverPath) return this.media.coverPath
return 'Logo.png'
},
user() {
return this.$store.state.user.user
},
userAudiobook() {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userAudiobookCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
userItemCurrentTime() {
return this.userMediaProgress ? this.userMediaProgress.currentTime || 0 : 0
},
bookmarks() {
if (!this.userAudiobook) return []
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
if (!this.libraryItemId) return []
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
},
streamAudiobook() {
return this.$store.state.streamAudiobook
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
audiobookId() {
return this.streamAudiobook ? this.streamAudiobook.id : null
libraryItemId() {
return this.streamLibraryItem ? this.streamLibraryItem.id : null
},
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
media() {
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
},
isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
},
isMusic() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
},
isExplicit() {
return this.mediaMetadata.explicit || false
},
mediaMetadata() {
return this.media.metadata || {}
},
chapters() {
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
return this.media.chapters || []
},
title() {
return this.book.title || 'No Title'
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
return this.mediaMetadata.title || 'No Title'
},
author() {
return this.book.author || 'Unknown'
},
authorFL() {
return this.book.authorFL
},
authorsList() {
return this.authorFL ? this.authorFL.split(', ') : []
authors() {
return this.mediaMetadata.authors || []
},
libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
podcastAuthor() {
if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown'
},
musicArtists() {
if (!this.isMusic) return null
return this.mediaMetadata.artists.join(', ')
},
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 - using first in queue', this.playerQueueItems)
currentQueueIndex = -1
}
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)
this.updateMediaSessionPlaybackState()
},
setSleepTimer(seconds) {
this.sleepTimerSet = true
this.sleepTimerTime = seconds
this.sleepTimerRemaining = seconds
this.runSleepTimer()
this.showSleepTimerModal = false
},
runSleepTimer() {
var lastTick = Date.now()
clearInterval(this.sleepTimer)
this.sleepTimer = setInterval(() => {
var elapsed = Date.now() - lastTick
lastTick = Date.now()
this.sleepTimerRemaining -= elapsed / 1000
if (this.sleepTimerRemaining <= 0) {
this.clearSleepTimer()
this.playerHandler.pause()
this.$toast.info('Sleep Timer Done.. zZzzZz')
}
}, 1000)
},
cancelSleepTimer() {
this.showSleepTimerModal = false
this.clearSleepTimer()
},
clearSleepTimer() {
clearInterval(this.sleepTimer)
this.sleepTimerRemaining = 0
this.sleepTimer = null
this.sleepTimerSet = false
},
incrementSleepTimer(amount) {
if (!this.sleepTimerSet) return
this.sleepTimerRemaining += amount
},
decrementSleepTimer(amount) {
if (this.sleepTimerRemaining < amount) {
this.sleepTimerRemaining = 3
return
}
this.sleepTimerRemaining = Math.max(0, this.sleepTimerRemaining - amount)
},
playPause() {
this.playerHandler.playPause()
},
@@ -124,6 +255,7 @@ export default {
this.playerHandler.setVolume(volume)
},
setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate
this.playerHandler.setPlaybackRate(playbackRate)
},
seek(time) {
@@ -146,9 +278,8 @@ export default {
this.$refs.audioPlayer.setBufferTime(buffertime)
}
},
showBookmarks(currentTime) {
this.bookmarkAudiobookId = this.audiobookId
this.bookmarkCurrentTime = currentTime
showBookmarks() {
this.bookmarkCurrentTime = this.currentTime
this.showBookmarksModal = true
},
selectBookmark(bookmark) {
@@ -157,31 +288,116 @@ export default {
},
closePlayer() {
this.playerHandler.closePlayer()
this.$store.commit('setStreamAudiobook', null)
this.$store.commit('setMediaPlaying', null)
},
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
console.error('No Audio Ref')
mediaSessionPlay() {
console.log('Media session play')
this.playerHandler.play()
},
mediaSessionPause() {
console.log('Media session pause')
this.playerHandler.pause()
},
mediaSessionStop() {
console.log('Media session stop')
this.playerHandler.pause()
},
mediaSessionSeekBackward() {
console.log('Media session seek backward')
this.playerHandler.jumpBackward()
},
mediaSessionSeekForward() {
console.log('Media session seek forward')
this.playerHandler.jumpForward()
},
mediaSessionSeekTo(e) {
console.log('Media session seek to', e)
if (e.seekTime !== null && !isNaN(e.seekTime)) {
this.playerHandler.seek(e.seekTime)
}
},
streamOpen(stream) {
this.$store.commit('setStreamAudiobook', stream.audiobook)
this.playerHandler.prepareStream(stream)
mediaSessionPreviousTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.prevChapter()
}
},
mediaSessionNextTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.nextChapter()
}
},
updateMediaSessionPlaybackState() {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
}
},
setMediaSession() {
if (!this.streamLibraryItem) {
console.error('setMediaSession: No library item set')
return
}
if ('mediaSession' in navigator) {
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
const artwork = [
{
src: coverImageSrc
}
]
navigator.mediaSession.metadata = new MediaMetadata({
title: this.title,
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
album: this.mediaMetadata.seriesName || '',
artwork
})
console.log('Set media session metadata', navigator.mediaSession.metadata)
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
} else {
console.warn('Media session not available')
}
},
streamProgress(data) {
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
console.error('No Audio Ref')
}
}
},
sessionOpen(session) {
// For opening session on init (temporarily unused)
this.$store.commit('setMediaPlaying', {
libraryItem: session.libraryItem,
episodeId: session.episodeId
})
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
},
streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session)
},
streamClosed(streamId) {
// Stream was closed from the server
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to request from server')
this.playerHandler.closePlayer()
}
},
streamReady() {
console.log(`[STREAM-CONTAINER] Stream Ready`)
console.log(`[StreamContainer] Stream Ready`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setStreamReady()
} else {
@@ -190,7 +406,7 @@ export default {
},
streamError(streamId) {
// Stream had critical error from the server
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to stream error from server')
this.playerHandler.closePlayer()
}
@@ -199,32 +415,63 @@ export default {
this.playerHandler.resetStream(startTime, streamId)
},
castSessionActive(isActive) {
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
if (isActive && this.playerHandler.isPlayingLocalItem) {
// Cast session started switch to cast player
this.playerHandler.switchPlayer()
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
} else if (!isActive && this.playerHandler.isPlayingCastedItem) {
// Cast session ended switch to local player
this.playerHandler.switchPlayer()
}
},
async playAudiobook(audiobookId) {
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
console.error('Failed to fetch full audiobook', error)
async playLibraryItem(payload) {
const libraryItemId = payload.libraryItemId
const episodeId = payload.episodeId || null
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
if (payload.startTime !== null && !isNaN(payload.startTime)) {
this.seek(payload.startTime)
} else {
this.playerHandler.play()
}
return
}
const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to fetch full item', error)
return null
})
if (!audiobook) return
this.$store.commit('setStreamAudiobook', audiobook)
if (!libraryItem) return
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
this.$store.commit('setMediaPlaying', {
libraryItem,
episodeId,
queueItems: payload.queueItems || []
})
this.$nextTick(() => {
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
},
pauseItem() {
this.playerHandler.pause()
},
showFailedProgressSyncs() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
}
},
mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('play-audiobook', this.playAudiobook)
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('play-audiobook', this.playAudiobook)
this.$eventBus.$off('playback-seek', this.seek)
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
}
}
</script>
@@ -233,4 +480,4 @@ export default {
#streamContainer {
box-shadow: 0px -6px 8px #1111113f;
}
</style>
</style>

View File

@@ -1,28 +1,38 @@
<template>
<div>
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-lg relative">
<div class="w-full h-full overflow-hidden max-w-full max-h-full relative">
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
fill="white"
/>
<path
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
fill="white"
/>
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg>
<nuxt-link :to="`/author/${author.id}`">
<div @mouseover="mouseover" @mouseleave="mouseleave">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder -->
<covers-author-image :author="author" />
<div class="absolute bottom-0 left-0 w-full py-2 bg-black bg-opacity-25 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.85 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
<!-- Author name & num books overlay -->
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
<!-- Search icon btn -->
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
<span class="material-icons text-lg">search</span>
</ui-tooltip>
</div>
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
<span class="material-icons text-lg">edit</span>
</ui-tooltip>
</div>
<!-- Loading spinner -->
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
<widgets-loading-spinner size="" />
</div>
</div>
<div v-show="nameBelow" class="w-full py-1 px-2">
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
</div>
</div>
</div>
</nuxt-link>
</template>
<script>
@@ -34,11 +44,16 @@ export default {
},
width: Number,
height: Number,
sizeMultiplier: Number
sizeMultiplier: {
type: Number,
default: 1
},
nameBelow: Boolean
},
data() {
return {
placeholder: '/Logo.png'
searching: false,
isHovering: false
}
},
computed: {
@@ -48,30 +63,58 @@ export default {
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
name() {
return this._author.name || ''
},
image() {
return this._author.image || null
},
description() {
return this._author.description
},
lastUpdate() {
return this._author.lastUpdate
asin() {
return this._author.asin || ''
},
numBooks() {
return this._author.numBooks || 0
},
imgSrc() {
if (!this.image) return this.placeholder
var encodedImg = this.image.replace(/%/g, '%25').replace(/#/g, '%23')
var url = new URL(encodedImg, document.baseURI)
return url.href + `?token=${this.userToken}&ts=${this.lastUpdate}`
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {},
mounted() {}
methods: {
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
async searchAuthor() {
this.searching = true
const payload = {}
if (this.asin) payload.asin = this.asin
else payload.q = this.name
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
this.$toast.error(`Author ${this.name} not found`)
} else if (response.updated) {
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 ${response.author.name}`)
}
this.searching = false
},
setSearching(isSearching) {
this.searching = isSearching
}
},
mounted() {
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
},
beforeDestroy() {
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
}
}
</script>

View File

@@ -1,8 +1,10 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
<div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
<covers-author-image :author="author" />
</div>
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p>
<p class="truncate text-sm">{{ name }}</p>
</div>
</div>
</template>
@@ -10,12 +12,19 @@
<script>
export default {
props: {
author: String
author: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {},
computed: {
name() {
return this.author.name
}
},
methods: {},
mounted() {}
}

View File

@@ -1,18 +1,42 @@
<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">
<img :src="selectedCover || '/book_placeholder.jpg'" class="h-24 object-cover" :style="{ width: 96 / bookCoverAspectRatio + 'px' }" />
<div class="px-4 flex-grow">
<div class="flex items-center">
<h1>{{ book.title }}</h1>
<div class="flex-grow" />
<p>{{ book.publishYear }}</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>
</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.sequence">&nbsp;#{{ series.sequence }}</span>
</p>
</div>
</div>
<p class="text-gray-400">{{ book.author }}</p>
<div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs">{{ book.description }}</p>
</div>
</div>
<div v-else class="px-4 flex-grow">
<h1>
<div class="flex items-center">
{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" />
</div>
</h1>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
</div>
</div>
<div v-if="bookCovers.length > 1" class="flex">
<template v-for="cover in bookCovers">
@@ -31,6 +55,7 @@ export default {
type: Object,
default: () => {}
},
isPodcast: Boolean,
bookCoverAspectRatio: Number
},
data() {
@@ -47,7 +72,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
@@ -57,4 +82,4 @@ export default {
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
}
}
</script>
</script>

View File

@@ -1,112 +0,0 @@
<template>
<div class="relative">
<div class="rounded-sm h-full relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
<covers-collection-cover ref="groupcover" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
</div> -->
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
</div>
</div>
</nuxt-link>
</div>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ collectionName }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
collection: {
type: Object,
default: () => null
},
width: {
type: Number,
default: 120
},
paddingY: {
type: Number,
default: 24
}
},
data() {
return {
isHovering: false
}
},
watch: {
width(newVal) {
this.$nextTick(() => {
if (this.$refs.groupcover && this.$refs.groupcover.init) {
this.$refs.groupcover.init()
}
})
}
},
computed: {
labelFontSize() {
if (this.coverWidth < 160) return 0.75
return 0.875
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
_collection() {
return this.collection || {}
},
groupTo() {
return `/collection/${this._collection.id}`
},
coverWidth() {
return this.width * 2
},
coverHeight() {
return this.width * 1.6
},
sizeMultiplier() {
return this.width / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
bookItems() {
return this._collection.books || []
},
collectionName() {
return this._collection.name || 'No Name'
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
toggleSelected() {
// Selected
},
clickEdit() {
this.$store.commit('globals/setEditCollection', this.collection)
},
mouseoverCard() {
this.isHovering = true
},
mouseleaveCard() {
this.isHovering = false
},
clickCard() {
this.$emit('click', this.collection)
}
}
}
</script>

View File

View File

@@ -1,29 +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 :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 bottom-0 left-0 w-full h-1 flex flex-nowrap z-40">
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
</div>
<div 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>
@@ -34,11 +23,8 @@ export default {
type: Object,
default: () => null
},
width: {
type: Number,
default: 120
},
isCategorized: Boolean,
width: Number,
height: Number,
bookCoverAspectRatio: Number
},
data() {
@@ -46,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
},
@@ -73,42 +43,15 @@ export default {
return this._group.type
},
groupTo() {
if (this.groupType === 'series') {
return `/library/${this.currentLibraryId}/series/${this.groupEncode}`
} 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 || []
},
userAudiobooks() {
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
},
userProgressItems() {
return this.bookItems.map((item) => {
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
return userAudiobook || {}
})
},
groupName() {
return this._group.name || 'No Name'
},
@@ -119,21 +62,16 @@ export default {
return `${this.groupType}.${this.$encode(this.groupName)}`
},
hasValidCovers() {
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
return !!validCovers.length
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
mouseoverCard() {
this.isHovering = true
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
},
mouseleaveCard() {
this.isHovering = false
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
},
clickCard() {
this.$emit('click', this.group)

View File

@@ -1,16 +1,16 @@
<template>
<div class="flex items-center h-full px-1 overflow-hidden">
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 audiobookSearchCardContent">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div>
</div>
</template>
@@ -18,7 +18,7 @@
<script>
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
},
@@ -31,45 +31,45 @@ export default {
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
isPodcast() {
return this.mediaType == 'podcast'
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.book ? this.book.title : 'No Title'
return this.mediaMetadata.title || 'No Title'
},
subtitle() {
return this.book ? this.book.subtitle : ''
return this.mediaMetadata.subtitle || ''
},
authorFL() {
return this.book ? this.book.authorFL : 'Unknown'
authorName() {
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
return this.mediaMetadata.authorName || 'Unknown'
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
if (matchSplit.length < 2) return ''
var html = ''
var totalLenSoFar = 0
for (let i = 0; i < matchSplit.length - 1; i++) {
var indexOf = matchSplit[i].length
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
totalLenSoFar += indexOf + this.search.length
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
}
var lastPart = this.matchText.substr(totalLenSoFar)
html += lastPart
// This used to highlight the part of the search found
// but with removing commas periods etc this is no longer plausible
const html = this.matchText
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authorFL') return `by ${html}`
if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`

View File

@@ -0,0 +1,85 @@
<template>
<div class="flex items-center h-full px-1 overflow-hidden">
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
<widgets-loading-spinner v-else />
</div>
<div class="flex-grow px-2 taskRunningCardContent">
<p class="truncate text-sm">{{ title }}</p>
<p class="truncate text-xs text-gray-300">{{ description }}</p>
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
task: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
title() {
return this.task.title || 'No Title'
},
description() {
return this.task.description || ''
},
details() {
return this.task.details || 'Unknown'
},
isFinished() {
return this.task.isFinished || false
},
isFailed() {
return this.task.isFailed || false
},
failedMessage() {
return this.task.error || ''
},
action() {
return this.task.action || ''
},
actionIcon() {
switch (this.action) {
case 'download-podcast-episode':
return 'cloud_download'
case 'encode-m4b':
return 'sync'
default:
return 'settings'
}
},
taskIconStatus() {
if (this.isFinished && this.isFailed) {
return 'text-red-500'
}
if (this.isFinished && !this.isFailed) {
return 'text-green-500'
}
return ''
}
},
methods: {
},
mounted() {}
}
</script>
<style>
.taskRunningCardContent {
width: calc(100% - 80px);
height: 75px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p>
<p class="text-base text-white text-opacity-80 font-mono">#{{ item.index }}</p>
</div>
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
@@ -15,37 +15,41 @@
<div class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" />
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
<div v-else class="w-full">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
</div>
</div>
</div>
<div class="flex my-2 -mx-2">
<div v-if="!isPodcast" class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" label="Series" note="(optional)" />
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" />
</div>
<div class="w-1/2 px-2">
<div class="w-full">
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
</div>
</div>
</div>
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
<tables-uploaded-files-table :files="item.itemFiles" :title="$strings.HeaderItemFiles" class="mt-8" />
<tables-uploaded-files-table v-if="item.otherFiles.length" :title="$strings.HeaderOtherFiles" :files="item.otherFiles" />
<tables-uploaded-files-table v-if="item.ignoredFiles.length" :title="$strings.HeaderIgnoredFiles" :files="item.ignoredFiles" />
</template>
<widgets-alert v-if="uploadSuccess" type="success">
<p class="text-base">Successfully Uploaded!</p>
<p class="text-base">{{ $strings.MessageUploaderItemSuccess }}</p>
</widgets-alert>
<widgets-alert v-if="uploadFailed" type="error">
<p class="text-base">Failed to upload</p>
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
</widgets-alert>
<div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
<ui-loading-indicator text="Uploading..." />
<ui-loading-indicator :text="$strings.MessageUploading" />
</div>
</div>
</template>
@@ -55,15 +59,16 @@ import Path from 'path'
export default {
props: {
book: {
item: {
type: Object,
default: () => {}
},
mediaType: String,
processing: Boolean
},
data() {
return {
bookData: {
itemData: {
title: '',
author: '',
series: ''
@@ -75,14 +80,19 @@ export default {
}
},
computed: {
isPodcast() {
return this.mediaType === 'podcast'
},
directory() {
if (!this.bookData.title) return ''
if (this.bookData.series && this.bookData.author) {
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
} else if (this.bookData.author) {
return Path.join(this.bookData.author, this.bookData.title)
if (!this.itemData.title) return ''
if (this.isPodcast) return this.itemData.title
if (this.itemData.series && this.itemData.author) {
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
} else if (this.itemData.author) {
return Path.join(this.itemData.author, this.itemData.title)
} else {
return this.bookData.title
return this.itemData.title
}
}
},
@@ -96,24 +106,24 @@ export default {
this.error = ''
},
getData() {
if (!this.bookData.title) {
if (!this.itemData.title) {
this.error = 'Must have a title'
return null
}
this.error = ''
var files = this.book.bookFiles.concat(this.book.otherFiles)
var files = this.item.itemFiles.concat(this.item.otherFiles)
return {
index: this.book.index,
...this.bookData,
index: this.item.index,
...this.itemData,
files
}
}
},
mounted() {
if (this.book) {
this.bookData.title = this.book.title
this.bookData.author = this.book.author
this.bookData.series = this.book.series
if (this.item) {
this.itemData.title = this.item.title
this.itemData.author = this.item.author
this.itemData.series = this.item.series
}
}
}

View File

@@ -0,0 +1,114 @@
<template>
<div ref="card" :id="`album-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 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">
<covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ artist || '&nbsp;' }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
albumMount: {
type: Object,
default: () => null
}
},
data() {
return {
album: null,
isSelectionMode: false,
selected: false,
isHovering: false
}
},
computed: {
coverSrc() {
const config = this.$config || this.$nuxt.$config
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
},
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
const baseSize = this.bookCoverAspectRatio === 1 ? 192 : 120
return this.width / baseSize
},
title() {
return this.album ? this.album.title : ''
},
artist() {
return this.album ? this.album.artist : ''
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
}
},
methods: {
setEntity(album) {
this.album = album
},
setSelectionMode(val) {
this.isSelectionMode = val
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickCard() {
if (!this.album) return
// const router = this.$router || this.$nuxt.$router
// router.push(`/album/${this.$encode(this.title)}`)
},
clickEdit() {
this.$emit('edit', this.album)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.albumMount) {
this.setEntity(this.albumMount)
}
}
}
</script>

View File

@@ -5,39 +5,53 @@
<div class="absolute cover-bg" ref="coverBg" />
</div>
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<span v-if="volumeNumber">#{{ volumeNumber }}&nbsp;</span>{{ displayTitle }}
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<div class="flex items-center">
<span class="truncate">{{ displayTitle }}</span>
<widgets-explicit-indicator :explicit="isExplicit" />
</div>
</div>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
</div>
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
</div>
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Cover Image -->
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
{{ titleCleaned }}
</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
<!-- No progress shown for collapsed series in library -->
<div v-if="!booksInSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- 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 && audiobook && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !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>
@@ -50,7 +64,7 @@
</div>
</div>
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
</div>
@@ -58,22 +72,48 @@
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
</div>
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<!-- More Menu Icon -->
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
</div>
<div v-if="booksInSeries && audiobook && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
<!-- 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>
</div>
<!-- Error widget -->
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
</ui-tooltip>
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
<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 !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
</p>
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div>
</div>
</template>
@@ -94,7 +134,6 @@ export default {
default: 192
},
bookCoverAspectRatio: Number,
showVolumeNumber: Boolean,
bookshelfView: Number,
bookMount: {
// Book can be passed as prop or set with setEntity()
@@ -103,16 +142,16 @@ export default {
},
orderBy: String,
filterBy: String,
sortingIgnorePrefix: Boolean
sortingIgnorePrefix: Boolean,
continueListeningShelf: Boolean
},
data() {
return {
isHovering: false,
isMoreMenuOpen: false,
isProcessingReadUpdate: false,
audiobook: null,
processing: false,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false
@@ -122,144 +161,240 @@ export default {
bookMount: {
handler(newVal) {
if (newVal) {
this.audiobook = newVal
this.libraryItem = newVal
}
}
}
},
computed: {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_audiobook() {
return this.audiobook || {}
enableEReader() {
return this.store.getters['getServerSetting']('enableEReader')
},
book() {
return this._audiobook.book || {}
_libraryItem() {
return this.libraryItem || {}
},
isFile() {
// Library item is not in a folder
return this._libraryItem.isFile
},
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this._libraryItem.mediaType
},
isPodcast() {
return this.mediaType === 'podcast'
},
isMusic() {
return this.mediaType === 'music'
},
isExplicit() {
return this.mediaMetadata.explicit || false
},
placeholderUrl() {
return '/book_placeholder.jpg'
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
},
bookCoverSrc() {
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
},
audiobookId() {
return this._audiobook.id
libraryItemId() {
return this._libraryItem.id
},
series() {
return this.book.series
// Only included when filtering by series or collapse series or Continue Series shelf on home page
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
},
libraryId() {
return this._audiobook.libraryId
return this._libraryItem.libraryId
},
hasEbook() {
return this._audiobook.numEbooks
return this.media.ebookFormat
},
hasTracks() {
return this._audiobook.numTracks
numTracks() {
if (this.media.tracks) return this.media.tracks.length
return this.media.numTracks || 0 // toJSONMinified
},
numEpisodes() {
if (!this.isPodcast) return 0
return this.media.numEpisodes || 0
},
processingBatch() {
return this.store.state.processingBatch
},
recentEpisode() {
// Only added to item when getting currently listening podcasts
return this._libraryItem.recentEpisode
},
recentEpisodeNumber() {
if (!this.recentEpisode) return null
if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '')
}
return ''
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
return this._libraryItem.collapsedSeries
},
booksInSeries() {
// Only added to audiobook object when collapseSeries is enabled
return this._audiobook.booksInSeries
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
},
seriesSequenceList() {
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
},
libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
const baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
title() {
return this.book.title || ''
return this.mediaMetadata.title || ''
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
if (this.isPodcast) return this.mediaMetadata.author
return this.mediaMetadata.authorName
},
authorLF() {
return this.book.authorLF || this.author
return this.mediaMetadata.authorNameLF
},
volumeNumber() {
return this.book.volumeNumber || null
artist() {
const artists = this.mediaMetadata.artists || []
return artists.join(', ')
},
displayTitle() {
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
if (this.recentEpisode) return this.recentEpisode.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
},
displayAuthor() {
if (this.orderBy === 'book.authorLF') return this.authorLF
return this.authorFL
displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author
if (this.isMusic) return this.artist
if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || ''
}
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author
},
displaySortLine() {
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size)
if (this.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)
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
return null
},
episodeProgress() {
// Only used on home page currently listening podcast shelf
if (!this.recentEpisode) return null
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() {
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
if (this.isMusic) return null
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
},
seriesIsFinished() {
return !this.libraryItemIdsInSeries.some((lid) => {
const progress = this.store.getters['user/getUserMediaProgress'](lid)
return !progress || !progress.isFinished
})
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
if (this.recentEpisode) return false // Dont show podcast error on episode card
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
},
libraryItemIdStreaming() {
return this.store.getters['getLibraryItemIdStreaming']
},
isStreaming() {
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
return this.libraryItemIdStreaming === this.libraryItemId
},
isQueued() {
const episodeId = this.recentEpisode ? this.recentEpisode.id : null
return this.store.getters['getIsMediaQueued'](this.libraryItemId, episodeId)
},
isStreamingFromDifferentLibrary() {
return this.store.getters['getIsStreamingFromDifferentLibrary']
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.hasTracks && !this.isStreaming
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
},
isMissing() {
return this._audiobook.isMissing
return this._libraryItem.isMissing
},
isInvalid() {
return this._audiobook.isInvalid
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._audiobook.hasMissingParts
numMissingParts() {
if (this.isPodcast) return 0
return this.media.numMissingParts
},
hasInvalidParts() {
return this._audiobook.hasInvalidParts
numInvalidAudioFiles() {
if (this.isPodcast) return 0
return this.media.numInvalidAudioFiles
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
else if (this.isInvalid) return 'Audiobook has no audio tracks & ebook'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) {
if (this.isPodcast) return 'Podcast has no episodes'
return 'Item has no audio tracks & ebook'
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
let txt = ''
if (this.numMissingParts) {
txt += `${this.numMissingParts} missing parts.`
}
if (this.numInvalidAudioFiles) {
if (txt) txt += ' '
txt += `${this.numInvalidAudioFiles} invalid audio files.`
}
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
const classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
@@ -279,44 +414,113 @@ export default {
userCanDownload() {
return this.store.getters['user/getUserCanDownload']
},
userIsRoot() {
return this.store.getters['user/getIsRoot']
userIsAdminOrUp() {
return this.store.getters['user/getIsAdminOrUp']
},
moreMenuItems() {
var items = [
{
func: 'toggleRead',
text: `Mark as ${this.userIsRead ? 'Not Read' : 'Read'}`
},
{
func: 'openCollections',
text: 'Add to Collection'
}
]
if (this.userCanUpdate) {
if (this.hasTracks) {
if (this.isMusic) return []
if (this.recentEpisode) {
const items = [
{
func: 'editPodcast',
text: this.$strings.ButtonEditPodcast
},
{
func: 'toggleFinished',
text: this.itemIsFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished
},
{
func: 'openPlaylists',
text: this.$strings.LabelAddToPlaylist
}
]
if (this.continueListeningShelf) {
items.push({
func: 'showEditModalTracks',
text: 'Tracks'
func: 'removeFromContinueListening',
text: this.$strings.ButtonRemoveFromContinueListening
})
}
if (this.libraryItemIdStreaming && !this.isStreamingFromDifferentLibrary) {
if (!this.isQueued) {
items.push({
func: 'addToQueue',
text: this.$strings.ButtonQueueAddItem
})
} else if (!this.isStreaming) {
items.push({
func: 'removeFromQueue',
text: this.$strings.ButtonQueueRemoveItem
})
}
}
return items
}
let items = []
if (!this.isPodcast) {
items = [
{
func: 'toggleFinished',
text: this.itemIsFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished
}
]
if (this.userCanUpdate) {
items.push({
func: 'openCollections',
text: this.$strings.LabelAddToCollection
})
}
if (this.numTracks) {
items.push({
func: 'openPlaylists',
text: this.$strings.LabelAddToPlaylist
})
}
}
if (this.userCanUpdate) {
items.push({
func: 'showEditModalFiles',
text: this.$strings.HeaderFiles
})
items.push({
func: 'showEditModalMatch',
text: 'Match'
text: this.$strings.HeaderMatch
})
}
if (this.userCanDownload) {
items.push({
func: 'showEditModalDownload',
text: 'Download'
})
}
if (this.userIsRoot) {
if (this.userIsAdminOrUp && !this.isFile) {
items.push({
func: 'rescan',
text: 'Re-Scan'
text: this.$strings.ButtonReScan
})
}
if (this.series && this.bookMount) {
items.push({
func: 'removeSeriesFromContinueListening',
text: this.$strings.ButtonRemoveSeriesFromContinueSeries
})
}
if (this.continueListeningShelf) {
items.push({
func: 'removeFromContinueListening',
text: this.$strings.ButtonRemoveFromContinueListening
})
}
if (!this.isPodcast) {
if (this.libraryItemIdStreaming && !this.isStreamingFromDifferentLibrary) {
if (!this.isQueued) {
items.push({
func: 'addToQueue',
text: this.$strings.ButtonQueueAddItem
})
} else if (!this.isStreaming) {
items.push({
func: 'removeFromQueue',
text: this.$strings.ButtonQueueRemoveItem
})
}
}
}
return items
},
_socket() {
@@ -342,20 +546,28 @@ export default {
return this.title
},
authorCleaned() {
if (!this.authorFL) return ''
if (this.authorFL.length > 30) {
return this.authorFL.slice(0, 27) + '...'
if (!this.author) return ''
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.authorFL
return this.author
},
isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.TITLES
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.DETAIL
},
isAuthorBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.AUTHOR
},
titleDisplayBottomOffset() {
if (!this.isAlternativeBookshelfView) return 0
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._libraryItem.rssFeed || null
}
},
methods: {
@@ -363,78 +575,202 @@ export default {
this.isSelectionMode = val
if (!val) this.selected = false
},
setEntity(audiobook) {
this.audiobook = audiobook
setEntity(_libraryItem) {
var libraryItem = _libraryItem
// this code block is only necessary when showing a selected series with sequence #
// it will update the selected series so we get realtime updates for series sequence changes
if (this.series) {
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
libraryItem = {
..._libraryItem,
media: {
..._libraryItem.media,
metadata: {
..._libraryItem.media.metadata
}
}
}
var mediaMetadata = libraryItem.media.metadata
if (mediaMetadata.series && Array.isArray(mediaMetadata.series)) {
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
if (newSeries) {
// update selected series
libraryItem.media.metadata.series = newSeries
this.libraryItem = libraryItem
return
}
}
}
this.libraryItem = libraryItem
},
clickCard(e) {
if (this.processing) return
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
this.selectBtnClick(e)
} else {
var router = this.$router || this.$nuxt.$router
if (router) {
if (this.booksInSeries) router.push(`/library/${this.libraryId}/series/${this.$encode(this.series)}`)
else router.push(`/audiobook/${this.audiobookId}`)
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
else router.push(`/item/${this.libraryItemId}`)
}
}
},
editClick() {
this.$emit('edit', this.audiobook)
},
toggleRead() {
// More menu func
var updatePayload = {
isRead: !this.userIsRead
if (this.recentEpisode) {
return this.$emit('edit', { libraryItem: this.libraryItem, episode: this.recentEpisode })
}
this.isProcessingReadUpdate = true
this.$emit('edit', this.libraryItem)
},
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.processing = true
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
.$patch(apiEndpoint, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
this.processing = false
toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
this.processing = false
toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
},
audiobookScanComplete(result) {
this.rescanning = false
var toast = this.$toast || this.$nuxt.$toast
if (!result) {
toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
toast.success(`Re-Scan complete audiobook was updated`)
} else if (result === 'UPTODATE') {
toast.success(`Re-Scan complete audiobook was up to date`)
} else if (result === 'REMOVED') {
toast.error(`Re-Scan complete audiobook was removed`)
}
editPodcast() {
this.$emit('editPodcast', this.libraryItem)
},
rescan() {
this.rescanning = true
this._socket.once('audiobook_scan_complete', this.audiobookScanComplete)
this._socket.emit('scan_audiobook', this.audiobookId)
if (this.processing) return
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
.$post(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
})
.finally(() => {
this.processing = false
})
},
showEditModalTracks() {
showEditModalFiles() {
// More menu func
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'tracks' })
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
},
showEditModalMatch() {
// More menu func
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'match' })
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
showEditModalDownload() {
// More menu func
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
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
})
},
addToQueue() {
var queueItem = {}
if (this.recentEpisode) {
queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: this.recentEpisode.id,
title: this.recentEpisode.title,
subtitle: this.mediaMetadata.title,
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: this.recentEpisode.audioFile.duration || null,
coverPath: this.media.coverPath || null
}
} else {
queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: null,
title: this.title,
subtitle: this.author,
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
}
}
this.store.commit('addItemToQueue', queueItem)
},
removeFromQueue() {
const episodeId = this.recentEpisode ? this.recentEpisode.id : null
this.store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId })
},
openCollections() {
this.store.commit('setSelectedAudiobook', this.audiobook)
this.store.commit('globals/setShowUserCollectionsModal', true)
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowCollectionsModal', true)
},
openPlaylists() {
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
this.store.commit('globals/setShowPlaylistsModal', true)
},
createMoreMenu() {
if (!this.$refs.moreIcon) return
@@ -486,17 +822,77 @@ export default {
clickShowMore() {
this.createMoreMenu()
},
clickReadEBook() {
this.store.commit('showEReader', this.audiobook)
async clickReadEBook() {
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to get lirbary item', this.libraryItemId)
return null
})
if (!libraryItem) return
this.store.commit('showEReader', libraryItem)
},
selectBtnClick() {
selectBtnClick(evt) {
if (this.processingBatch) return
this.selected = !this.selected
this.$emit('select', this.audiobook)
this.$emit('select', { entity: this.libraryItem, shiftKey: evt.shiftKey })
},
play() {
async play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-audiobook', this.audiobookId)
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,
libraryId: this.libraryId,
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})
}
}
}
}
} else {
const queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: null,
title: this.title,
subtitle: this.author,
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
}
queueItems.push(queueItem)
}
eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.recentEpisode ? this.recentEpisode.id : null,
queueItems
})
},
mouseover() {
this.isHovering = true

View File

@@ -4,21 +4,22 @@
<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 class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
</div> -->
<div 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>
</div>
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
</div> -->
<div 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' }">
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
</div>
</div>
</template>
@@ -28,7 +29,16 @@ export default {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number
bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
collectionMount: {
type: Object,
default: () => null
},
isTag: Boolean
},
data() {
return {
@@ -58,6 +68,16 @@ export default {
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
},
rssFeed() {
return this.collection ? this.collection.rssFeed : null
}
},
methods: {
@@ -93,6 +113,10 @@ export default {
}
}
},
mounted() {}
mounted() {
if (this.collectionMount) {
this.setEntity(this.collectionMount)
}
}
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 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">
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
</div>
<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>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
playlistMount: {
type: Object,
default: () => null
}
},
data() {
return {
playlist: null,
isSelectionMode: false,
selected: false,
isHovering: false
}
},
computed: {
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6)
return this.width / 120
},
title() {
return this.playlist ? this.playlist.name : ''
},
items() {
return this.playlist ? this.playlist.items || [] : []
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
}
},
methods: {
setEntity(playlist) {
this.playlist = playlist
},
setSelectionMode(val) {
this.isSelectionMode = val
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickCard() {
if (!this.playlist) return
var router = this.$router || this.$nuxt.$router
router.push(`/playlist/${this.playlist.id}`)
},
clickEdit() {
this.$emit('edit', this.playlist)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.playlistMount) {
this.setEntity(this.playlistMount)
}
}
}
</script>

View File

@@ -2,21 +2,28 @@
<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 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>
<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 :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</div>
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
</div> -->
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<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 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
</div>
</template>
@@ -27,11 +34,17 @@ export default {
width: Number,
height: Number,
bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
isCategorized: Boolean,
seriesMount: {
type: Object,
default: () => null
}
},
sortingIgnorePrefix: Boolean,
orderBy: String
},
data() {
return {
@@ -43,6 +56,9 @@ export default {
}
},
computed: {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
@@ -51,12 +67,50 @@ export default {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
seriesId() {
return this.series ? this.series.id : ''
},
title() {
return this.series ? this.series.name : ''
},
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) => {
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
})
.filter((p) => !!p)
},
seriesBooksFinished() {
return this.seriesBookProgress.filter((p) => p.isFinished)
},
isSeriesFinished() {
return this.books.length === this.seriesBooksFinished.length
},
store() {
return this.$store || this.$nuxt.$store
},
@@ -64,14 +118,18 @@ export default {
return this.store.state.libraries.currentLibraryId
},
seriesBooksRoute() {
return `/library/${this.currentLibraryId}/series/${this.$encode(this.title)}`
},
seriesId() {
return this.series ? this.$encode(this.title) : null
return `/library/${this.currentLibraryId}/series/${this.seriesId}`
},
hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
return !!validCovers.length
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
},
rssFeed() {
return this.series ? this.series.rssFeed : null
}
},
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

@@ -0,0 +1,71 @@
<template>
<div ref="wrapper" class="w-full p-2 border border-white border-opacity-10 rounded">
<div class="flex">
<div class="w-16 min-w-16">
<div class="w-full h-16 bg-primary">
<img v-if="image" :src="image" class="w-full h-full object-cover" />
</div>
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} {{ $strings.HeaderEpisodes }}</p>
</div>
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
<p class="mb-1">{{ title }}</p>
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
<p class="text-xs truncate text-blue-200">
{{ $strings.LabelFolder }}: <span class="font-mono">{{ folderPath }}</span>
</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
feed: {
type: Object,
default: () => {}
},
libraryFolderPath: String
},
data() {
return {
width: 900
}
},
computed: {
title() {
return this.metadata.title || 'No Title'
},
image() {
return this.metadata.imageUrl
},
description() {
return this.metadata.description || ''
},
author() {
return this.metadata.author || ''
},
metadata() {
return this.feed || {}
},
numEpisodes() {
return this.feed.numEpisodes || 0
},
folderPath() {
if (!this.libraryFolderPath) return ''
return `${this.libraryFolderPath}/${this.$sanitizeFilename(this.title)}`
},
detailsWidth() {
return this.width - 85
}
},
methods: {},
updated() {
this.width = this.$refs.wrapper.clientWidth
},
mounted() {
this.width = this.$refs.wrapper.clientWidth
}
}
</script>

View File

@@ -1,73 +0,0 @@
<template>
<div>
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<!-- <div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div> -->
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
</div>
</form>
</div>
</template>
<script>
export default {
props: {
authorName: String
},
data() {
return {
searchAuthor: null,
lastSearch: null,
isProcessing: false,
provider: 'audnexus',
providers: [
{
text: 'Audnexus',
value: 'audnexus'
}
]
}
},
watch: {
authorName: {
immediate: true,
handler(newVal) {
this.searchAuthor = newVal
}
}
},
computed: {},
methods: {
getSearchQuery() {
return `q=${this.searchAuthor}`
},
submitSearch() {
if (!this.searchAuthor) {
this.$toast.warning('Author name is required')
return
}
this.runSearch()
},
async runSearch() {
var searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.isProcessing = true
this.lastSearch = searchQuery
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
this.isProcessing = false
if (result) {
this.$emit('match', result)
}
}
},
mounted() {}
}
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<covers-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-group-cover :name="name" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 seriesSearchCardContent h-full">
<p class="truncate text-sm">{{ series }}</p>
<p class="truncate text-sm">{{ name }}</p>
</div>
</div>
</template>
@@ -10,7 +10,10 @@
<script>
export default {
props: {
series: String,
series: {
type: Object,
default: () => {}
},
bookItems: {
type: Array,
default: () => []
@@ -21,7 +24,10 @@ export default {
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
name() {
return this.series.name
}
},
methods: {},

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">
<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,66 +31,15 @@
<script>
export default {
props: {
value: String
value: String,
items: {
type: Array,
default: () => []
}
},
data() {
return {
showMenu: false,
sublist: null,
items: [
{
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: '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: {
@@ -132,47 +51,10 @@ export default {
this.$emit('input', val)
}
},
selectedItemSublist() {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
},
selectedText() {
if (!this.selected) return ''
var parts = this.selected.split('.')
if (parts.length > 1) {
return this.$decode(parts[1])
}
var _sel = this.items.find((i) => i.value === this.selected)
if (!_sel) return ''
return _sel.text
},
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 ['Read', 'Unread', 'In Progress']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
}
})
const filter = this.items.find((i) => i.value === this.selected)
return filter ? filter.text : ''
},
filterData() {
return this.$store.state.libraries.filterData || {}
@@ -185,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,56 +1,65 @@
<template>
<div class="w-80 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" />
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
</div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2">
<p>Thinking...</p>
<p>{{ $strings.MessageThinking }}</p>
</li>
<li v-else-if="isFetching" class="py-2 px-2">
<p>Fetching...</p>
<p>{{ $strings.MessageFetching }}</p>
</li>
<li v-else-if="!totalResults" class="py-2 px-2">
<p>No Results</p>
<p>{{ $strings.MessageNoResults }}</p>
</li>
<template v-else>
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in audiobookResults">
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelBooks }}</p>
<template v-for="item in bookResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
</li>
</template>
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelPodcasts }}</p>
<template v-for="item in podcastResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
</li>
</template>
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
<template v-for="item in authorResults">
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`">
<cards-author-search-card :author="item.author" />
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
<cards-author-search-card :author="item" />
</nuxt-link>
</li>
</template>
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelSeries }}</p>
<template v-for="item in seriesResults">
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/series/${$encode(item.series)}`">
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
<cards-series-search-card :series="item.series" :book-items="item.books" />
</nuxt-link>
</li>
</template>
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelTags }}</p>
<template v-for="item in tagResults">
<li :key="item.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.tag)}`">
<cards-tag-search-card :tag="item.tag" />
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
<cards-tag-search-card :tag="item.name" />
</nuxt-link>
</li>
</template>
@@ -70,7 +79,8 @@ export default {
isTyping: false,
isFetching: false,
search: null,
audiobookResults: [],
podcastResults: [],
bookResults: [],
authorResults: [],
seriesResults: [],
tagResults: [],
@@ -83,10 +93,13 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
totalResults() {
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
}
},
methods: {
clickOption() {
this.clearResults()
},
submitSearch() {
if (!this.search) return
var search = this.search
@@ -96,7 +109,8 @@ export default {
clearResults() {
this.search = null
this.lastSearch = null
this.audiobookResults = []
this.podcastResults = []
this.bookResults = []
this.authorResults = []
this.seriesResults = []
this.tagResults = []
@@ -136,7 +150,8 @@ export default {
// Search was canceled
if (!this.isFetching) return
this.audiobookResults = searchResults.audiobooks || []
this.podcastResults = searchResults.podcast || []
this.bookResults = searchResults.book || []
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || []

View File

@@ -0,0 +1,437 @@
<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 text-2xl">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 text-2xl">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">{{ $strings.MessageNoSeries }}</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<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
}
},
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)
}
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() {
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
seriesItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.LabelAuthor,
value: 'authors',
sublist: true
},
{
text: this.$strings.LabelNarrator,
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
sublist: true
},
{
text: this.$strings.LabelSeriesProgress,
value: 'progress',
sublist: true
}
]
},
bookItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.LabelSeries,
value: 'series',
sublist: true
},
{
text: this.$strings.LabelAuthor,
value: 'authors',
sublist: true
},
{
text: this.$strings.LabelNarrator,
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
sublist: true
},
{
text: this.$strings.LabelProgress,
value: 'progress',
sublist: true
},
{
text: this.$strings.LabelMissing,
value: 'missing',
sublist: true
},
{
text: this.$strings.LabelTracks,
value: 'tracks',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
},
{
text: this.$strings.LabelRSSFeedOpen,
value: 'feed-open',
sublist: false
}
]
},
podcastItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
}
]
},
musicItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
}
]
},
selectItems() {
if (this.isSeries) return this.seriesItems
if (this.isPodcast) return this.podcastItems
if (this.isMusic) return this.musicItems
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 [
{
id: 'finished',
name: this.$strings.LabelFinished
},
{
id: 'in-progress',
name: this.$strings.LabelInProgress
},
{
id: 'not-started',
name: this.$strings.LabelNotStarted
},
{
id: 'not-finished',
name: this.$strings.LabelNotFinished
}
]
},
tracks() {
return [
{
id: 'single',
name: this.$strings.LabelTracksSingleTrack
},
{
id: 'multi',
name: this.$strings.LabelTracksMultiTrack
}
]
},
missing() {
return [
{
id: 'asin',
name: 'ASIN'
},
{
id: 'isbn',
name: 'ISBN'
},
{
id: 'subtitle',
name: this.$strings.LabelSubtitle
},
{
id: 'authors',
name: this.$strings.LabelAuthor
},
{
id: 'publishedYear',
name: this.$strings.LabelPublishYear
},
{
id: 'series',
name: this.$strings.LabelSeries
},
{
id: 'description',
name: this.$strings.LabelDescription
},
{
id: 'genres',
name: this.$strings.LabelGenres
},
{
id: 'tags',
name: this.$strings.LabelTags
},
{
id: 'narrators',
name: this.$strings.LabelNarrator
},
{
id: 'publisher',
name: this.$strings.LabelPublisher
},
{
id: 'language',
name: this.$strings.LabelLanguage
},
{
id: 'cover',
name: this.$strings.LabelCover
}
]
},
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

@@ -0,0 +1,218 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</li>
</template>
</ul>
</div>
</template>
<script>
export default {
props: {
value: String,
descending: Boolean
},
data() {
return {
showMenu: false
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedDesc: {
get() {
return this.descending
},
set(val) {
this.$emit('update:descending', val)
}
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() {
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
podcastItems() {
return [
{
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: this.$strings.LabelAuthor,
value: 'media.metadata.author'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelSize,
value: 'size'
},
{
text: this.$strings.LabelNumberOfEpisodes,
value: 'media.numTracks'
},
{
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
},
bookItems() {
return [
{
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: this.$strings.LabelAuthorFirstLast,
value: 'media.metadata.authorName'
},
{
text: this.$strings.LabelAuthorLastFirst,
value: 'media.metadata.authorNameLF'
},
{
text: this.$strings.LabelPublishYear,
value: 'media.metadata.publishedYear'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelSize,
value: 'size'
},
{
text: this.$strings.LabelDuration,
value: 'media.duration'
},
{
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
},
seriesItems() {
return [
...this.bookItems,
{
text: this.$strings.LabelSequence,
value: 'sequence'
}
]
},
musicItems() {
return [
{
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelSize,
value: 'size'
},
{
text: this.$strings.LabelDuration,
value: 'media.duration'
},
{
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
},
selectItems() {
let items = null
if (this.isPodcast) {
items = this.podcastItems
} else if (this.isMusic) {
items = this.musicItems
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
items = this.seriesItems
} else {
items = this.bookItems
}
if (!items.some((i) => i.value === this.selected)) {
this.selected = items[0].value
this.selectedDesc = !this.defaultsToAsc(items[0].value)
}
return items
},
selectedText() {
var _selected = this.selected
if (!_selected) return ''
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
var _sel = this.selectItems.find((i) => i.value === _selected)
if (!_sel) return ''
return _sel.text
}
},
methods: {
clickOutside() {
this.showMenu = false
},
clickedOption(val) {
if (this.selected === val) {
this.selectedDesc = !this.selectedDesc
} else {
this.selected = val
if (this.defaultsToAsc(val)) this.selectedDesc = false
}
this.showMenu = false
this.$nextTick(() => this.$emit('change', val))
},
defaultsToAsc(val) {
return val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF' || val == 'sequence'
}
}
}
</script>

View File

@@ -1,17 +1,17 @@
<template>
<div class="relative ml-8" v-click-outside="clickOutside">
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
<span class="font-mono uppercase text-gray-200">{{ playbackRate.toFixed(1) }}<span class="text-lg"></span></span>
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
</div>
<div v-show="showMenu" class="absolute -top-20 left-0 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" style="left: -92px">
<div class="absolute -bottom-2 left-0 right-0 w-full flex justify-center">
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
<div class="arrow-down" />
</div>
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
<template v-for="rate in rates">
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
<div class="w-full h-full flex justify-center items-center">
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm"></span></p>
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">x</span></p>
</div>
</div>
</template>
@@ -19,7 +19,7 @@
<div class="w-full py-1 px-4">
<div class="flex items-center justify-between">
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
<p class="px-2 text-3xl">{{ playbackRate }}<span class="text-2xl"></span></p>
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
</div>
</div>
@@ -40,7 +40,9 @@ export default {
showMenu: false,
currentPlaybackRate: 0,
MIN_SPEED: 0.5,
MAX_SPEED: 3
MAX_SPEED: 10,
menuLeft: -92,
arrowLeft: 0
}
},
computed: {
@@ -80,8 +82,22 @@ export default {
var newPlaybackRate = this.playbackRate - 0.1
this.playbackRate = Number(newPlaybackRate.toFixed(1))
},
updateMenuPositions() {
if (!this.$refs.wrapper) return
const boundingBox = this.$refs.wrapper.getBoundingClientRect()
if (boundingBox.left + 110 > window.innerWidth - 10) {
this.menuLeft = window.innerWidth - 230 - boundingBox.left
this.arrowLeft = Math.abs(this.menuLeft) - 92
} else {
this.menuLeft = -92
this.arrowLeft = 0
}
},
setShowMenu(val) {
if (val) {
this.updateMenuPositions()
this.currentPlaybackRate = this.playbackRate
} else if (this.currentPlaybackRate !== this.playbackRate) {
this.$emit('change', this.playbackRate)

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,41 +26,15 @@
export default {
props: {
value: String,
descending: Boolean
descending: Boolean,
items: {
type: Array,
default: () => []
}
},
data() {
return {
showMenu: false,
items: [
{
text: 'Title',
value: 'book.title'
},
{
text: 'Author (First Last)',
value: 'book.authorFL'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
},
{
text: 'Added At',
value: 'addedAt'
},
{
text: 'Volume #',
value: 'book.volumeNumber'
},
{
text: 'Duration',
value: 'duration'
},
{
text: 'Size',
value: 'size'
}
]
showMenu: false
}
},
computed: {
@@ -81,7 +55,8 @@ export default {
}
},
selectedText() {
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
var _selected = this.selected
if (!_selected) return ''
var _sel = this.items.find((i) => i.value === _selected)
if (!_sel) return ''
return _sel.text

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<span class="material-icons text-3xl">{{ volumeIcon }}</span>
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
</div>
<transition name="menux">
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
@@ -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

@@ -0,0 +1,87 @@
<template>
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
fill="white"
/>
<path
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
fill="white"
/>
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg>
<div v-else class="w-full h-full relative">
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
</div>
</div>
</template>
<script>
export default {
props: {
author: {
type: Object,
default: () => {}
},
rounded: {
type: String,
default: 'lg'
}
},
data() {
return {
showCoverBg: false,
coverContain: true
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
imagePath() {
return this._author.imagePath
},
updatedAt() {
return this._author.updatedAt
},
imgSrc() {
if (!this.imagePath) return null
if (process.env.NODE_ENV !== 'production') {
// Testing
return `http://localhost:3333${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}`
}
},
methods: {
imageLoaded() {
var aspectRatio = 1.25
if (this.$refs.wrapper) {
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
}
if (this.$refs.img) {
var { naturalWidth, naturalHeight } = this.$refs.img
var imgAr = naturalHeight / naturalWidth
var arDiff = Math.abs(imgAr - aspectRatio)
if (arDiff > 0.15) {
this.showCoverBg = true
} else {
this.showCoverBg = false
this.coverContain = false
}
}
}
},
mounted() {}
}
</script>

View File

@@ -5,20 +5,11 @@
<div class="absolute cover-bg" ref="coverBg" />
</div>
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2">
<div class="la-ball-spin-clockwise la-sm">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<widgets-loading-spinner />
</div>
</div>
</div>
@@ -26,17 +17,17 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
<p class="text-center text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
</template>
@@ -44,11 +35,10 @@
<script>
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
@@ -75,12 +65,15 @@ export default {
height() {
return this.width * this.bookCoverAspectRatio
},
book() {
if (!this.audiobook) return {}
return this.audiobook.book || {}
media() {
if (!this.libraryItem) return {}
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.book.title || 'No Title'
return this.mediaMetadata.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 60) {
@@ -88,9 +81,11 @@ export default {
}
return this.title
},
authors() {
return this.mediaMetadata.authors || []
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
return this.authors.map((au) => au.name).join(', ')
},
authorCleaned() {
if (this.author.length > 30) {
@@ -99,18 +94,19 @@ 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.audiobook) return null
if (!this.libraryItem) return null
var store = this.$store || this.$nuxt.$store
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
},
cover() {
return this.book.cover || this.placeholderUrl
return this.media.coverPath || this.placeholderUrl
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
@@ -130,6 +126,9 @@ export default {
},
userToken() {
return this.$store.getters['user/getToken']
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
}
},
methods: {
@@ -138,12 +137,12 @@ export default {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
}
},
hideCoverBg() {},
imageLoaded() {
this.loading = false
this.$nextTick(() => {
this.imageReady = true
})
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
@@ -168,214 +167,3 @@ export default {
}
</script>
<style>
/*!
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
* Licensed under MIT
*/
.la-ball-spin-clockwise,
.la-ball-spin-clockwise > div {
position: relative;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.la-ball-spin-clockwise {
display: block;
font-size: 0;
color: #fff;
}
.la-ball-spin-clockwise.la-dark {
color: #262626;
}
.la-ball-spin-clockwise > div {
display: inline-block;
float: none;
background-color: currentColor;
border: 0 solid currentColor;
}
.la-ball-spin-clockwise {
width: 32px;
height: 32px;
}
.la-ball-spin-clockwise > div {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
border-radius: 100%;
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
animation: ball-spin-clockwise 1s infinite ease-in-out;
}
.la-ball-spin-clockwise > div:nth-child(1) {
top: 5%;
left: 50%;
-webkit-animation-delay: -0.875s;
-moz-animation-delay: -0.875s;
-o-animation-delay: -0.875s;
animation-delay: -0.875s;
}
.la-ball-spin-clockwise > div:nth-child(2) {
top: 18.1801948466%;
left: 81.8198051534%;
-webkit-animation-delay: -0.75s;
-moz-animation-delay: -0.75s;
-o-animation-delay: -0.75s;
animation-delay: -0.75s;
}
.la-ball-spin-clockwise > div:nth-child(3) {
top: 50%;
left: 95%;
-webkit-animation-delay: -0.625s;
-moz-animation-delay: -0.625s;
-o-animation-delay: -0.625s;
animation-delay: -0.625s;
}
.la-ball-spin-clockwise > div:nth-child(4) {
top: 81.8198051534%;
left: 81.8198051534%;
-webkit-animation-delay: -0.5s;
-moz-animation-delay: -0.5s;
-o-animation-delay: -0.5s;
animation-delay: -0.5s;
}
.la-ball-spin-clockwise > div:nth-child(5) {
top: 94.9999999966%;
left: 50.0000000005%;
-webkit-animation-delay: -0.375s;
-moz-animation-delay: -0.375s;
-o-animation-delay: -0.375s;
animation-delay: -0.375s;
}
.la-ball-spin-clockwise > div:nth-child(6) {
top: 81.8198046966%;
left: 18.1801949248%;
-webkit-animation-delay: -0.25s;
-moz-animation-delay: -0.25s;
-o-animation-delay: -0.25s;
animation-delay: -0.25s;
}
.la-ball-spin-clockwise > div:nth-child(7) {
top: 49.9999750815%;
left: 5.0000051215%;
-webkit-animation-delay: -0.125s;
-moz-animation-delay: -0.125s;
-o-animation-delay: -0.125s;
animation-delay: -0.125s;
}
.la-ball-spin-clockwise > div:nth-child(8) {
top: 18.179464974%;
left: 18.1803700518%;
-webkit-animation-delay: 0s;
-moz-animation-delay: 0s;
-o-animation-delay: 0s;
animation-delay: 0s;
}
.la-ball-spin-clockwise.la-sm {
width: 16px;
height: 16px;
}
.la-ball-spin-clockwise.la-sm > div {
width: 4px;
height: 4px;
margin-top: -2px;
margin-left: -2px;
}
.la-ball-spin-clockwise.la-2x {
width: 64px;
height: 64px;
}
.la-ball-spin-clockwise.la-2x > div {
width: 16px;
height: 16px;
margin-top: -8px;
margin-left: -8px;
}
.la-ball-spin-clockwise.la-3x {
width: 96px;
height: 96px;
}
.la-ball-spin-clockwise.la-3x > div {
width: 24px;
height: 24px;
margin-top: -12px;
margin-left: -12px;
}
/*
* Animation
*/
@-webkit-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
transform: scale(0);
}
}
@-moz-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-moz-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-moz-transform: scale(0);
transform: scale(0);
}
}
@-o-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-o-transform: scale(0);
transform: scale(0);
}
}
@keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
-moz-transform: scale(1);
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
-moz-transform: scale(0);
-o-transform: scale(0);
transform: scale(0);
}
}
</style>

View File

@@ -13,13 +13,13 @@
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<covers-book-cover :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
</div>
</div>
</template>

View File

@@ -17,7 +17,6 @@ export default {
},
width: Number,
height: Number,
groupTo: String,
bookCoverAspectRatio: Number
},
data() {
@@ -44,6 +43,14 @@ export default {
this.$nextTick(this.init)
}
}
},
width: {
handler(newVal) {
if (newVal) {
this.isInit = false
this.$nextTick(this.init)
}
}
}
},
computed: {
@@ -51,9 +58,6 @@ export default {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
store() {
return this.$store || this.$nuxt.$store
},
@@ -63,7 +67,7 @@ export default {
},
methods: {
getCoverUrl(book) {
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
},
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl
@@ -134,7 +138,7 @@ export default {
var innerP = document.createElement('p')
innerP.textContent = this.name
innerP.className = 'text-sm font-book text-white'
innerP.className = 'text-sm text-white'
imgdiv.appendChild(innerP)
return imgdiv
@@ -151,7 +155,6 @@ export default {
.map((bookItem) => {
return {
id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem)
}
})

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['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
},
hasCover() {
return !!this.audiobook.book.cover
}
},
methods: {
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
</div>
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
width: Number,
height: Number
},
data() {
return {}
},
computed: {
sizeMultiplier() {
return this.width / (120 * 1.6 * 2)
},
itemCoverWidth() {
if (this.libraryItemCovers.length === 1) return this.width
return this.width / 2
},
libraryItemCovers() {
if (!this.items.length) return []
if (this.items.length === 1) return [this.items[0].libraryItem]
const covers = []
for (let i = 0; i < 4; i++) {
let index = i % this.items.length
if (this.items.length === 2 && i >= 2) index = (i + 1) % 2 // for playlists with 2 items show covers in checker pattern
covers.push(this.items[index].libraryItem)
}
return covers
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,10 +1,10 @@
<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>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
@@ -14,9 +14,11 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
<p class="text-center 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,13 @@ export default {
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
},
placeholderUrl() {
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
}
},
methods: {
@@ -63,7 +76,7 @@ export default {
}
},
imageLoaded() {
if (this.$refs.cover) {
if (this.$refs.cover && this.src !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
this.naturalHeight = naturalHeight
this.naturalWidth = naturalWidth

View File

@@ -1,87 +1,109 @@
<template>
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full p-8">
<div class="flex py-2 -mx-2">
<div class="flex py-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
<ui-text-input-with-label v-model="newUser.username" :label="$strings.LabelUsername" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
</div>
</div>
<div class="flex py-2">
<div class="px-2">
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
<div v-show="!isEditingRoot" class="flex py-2">
<div class="px-2 w-52">
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
</div>
<div class="flex-grow" />
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
<div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
</div>
</div>
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
<p class="text-lg mb-2 font-semibold">Permissions</p>
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Download</p>
<p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.download" />
<ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newUser.permissions.download" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Update</p>
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.update" />
<ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newUser.permissions.update" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Delete</p>
<p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.delete" />
<ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newUser.permissions.delete" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Upload</p>
<p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.upload" />
<ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newUser.permissions.upload" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Access All Libraries</p>
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newUser.permissions.accessExplicitContent" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
</div>
</div>
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" :label="$strings.LabelLibrariesAccessibleToUser" />
</div>
<div class="flex items-cen~ter my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsAccessAllTags }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
</div>
</div>
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" :label="$strings.LabelTagsAccessibleToUser" />
</div>
</div>
<div class="flex pt-4">
<div class="flex pt-4 px-2">
<ui-btn v-if="isEditingRoot" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn>
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</div>
@@ -103,7 +125,8 @@ export default {
processing: false,
newUser: {},
isNew: true,
accountTypes: ['guest', 'user', 'admin']
tags: [],
loadingTags: false
}
},
watch: {
@@ -124,8 +147,27 @@ export default {
this.$emit('input', val)
}
},
accountTypes() {
return [
{
text: this.$strings.LabelAccountTypeGuest,
value: 'guest'
},
{
text: this.$strings.LabelAccountTypeUser,
value: 'user'
},
{
text: this.$strings.LabelAccountTypeAdmin,
value: 'admin'
}
]
},
user() {
return this.$store.state.user.user
},
title() {
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
},
isEditingRoot() {
return this.account && this.account.type === 'root'
@@ -135,9 +177,39 @@ export default {
},
libraryItems() {
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
},
itemTags() {
return this.tags.map((t) => {
return {
text: t,
value: t
}
})
}
},
methods: {
close() {
// Force close when navigating - used in UsersTable
if (this.$refs.modal) this.$refs.modal.setHide()
},
accessAllTagsToggled(val) {
if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = []
}
},
fetchAllTags() {
this.loadingTags = true
this.$axios
.$get(`/api/tags`)
.then((res) => {
this.tags = res.tags
this.loadingTags = false
})
.catch((error) => {
console.error('Failed to load tags', error)
this.loadingTags = false
})
},
accessAllLibrariesToggled(val) {
if (!val && !this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
@@ -154,6 +226,10 @@ export default {
this.$toast.error('Must select at least one library')
return
}
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
this.$toast.error('Must select at least one tag')
return
}
if (this.isNew) {
this.submitCreateAccount()
@@ -175,10 +251,16 @@ export default {
.then((data) => {
this.processing = false
if (data.error) {
this.$toast.error(`Failed to update account: ${data.error}`)
this.$toast.error(`${this.$strings.ToastAccountUpdateFailed}: ${data.error}`)
} else {
console.log('Account updated', data.user)
this.$toast.success('Account updated')
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(this.$strings.ToastAccountUpdateSuccess)
this.show = false
}
})
@@ -223,20 +305,24 @@ export default {
download: type !== 'guest',
update: type === 'admin',
delete: type === 'admin',
upload: type === 'admin'
upload: type === 'admin',
accessAllLibraries: true,
accessAllTags: true
}
},
init() {
this.fetchAllTags()
this.isNew = !this.account
if (this.account) {
var librariesAccessible = this.account.librariesAccessible || []
this.newUser = {
username: this.account.username,
password: this.account.password,
type: this.account.type,
isActive: this.account.isActive,
permissions: { ...this.account.permissions },
librariesAccessible: [...librariesAccessible]
librariesAccessible: [...(this.account.librariesAccessible || [])],
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
}
} else {
this.newUser = {
@@ -249,7 +335,8 @@ export default {
update: false,
delete: false,
upload: false,
accessAllLibraries: true
accessAllLibraries: true,
accessAllTags: true
},
librariesAccessible: []
}

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="text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
</div>
</template>
<div v-if="show && newCronExpression" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<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 ? $strings.ButtonSave : $strings.MessageNoUpdateNecessary }}</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,135 @@
<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="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 py-4">
<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">{{ $strings.LabelProvider }}</p>
<ui-dropdown v-model="options.provider" :items="providers" small />
</div>
<p class="text-base px-8 py-2">{{ $strings.MessageBatchQuickMatchDescription }}</p>
<div class="flex px-8 items-end py-2">
<ui-toggle-switch v-model="options.overrideCover" />
<ui-tooltip :text="$strings.LabelUpdateCoverHelp">
<p class="pl-4">
{{ $strings.LabelUpdateCover }}
<span class="material-icons icon-text">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="$strings.LabelUpdateDetailsHelp">
<p class="pl-4">
{{ $strings.LabelUpdateDetails }}
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="mt-4 pt-4 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">{{ $strings.ButtonCancel }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" @click="doBatchQuickMatch">{{ $strings.ButtonSubmit }}</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
}
}
},
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.$getString('MessageItemsSelected', [this.selectedBookIds.length])
},
showBatchQuickMatchModal() {
return this.$store.state.globals.showBatchQuickMatchModal
},
selectedBookIds() {
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
},
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,15 +1,20 @@
<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="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<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" />
</template>
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
<p class="text-xl">No Bookmarks</p>
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</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">
@@ -19,7 +24,7 @@
<div class="flex-grow px-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">add</span></ui-btn>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">add</span></ui-btn>
</div>
</form>
</div>
@@ -39,7 +44,8 @@ export default {
type: Number,
default: 0
},
audiobookId: String
libraryItemId: String,
hideCreate: Boolean
},
data() {
return {
@@ -67,6 +73,12 @@ export default {
},
canCreateBookmark() {
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
}
},
methods: {
@@ -76,8 +88,15 @@ export default {
this.showBookmarkTitleInput = true
},
deleteBookmark(bm) {
var bookmark = { ...bm, audiobookId: this.audiobookId }
this.$root.socket.emit('delete_bookmark', bookmark)
this.$axios
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
.then(() => {
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
})
.catch((error) => {
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
console.error(error)
})
this.show = false
},
clickBookmark(bm) {
@@ -85,21 +104,34 @@ export default {
},
submitUpdateBookmark(updatedBookmark) {
var bookmark = { ...updatedBookmark }
bookmark.audiobookId = this.audiobookId
this.$root.socket.emit('update_bookmark', bookmark)
this.$axios
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
})
.catch((error) => {
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
console.error(error)
})
this.show = false
},
submitCreateBookmark() {
if (!this.newBookmarkTitle) {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
}
var bookmark = {
audiobookId: this.audiobookId,
title: this.newBookmarkTitle,
time: this.currentTime
time: Math.floor(this.currentTime)
}
this.$root.socket.emit('create_bookmark', bookmark)
this.$axios
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success(this.$strings.ToastBookmarkCreateSuccess)
})
.catch((error) => {
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
console.error(error)
})
this.newBookmarkTitle = ''
this.showBookmarkTitleInput = false
@@ -108,4 +140,4 @@ export default {
}
}
}
</script>
</script>

View File

@@ -1,68 +0,0 @@
<template>
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :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">Bookshelf Texture</p>
</div>
</template>
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
<div class="overflow-y-hidden overflow-x-auto">
<div class="flex -mx-1">
<template v-for="texture in textures">
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
<span class="material-icons text-4xl text-success">check</span>
</div>
</div>
</template>
</div>
</div>
<!-- <div class="flex pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn>
</div> -->
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
processing: false
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showBookshelfTextureModal
},
set(val) {
this.$store.commit('globals/setShowBookshelfTextureModal', val)
}
},
selectedBookshelfTexture() {
return this.$store.state.selectedBookshelfTexture
}
},
methods: {
init() {},
setTexture(img) {
this.$store.dispatch('setBookshelfTexture', img)
}
},
mounted() {}
}
</script>

View File

@@ -1,11 +1,14 @@
<template>
<modals-modal v-model="show" name="chapters" :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">
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
{{ chap.title }}
<p class="chapter-title truncate text-sm md:text-base">
{{ chap.title }}
</p>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
<span class="flex-grow" />
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div>
@@ -70,4 +73,15 @@ export default {
}
}
}
</script>
</script>
<style>
#chapter-modal-wrapper .chapter-title {
max-width: calc(100% - 120px);
}
@media (min-width: 640px) {
#chapter-modal-wrapper .chapter-title {
max-width: calc(100% - 150px);
}
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<template #outer>
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">{{ title }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<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-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</div>
</li>
</template>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
title: String,
items: {
type: Array,
default: () => []
},
selected: String // optional
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickedOption(action) {
this.$emit('action', action)
}
},
mounted() {}
}
</script>

View File

@@ -1,45 +0,0 @@
<template>
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
library: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.library ? 'Update Library' : 'New Library'
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -0,0 +1,137 @@
<template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
<span class="material-icons text-2xl md:text-4xl">close</span>
</div>
<div ref="content" class="text-white">
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
<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="!isNewSeries" :label="$strings.LabelSeriesName" />
</div>
<div class="w-24 sm:w-28 md:w-40 p-1">
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
</div>
</div>
<div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
selectedSeries: {
type: Object,
default: () => {}
},
existingSeriesNames: {
type: Array,
default: () => []
}
},
data() {
return {
el: null,
content: null
}
},
watch: {
show(newVal) {
if (newVal) {
this.$nextTick(this.setShow)
} else {
this.setHide()
}
}
},
computed: {
show: {
get() {
return this.value
},
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()
}
this.$emit('submit')
},
clickClose() {
this.show = false
},
hotkey(action) {
if (action === this.$hotkeys.Modal.CLOSE) {
this.show = false
}
},
setShow() {
if (!this.el || !this.content) {
this.init()
}
if (!this.el || !this.content) {
return
}
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
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()
this.$store.commit('setInnerModalOpen', false)
this.$eventBus.$off('modal-hotkey', this.hotkey)
},
init() {
this.el = this.$refs.wrapper
this.content = this.$refs.content
if (this.content && this.el) {
this.el.classList.remove('hidden')
this.el.classList.add('flex')
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()
}
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<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="text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
<div class="flex items-center">
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<div class="flex flex-wrap mb-4">
<div class="w-full md:w-2/3">
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2">{{ $strings.HeaderDetails }}</p>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div>
<div class="px-1">
{{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div>
<div class="px-1">
{{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelTimeListened }}</div>
<div class="px-1">
{{ $elapsedPrettyExtended(_session.timeListening) }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartTime }}</div>
<div class="px-1">
{{ $secondsToTimestamp(_session.startTime) }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLastTime }}</div>
<div class="px-1">
{{ $secondsToTimestamp(_session.currentTime) }}
</div>
</div>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
<div class="px-1">
{{ _session.libraryId }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
<div class="px-1">
{{ _session.libraryItemId }}
</div>
</div>
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
<div class="px-1">
{{ _session.episodeId }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelMediaType }}</div>
<div class="px-1">
{{ _session.mediaType }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelDuration }}</div>
<div class="px-1">
{{ $elapsedPretty(_session.duration) }}
</div>
</div>
</div>
<div class="w-full md:w-1/3">
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
<p class="mb-1">{{ _session.userId }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
<p class="mb-1">{{ playMethodName }}</p>
<p class="mb-1">{{ _session.mediaPlayer }}</p>
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelDevice }}</p>
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK {{ $strings.LabelVersion }}: {{ deviceInfo.sdkVersion }}</p>
<p v-if="deviceInfo.deviceType" class="mb-1">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>
</div>
</div>
<div class="flex items-center">
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
session: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
_session() {
return this.session || {}
},
deviceInfo() {
return this._session.deviceInfo || {}
},
hasDeviceInfo() {
return Object.keys(this.deviceInfo).length
},
osDisplayName() {
if (!this.deviceInfo.osName) return null
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
},
clientDisplayName() {
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
},
playMethodName() {
const playMethod = this._session.playMethod
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
}
},
methods: {
deleteSessionClick() {
const payload = {
message: this.$strings.MessageConfirmDeleteSession,
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(this.$strings.ToastSessionDeleteSuccess)
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 || this.$strings.ToastSessionDeleteFailed)
})
}
},
mounted() {}
}
</script>

View File

@@ -2,11 +2,11 @@
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<span class="material-icons text-4xl">close</span>
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</div>
<slot name="outer" />
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator />
@@ -39,7 +39,7 @@ export default {
},
zIndex: {
type: Number,
default: 50
default: 60
},
bgOpacity: {
type: Number,
@@ -50,7 +50,8 @@ export default {
return {
el: null,
content: null,
preventClickoutside: false
preventClickoutside: false,
isShowingPrompt: false
}
},
watch: {
@@ -93,16 +94,18 @@ export default {
this.show = false
},
clickBg(ev) {
if (!this.show || this.isShowingPrompt) return
if (this.preventClickoutside) {
this.preventClickoutside = false
return
}
if (this.processing && this.persistent) return
if (ev.srcElement.classList.contains('modal-bg')) {
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
this.show = false
}
},
hotkey(action) {
if (this.$store.state.innerModalOpen) return
if (action === this.$hotkeys.Modal.CLOSE) {
this.show = false
}
@@ -145,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

@@ -0,0 +1,115 @@
<template>
<modals-modal v-model="show" name="sleep-timer" :width="350" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="!timerSet" class="w-full">
<template v-for="time in sleepTimes">
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)">
<p class="text-xl text-center">{{ time.text }}</p>
</div>
</template>
</div>
<div v-else class="w-full p-4">
<div class="mb-4 flex items-center justify-center">
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center mr-4" @click="decrement(30 * 60)">
<span class="material-icons text-lg">remove</span>
<span class="pl-1 text-base font-mono">30m</span>
</ui-btn>
<ui-icon-btn icon="remove" @click="decrement(60 * 5)" />
<p class="mx-6 text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
<ui-icon-btn icon="add" @click="increment(60 * 5)" />
<ui-btn :padding-x="2" small class="flex items-center ml-4" @click="increment(30 * 60)">
<span class="material-icons text-lg">add</span>
<span class="pl-1 text-base font-mono">30m</span>
</ui-btn>
</div>
<ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
timerSet: Boolean,
timerTime: Number,
remaining: Number
},
data() {
return {
sleepTimes: [
{
seconds: 10,
text: '10 seconds'
},
{
seconds: 60 * 5,
text: '5 minutes'
},
{
seconds: 60 * 30,
text: '30 minutes'
},
{
seconds: 60 * 60,
text: '60 minutes'
},
{
seconds: 60 * 90,
text: '90 minutes'
},
{
seconds: 60 * 120,
text: '2 hours'
},
{
seconds: 60 * 180,
text: '3 hours'
}
]
}
},
watch: {
show(newVal) {
if (newVal) {
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
setTime(time) {
this.$emit('set', time.seconds)
},
increment(amount) {
this.$emit('increment', amount)
},
decrement(amount) {
if (amount > this.remaining) {
if (this.remaining > 60) amount = 60
else amount = 5
}
this.$emit('decrement', amount)
}
}
}
</script>

View File

@@ -0,0 +1,174 @@
<template>
<modals-modal v-model="show" name="edit-author" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<form v-if="author" @submit.prevent="submitForm">
<div class="flex">
<div class="w-40 p-2">
<div class="w-full h-45 relative">
<covers-author-image :author="author" />
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
</div>
</div>
</div>
<div class="flex-grow">
<div class="flex">
<div class="w-3/4 p-2">
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
</div>
<div class="flex-grow p-2">
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
</div>
</div>
<div class="p-2">
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
</div>
<div class="p-2">
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
</div>
<div class="flex pt-2 px-2">
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
</div>
</form>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
authorCopy: {
name: '',
asin: '',
description: '',
imagePath: ''
},
processing: false
}
},
watch: {
author: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showEditAuthorModal
},
set(val) {
this.$store.commit('globals/setShowEditAuthorModal', val)
}
},
author() {
return this.$store.state.globals.selectedAuthor
},
authorId() {
if (!this.author) return ''
return this.author.id
},
title() {
return this.$strings.HeaderUpdateAuthor
}
},
methods: {
init() {
this.authorCopy.name = this.author.name
this.authorCopy.asin = this.author.asin
this.authorCopy.description = this.author.description
this.authorCopy.imagePath = this.author.imagePath
},
async submitForm() {
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
var updatePayload = {}
keysToCheck.forEach((key) => {
if (this.authorCopy[key] !== this.author[key]) {
updatePayload[key] = this.authorCopy[key]
}
})
if (!Object.keys(updatePayload).length) {
this.$toast.info(this.$strings.MessageNoUpdateNecessary)
return
}
this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
console.error('Failed', error)
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || this.$strings.ToastAuthorUpdateFailed)
return null
})
if (result) {
if (result.updated) {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
this.show = false
} else if (result.merged) {
this.$toast.success(this.$strings.ToastAuthorUpdateMerged)
this.show = false
} else this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
this.processing = false
},
async removeCover() {
var updatePayload = {
imagePath: null
}
this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
return null
})
if (result && result.updated) {
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
this.$store.commit('globals/showEditAuthorModal', result.author)
}
this.processing = false
},
async searchAuthor() {
if (!this.authorCopy.name && !this.authorCopy.asin) {
this.$toast.error('Must enter an author name')
return
}
this.processing = true
const payload = {}
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
else payload.q = this.authorCopy.name
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
this.$toast.error('Author not found')
} else if (response.updated) {
if (response.author.imagePath) {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
this.$store.commit('globals/showEditAuthorModal', response.author)
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
} else {
this.$toast.info('No updates were made for Author')
}
this.processing = false
}
},
mounted() {},
beforeDestroy() {}
}
</script>

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) }}
@@ -13,7 +12,7 @@
<div class="flex-grow pr-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">forward</span></ui-btn>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">forward</span></ui-btn>
<div class="pl-2 flex items-center">
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
</div>

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="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

@@ -1,34 +1,34 @@
<template>
<modals-modal v-model="show" name="collections" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="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 v-if="!showBatchUserCollectionModal" class="text-2xl">Add to Collection</h1>
<h1 v-else class="text-2xl">Add {{ selectedBookIds.length }} Books to Collection</h1>
<h1 v-if="!showBatchCollectionModal" class="text-2xl">{{ $strings.LabelAddToCollection }}</h1>
<h1 v-else class="text-2xl">{{ $getString('LabelAddToCollectionBatch', [selectedBookIds.length]) }}</h1>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<transition-group name="list-complete" tag="div">
<template v-for="collection in sortedCollections">
<modals-collections-user-collection-item :key="collection.id" :collection="collection" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
<modals-collections-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
</template>
</transition-group>
</div>
<div v-if="!collections.length" class="flex h-32 items-center justify-center">
<p class="text-xl">No Collections</p>
<p class="text-xl">{{ $strings.MessageNoCollections }}</p>
</div>
<div class="w-full h-px bg-white bg-opacity-10" />
<form @submit.prevent="submitCreateCollection">
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
<div class="flex-grow px-2">
<ui-text-input v-model="newCollectionName" placeholder="New Collection" class="w-full" />
<ui-text-input v-model="newCollectionName" :placeholder="$strings.PlaceholderNewCollection" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10">Create</ui-btn>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10">{{ $strings.ButtonCreate }}</ui-btn>
</div>
</form>
</div>
@@ -50,44 +50,47 @@ export default {
this.loadCollections()
this.newCollectionName = ''
} else {
this.$store.commit('setSelectedAudiobook', null)
this.$store.commit('setSelectedLibraryItem', null)
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showUserCollectionsModal
return this.$store.state.globals.showCollectionsModal
},
set(val) {
this.$store.commit('globals/setShowUserCollectionsModal', val)
this.$store.commit('globals/setShowCollectionsModal', val)
}
},
title() {
if (this.showBatchUserCollectionModal) {
return `${this.selectedBookIds.length} Books Selected`
if (this.showBatchCollectionModal) {
return this.$getString('MessageItemsSelected', [this.selectedBookIds.length])
}
return this.selectedAudiobook ? this.selectedAudiobook.book.title : ''
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook
},
selectedAudiobookId() {
return this.selectedAudiobook ? this.selectedAudiobook.id : null
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
},
collections() {
return this.$store.state.user.collections || []
return this.$store.state.libraries.collections || []
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem
},
selectedLibraryItemId() {
return this.selectedLibraryItem ? this.selectedLibraryItem.id : null
},
sortedCollections() {
return this.collections
.map((c) => {
var includesBook = false
if (this.showBatchUserCollectionModal) {
if (this.showBatchCollectionModal) {
// Only show collection added if all books are in the collection
var collectionBookIds = c.books.map((b) => b.id)
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
} else {
includesBook = !!c.books.find((b) => b.id === this.selectedAudiobookId)
includesBook = !!c.books.find((b) => b.id === this.selectedLibraryItemId)
}
return {
@@ -97,11 +100,11 @@ export default {
})
.sort((a, b) => (a.isBookIncluded ? -1 : 1))
},
showBatchUserCollectionModal() {
return this.$store.state.globals.showBatchUserCollectionModal
showBatchCollectionModal() {
return this.$store.state.globals.showBatchCollectionModal
},
selectedBookIds() {
return this.$store.state.selectedAudiobooks || []
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
@@ -109,47 +112,61 @@ export default {
},
methods: {
loadCollections() {
this.$store.dispatch('user/loadUserCollections')
this.processing = true
this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/collections`)
.then((data) => {
if (data.results) {
this.$store.commit('libraries/setCollections', data.results || [])
}
})
.catch((error) => {
console.error('Failed to get collections', error)
this.$toast.error('Failed to load collections')
})
.finally(() => {
this.processing = false
})
},
removeFromCollection(collection) {
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
this.processing = true
if (this.showBatchUserCollectionModal) {
if (this.showBatchCollectionModal) {
// BATCH Remove books
this.$axios
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
.then((updatedCollection) => {
console.log(`Books removed from collection`, updatedCollection)
this.$toast.success('Books removed from collection')
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
this.processing = false
})
.catch((error) => {
console.error('Failed to remove books from collection', error)
this.$toast.error('Failed to remove books from collection')
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
this.processing = false
})
} else {
// Remove single book
this.$axios
.$delete(`/api/collections/${collection.id}/book/${this.selectedAudiobookId}`)
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
.then((updatedCollection) => {
console.log(`Book removed from collection`, updatedCollection)
this.$toast.success('Book removed from collection')
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
this.processing = false
})
.catch((error) => {
console.error('Failed to remove book from collection', error)
this.$toast.error('Failed to remove book from collection')
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
this.processing = false
})
}
},
addToCollection(collection) {
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
this.processing = true
if (this.showBatchUserCollectionModal) {
if (this.showBatchCollectionModal) {
// BATCH Remove books
this.$axios
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
@@ -164,10 +181,10 @@ export default {
this.processing = false
})
} else {
if (!this.selectedAudiobookId) return
if (!this.selectedLibraryItemId) return
this.$axios
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedAudiobookId })
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
.then((updatedCollection) => {
console.log(`Book added to collection`, updatedCollection)
this.$toast.success('Book added to collection')
@@ -181,12 +198,12 @@ export default {
}
},
submitCreateCollection() {
if (!this.newCollectionName || (!this.selectedAudiobookId && !this.selectedBookIds.length)) {
if (!this.newCollectionName || (!this.selectedLibraryItemId && !this.selectedBookIds.length)) {
return
}
this.processing = true
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedAudiobookId]
var books = this.showBatchCollectionModal ? this.selectedBookIds : [this.selectedLibraryItemId]
var newCollection = {
books: books,
libraryId: this.currentLibraryId,
@@ -212,19 +229,3 @@ export default {
mounted() {}
}
</script>
<style>
.list-complete-item {
transition: all 0.8s ease;
}
.list-complete-enter-from,
.list-complete-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-complete-leave-active {
position: absolute;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-20 max-w-20 text-center">
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="flex-grow overflow-hidden px-2">
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
</div>
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
</div>
</div>
</template>
<script>
export default {
props: {
collection: {
type: Object,
default: () => {}
},
bookCoverAspectRatio: Number
},
data() {
return {
isHovering: false
}
},
computed: {
isBookIncluded() {
return !!this.collection.isBookIncluded
},
books() {
return this.collection.books || []
}
},
methods: {
clickNuxtLink() {
this.$emit('close')
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickAdd() {
this.$emit('add', this.collection)
},
clickRem() {
this.$emit('remove', this.collection)
}
},
mounted() {}
}
</script>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="edit-collection" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Collection</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderCollection }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
@@ -14,15 +14,15 @@
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
</div>
<div class="flex-grow px-4">
<ui-text-input-with-label v-model="newCollectionName" label="Name" class="mb-2" />
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
<ui-textarea-with-label v-model="newCollectionDescription" label="Description" />
<ui-textarea-with-label v-model="newCollectionDescription" :label="$strings.LabelDescription" />
</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">{{ $strings.ButtonRemove }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">Save</ui-btn>
<ui-btn color="success" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
</template>
@@ -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: {
@@ -93,20 +96,19 @@ export default {
this.newCollectionDescription = this.collection.description || ''
},
removeClick() {
if (confirm(`Are you sure you want to remove collection "${this.collectionName}"?`)) {
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
this.processing = true
var collectionName = this.collectionName
this.$axios
.$delete(`/api/collections/${this.collection.id}`)
.then(() => {
this.processing = false
this.show = false
this.$toast.success(`Collection "${collectionName}" Removed`)
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove collection', error)
this.processing = false
this.$toast.error(`Failed to remove collection`)
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
})
}
},
@@ -130,12 +132,12 @@ export default {
console.log('Collection Updated', collection)
this.processing = false
this.show = false
this.$toast.success(`Collection "${collection.name}" Updated`)
this.$toast.success(this.$strings.ToastCollectionUpdateSuccess)
})
.catch((error) => {
console.error('Failed to update collection', error)
this.processing = false
this.$toast.error(`Failed to update collection`)
this.$toast.error(this.$strings.ToastCollectionUpdateFailed)
})
}
},

View File

@@ -1,94 +0,0 @@
<template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" :class="wrapperClass" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
<div class="w-20 max-w-20 text-center">
<!-- <img src="/Logo.png" /> -->
<covers-collection-cover :book-items="books" :width="80" :height="40 * 1.6" />
</div>
<div class="flex-grow overflow-hidden px-2">
<!-- <template v-if="isEditing">
<form @submit.prevent="submitUpdate">
<div class="flex items-center">
<div class="flex-grow pr-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">forward</span></ui-btn>
<div class="pl-2 flex items-center">
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
</div>
</div>
</form>
</template> -->
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
</div>
<div v-if="!isEditing" class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons pt-px">add</span></ui-btn>
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons pt-px">remove</span></ui-btn>
<!-- <span class="material-icons text-xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
<span class="material-icons text-xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span> -->
</div>
</div>
</template>
<script>
export default {
props: {
collection: {
type: Object,
default: () => {}
},
highlight: Boolean
},
data() {
return {
isHovering: false,
isEditing: false
}
},
computed: {
isBookIncluded() {
return !!this.collection.isBookIncluded
},
wrapperClass() {
var classes = []
if (this.highlight) classes.push('bg-bg bg-opacity-60')
if (!this.isEditing) classes.push('cursor-pointer')
return classes.join(' ')
},
books() {
return this.collection.books || []
}
},
methods: {
clickNuxtLink() {
this.$emit('close')
},
mouseover() {
if (this.isEditing) return
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickAdd() {
this.$emit('add', this.collection)
},
clickRem() {
this.$emit('remove', this.collection)
},
deleteClick() {
if (this.isEditing) return
this.$emit('delete', this.collection)
},
editClick() {
this.isEditing = true
this.isHovering = false
},
cancelEditing() {
this.isEditing = false
}
},
mounted() {}
}
</script>

View File

@@ -1,201 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
<template v-for="(authorName, index) in searchAuthors">
<cards-search-author-card :key="index" :author-name="authorName" @match="setSelectedMatch" />
</template>
<div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p>
</div>
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-2">
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-3">Update Author Details</p>
</div>
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatch.image" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.image" />
<img :src="selectedMatch.image" class="w-24 object-contain ml-4" />
<ui-text-input-with-label v-model="selectedMatch.image" :disabled="!selectedMatchUsage.image" label="Image" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.name" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.name" />
<ui-text-input-with-label v-model="selectedMatch.name" :disabled="!selectedMatchUsage.name" label="Name" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" />
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">Update</ui-btn>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
searchAuthors: [],
audiobookId: null,
searchAuthor: null,
lastSearch: null,
hasSearched: false,
selectedMatch: null,
selectedMatchUsage: {
image: true,
name: true,
description: true
}
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
}
},
methods: {
// getSearchQuery() {
// return `q=${this.searchAuthor}`
// },
// submitSearch() {
// if (!this.searchTitle) {
// this.$toast.warning('Search title is required')
// return
// }
// this.runSearch()
// },
// async runSearch() {
// var searchQuery = this.getSearchQuery()
// if (this.lastSearch === searchQuery) return
// this.selectedMatch = null
// this.isProcessing = true
// this.lastSearch = searchQuery
// var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
// console.error('Failed', error)
// return []
// })
// if (result) {
// this.selectedMatch = result
// }
// this.isProcessing = false
// this.hasSearched = true
// },
init() {
this.selectedMatch = null
// this.selectedMatchUsage = {
// title: true,
// subtitle: true,
// cover: true,
// author: true,
// description: true,
// isbn: true,
// publisher: true,
// publishYear: true
// }
if (this.audiobook.id !== this.audiobookId) {
this.selectedMatch = null
this.hasSearched = false
this.audiobookId = this.audiobook.id
}
if (!this.audiobook.book || !this.audiobook.book.authorFL) {
this.searchAuthors = []
return
}
this.searchAuthors = (this.audiobook.book.authorFL || '').split(', ')
},
selectMatch(match) {
this.selectedMatch = match
},
buildMatchUpdatePayload() {
var updatePayload = {}
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
updatePayload[key] = this.selectedMatch[key]
}
}
return updatePayload
},
async submitMatchUpdate() {
var updatePayload = this.buildMatchUpdatePayload()
if (!Object.keys(updatePayload).length) {
return
}
this.isProcessing = true
if (updatePayload.cover) {
var coverPayload = {
url: updatePayload.cover
}
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Cover Updated')
} else {
this.$toast.error('Book Cover Failed to Update')
}
console.log('Updated cover')
delete updatePayload.cover
}
if (Object.keys(updatePayload).length) {
var bookUpdatePayload = {
book: updatePayload
}
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Details Updated')
this.selectedMatch = null
this.$emit('selectTab', 'details')
} else {
this.$toast.error('Book Details Failed to Update')
}
} else {
this.selectedMatch = null
}
this.isProcessing = false
},
setSelectedMatch(authorMatchObj) {
this.selectedMatch = authorMatchObj
}
}
}
</script>
<style>
.matchListWrapper {
height: calc(100% - 80px);
}
</style>

View File

@@ -1,59 +0,0 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<template v-for="chapter in chapters">
<tr :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</template>
</table>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
chapters: []
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {},
methods: {
init() {
this.chapters = this.audiobook.chapters || []
}
}
}
</script>

View File

@@ -1,342 +0,0 @@
<template>
<div class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm">
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<div class="flex -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.title" label="Title" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-text-input-with-label v-model="details.author" label="Author" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
</div>
</div>
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
</div>
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.language" label="Language" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
</div>
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
</div>
</div>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex-grow" />
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<ui-btn type="submit">Submit</ui-btn>
</div>
</div>
</form>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
details: {
title: null,
subtitle: null,
description: null,
author: null,
narrator: null,
series: null,
volumeNumber: null,
publishYear: null,
publisher: null,
language: null,
isbn: null,
asin: null,
genres: []
},
newTags: [],
resettingProgress: false,
isScrollable: false,
savingMetadata: false,
rescanning: false,
quickMatching: false
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
isMissing() {
return !!this.audiobook && !!this.audiobook.isMissing
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
genres() {
return this.filterData.genres || []
},
tags() {
return this.filterData.tags || []
},
series() {
return this.filterData.series || []
},
filterData() {
return this.$store.state.libraries.filterData || {}
},
libraryId() {
return this.audiobook ? this.audiobook.libraryId : null
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
},
libraryScan() {
if (!this.libraryId) return null
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
}
},
methods: {
quickMatch() {
this.quickMatching = true
var matchOptions = {
provider: this.libraryProvider,
title: this.details.title,
author: this.details.author !== this.book.author ? this.details.author : null
}
this.$axios
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
.then((res) => {
this.quickMatching = false
if (res.warning) {
this.$toast.warning(res.warning)
} else if (res.updated) {
this.$toast.success('Audiobook details updated')
} else {
this.$toast.info('No updates were made')
}
})
.catch((error) => {
var errMsg = error.response ? error.response.data || '' : ''
console.error('Failed to match', error)
this.$toast.error(errMsg || 'Failed to match')
this.quickMatching = false
})
},
audiobookScanComplete(result) {
this.rescanning = false
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete audiobook was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete audiobook was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete audiobook was removed`)
}
},
rescan() {
this.rescanning = true
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
this.$root.socket.emit('scan_audiobook', this.audiobookId)
},
saveMetadataComplete(result) {
this.savingMetadata = false
if (result.error) {
this.$toast.error(result.error)
} else if (result.audiobookId) {
var { savedPath } = result
if (!savedPath) {
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
} else {
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
}
}
},
saveMetadata() {
this.savingMetadata = true
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata', this.audiobookId)
},
submitForm() {
if (this.isProcessing) {
return
}
this.isProcessing = true
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
this.$refs.seriesDropdown.blur()
}
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
this.$refs.genresSelect.forceBlur()
}
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
this.$refs.tagsSelect.forceBlur()
}
this.$nextTick(this.handleForm)
},
async handleForm() {
const updatePayload = {
book: this.details,
tags: this.newTags
}
var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
this.$toast.success('Update Successful')
this.$emit('close')
}
},
init() {
this.details.title = this.book.title
this.details.subtitle = this.book.subtitle
this.details.description = this.book.description
this.details.author = this.book.author
this.details.narrator = this.book.narrator
this.details.genres = this.book.genres || []
this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber
this.details.publishYear = this.book.publishYear
this.details.publisher = this.book.publisher || null
this.details.language = this.book.language || null
this.details.isbn = this.book.isbn || null
this.details.asin = this.book.asin || null
this.newTags = this.audiobook.tags || []
},
deleteAudiobook() {
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/books/${this.audiobookId}`)
.then(() => {
console.log('Audiobook removed')
this.$toast.success('Audiobook Removed')
this.$emit('close')
this.isProcessing = false
})
.catch((error) => {
console.error('Remove Audiobook failed', error)
this.isProcessing = false
})
}
},
checkIsScrollable() {
this.$nextTick(() => {
if (this.$refs.formWrapper) {
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
this.isScrollable = true
} else {
this.isScrollable = false
}
}
})
},
setResizeObserver() {
try {
this.$nextTick(() => {
const resizeObserver = new ResizeObserver(() => {
this.checkIsScrollable()
})
resizeObserver.observe(this.$refs.formWrapper)
})
} catch (error) {
console.error('Failed to set resize observer')
}
}
},
mounted() {
this.setResizeObserver()
}
}
</script>
<style scoped>
.details-form-wrapper {
height: calc(100% - 70px);
max-height: calc(100% - 70px);
}
</style>

View File

@@ -1,215 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<div>
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
</div>
</div>
</div>
</div>
<div class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<div>
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
<p v-else>Zip 1 File</p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
</div>
</div>
</div>
</div>
<div v-if="showM4bDownload" class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
</div>
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
<div class="w-full h-full flex items-center justify-center">
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
<p class="w-24 font-mono pl-8 text-right">
{{ downloadAmount }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tempDisable: false,
isDownloading: false,
downloadPercent: '0',
downloadAmount: '0 KB'
}
},
watch: {
singleDownloadStatus(newVal) {
if (newVal) {
this.tempDisable = false
}
}
},
computed: {
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
_audiobook() {
return this.audiobook || {}
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
},
singleAudioDownload() {
return this.downloads.find((d) => d.type === 'singleAudio')
},
singleDownloadStatus() {
return this.singleAudioDownload ? this.singleAudioDownload.status : false
},
zipDownload() {
return this.downloads.find((d) => d.type === 'zip')
},
zipDownloadStatus() {
return this.zipDownload ? this.zipDownload.status : false
},
isSingleTrack() {
if (!this.audiobook.tracks) return false
return this.audiobook.tracks.length === 1
},
singleTrackPath() {
if (!this.isSingleTrack) return null
return this.audiobook.tracks[0].path
},
audioFiles() {
return this.audiobook ? this.audiobook.audioFiles || [] : []
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
totalFiles() {
return this.audioFiles.length + this.otherFiles.length
},
showM4bDownload() {
return !this._audiobook.isMissing && !this._audiobook.isInvalid && this._audiobook.tracks.length
}
},
methods: {
startZipDownload() {
// console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'zip'
}
this.$root.socket.emit('download', downloadPayload)
},
startSingleAudioDownload() {
// console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'singleAudio',
includeMetadata: true,
includeCover: true
}
this.$root.socket.emit('download', downloadPayload)
},
downloadWithProgress(download) {
var downloadId = download.id
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
var filename = download.filename
this.isDownloading = true
var request = new XMLHttpRequest()
request.responseType = 'blob'
request.open('get', downloadUrl, true)
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
request.send()
request.onreadystatechange = () => {
if (request.readyState === 4) {
this.isDownloading = false
}
if (request.readyState == 4 && request.status == 200) {
const url = window.URL.createObjectURL(request.response)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
setTimeout(() => {
if (anchor) anchor.remove()
}, 1000)
}
}
request.onerror = (err) => {
console.error('Download error', err)
this.isDownloading = false
}
request.onprogress = (e) => {
const percent_complete = Math.floor((e.loaded / e.total) * 100)
this.downloadAmount = this.$bytesPretty(e.loaded)
this.downloadPercent = percent_complete
// const duration = (new Date().getTime() - startTime) / 1000
// const bps = e.loaded / duration
// const kbps = Math.floor(bps / 1024)
// const time = (e.total - e.loaded) / bps
// const seconds = Math.floor(time % 60)
// const minutes = Math.floor(time / 60)
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
}
}
},
mounted() {}
}
</script>

View File

@@ -1,115 +0,0 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="mb-4">
<template v-if="hasTracks">
<div class="w-full bg-primary px-4 py-2 flex items-center">
<p class="pr-4">Audio Tracks</p>
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracksCleaned">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</template>
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
</div>
<tables-all-files-table :audiobook="audiobook" />
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tracks: null,
showFullPath: false
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
hasTracks() {
return this.audiobook.tracks.length
}
},
methods: {
init() {
this.tracks = this.audiobook.tracks
}
}
}
</script>

View File

@@ -1,286 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
</div>
</form>
<div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p>
</div>
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
<p>No Results</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
<template v-for="(res, index) in searchResults">
<cards-book-match-card :key="index" :book="res" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
</template>
</div>
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-2">
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-3">Update Book Details</p>
</div>
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatch.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.title" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.title" />
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.author" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.author" />
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.narrator" />
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" />
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publisher" />
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.publishYear" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publishYear" />
<ui-text-input-with-label v-model="selectedMatch.publishYear" :disabled="!selectedMatchUsage.publishYear" label="Publish Year" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.series" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.series" />
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.isbn" />
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.asin" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.asin" />
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" class="flex-grow ml-4" />
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">Update</ui-btn>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
audiobookId: null,
searchTitle: null,
searchAuthor: null,
lastSearch: null,
provider: 'google',
searchResults: [],
hasSearched: false,
selectedMatch: null,
selectedMatchUsage: {
title: true,
subtitle: true,
cover: true,
author: true,
narrator: true,
description: true,
publisher: true,
publishYear: true,
series: true,
volumeNumber: true,
asin: true,
isbn: true
}
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
providers() {
return this.$store.state.scanners.providers
}
},
methods: {
persistProvider() {
try {
localStorage.setItem('book-provider', this.provider)
} catch (error) {
console.error('PersistProvider', error)
}
},
getSearchQuery() {
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
submitSearch() {
if (!this.searchTitle) {
this.$toast.warning('Search title is required')
return
}
this.persistProvider()
this.runSearch()
},
async runSearch() {
var searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.searchResults = []
this.isProcessing = true
this.lastSearch = searchQuery
var results = await this.$axios.$get(`/api/search/books?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
results = results.filter((res) => {
return !!res.title
})
this.searchResults = results
this.isProcessing = false
this.hasSearched = true
},
init() {
this.selectedMatch = null
this.selectedMatchUsage = {
title: true,
subtitle: true,
cover: true,
author: true,
narrator: true,
description: true,
publisher: true,
publishYear: true,
series: true,
volumeNumber: true,
asin: true,
isbn: true
}
if (this.audiobook.id !== this.audiobookId) {
this.searchResults = []
this.hasSearched = false
this.audiobookId = this.audiobook.id
}
if (!this.audiobook.book || !this.audiobook.book.title) {
this.searchTitle = null
this.searchAuthor = null
return
}
this.searchTitle = this.audiobook.book.title
this.searchAuthor = this.audiobook.book.authorFL || ''
this.provider = localStorage.getItem('book-provider') || 'google'
},
selectMatch(match) {
this.selectedMatch = match
},
buildMatchUpdatePayload() {
var updatePayload = {}
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
updatePayload[key] = this.selectedMatch[key]
}
}
return updatePayload
},
async submitMatchUpdate() {
var updatePayload = this.buildMatchUpdatePayload()
if (!Object.keys(updatePayload).length) {
return
}
this.isProcessing = true
if (updatePayload.cover) {
var coverPayload = {
url: updatePayload.cover
}
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Cover Updated')
} else {
this.$toast.error('Book Cover Failed to Update')
}
console.log('Updated cover')
delete updatePayload.cover
}
if (Object.keys(updatePayload).length) {
var bookUpdatePayload = {
book: updatePayload
}
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Details Updated')
this.selectedMatch = null
this.$emit('selectTab', 'details')
} else {
this.$toast.error('Book Details Failed to Update')
}
} else {
this.selectedMatch = null
}
this.isProcessing = false
}
}
}
</script>
<style>
.matchListWrapper {
height: calc(100% - 80px);
}
</style>

View File

@@ -1,110 +0,0 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<template v-if="hasTracks">
<div class="w-full bg-primary px-4 py-2 flex items-center">
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracksCleaned">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</template>
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tracks: null,
showFullPath: false
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
hasTracks() {
return this.audiobook.tracks.length
}
},
methods: {
init() {
this.tracks = this.audiobook.tracks
}
}
}
</script>

View File

@@ -1,13 +1,13 @@
<template>
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75">
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 w-full flex">
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
@@ -18,8 +18,8 @@
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
</div>
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<component v-if="audiobook && show" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div>
</modals-modal>
</template>
@@ -29,50 +29,9 @@ export default {
data() {
return {
processing: false,
audiobook: null,
fetchOnShow: false,
tabs: [
{
id: 'details',
title: 'Details',
component: 'modals-edit-tabs-details'
},
{
id: 'cover',
title: 'Cover',
component: 'modals-edit-tabs-cover'
},
// {
// id: 'tracks',
// title: 'Tracks',
// component: 'modals-edit-tabs-tracks'
// },
{
id: 'chapters',
title: 'Chapters',
component: 'modals-edit-tabs-chapters'
},
{
id: 'files',
title: 'Files',
component: 'modals-edit-tabs-files'
},
{
id: 'download',
title: 'Download',
component: 'modals-edit-tabs-download'
},
{
id: 'match',
title: 'Match',
component: 'modals-edit-tabs-match'
}
// {
// id: 'authors',
// title: 'Authors',
// component: 'modals-edit-tabs-authors'
// }
]
libraryItem: null,
availableHeight: 0,
marginTop: 0
}
},
watch: {
@@ -89,12 +48,7 @@ export default {
this.selectedTab = availableTabIds[0]
}
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
if (this.fetchOnShow) this.fetchFull()
return
}
this.fetchOnShow = false
this.audiobook = null
this.libraryItem = null
this.init()
this.registerListeners()
} else {
@@ -120,54 +74,117 @@ export default {
this.$store.commit('setEditModalTab', val)
}
},
tabs() {
return [
{
id: 'details',
title: this.$strings.HeaderDetails,
component: 'modals-item-tabs-details'
},
{
id: 'cover',
title: this.$strings.HeaderCover,
component: 'modals-item-tabs-cover'
},
{
id: 'chapters',
title: this.$strings.HeaderChapters,
component: 'modals-item-tabs-chapters',
mediaType: 'book'
},
{
id: 'episodes',
title: this.$strings.HeaderEpisodes,
component: 'modals-item-tabs-episodes',
mediaType: 'podcast'
},
{
id: 'files',
title: this.$strings.HeaderFiles,
component: 'modals-item-tabs-files'
},
{
id: 'match',
title: this.$strings.HeaderMatch,
component: 'modals-item-tabs-match'
},
{
id: 'tools',
title: this.$strings.HeaderTools,
component: 'modals-item-tabs-tools',
mediaType: 'book',
admin: true
},
{
id: 'schedule',
title: this.$strings.HeaderSchedule,
component: 'modals-item-tabs-schedule',
mediaType: 'podcast',
admin: true
}
]
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.id === 'download' && this.isMissing) return false
if ((tab.id === 'download' || tab.id === 'files' || tab.id === 'authors') && this.userCanDownload) return true
if (tab.id !== 'download' && tab.id !== 'files' && tab.id !== 'authors' && this.userCanUpdate) return true
if (tab.id === 'match' && this.userCanUpdate && this.showExperimentalFeatures) return true
if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
if (tab.admin && !this.userIsAdminOrUp) return false
if (tab.id === 'tools' && this.isMissing) return false
if ((tab.id === '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
})
},
height() {
var maxHeightAllowed = window.innerHeight - 150
return Math.min(maxHeightAllowed, 650)
return Math.min(this.availableHeight, 650)
},
tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
},
isMissing() {
return this.selectedAudiobook.isMissing
return this.selectedLibraryItem.isMissing
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook || {}
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedAudiobookId() {
return this.selectedAudiobook.id
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
title() {
return this.book.title || 'No Title'
return this.mediaMetadata.title || 'No Title'
},
bookshelfBookIds() {
return this.$store.state.bookshelfBookIds || []
},
currentBookshelfIndex() {
if (!this.bookshelfBookIds.length) return 0
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedLibraryItemId)
},
canGoPrev() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
@@ -181,15 +198,17 @@ export default {
if (this.currentBookshelfIndex - 1 < 0) return
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
this.processing = true
var prevBook = await this.$axios.$get(`/api/books/${prevBookId}`).catch((error) => {
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
this.$toast.error(errorMsg)
return null
})
this.processing = false
if (prevBook) {
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
this.$nextTick(this.init)
this.unregisterListeners()
this.libraryItem = prevBook
this.$store.commit('setSelectedLibraryItem', prevBook)
this.$nextTick(this.registerListeners)
} else {
console.error('Book not found', prevBookId)
}
@@ -198,41 +217,41 @@ export default {
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
this.processing = true
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
var nextBook = await this.$axios.$get(`/api/books/${nextBookId}`).catch((error) => {
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
this.$toast.error(errorMsg)
return null
})
this.processing = false
if (nextBook) {
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
this.$nextTick(this.init)
this.unregisterListeners()
this.libraryItem = nextBook
this.$store.commit('setSelectedLibraryItem', nextBook)
this.$nextTick(this.registerListeners)
} else {
console.error('Book not found', nextBookId)
}
},
selectTab(tab) {
if (this.selectedTab === tab) return
if (this.availableTabs.find((t) => t.id === tab)) {
this.selectedTab = tab
this.processing = false
}
},
audiobookUpdated() {
if (!this.show) this.fetchOnShow = true
else {
this.fetchFull()
}
libraryItemUpdated(expandedLibraryItem) {
this.libraryItem = expandedLibraryItem
},
init() {
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
this.fetchFull()
},
async fetchFull() {
try {
this.processing = true
this.audiobook = await this.$axios.$get(`/api/books/${this.selectedAudiobookId}`)
this.libraryItem = await this.$axios.$get(`/api/items/${this.selectedLibraryItemId}?expanded=1`)
this.processing = false
} catch (error) {
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
console.error('Failed to fetch audiobook', this.selectedLibraryItemId, error)
this.processing = false
this.show = false
}
@@ -245,20 +264,36 @@ export default {
}
},
registerListeners() {
window.addEventListener('orientationchange', this.orientationChange)
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
},
unregisterListeners() {
window.removeEventListener('orientationchange', this.orientationChange)
this.$eventBus.$off('modal-hotkey', this.hotkey)
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
},
orientationChange() {
setTimeout(this.setHeight, 50)
},
setHeight() {
const smAndBelow = window.innerWidth < 1024 && window.innerWidth > window.innerHeight
this.marginTop = smAndBelow ? 90 : 75
const heightModifier = smAndBelow ? 95 : 150
this.availableHeight = window.innerHeight - heightModifier
}
},
mounted() {},
mounted() {
this.setHeight()
},
beforeDestroy() {
this.unregisterListeners()
}
}
</script>
<style>
<style scoped>
.tab {
height: 40px;
}

View File

@@ -0,0 +1,37 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4">
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
<div v-if="!chapters.length" class="py-4 text-center">
<p class="mb-8 text-xl">{{ $strings.MessageNoChapters }}</p>
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">{{ $strings.ButtonAddChapters }}</ui-btn>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
chapters() {
return this.media.chapters || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {}
}
</script>

View File

@@ -1,24 +1,29 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
<div class="flex">
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap">
<div class="relative">
<covers-book-cover :audiobook="audiobook" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay -->
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
<span class="material-icons">delete</span>
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
<span class="material-icons text-2xl">delete</span>
</ui-tooltip>
</div>
</div>
</div>
<div class="flex-grow pl-6 pr-2">
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex items-center">
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected"
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">{{ $strings.ButtonSave }}</ui-btn>
</form>
</div>
@@ -26,14 +31,14 @@
<div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
<div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div :key="cover.path" class="m-0.5 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>
@@ -44,35 +49,35 @@
<form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" />
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
<div v-show="provider != 'itunes'" class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
</div>
</form>
<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>
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</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>
</div>
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
<p class="text-lg">Preview Cover</p>
<p class="text-lg">{{ $strings.HeaderPreviewCover }}</p>
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
<div class="flex justify-center py-4">
<covers-preview-cover :src="previewUpload" :width="240" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="absolute bottom-0 right-0 flex py-4 px-5">
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">Clear</ui-btn>
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">Upload</ui-btn>
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">{{ $strings.ButtonUpload }}</ui-btn>
</div>
</div>
</div>
@@ -82,7 +87,7 @@
export default {
props: {
processing: Boolean,
audiobook: {
libraryItem: {
type: Object,
default: () => {}
}
@@ -98,25 +103,11 @@ export default {
showLocalCovers: false,
previewUpload: null,
selectedFile: null,
providers: [
{
text: 'Google Books',
value: 'google'
},
{
text: 'Open Library',
value: 'openlibrary'
},
{
text: 'Audible',
value: 'audible'
}
],
provider: 'google'
}
},
watch: {
audiobook: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) {
@@ -134,23 +125,38 @@ export default {
this.$emit('update:processing', val)
}
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
},
searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
else if (this.provider == 'itunes') return this.$strings.LabelSearchTerm
return this.$strings.LabelSearchTitle
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
audiobookPath() {
return this.audiobook ? this.audiobook.path : null
isPodcast() {
return this.mediaType == 'podcast'
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
coverPath() {
return this.media.coverPath
},
mediaMetadata() {
return this.media.metadata || {}
},
libraryFiles() {
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
@@ -159,12 +165,11 @@ export default {
return this.$store.getters['user/getToken']
},
localCovers() {
return this.otherFiles
.filter((f) => f.filetype === 'image')
return this.libraryFiles
.filter((f) => f.fileType === 'image')
.map((file) => {
var _file = { ...file }
var imgRelPath = _file.path.replace(this.audiobookPath, '')
_file.localPath = `/s/book/${this.audiobookId}/${imgRelPath}`
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
return _file
})
}
@@ -176,7 +181,7 @@ export default {
form.set('cover', this.selectedFile)
this.$axios
.$post(`/api/books/${this.audiobook.id}/cover`, form)
.$post(`/api/items/${this.libraryItemId}/cover`, form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
@@ -209,17 +214,18 @@ export default {
},
init() {
this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) {
if (this.coversFound.length && (this.searchTitle !== this.mediaMetadata.title || this.searchAuthor !== this.mediaMetadata.authorName)) {
this.coversFound = []
this.hasSearched = false
}
this.imageUrl = this.book.cover || ''
this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.authorFL || ''
this.provider = localStorage.getItem('book-provider') || 'openlibrary'
this.imageUrl = this.media.coverPath || ''
this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
},
removeCover() {
if (!this.book.cover) {
if (!this.media.coverPath) {
this.imageUrl = ''
return
}
@@ -229,7 +235,7 @@ export default {
this.updateCover(this.imageUrl)
},
async updateCover(cover) {
if (cover === this.book.cover) {
if (cover === this.coverPath) {
console.warn('Cover has not changed..', cover)
return
}
@@ -237,9 +243,21 @@ export default {
this.isProcessing = true
var success = false
// Download cover from url and use
if (cover.startsWith('http:') || cover.startsWith('https:')) {
success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
if (!cover) {
// Remove cover
success = await this.$axios
.$delete(`/api/items/${this.libraryItemId}/cover`)
.then(() => true)
.catch((error) => {
console.error('Failed to remove cover', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
})
} else if (cover.startsWith('http:') || cover.startsWith('https:')) {
// Download cover from url and use
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
console.error('Failed to download cover from url', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
@@ -249,11 +267,9 @@ export default {
} else {
// Update local cover url
const updatePayload = {
book: {
cover: cover
}
cover
}
success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
@@ -263,15 +279,16 @@ export default {
}
if (success) {
this.$toast.success('Update Successful')
this.$emit('close')
// this.$emit('close')
} else {
this.imageUrl = this.book.cover || ''
this.imageUrl = this.media.coverPath || ''
}
this.isProcessing = false
},
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
if (this.isPodcast) searchQuery += '&podcast=1'
return searchQuery
},
persistProvider() {
@@ -286,33 +303,20 @@ export default {
this.persistProvider()
this.isProcessing = true
var searchQuery = this.getSearchQuery()
var results = await this.$axios.$get(`/api/search/covers?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
const searchQuery = this.getSearchQuery()
const results = await this.$axios
.$get(`/api/search/covers?${searchQuery}`)
.then((res) => res.results)
.catch((error) => {
console.error('Failed', error)
return []
})
this.coversFound = results
this.isProcessing = false
this.hasSearched = true
},
setCover(coverFile) {
this.isProcessing = true
this.$axios
.$patch(`/api/books/${this.audiobook.id}/coverfile`, coverFile)
.then((data) => {
console.log('response data', data)
if (data && typeof data === 'string') {
this.$toast.success(data)
}
this.isProcessing = false
})
.catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
this.isProcessing = false
})
this.updateCover(coverFile.metadata.path)
}
}
}

View File

@@ -0,0 +1,243 @@
<template>
<div class="w-full h-full relative">
<div id="formWrapper" class="w-full overflow-y-auto">
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
</div>
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">{{ $strings.ButtonRemove }}</ui-btn>
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
<div class="flex-grow" />
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
</ui-tooltip>
<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">{{ $strings.ButtonReScan }}</ui-btn>
</ui-tooltip>
<!-- desktop -->
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
<!-- mobile -->
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
resettingProgress: false,
isScrollable: false,
rescanning: false,
quickMatching: false
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
isFile() {
return !!this.libraryItem && this.libraryItem.isFile
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isMissing() {
return !!this.libraryItem && !!this.libraryItem.isMissing
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
mediaMetadata() {
return this.media.metadata || {}
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
libraryId() {
return this.libraryItem ? this.libraryItem.libraryId : null
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
},
libraryScan() {
if (!this.libraryId) return null
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
}
},
methods: {
quickMatch() {
if (this.quickMatching) return
if (!this.$refs.itemDetailsEdit) return
var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()
if (!title) {
this.$toast.error('Must have a title for quick match')
return
}
this.quickMatching = true
var matchOptions = {
provider: this.libraryProvider,
title: title || null,
author: author || null
}
this.$axios
.$post(`/api/items/${this.libraryItemId}/match`, matchOptions)
.then((res) => {
this.quickMatching = false
if (res.warning) {
this.$toast.warning(res.warning)
} else if (res.updated) {
this.$toast.success('Item details updated')
} else {
this.$toast.info('No updates were made')
}
})
.catch((error) => {
var errMsg = error.response ? error.response.data || '' : ''
console.error('Failed to match', error)
this.$toast.error(errMsg || 'Failed to match')
this.quickMatching = false
})
},
rescan() {
this.rescanning = true
this.$axios
.$post(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
},
async saveAndClose() {
const wasUpdated = await this.save()
if (wasUpdated !== null) this.$emit('close')
},
async save() {
if (this.isProcessing) {
return null
}
if (!this.$refs.itemDetailsEdit) {
return null
}
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
if (!updatedDetails.hasChanges) {
this.$toast.info('No changes were made')
return false
}
return this.updateDetails(updatedDetails)
},
async updateDetails(updatedDetails) {
this.isProcessing = true
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updateResult) {
if (updateResult.updated) {
this.$toast.success('Item details updated')
return true
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
}
return false
},
removeItem() {
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}`)
.then(() => {
console.log('Item removed')
this.$toast.success('Item Removed')
this.$emit('close')
this.isProcessing = false
})
.catch((error) => {
console.error('Remove item failed', error)
this.isProcessing = false
})
}
},
checkIsScrollable() {
this.$nextTick(() => {
var formWrapper = document.getElementById('formWrapper')
if (formWrapper) {
if (formWrapper.scrollHeight > formWrapper.clientHeight) {
this.isScrollable = true
} else {
this.isScrollable = false
}
}
})
},
setResizeObserver() {
try {
var formWrapper = document.getElementById('formWrapper')
if (formWrapper) {
this.$nextTick(() => {
const resizeObserver = new ResizeObserver(() => {
this.checkIsScrollable()
})
resizeObserver.observe(formWrapper)
})
}
} catch (error) {
console.error('Failed to set resize observer')
}
}
},
mounted() {
this.setResizeObserver()
}
}
</script>
<style scoped>
#formWrapper {
height: calc(100% - 80px);
max-height: calc(100% - 80px);
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4">
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" :label="$strings.LabelLookForNewEpisodesAfterDate" class="max-w-xs mr-2" />
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" :label="$strings.LabelLimit" 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 }">{{ $strings.LabelLimit }}</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">{{ $strings.ButtonCheckAndDownloadNewEpisodes }}</ui-btn>
</div>
<div v-if="episodes.length" class="w-full p-4 bg-primary">
<p>{{ $strings.HeaderEpisodes }}</p>
</div>
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
<table v-else class="text-sm tracksTable">
<tr>
<th class="text-left">Sort #</th>
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
<th class="text-center w-28">{{ $strings.EpisodeDuration }}</th>
<th class="text-center w-28">{{ $strings.EpisodeSize }}</th>
</tr>
<tr v-for="episode in episodes" :key="episode.id">
<td class="text-left">
<p class="px-4">{{ episode.index }}</p>
</td>
<td class="text-left">
<p class="px-4">{{ episode.episode }}</p>
</td>
<td>
{{ episode.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(episode.duration) }}
</td>
<td class="font-mono text-center">
{{ $bytesPretty(episode.size) }}
</td>
</tr>
</table>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
checkingNewEpisodes: false,
lastEpisodeCheckInput: null,
maxEpisodesToDownload: 3
}
},
watch: {
lastEpisodeCheck: {
handler(newVal) {
if (newVal) {
this.setLastEpisodeCheckInput()
}
}
}
},
computed: {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
autoDownloadEpisodes() {
return !!this.media.autoDownloadEpisodes
},
lastEpisodeCheck() {
return this.media.lastEpisodeCheck
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
episodes() {
return this.media.episodes || []
}
},
methods: {
async checkForNewEpisodes() {
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()
// If last episode check changed then update it first
if (lastEpisodeCheck && lastEpisodeCheck !== this.lastEpisodeCheck) {
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, { lastEpisodeCheck }).catch((error) => {
console.error('Failed to update', error)
return false
})
console.log('updateResult', updateResult)
}
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/checknew?limit=${this.maxEpisodesToDownload}`)
.then((response) => {
if (response.episodes && response.episodes.length) {
console.log('New episodes', response.episodes.length)
this.$toast.success(`${response.episodes.length} new episodes found!`)
} else {
this.$toast.info('No new episodes found')
}
this.checkingNewEpisodes = false
})
.catch((error) => {
console.error('Failed', error)
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
this.$toast.error(errorMsg)
this.checkingNewEpisodes = false
})
},
setLastEpisodeCheckInput() {
this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, "yyyy-MM-dd'T'HH:mm") : null
}
},
mounted() {
this.setLastEpisodeCheckInput()
}
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
tracks: [],
showFullPath: false
}
},
watch: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
media() {
return this.libraryItem.media || {}
},
libraryFiles() {
return this.libraryItem.libraryFiles || []
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.libraryItem.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
}
},
methods: {
init() {
this.tracks = this.media.tracks || []
}
}
}
</script>

View File

@@ -0,0 +1,562 @@
<template>
<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 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="$strings.LabelProvider" small />
</div>
<div class="flex-grow md:w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
</div>
<div v-show="provider != 'itunes'" class="w-60 md:w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
</div>
</form>
<div v-show="processing" class="flex h-full items-center justify-center">
<p>{{ $strings.MessageLoading }}</p>
</div>
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
<p>{{ $strings.MessageNoResults }}</p>
</div>
<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="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">{{ $strings.HeaderUpdateDetails }}</p>
</div>
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
</a>
</div>
</div>
<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="$strings.LabelTitle" />
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.title || '' }}</p>
</div>
</div>
<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="$strings.LabelSubtitle" />
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.subtitle || '' }}</p>
</div>
</div>
<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="$strings.LabelAuthor" />
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.authorName || '' }}</p>
</div>
</div>
<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="$strings.LabelNarrators" />
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.narratorName || '' }}</p>
</div>
</div>
<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="$strings.LabelDescription" />
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</p>
</div>
</div>
<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="$strings.LabelPublisher" />
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.publisher || '' }}</p>
</div>
</div>
<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="$strings.LabelPublishYear" />
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.publishedYear || '' }}</p>
</div>
</div>
<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" :disabled="!selectedMatchUsage.series" />
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.seriesName || '' }}</p>
</div>
</div>
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-multi-select v-model="selectedMatch.genres" :items="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
</div>
</div>
<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="$strings.LabelTags" />
<p v-if="media.tags" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ media.tags.join(', ') }}</p>
</div>
</div>
<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="$strings.LabelLanguage" />
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.language || '' }}</p>
</div>
</div>
<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">{{ $strings.LabelCurrently }} {{ mediaMetadata.isbn || '' }}</p>
</div>
</div>
<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">{{ $strings.LabelCurrently }} {{ mediaMetadata.asin || '' }}</p>
</div>
</div>
<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">{{ $strings.LabelCurrently }} {{ mediaMetadata.itunesId || '' }}</p>
</div>
</div>
<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">{{ $strings.LabelCurrently }} {{ mediaMetadata.feedUrl || '' }}</p>
</div>
</div>
<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">{{ $strings.LabelCurrently }} {{ mediaMetadata.itunesPageUrl || '' }}</p>
</div>
</div>
<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="$strings.LabelReleaseDate" />
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
</div>
</div>
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
</div>
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
libraryItemId: null,
searchTitle: null,
searchAuthor: null,
lastSearch: null,
provider: 'google',
searchResults: [],
hasSearched: false,
selectedMatch: null,
selectedMatchOrig: null,
selectedMatchUsage: {
title: true,
subtitle: true,
cover: true,
author: true,
narrator: true,
description: true,
publisher: true,
publishedYear: true,
series: true,
genres: true,
tags: true,
language: true,
explicit: true,
asin: true,
isbn: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
feedUrl: true,
releaseDate: true
},
selectAll: true
}
},
watch: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
seriesItems: {
get() {
return this.selectedMatch.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
name: se.series,
sequence: se.sequence || ''
}
})
},
set(val) {
this.selectedMatch.series = val
}
},
bookCoverAspectRatio() {
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.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
else if (this.provider == 'itunes') return this.$strings.LabelSearchTerm
return this.$strings.LabelSearchTitle
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
isPodcast() {
return this.mediaType == 'podcast'
}
},
methods: {
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)
} catch (error) {
console.error('PersistProvider', error)
}
},
getSearchQuery() {
if (this.isPodcast) return `term=${this.searchTitle}`
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
submitSearch() {
if (!this.searchTitle) {
this.$toast.warning('Search title is required')
return
}
this.persistProvider()
this.runSearch()
},
async runSearch() {
const searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.searchResults = []
this.isProcessing = true
this.lastSearch = searchQuery
const searchEntity = this.isPodcast ? 'podcast' : 'books'
let results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
console.error('Failed', error)
return []
})
// console.log('Got search results', results)
results = (results || []).filter((res) => {
return !!res.title
})
if (this.isPodcast) {
// Map to match PodcastMetadata keys
results = results.map((res) => {
res.itunesPageUrl = res.pageUrl || null
res.itunesId = res.id || null
res.author = res.artistName || null
res.explicit = res.explicit || false
return res
})
}
this.searchResults = results || []
this.isProcessing = false
this.hasSearched = true
},
initSelectedMatchUsage() {
this.selectedMatchUsage = {
title: true,
subtitle: true,
cover: true,
author: true,
narrator: true,
description: true,
publisher: true,
publishedYear: true,
series: true,
genres: true,
tags: true,
language: true,
explicit: true,
asin: true,
isbn: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
feedUrl: true,
releaseDate: true
}
// Load saved selected match from local storage
try {
let savedSelectedMatchUsage = localStorage.getItem('selectedMatchUsage')
if (!savedSelectedMatchUsage) return
savedSelectedMatchUsage = JSON.parse(savedSelectedMatchUsage)
for (const key in savedSelectedMatchUsage) {
if (this.selectedMatchUsage[key] !== undefined) {
this.selectedMatchUsage[key] = !!savedSelectedMatchUsage[key]
}
}
} catch (error) {
console.error('Failed to load saved selectedMatchUsage', error)
}
this.checkboxToggled()
},
init() {
this.clearSelectedMatch()
this.initSelectedMatchUsage()
if (this.libraryItem.id !== this.libraryItemId) {
this.searchResults = []
this.hasSearched = false
this.libraryItemId = this.libraryItem.id
}
if (!this.libraryItem.media || !this.libraryItem.media.metadata.title) {
this.searchTitle = null
this.searchAuthor = null
return
}
this.searchTitle = this.libraryItem.media.metadata.title
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
// Prefer using ASIN if set and using audible provider
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
this.searchTitle = this.libraryItem.media.metadata.asin
this.searchAuthor = ''
}
if (this.searchTitle) {
this.submitSearch()
}
},
selectMatch(match) {
if (match) {
if (match.series) {
if (!match.series.length) {
delete match.series
} else {
match.series = match.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
name: se.series,
sequence: se.sequence || ''
}
})
}
}
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 = {}
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
if (key === 'series') {
if (!Array.isArray(this.selectedMatch[key])) {
console.error('Invalid series in selectedMatch', this.selectedMatch[key])
} else {
var seriesPayload = []
this.selectedMatch[key].forEach((seriesItem) =>
seriesPayload.push({
id: seriesItem.id,
name: seriesItem.name,
sequence: seriesItem.sequence
})
)
updatePayload.metadata.series = seriesPayload
}
} else if (key === 'author' && !this.isPodcast) {
var authors = this.selectedMatch[key]
if (!Array.isArray(authors)) {
authors = authors.split(',').map((au) => au.trim())
}
var authorPayload = []
authors.forEach((authorName) =>
authorPayload.push({
id: `new-${Math.floor(Math.random() * 10000)}`,
name: authorName
})
)
updatePayload.metadata.authors = authorPayload
} else if (key === 'narrator') {
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'genres') {
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
updatePayload.metadata.genres = [...this.selectedMatch[key]]
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'itunesId') {
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
} else {
updatePayload.metadata[key] = this.selectedMatch[key]
}
}
}
return updatePayload
},
async submitMatchUpdate() {
var updatePayload = this.buildMatchUpdatePayload()
if (!Object.keys(updatePayload).length) {
return
}
console.log('Match payload', updatePayload)
this.isProcessing = true
// Persist in local storage
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
if (updatePayload.metadata.cover) {
const coverPayload = {
url: updatePayload.metadata.cover
}
const success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success(this.$strings.ToastItemCoverUpdateSuccess)
} else {
this.$toast.error(this.$strings.ToastItemCoverUpdateFailed)
}
console.log('Updated cover')
delete updatePayload.metadata.cover
}
if (Object.keys(updatePayload).length) {
const mediaUpdatePayload = updatePayload
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (updateResult) {
if (updateResult.updated) {
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
} else {
this.$toast.info(this.$strings.ToastItemDetailsUpdateUnneeded)
}
this.clearSelectedMatch()
this.$emit('selectTab', 'details')
} else {
this.$toast.error(this.$strings.ToastItemDetailsUpdateFailed)
}
} else {
this.clearSelectedMatch()
}
this.isProcessing = false
},
clearSelectedMatch() {
this.selectedMatch = null
this.selectedMatchOrig = null
}
}
}
</script>
<style>
.matchListWrapper {
height: calc(100% - 124px);
}
@media (min-width: 768px) {
.matchListWrapper {
height: calc(100% - 80px);
}
}
</style>

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