Compare commits

...

617 Commits

Author SHA1 Message Date
advplyr
8b27c726d5 Version bump 2.9.0 2024-04-21 16:45:13 -05:00
advplyr
68418c1d3b Merge pull request #2820 from apocer/openid_signing_algorithm
Add option to set Signing Algorithm for OpenID Authentification
2024-04-21 16:07:30 -05:00
advplyr
a8af6db3d6 Format update of authentication page for supported algorithms 2024-04-21 16:05:41 -05:00
advplyr
af856ce1ec Merge branch 'master' into openid_signing_algorithm 2024-04-21 15:38:33 -05:00
advplyr
aae8e7535a Fix:Home page always showing horizontal scrollbar 2024-04-21 15:36:01 -05:00
advplyr
359a2752d8 Fix:Server crash when scanning in invalid epub #2856 2024-04-21 15:07:53 -05:00
advplyr
9102a0045f Merge pull request #2803 from nichwall/vacuum_bundling
OpenAPI Spec, try 2
2024-04-20 14:57:32 -05:00
advplyr
b124d61826 Update yaml docs to include BearerAuth 2024-04-20 14:57:38 -05:00
advplyr
8e6ead59ce Update yaml keys to camelCase 2024-04-20 14:55:57 -05:00
advplyr
f74d741821 Fix:Server crash when updating media with external cover url that fails to download #2857 2024-04-20 11:34:21 -05:00
advplyr
15f83986e7 Update library stats previewicons padding 2024-04-18 17:45:47 -05:00
advplyr
a57fe42dff Update:Library stats to format numbers using selected language #2861, clean up UI for library stats preview icons 2024-04-18 17:30:06 -05:00
advplyr
b03198abd9 Add comments/jsdocs to i18n.js 2024-04-18 17:06:12 -05:00
advplyr
ad30977781 Fix:Custom metadata provider including extra curly bracket in query string #2860 2024-04-18 16:16:59 -05:00
advplyr
dbe10382fd Update:Podcast episode downloader only takes audio streams #2858 2024-04-17 17:09:36 -05:00
advplyr
f0caf1a933 Update:Book matches support lowercase letters in audible ASIN #2849 2024-04-16 16:39:57 -05:00
advplyr
0f7c99d989 Fix:Retry transcode forcing AAC to handle the bad audible m4bs #2720 2024-04-15 15:14:30 -05:00
advplyr
60c65008dc Fix:Match all books only matching first 100 #2096 2024-04-14 17:19:21 -05:00
advplyr
c4fd4ff9de Fix:Update metadata.json when using item metadata utils #2837 2024-04-12 17:34:10 -05:00
advplyr
29fc503503 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-04-11 17:29:31 -05:00
advplyr
bca49616e1 Update:Podcast episode audio file ID3 tags use comment and description tag for description instead of subtitle #2843 2024-04-11 17:29:23 -05:00
advplyr
cb49c17fc5 Merge pull request #2841 from nichwall/i18n-integration-fix
I18n integration fix
2024-04-11 17:17:13 -05:00
Nicholas Wallace
9e1686232b Example: es.json is fixed 2024-04-11 01:34:32 +00:00
Nicholas Wallace
f702358bbd Example: missing key in es.json 2024-04-11 01:33:38 +00:00
Nicholas Wallace
9a0b8de354 Example: bad key in es.json 2024-04-11 01:31:22 +00:00
Nicholas Wallace
6ed6fff6bd Update i18n workflow to 1.2.0 2024-04-11 01:29:00 +00:00
Nicholas Wallace
75007bb371 Fix: i18n-integration not running on PRs 2024-04-11 01:28:40 +00:00
advplyr
df9da095ef Map i18n strings to uk.json 2024-04-10 17:27:38 -05:00
advplyr
64c98722c3 Merge pull request #2840 from soaibsafi/master
Add Bengali translation
2024-04-10 17:25:59 -05:00
advplyr
36c1a8b2df Fix bn i18n string keys 2024-04-10 17:23:12 -05:00
soaibsafi
710d6af4b3 Adds Bengali translation 2024-04-10 19:46:39 +02:00
advplyr
cd7ecb9933 Update:User permission tags accessible to user are alphabetized #2667 2024-04-09 17:54:09 -05:00
apocer
f75f0b8cc8 show dropdown if issuer has list of algorithms 2024-04-09 22:29:06 +02:00
advplyr
e60d2a9858 Add:Podcast library filter for languages and show language on podcast item page 2024-04-08 15:48:41 -05:00
advplyr
04993dd63d Update:Show language on book item page w/ link to filter #2834 2024-04-08 15:38:34 -05:00
advplyr
41af913280 Update:Edit item cover tab UI for small screen sizes #2832 2024-04-07 16:24:23 -05:00
advplyr
8dc0f2c67c Fix:Duplicate keys error when the same library item is shown twice in continue series 2024-04-06 17:48:40 -05:00
advplyr
fc196180b3 Merge pull request #2805 from rasmuslos/master
Add client name to possible device info lines
2024-04-05 16:50:26 -05:00
advplyr
4a127d35b9 Update:Add client name and version to sessions table and session modal 2024-04-05 16:50:15 -05:00
advplyr
1525fdf4f6 Merge pull request #2821 from lkiesow/series-separator
Separator between multiple series
2024-04-04 17:55:53 -05:00
advplyr
8a29c998da Update item page series comma separated list to not include comma in link 2024-04-04 17:54:43 -05:00
Lars Kiesow
f56d9f128f Separator between multiple series
If a book is part of multiple series, this patch adds a separator
between the series on the library item details page. With no separator,
it is not immediately clear that they are separate series.
2024-04-04 21:55:52 +02:00
advplyr
c5785e9c20 Update:Increase breakpoint for player to change buttons to two lines #2799 2024-04-03 18:41:41 -05:00
advplyr
0ca91ecfff Merge pull request #2817 from springsunx/patch-2
Update zh-cn.json
2024-04-03 18:15:29 -05:00
basti
304d0f6d43 id_token_signed_respo... should be in new Client 2024-04-03 22:52:49 +02:00
basti
6c9a811472 Add ui and settings for OpenID Signing Algorithm 2024-04-03 16:18:13 +02:00
SunX
116a7fb994 Update zh-cn.json 2024-04-03 09:55:33 +08:00
advplyr
8e46181ba0 Update:Adding tooltips to player controls forward/backward and next/prev #2800 2024-04-02 18:05:44 -05:00
advplyr
a336686e42 Merge pull request #2802 from pmangro/master
[PT-BR] OpenID permission strings
2024-04-01 17:14:16 -05:00
Rasmus Krämer
c8957fe373 Add client name to possible device info lines 2024-04-01 16:20:09 +02:00
Nicholas Wallace
ca7eaf9750 OpenAPI spec readme 2024-04-01 00:44:51 +00:00
Nicholas Wallace
74dd24febf Bundled spec 2024-04-01 00:26:55 +00:00
Nicholas Wallace
7b856474af Rename base document 2024-03-31 22:48:58 +00:00
Nicholas Wallace
c7ac12a67a Split schema to sub files 2024-03-31 22:47:14 +00:00
pmangro
3264359771 [PT-BR] OpenID permission strings 2024-03-31 19:44:53 -03:00
advplyr
c7cc994532 Fix:Handle enabling/disabling library watchers #2775 2024-03-31 14:57:55 -05:00
Nicholas Wallace
afe40be957 Initial large file 2024-03-30 23:47:13 +00:00
advplyr
a9c9c447f1 Merge pull request #2769 from Sapd/openid-permissions
OpenID: Integrate permissions (Fixes #2523)
2024-03-30 14:38:32 -05:00
advplyr
aa1aeacc09 Map new translation strings 2024-03-30 14:26:55 -05:00
advplyr
fc595bd799 Updates to authentication page for mobile screen sizes 2024-03-30 14:25:38 -05:00
advplyr
a5d7a81519 Clean up formatting of advanced group/permission claims on authentication page 2024-03-30 14:17:34 -05:00
advplyr
7e8fd91fc5 Update OIDC advanced permissions check to only perform an update on changes
- Update permissions example to use UUIDv4 strings for allowedLibraries
- More validation on advanced permission JSON to ensure arrays are array of strings
- Only set allowedTags and allowedLibraries if the corresponding access all permission is false
2024-03-30 14:04:02 -05:00
advplyr
c2ed0b7d3d Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-03-30 11:40:43 -05:00
advplyr
aefda8bd51 Fix:Local sessions set date and dayOfWeek using the updatedAt timestamp passed in from the client #2795 2024-03-30 11:40:35 -05:00
advplyr
93bec282d2 Merge pull request #1888 from jorgectf/jorgectf/add-codeql-workflow
Add CodeQL workflow
2024-03-29 16:47:07 -05:00
advplyr
1396a432a4 Merge pull request #2797 from mikiher/rtl-fixes
Add dir="auto" attribute where RTL display is needed
2024-03-29 16:08:50 -05:00
Denis Arnst
90e1283058 OpenID: Allow email_verified null and also check username
Only disallow when email_verified explicitly false
Also check username besides preferred_username, even when its not included in OIDC checks (synology uses username)
2024-03-29 15:11:56 +01:00
Denis Arnst
8cd50d5684 OpenID: Don't downgrade root 2024-03-29 14:51:34 +01:00
advplyr
50bd2648aa Fix:Server crash on matching book with an author name ending in comma #2796 2024-03-28 17:00:07 -05:00
mikiher
33254654d5 Add dir="auto" attribute where it makes sense 2024-03-28 23:56:59 +02:00
Denis Arnst
617b8f4487 OpenID: Rename tags switch 2024-03-28 16:16:26 +01:00
advplyr
f9b95bb003 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-03-27 16:11:57 -05:00
advplyr
740640884f Update:Support for comic files with webp images #2792 2024-03-27 16:11:47 -05:00
advplyr
86fea5c667 Merge pull request #2791 from mikiher/abs-windows-dispatch
Workflow to dispatch an ABS windows event on server release
2024-03-27 15:37:52 -05:00
mikiher
33e4b51aee Revert "add dummy pull_request event for the workflow to appear in the list"
This reverts commit 1cf0bd0f01.
2024-03-27 13:38:17 +02:00
mikiher
1cf0bd0f01 add dummy pull_request event for the workflow to appear in the list 2024-03-27 13:30:00 +02:00
mikiher
8ce5a5cdbd Add workflow to dispatch an abs-windows event 2024-03-27 13:18:02 +02:00
advplyr
fc26b7af0a Merge pull request #2789 from justcallmelarry/bugfix/corretly-working-limit-for-continue-series-toggle
Fix book limit for the Continue Series shelf (with skip earlier books toggle active)
2024-03-25 15:45:45 -05:00
Lauri Vuorela
2d68fa2c27 fix book limit for the contiue series shelf 2024-03-25 16:32:29 +01:00
advplyr
f241cb2280 Merge pull request #2787 from nichwall/translation_faq_link
Translation guide link added to readme
2024-03-24 05:07:08 -05:00
Nicholas Wallace
125346bb5c Translation guide link added to readme 2024-03-24 03:11:00 +00:00
advplyr
b60f62cebf Merge pull request #2784 from rasmuslos/patch-1
Fix custom metadata provider crash
2024-03-23 15:27:51 -05:00
advplyr
51ff62356d Merge pull request #2773 from mikiher/fix-library-files-inconsistencies
Fix library files inconsistencies
2024-03-23 15:25:33 -05:00
advplyr
f827aa97f8 Update library scanner findLibraryItemByItemToFileInoMatch query to iterate through json objects comparing inodes 2024-03-23 14:56:32 -05:00
mikiher
68276fe30b Fix handling of file moves from root folder to sub folder and back 2024-03-23 18:31:52 +02:00
Rasmus Krämer
961533765f Fix custom metadata provider crash 2024-03-23 14:54:34 +01:00
advplyr
c1bbec22f0 Merge pull request #2780 from nichwall/i18n_integration_check
Add i18n integration workflow
2024-03-22 15:21:14 -05:00
Nicholas Wallace
7d0eb215d6 Add integration workflow 2024-03-22 01:28:50 +00:00
advplyr
ff5226fa93 Update:Remove unused missing/invalid audiobook parts logic and keys 2024-03-21 14:38:52 -05:00
advplyr
8d7530254c Update:Re-order chapters table infront of audio tracks table on book item page #2778 2024-03-21 14:23:49 -05:00
advplyr
6957b4baf6 Merge pull request #2777 from burghy86/patch-14
Update it.json
2024-03-21 08:41:34 -05:00
burghy86
01c8d42291 Update it.json
month fix string
2024-03-21 12:52:49 +01:00
advplyr
1e21847852 Merge pull request #2772 from cor-bee/master
Add Ukrainian Translation and Podcast Region
2024-03-20 16:39:30 -05:00
mikiher
1bee082720 Update libraryFolderID correctly in scanFolderUpdates 2024-03-20 11:40:50 +02:00
Illia Pyshniak
b0a9bed15a Update i18n.js
Add Ukrainian language and podcast region
2024-03-19 23:18:34 +02:00
Illia Pyshniak
1d7434cbbb Create uk.json 2024-03-19 23:12:29 +02:00
Denis Arnst
1646f0ebc2 OpenID: Ignore admin for advanced permissions
Also removed some semicolons
2024-03-19 19:35:34 +01:00
Denis Arnst
50330b0a60 Auth: Add translations 2024-03-19 19:18:47 +01:00
Denis Arnst
f661e0835c Auth: Simplify Code 2024-03-19 19:18:38 +01:00
mikiher
9511122bae Fix LibraryItem and Media file update logic for library scans 2024-03-19 19:28:26 +02:00
Denis Arnst
56f1bfef50 Auth/OpenID: Implement Permissions via OpenID
* Ability to set group
* Ability to set more advanced permissions
* Modified TextInputWithLabel to provide an ability to specify a different placeholder then the name
2024-03-19 17:57:24 +01:00
advplyr
8e5b7504ae Merge pull request #2760 from pmangro/Continue-series
[PT-BR] Continue Series
2024-03-17 19:22:58 -05:00
advplyr
0a0006f949 Merge pull request #2756 from arcmagedr/master
Add Hebrew translation json and Hebrew to i18n.js
2024-03-17 19:22:21 -05:00
advplyr
5b836dfa28 Remove duplicate he language code 2024-03-17 19:19:52 -05:00
advplyr
8396900178 Merge pull request #2757 from mikiher/fix-unit-tests-flow
Change unit tests workflow to include conditional checkout steps
2024-03-17 19:14:41 -05:00
pmangro
8f80948211 [PT-BR] Continue Series 2024-03-17 19:09:16 -03:00
arcmagedr
4ad09ec3d8 Merge branch 'advplyr:master' into master 2024-03-17 22:26:49 +02:00
dor
be4eb28b21 finished proofing Hebrew translation 2024-03-17 22:25:49 +02:00
mikiher
f938fca2c7 Fix bug in workflow_dispatch checkout step 2024-03-17 07:57:28 +02:00
mikiher
d562f6a69f Change unit-tests.yml workflow to include conditional checkout step 2024-03-17 07:36:13 +02:00
advplyr
166454ef43 Version bump v2.8.1 2024-03-16 17:15:33 -05:00
advplyr
d5c854d606 Update:Add robots.txt and noindex meta tag 2024-03-16 16:35:05 -05:00
advplyr
eace46bf55 Merge pull request #2688 from mfcar/mf/loginPage
Update Login Page with Logo and Input Form Styling
2024-03-16 15:57:45 -05:00
advplyr
b9ffce166e Login page add overflow scroll for mobile landscape, update z index for logo 2024-03-16 15:55:13 -05:00
advplyr
9713e94aed Reformat login page with logo in top left 2024-03-16 15:41:35 -05:00
advplyr
d71bc89c9d Merge branch 'master' into mf/loginPage 2024-03-16 15:24:22 -05:00
advplyr
a2b2a2d060 Fix:Applying backup not properly overwriting existing sqlite file
- Fixed resetting api cache on backup
- Added loading indicator in backups table
- Fixed apply backup api not responding with 200 http status code
- Added additional logging and failsafes
2024-03-16 15:12:33 -05:00
dor
752268effb mid point proofing 2024-03-16 21:00:44 +02:00
dor
9e3b3f3e12 add Hebrew translation json and Hebrew to i18n.js 2024-03-16 19:26:22 +02:00
advplyr
88f9533b37 Fix:HLS.js retry fragments #2748 #2720 2024-03-15 17:10:43 -05:00
advplyr
630ece82ad Fix:Chapter modal scroll to current chapter 2024-03-15 14:35:09 -05:00
advplyr
5777184cae Merge pull request #2745 from mikiher/unit-tests-flow
Unit tests flow
2024-03-15 14:20:57 -05:00
mfcar
a76da14fb0 Merge branch 'refs/heads/master' into mf/loginPage 2024-03-15 08:14:45 +00:00
mikiher
0c612b4836 Update unit test workflow to include push event 2024-03-15 09:51:40 +02:00
mikiher
a1af672c7c Add unit test workflow 2024-03-15 08:50:51 +02:00
advplyr
5fcd23409a Update:dev.js in devcontainer to include the SkipBinariesCheck flag #2741 2024-03-14 16:32:23 -05:00
advplyr
99f0799a11 Update:Adding support for skipping check for ffmpeg/ffprobe binaries with environment variable SKIP_BINARIES_CHECK
- Set SKIP_BINARIES_CHECK=1 env variable to skip
- Or set SkipBinariesCheck: true in dev.js #2741
2024-03-14 16:29:01 -05:00
advplyr
316aeba1b0 Merge pull request #2740 from Schiriki123/master
Add name labels to login form
2024-03-14 15:40:45 -05:00
advplyr
bfd4a378f3 Merge pull request #2737 from justcallmelarry/feature/add-toggle-for-skipping-earlier-instalments-in-continue-series
Add library toggle setting for skipping earlier instalments in Continue Series
2024-03-14 14:40:20 -05:00
advplyr
521db90ae0 Update JSDocs, remove unused libraryId replacement 2024-03-14 14:37:24 -05:00
advplyr
d02fc2debe Update continue series skip earlier books query attribute to look for finished books, update wording on help text, map translations 2024-03-14 14:27:33 -05:00
advplyr
e6c21c5be1 Merge pull request #2742 from mikiher/broken-binary-manager-test
Fix broken BinaryManager.isBinaryGood test
2024-03-14 13:13:59 -05:00
advplyr
91248b496e Merge pull request #2734 from mikiher/fix-sequence-cleanup
Make series sequence cleanup slighlty less aggressive
2024-03-14 13:12:04 -05:00
mikiher
f7ae7783bd Fix broken BinaryManager.isBinaryGood test 2024-03-14 19:58:42 +02:00
mikiher
ae395497a5 Add tests for cleanSeriesSequence 2024-03-14 19:37:51 +02:00
mikiher
8826d3af62 fix cleanSeriesSequence method to extract first numeric value 2024-03-14 19:36:51 +02:00
Lauri Vuorela
65153fae9d var => let 2024-03-14 09:42:50 +01:00
Lauri Vuorela
d4c1bc5dfc use already fetched library settings, only fetch maxSequence if setting is turned on 2024-03-14 09:41:48 +01:00
Schiriki
d6f13513ae Add name labels to login form 2024-03-13 23:46:56 +01:00
advplyr
2584c3b432 Merge pull request #2733 from kaldigo/master
Added isbn to CustomProviderAdapter
2024-03-13 17:21:51 -05:00
advplyr
b54421412d Merge pull request #2738 from Sapd/auth-fix
Auth: Fix crash on missing logout URL
2024-03-13 17:18:35 -05:00
advplyr
e2451a3281 Merge pull request #2732 from den13501/i18n-add-zhTW
Add traditional Chinese(zh-TW) to i18n
2024-03-12 17:47:04 -05:00
advplyr
dbf4bd5c3d Merge pull request #2691 from lkiesow/hash-in-filename
Fix file names with URL control characters
2024-03-12 17:40:37 -05:00
Denis Arnst
2a722ab163 Auth: Fix crash on missing logout URL
When using OpenID
Also added debug information on openid errors
2024-03-12 18:07:13 +01:00
Lauri Vuorela
c83399c7b5 use the toggle to not show earlier works than the ones already read 2024-03-12 17:04:26 +01:00
Lauri Vuorela
a814e45150 add a toggle for the new continue series setting 2024-03-12 17:00:21 +01:00
mikiher
29e9216bb1 Make series sequence cleanup slighlty less aggressive 2024-03-12 13:17:52 +02:00
Kaldigo
94d1732b0d Added isbn to CustomProviderAdapter 2024-03-12 08:18:52 +00:00
-Shiken-
7610084627 Update zh-tw.json
fix
2024-03-12 15:39:01 +08:00
-Shiken-
d840905a97 Create zh-tw.json
add traditional Chinese translation text.
2024-03-12 15:36:42 +08:00
-Shiken-
7b1b448795 Add traditional Chinese translation
for traditional user.
2024-03-12 11:29:24 +08:00
advplyr
77559d29bb Merge pull request #2724 from mikiher/fix-library-filter-data-access
Fix library filter data direct access
2024-03-11 17:08:41 -05:00
advplyr
c14f9accaf Update functions for #2724 and add jsdocs 2024-03-11 17:07:03 -05:00
advplyr
76a1f48c62 Remove UID/GID from Server constructor 2024-03-11 11:11:13 -05:00
mikiher
ae0a9bcf86 Merge branch 'advplyr:master' into fix-library-filter-data-access 2024-03-11 08:33:47 +02:00
advplyr
9e44fe5524 Merge pull request #2721 from mikiher/keyboard-navigation-2
Add keyboard navigation to multi-select components
2024-03-10 09:45:16 -05:00
advplyr
727dad7e19 Update multi select highlight color to yellow, remove console logs 2024-03-10 09:43:24 -05:00
advplyr
0c2de91097 Merge pull request #2726 from nichwall/vietnamese_translations
Vietnamese translations
2024-03-09 20:09:09 -06:00
advplyr
450fa45360 Update Vietnamese datefns locale code 2024-03-09 20:09:08 -06:00
Nicholas Wallace
e0dddae2c2 Added missing keys 2024-03-09 23:13:20 +00:00
Nicholas Wallace
daa9fccc14 Add: Vietnamese translations 2024-03-09 23:00:01 +00:00
mikiher
ad45dadc15 Remove redundant space 2024-03-09 12:07:08 +02:00
mikiher
0e8148001e Fix direct access to Database.libraryFilterData 2024-03-09 11:59:50 +02:00
advplyr
fa71f9db2e Merge master 2024-03-08 12:22:29 -06:00
advplyr
0d9d2fa4be Merge pull request #2714 from mikiher/keyboard-navigation
Fix input width in MultiSelect UI components  - replacement solution
2024-03-08 12:20:46 -06:00
advplyr
c34e9cde05 Merge branch 'master' into keyboard-navigation 2024-03-08 12:15:07 -06:00
advplyr
b934a755b5 Merge branch 'master' into keyboard-navigation-2 2024-03-08 12:04:13 -06:00
mikiher
a5772f6b66 Add keyboard navigation to multi-select components 2024-03-08 08:51:05 +02:00
advplyr
153f149d58 Merge pull request #2580 from KeyboardHammer/authorSort
Add sorting to author page
2024-03-07 12:28:55 -06:00
advplyr
e50b06183e Merge branch 'master' into authorSort 2024-03-07 12:26:07 -06:00
advplyr
305689d513 Update authors sort 2024-03-07 12:26:04 -06:00
advplyr
4dd140585d Add:Abridged checkbox to batch edit overwrite map details #2695 2024-03-06 15:29:10 -06:00
mikiher
cd60d0219f Bring back setInputWidth 2024-03-06 14:02:15 +02:00
mikiher
8ec18e8d7b Merge branch 'keyboard-navigation' of https://github.com/mikiher/audiobookshelf into keyboard-navigation 2024-03-06 13:53:49 +02:00
mikiher
15545654ea Alternative input width fix in MultiSelect components 2024-03-06 13:41:54 +02:00
advplyr
8a0fab2b20 Fix:Resizing page update chapter ticks and track bar #2707 2024-03-05 14:30:39 -06:00
advplyr
6e8c6aa740 Merge pull request #2701 from mikiher/keyboard-navigation
Fix input width in MultiSelect UI components
2024-03-05 13:13:52 -06:00
mikiher
5005aabe5e Fix input width in MultiSelect components 2024-03-03 23:40:47 +02:00
advplyr
abc2d28617 Merge pull request #2699 from pmangro/master
[PT-BR] enhance-ebook-filter strings translation
2024-03-03 11:42:56 -06:00
pmangro
7569a14510 Merge pull request #1 from pmangro/enhance-ebook-filter-strings
[PT-BR] enhance-ebook-filter strings translation
2024-03-03 13:33:01 -03:00
pmangro
b52341dbcf [PT-BR] enhance-ebook-filter strings translation 2024-03-03 13:32:01 -03:00
advplyr
b4eed3bad2 Merge pull request #2694 from mikiher/client-image-caching
Client side cover image caching
2024-03-01 17:48:08 -06:00
mikiher
4fe672f09d Update cover image URLs with timestamp where available 2024-03-01 11:55:53 +02:00
advplyr
49af7eb7b0 Merge pull request #2692 from lkiesow/log-src
Fix log source in log file
2024-02-29 17:01:59 -06:00
advplyr
c93c863d82 Merge pull request #2677 from Teekeks/enhance-ebook-filter
feat: Expanded filter to include "has no ebook" and "has no supplementary ebooks" options
2024-02-29 14:00:50 -06:00
advplyr
763bb1b829 Map ebook filter translations 2024-02-29 13:59:00 -06:00
Lars Kiesow
79d32274aa Fix log source in log file
The logger should include a source containing the location where the
logger was called. This works well for logging to `stdout`. Unfortunately,
the file logs contain the locations where the file logging is called
inside of the logger. This is not helpful:

```
{"timestamp":"2023-11-19 16:35:43","source":"Logger.js:114","message":"[oldDbFiles] Processed db data file with 1 entities","levelName":"INFO","level":2}
{"timestamp":"2023-11-19 16:35:43","source":"Logger.js:114","message":"[oldDbFiles] Finished loading db data with 2 entities","levelName":"INFO","level":2}
{"timestamp":"2023-11-19 16:35:43","source":"Logger.js:114","message":"[oldDbFiles] 2 settings loaded","levelName":"INFO","level":2}
```

This patch fixes the issue, ensureing that the actual source location
will be logged:

```
{"timestamp":"2024-02-29 18:12:59.832","source":"DailyLog.js:132","message":"[DailyLog] 2024-02-29: Loaded 20 Logs","levelName":"DEBUG","level":1}
{"timestamp":"2024-02-29 18:12:59.638","source":"Server.js:172","message":"=== Starting Server ===","levelName":"INFO","level":2}
{"timestamp":"2024-02-29 18:12:59.638","source":"Server.js:103","message":"[Server] Init v2.8.0","levelName":"INFO","level":2}
```
2024-02-29 18:16:29 +01:00
Lars Kiesow
987842ed04 Fix file names with URL control characters
This patch ensures that files names like `series #3 xy.jpg` are actually
handled correctly instead of the part after `#` being interpreted as
fragment and being discarded.

I noticed that in a few rare cases the App wouldn't properly display
cover images. It turns out that due the file names containing a `#`, the
file path got corrupted, causing Audiobookshelf to return a 403.
2024-02-29 17:56:55 +01:00
advplyr
d2b006b909 Update:Windows binary manager to install ffmpeg/ffprobe 5.1 #1098 2024-02-28 16:16:44 -06:00
mfcar
f4a19e48ad Update login page 2024-02-28 19:21:11 +00:00
advplyr
38f12f4795 Fix:Podcast schedule max new episodes to download setting to 0 and fix input blurs #2680 2024-02-27 17:17:33 -06:00
advplyr
7a4f4b1586 Merge pull request #2676 from pmangro/2.8.0.a-PT-BT
[PT-BR] Updated strings
2024-02-27 15:59:09 -06:00
pmangro
20ec54e085 [PT-BR] Updated strings 2024-02-27 14:31:21 -03:00
Teekeks
655bebfec4 feat: Expanded filter to include "has no ebook" and "has no supplementary ebooks" options 2024-02-27 18:30:05 +01:00
advplyr
71e1abd263 Merge pull request #2673 from Machou/master
Update fr.json
2024-02-27 08:56:03 -06:00
Machou
72172dcb33 Update fr.json 2024-02-27 08:56:31 +01:00
advplyr
def2988e12 Update:Passport openid-client request timeout set to 10s (default was 3.5s) #2669 2024-02-26 17:20:11 -06:00
mikiher
b47793c365 Add cache control header for timestamped cover image requests 2024-02-26 14:00:25 +02:00
advplyr
3a99cc56b7 Update:Debian packager script to use xz compression instead of zstd 2024-02-25 12:56:04 -06:00
advplyr
24c35dede5 Merge pull request #2659 from mikiher/quick-match-dup-authors
Fix dup author addition logic
2024-02-25 08:12:05 -06:00
advplyr
8c4400dff1 Merge pull request #2657 from JBlond/master
Update de strings
2024-02-25 08:08:17 -06:00
advplyr
af8dffaa33 Merge pull request #2573 from mikiher/fix-match-update
Merge cover and media update in Match.vue into a single /media API call
2024-02-25 08:07:51 -06:00
advplyr
4a36a3c8e6 Merge branch 'master' into fix-match-update 2024-02-25 08:00:29 -06:00
mikiher
e6735e042e Fix dup author addition logic 2024-02-25 09:01:26 +02:00
JBlond
c799379a54 Update de strings 2024-02-24 20:23:51 +01:00
advplyr
d8b9f08e5a Merge pull request #2641 from Teekeks/year-review-translation
feat(i18n): made "Year in Review" UI elements translatable and added german translation for those
2024-02-23 17:01:43 -06:00
advplyr
608b25de45 Map en-us translations 2024-02-23 16:59:46 -06:00
advplyr
2db8869908 Merge branch 'master' into year-review-translation 2024-02-23 16:56:53 -06:00
advplyr
9500737bbe Merge pull request #2644 from RasmusKoit/estonian-translations
Adds estonian translation
2024-02-23 16:55:41 -06:00
advplyr
def2b6425b Update:Username and password inputs on login page trim whitespace #2628 2024-02-22 16:30:41 -06:00
mikiher
0f4b11494e Merge branch 'advplyr:master' into fix-match-update 2024-02-22 12:20:49 +02:00
rasmuskoit
46448ce1e9 Adds estonian translation 2024-02-22 09:46:09 +02:00
advplyr
fbe12b393f Merge pull request #2639 from pmangro/2.8.0.a-PT-BT
[PT-BR] Terminology adjustments and typo fixes
2024-02-21 18:29:29 -06:00
advplyr
ccf59b2c1a Merge pull request #2638 from DownloadableFox/master
Updated and fixed Spanish translation
2024-02-21 18:28:28 -06:00
Lena During
d7af3b7788 Merge branch 'master' into year-review-translation 2024-02-22 00:39:49 +01:00
Teekeks
682aca0b2a feat(i18n): made "Year in Review" UI elements translatable and added german translation for those 2024-02-22 00:36:43 +01:00
advplyr
3328ffe1b9 Merge pull request #2636 from megamegax/master
feat(i18n): add Hungarian translation
2024-02-21 16:07:36 -06:00
DownloadableFox
c07b7840e2 Updated and fixed Spanish translation 2024-02-20 21:14:55 -05:00
pmangro
9f848b2c64 Update pt-br.json - adjust terminology and typo 2024-02-20 17:28:37 -03:00
pmangro
3d66ec0761 PT-BR size adjustment 2024-02-20 11:42:48 -03:00
advplyr
f50920be69 Merge pull request #2635 from lonezel/lonezel-updated-german-translation
Update de.json
2024-02-20 08:37:48 -06:00
advplyr
d31add9d5a Merge pull request #2634 from pmangro/2.8.0-PT-BR
Updated pt-br string
2024-02-20 08:36:33 -06:00
Hunyady Mihály
a4dcb4f92e feat(i18n): add Hungarian translation 2024-02-20 12:25:43 +01:00
lonezel
2c589c1dbd Update de.json
Translated new strings to german - this is my first ever commit on Github, if I need to change something in my workflow let me know!
2024-02-20 10:19:48 +01:00
pmangro
60ea386c6d Updated pt-br string 2024-02-20 05:57:52 -03:00
advplyr
24be1a0ec5 Merge pull request #2629 from burghy86/patch-13
Update it.json
2024-02-19 08:56:39 -06:00
burghy86
e71a14756b Update it.json
new string update
2024-02-19 15:53:12 +01:00
advplyr
85fecbd1b9 Version bump v2.8.0 2024-02-18 16:43:16 -06:00
advplyr
335d39f317 Update guide link for custom metadata providers 2024-02-18 16:22:10 -06:00
advplyr
973a18d346 Update:Added button to user edit modal for unlinking user from openid #2587 2024-02-18 15:38:45 -06:00
advplyr
a43b93d796 Fix:Clear library filter data cache when library item is updated #2597 2024-02-18 14:58:46 -06:00
advplyr
acf75abdf1 Update:Match author use closest name match by levenshtein distance #2624 2024-02-18 13:06:51 -06:00
advplyr
58598bfcf2 Update:Clamp author description to 4 lines and add more button #2614 2024-02-18 11:32:24 -06:00
advplyr
7a570439db Update:Clamp item descriptions to 4 lines and show more button #2614 2024-02-18 11:24:36 -06:00
advplyr
6e769d1c20 Merge pull request #2554 from mikiher/ffmpeg-latest
Modify BinaryManager to download version 6.1
2024-02-17 17:42:24 -06:00
advplyr
d9e7f5d133 Update BinaryManager JSDocs, move validVersions to required binary objects 2024-02-17 17:40:33 -06:00
advplyr
a119b05d85 Merge branch 'master' into ffmpeg-latest 2024-02-17 17:05:51 -06:00
advplyr
7bf7b6bcf9 Merge pull request #2553 from Sapd/sso
OpenID: Implement Logout + Fix state + Fix URL Regex
2024-02-17 17:03:12 -06:00
advplyr
e47ea98cdd Fix:Disconnect from socket on logout, remove unnecessary logout function 2024-02-17 16:58:49 -06:00
advplyr
bf66e13377 Update jsdocs 2024-02-17 16:06:25 -06:00
advplyr
d7aba5629e Remove old login rate limiter 2024-02-17 15:29:06 -06:00
advplyr
a5c200ac79 Merge branch 'master' into sso 2024-02-17 14:15:41 -06:00
advplyr
fdc1fc1b2a Merge pull request #2491 from liaochuan/liaocl
Add Podcast Search Region
2024-02-17 13:32:23 -06:00
advplyr
42a4b762bd Fix translations sort order and pt-br translations 2024-02-17 13:30:30 -06:00
advplyr
180c328ed1 Update jsdocs for search podcasts 2024-02-17 13:24:49 -06:00
advplyr
2ec52a7a45 Merge branch 'master' into liaocl 2024-02-17 12:56:05 -06:00
advplyr
aacf37e32b Fix:Year in Review crashing when listening session has a null genre #2623 2024-02-16 16:16:55 -06:00
advplyr
52323b7eb5 Update:Podcast episode download show ffmpeg progress and print full debug log dump on error 2024-02-16 16:05:02 -06:00
advplyr
5b5613a762 Merge pull request #2617 from ipcintron/pwa
Update pwa icon to use iOS icon
2024-02-16 13:44:57 -06:00
ipcintron
de6df0c029 Forgot to modify nuxt.config.js 2024-02-16 12:29:52 -06:00
ipcintron
e180b3c171 Renamed the icon to make it clear it is being used for iOS 2024-02-16 12:27:10 -06:00
ipcintron
1364b79cbf Put the icon in the link array for iOS only 2024-02-16 10:10:09 -06:00
ipcintron
ef96f3102f Merge branch 'advplyr:master' into pwa 2024-02-16 09:51:10 -06:00
advplyr
06ce3b08f7 Merge pull request #2619 from ipcintron/theme
PWA (iOS) theme color fix
2024-02-16 09:31:11 -06:00
advplyr
a13217dddf Fix:Initial language code setting eventBus not yet defined 2024-02-16 09:12:47 -06:00
advplyr
ce528d4012 Merge pull request #2620 from pmangro/pmangro-patch-1
PT-BR Strings
2024-02-16 09:05:31 -06:00
pmangro
89207b6d2a Update i18n.js
Added PT-BR
2024-02-16 11:57:54 -03:00
pmangro
e9591caf81 Spelling 2024-02-16 11:56:31 -03:00
pmangro
24f1aae6b6 Update pt-br.json
Strings 541-766
2024-02-16 11:44:25 -03:00
ipcintron
04fbc9a22b change theme color 2024-02-16 02:15:27 -06:00
ipcintron
14e31d5690 update pwa icon 2024-02-16 01:32:04 -06:00
advplyr
a9e9808183 Fix:trim whitespace from asin for chapter lookup #2605 2024-02-15 17:05:48 -06:00
advplyr
af7cb2432b Update:Log uncaught exceptions to crash_logs.txt #706 & cleanup logger 2024-02-15 16:46:19 -06:00
pmangro
e0c1364916 Create pt-br.json
540 linhas iniciais
2024-02-15 19:08:05 -03:00
advplyr
04d16fc535 Fix:Audio player buttons to use button el and add aria-label translations #2599 2024-02-14 18:28:19 -06:00
advplyr
44135b3fed Rename StreamContainer to MediaPlayerContainer 2024-02-14 18:12:35 -06:00
advplyr
6111e8f0da Fix:Global search menu for mobile 2024-02-13 18:45:01 -06:00
advplyr
4e3e7b10ce Update:Custom metadata provider adapter sends mediaType as a query param 2024-02-12 17:12:49 -06:00
advplyr
ce7f81d676 Merge pull request #2486 from FlyinPancake/dewyer/add-custom-metadata-provider
[Feature] Add support for custom metadata providers through a REST API
2024-02-11 17:04:55 -06:00
advplyr
0cf2f8885e Add custom metadata provider controller, update model, move to item metadata utils 2024-02-11 16:48:16 -06:00
advplyr
ddf4b2646c Merge branch 'master' into dewyer/add-custom-metadata-provider 2024-02-11 09:10:29 -06:00
advplyr
fe1e0749a2 Update:Listening sessions table rows per page text wrapping 2024-02-08 19:12:59 -06:00
advplyr
2093468c92 Fix:Local playback sessions not persisting the last updatedAt value 2024-02-08 19:12:35 -06:00
mikiher
19af7454f2 Force Update LibraryItem model updatedAt refresh (fixes #2593) 2024-02-07 20:57:50 +02:00
KeyboardHammer
d24427aad8 fix property 2024-02-03 22:04:40 -06:00
KeyboardHammer
e2bb0cfb7c add sorting to author page 2024-02-03 21:48:35 -06:00
mikiher
2ebdb44826 Merge cover and media update in Match.vue into a single /media API call 2024-02-01 12:03:12 +02:00
advplyr
432e25565e Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-01-31 17:23:20 -06:00
advplyr
ebe511404a Remove updateMedia endpoint cover cache purge 2024-01-31 17:23:16 -06:00
advplyr
e0a79fb86c Merge pull request #2570 from Weldawadyathink/main
Return png from AudiobookCovers.com
2024-01-30 17:02:14 -06:00
Spenser Bushey
295ca3d9a2 Return png from AudiobookCovers.com
Changes AudiobookCovers.com provider to return the full size png file from the server. The original file url has the incorrect content-type header set, which caused issues downloading new cover images.
2024-01-30 09:15:50 -08:00
advplyr
dbad8bdb96 Merge pull request #2567 from ipcintron/lockscreen_cover
added raw cover on lockscreen for iOS
2024-01-29 15:21:44 -06:00
ipcintron
8c703859a0 added raw cover 2024-01-29 13:18:58 -06:00
advplyr
bedb260b00 Update:Epub ereader allow scripted content 2024-01-28 16:02:02 -06:00
advplyr
b49592301f Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-01-28 16:00:32 -06:00
advplyr
c6c67078b8 Update:PWA manifest icon to include PNG #2520 2024-01-28 16:00:26 -06:00
advplyr
9e45ad10f1 Merge pull request #2564 from Teekeks/patch-1
Update de.json
2024-01-28 10:10:13 -06:00
Lena During
24da859975 Update de.json 2024-01-28 16:06:07 +01:00
advplyr
0b6a8a9641 Update:Remove 64x64 app icon from PWA manifest so that only the SVG is available #2520 2024-01-27 10:50:44 -06:00
advplyr
e43c4f082e Fix:Rich text editor labels and add translations 2024-01-26 17:22:37 -06:00
advplyr
0b334cf957 Add:Authentication setting to show a custom message on login #2552 2024-01-26 17:08:23 -06:00
advplyr
ae387ab397 Merge pull request #2559 from bloodscript/master
German localization optimization
2024-01-26 16:28:02 -06:00
bloodscript
056e62dce8 added plural to metadata order hint 2024-01-26 23:15:27 +01:00
bloodscript
47999214bd corrected misspelling of adress 2024-01-26 23:13:11 +01:00
bloodscript
68473ee345 added missing metadata translation 2024-01-26 23:11:38 +01:00
bloodscript
455f27d443 mainly changed usage formular wording for you to the more commonly used, also corrected some misspellings 2024-01-26 23:09:54 +01:00
bloodscript
ba996c3b55 translation wasnt accurate verschluesselung means encryption 2024-01-26 20:12:37 +01:00
mikiher
d43a1109c8 Modify BinaryManager to download version 6.1 and remove old dowloaded versions 2024-01-25 17:51:06 +02:00
Denis Arnst
c3ba7daa16 Auth: Remove is_rest cookie 2024-01-25 16:05:41 +01:00
Denis Arnst
82048cd4f3 SSO: Also save openid_id_token longer 2024-01-25 15:13:56 +01:00
Denis Arnst
71b0a5cc81 SSO Settings: Fix Redirect URL Regex
Forgot to include subpaths
2024-01-25 11:49:10 +01:00
Denis Arnst
edb5ff1e33 SSO: Remove pick function 2024-01-25 11:44:20 +01:00
Denis Arnst
d4ed6348ee Auth: Store auth_method longer
Its not unrealistic that someone keeps being logged into the app for more than a year
if not stored longer logout process might not work anymore
2024-01-25 11:20:44 +01:00
Denis Arnst
f12ac685e8 /auth/openid: Restructure
- Distingush more explictly between mobile and web flow and simplify logic
- Allow state parameter to be passed in mobile flow
- Additional checks for correct parameters
- Remove unused id_token code
- Enforce S256 and don't allow plain PKCE
2024-01-25 11:13:34 +01:00
advplyr
b9ec4068ee Merge pull request #2510 from Torstein-Eide/patch-1
README, add HAproxy example
2024-01-24 16:55:32 -06:00
advplyr
02aabb8f97 Update readme.md 2024-01-24 16:55:21 -06:00
advplyr
dcec2154c0 Update readme.md 2024-01-24 16:55:17 -06:00
advplyr
bbc1d20396 Update readme.md 2024-01-24 16:55:12 -06:00
advplyr
e682213681 Update readme.md 2024-01-24 16:55:06 -06:00
advplyr
0153c0faae Update readme.md 2024-01-24 16:54:54 -06:00
Denis Arnst
87ebf4722b OpenID/SSO: Implement Logout functionality 2024-01-24 22:47:50 +01:00
advplyr
3906dca04e Update:RSS feeds only use chapter titles for episode titles if all audio tracks match chapter times #2543 2024-01-23 17:51:34 -06:00
advplyr
399ba314a3 Update github issue template to include Windows Tray App 2024-01-23 15:48:58 -06:00
advplyr
19e1803633 Remove unused import 2024-01-22 17:56:41 -06:00
advplyr
71048c7ff0 Remove support for Docker armv7 builds 2024-01-20 16:39:43 -06:00
advplyr
7f350279fa Update to node20
- updates many dependencies
- removes @nuxtjs/tailwindcss and postcss8
- pkg targets are using node18 until node20 targets are available
2024-01-19 17:54:41 -06:00
advplyr
90f4833c9e Version bump v2.7.2 2024-01-16 17:26:50 -06:00
advplyr
c0cb3a176f Update:Hide audiobook tools for windows install, remove debian folder picker alert 2024-01-16 17:19:45 -06:00
advplyr
7b0fa48e2e Update jsdocs for expanded library items 2024-01-16 16:31:16 -06:00
advplyr
b51853b3df Update:Use raw cover art for media session #2514 2024-01-15 08:34:12 -06:00
advplyr
f5545cd3f4 Add:Scanner extracts cover from comic files #1837 and ComicInfo.xml parser 2024-01-14 17:51:26 -06:00
advplyr
e76af3bfc2 Fix comic page menu dropdown highlight correct page 2024-01-13 16:41:13 -06:00
FlyinPancake
6ef4944d89 Merge branch 'advplyr:master' into dewyer/add-custom-metadata-provider 2024-01-13 01:08:23 +01:00
advplyr
850397e4c1 Add:Playlist button to podcast episodes on latest page #2455 2024-01-12 17:58:07 -06:00
FlyinPancake
3b531144cf implemented suggestions, extended CMPs with series 2024-01-12 21:45:03 +01:00
Torstein Eide
6ca684603c Fix typos 2024-01-12 14:35:30 +01:00
Torstein Eide
cf85d66b2f Add example for HAproxy 2024-01-12 14:26:32 +01:00
advplyr
e8fa029df7 Fix:Specific podcast rss feed cannot be fetched due to accept header #2446 2024-01-10 08:12:26 -06:00
advplyr
1a361c91f1 Merge pull request #2506 from FreedomBen/remove-dev-logs
Change `Logger.dev` calls to `Logger.debug`
2024-01-09 16:47:21 -06:00
Benjamin Porter
4a76059608 Change Logger.dev calls to Logger.debug
Logger.dev is kind of in a weird spot where it doesn't fit into the
standard log level.  It is called directly by some code and it only
checks whether a property is set (which comes from an env var) before
deciding to print out.

This standardizes on `debug` by changing the dev calls to debug. Also
removes the now unused code.
2024-01-09 15:24:23 -07:00
advplyr
da25eff5c1 Fix:Parse series sequence from OPF in cases where series_index is not directly underneath series meta #2505 2024-01-08 18:21:15 -06:00
advplyr
69e23ef9f2 Add:Epub metadata parser and cover extractor #1479 2024-01-07 17:51:07 -06:00
advplyr
48a08e9659 Merge pull request #2503 from Machou/master
Little Missed Update fr.json
2024-01-07 12:42:09 -06:00
Machou
4608f91ec6 Update fr.json 2024-01-07 02:41:16 +01:00
advplyr
e88c1fa329 Update:Show tooltip for library item card titles that are truncated #2451
- Refactored tooltip so that they dont overflow the window
2024-01-06 15:54:48 -06:00
advplyr
935e545caa Update readme for iOS beta full 2024-01-06 14:13:39 -06:00
advplyr
a426da534c Fix:Export OPML not escaping characters #2487 2024-01-05 14:45:25 -06:00
advplyr
eaf6bf29cc Fix:Improve performance for podcast rss feed episodes modal for large rss feeds 2024-01-05 14:39:25 -06:00
advplyr
a0eb6bd3dc Fix:Refresh podcast episode table when new episodes are downloaded 2024-01-05 14:38:29 -06:00
advplyr
fbe228a4f8 Merge pull request #2485 from Machou/patch-1
Update fr.json
2024-01-05 10:40:29 -06:00
advplyr
578a59063f Update discord invite link 2024-01-05 09:24:18 -06:00
mozhu
81020ff34d 播客搜索地区配置增加默认参数 2024-01-05 15:50:20 +08:00
mozhu
fea78898a5 移动播客搜索地区配置到媒体库配置 2024-01-05 14:45:35 +08:00
Machou
ffa7cc0d22 Update fr.json 2024-01-05 07:19:07 +01:00
advplyr
4f9969cd9b Merge pull request #2488 from FreedomBen/add-init-system-to-docker
Add tini as PID 1 handler in container image
2024-01-04 13:50:54 -06:00
mozhu
1be34564f2 数据绑定错误修改 2024-01-04 15:00:40 +08:00
mozhu
56eff7a236 增加播客搜索地区配置 2024-01-04 11:52:45 +08:00
advplyr
9f909b0d85 Update:Library folder browser to also work for debian and windows 2024-01-03 16:23:17 -06:00
Benjamin Porter
baa65b8155 Add tini as PID 1 handler in container image
This PR adds `tini` to the container image and uses it as PID 1 when
starting the container.  This ensures that proper PID 1 signal-handling
is implemented and passed to the underlying node.js process, thereby
ensuring that the ABS process has a chance to receive and handle signals
other than `SIGKILL`, such as the important `SIGINT`.

This is somewhat related to #2445 . Without this, the signal handled by
2445 won't be received when running in a container.

Some background:

In linux, PID 1 has special duties involving signal handling that are
different than other processes.  Node doesn't properly handle these
signals, which can lead to a number of problems ranging from annoying to
disruptive.  PID 1 also has reaping duties that can lead to resource
exhaustion if not properly handled.

For example, the container ignores `SIGINT` (Ctrl+C) as well as `docker stop`,
which can be annoying in development as you have to kill or wait for
the timeout to be reached.  In a production environment (such as Kubernetes)
this can lead to signal escalation and unnecessarily adds delays to
deployments and restarts as K8s has to wait for the timeout to be reached
before sending `SIGKILL`.

At best this is annoying and unnecessarily adds
delays.  At worst this can lead to file/data corruption as the process
doesn't get a chance to clean anything up when it is sent `SIGKILL`.
Without a proper PID 1 to forward signals, only SIGKILL can be used to
terminate the running process.
2024-01-03 13:55:43 -07:00
Barnabas Ratki
12c6a1baa0 Fix log messages 2024-01-03 20:42:35 +01:00
Barnabas Ratki
5ea423072b Small fixes 2024-01-03 20:40:36 +01:00
Barnabas Ratki
08a41e37b4 Add specification 2024-01-03 20:27:42 +01:00
Barnabas Ratki
8027c4a06f Added support for custom metadata providers
WiP but already open to feedback
2024-01-03 20:25:34 +01:00
Machou
a1e321b153 Update fr.json 2024-01-03 20:16:21 +01:00
advplyr
8c6a2ac5dd Merge pull request #2391 from mikiher/binary-manager
Add a binary manager that finds ffmpeg and ffprobe and installs them if not found
2024-01-02 14:25:56 -06:00
advplyr
b489bf9236 Restrict binary manager to Windows or development 2024-01-02 14:24:59 -06:00
advplyr
aa63aa6cf3 Merge branch 'master' into binary-manager 2024-01-02 14:16:27 -06:00
advplyr
9a2b93fb37 Version bump v2.7.1 2023-12-31 15:37:23 -06:00
advplyr
e8ea7efc98 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-12-31 15:36:37 -06:00
advplyr
81a76593da Fix:Merging chapters from multiple audio files with the same chapter titles #2461 2023-12-31 15:35:17 -06:00
advplyr
5336864f7d Merge pull request #2465 from thevoltagesource/getFileMtimeMs_Unhandled_Exception
Add try/catch to fileUtils.getFileMtimeMs
2023-12-31 15:34:43 -06:00
advplyr
d38058e1d2 Fix:Podcast episode time remaining shown on button showing 0 seconds after toggling mark as finished 2023-12-31 15:32:44 -06:00
advplyr
fececd4651 Fix:Playlists navigation button not showing on mobile screen #2469 2023-12-31 15:09:35 -06:00
advplyr
021adf3104 Update:Podcast episode table is lazy loaded #1549 2023-12-31 14:51:01 -06:00
advplyr
160c83df4a Update:podcastEpisodes table index added for createdAt column #2073 #2075 2023-12-30 16:14:14 -06:00
advplyr
456bb87a00 Update:Find one library item endpoint sequelize query split into two queries to improve performance #2073 #2075 2023-12-30 12:12:48 -06:00
advplyr
707451309c Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-12-29 17:05:40 -06:00
advplyr
269676e8a5 Update:CORS for /cover API endpoint for use in canvas in the mobile apps 2023-12-29 17:05:35 -06:00
Jacob Southard
e4effebc19 Add try/catch to fileutils.getFileMtimeMs 2023-12-29 10:04:59 -06:00
advplyr
fbbceddba8 Merge pull request #2454 from mikiher/socket-authority-close
Add SocketAuthority.close()
2023-12-28 16:32:40 -06:00
advplyr
9a634e0de5 Add JS docs for server stop 2023-12-28 16:32:21 -06:00
mikiher
21d0d43edc Add SocketAuthority.close() 2023-12-27 15:33:33 +02:00
mikiher
3051b963ef Merge branch 'advplyr:master' into binary-manager 2023-12-27 06:44:22 +02:00
advplyr
0d0bdce337 Fix:Fetch RSS feed request accept header #2446 2023-12-25 13:15:55 -06:00
advplyr
bdb5dc8c28 Merge pull request #2445 from mikiher/sigint-handler
Add a SIGINT handler for proper server shutdown
2023-12-25 12:51:22 -06:00
mikiher
209847d98a Add a SIGINT handler for proper server shutdown 2023-12-25 09:25:04 +02:00
advplyr
14f42e15d1 Fix:Book scanner update book series sequence if changed 2023-12-24 11:53:57 -06:00
advplyr
7402e4811d Merge pull request #2444 from jedrus2000/opf-multiple-series-support
Add: OPF file supports multiple series as sequence of : calibre:series and calibre:series_index; including tests
2023-12-24 11:42:06 -06:00
advplyr
6de0465b86 Update opf parser to ignore series with empty content and add tests 2023-12-24 11:41:27 -06:00
Andrzej Bargański
cd7c4baaaf Add: OPF file supports multiple series as sequence of : calibre:series and calibre:series_index; including tests 2023-12-24 00:43:42 +01:00
advplyr
a2db81bf7d Fix share button for year in review short card 2023-12-23 17:13:44 -06:00
advplyr
b376f89ce5 Version bump v2.7.0 2023-12-23 17:05:44 -06:00
advplyr
5633113f25 Update share buttons to not show an error on abort 2023-12-23 16:39:56 -06:00
advplyr
669415cfbf Merge pull request #2431 from pablojimenezmateo/comic-zoom
Add zoom controls to comic reader
2023-12-23 16:18:23 -06:00
advplyr
9f366863a9 Update comic reader buttons for mobile screens, add left scrollBy 2023-12-23 16:16:24 -06:00
advplyr
0d644fe0c9 Add:Year in review banner for user stats page #2373 2023-12-23 15:29:34 -06:00
advplyr
72fa6b8200 Fix:Show cover size widget when audio player is open #2443 2023-12-23 10:50:04 -06:00
advplyr
6d3f1d263a Merge pull request #2442 from JBlond/master
Follow up Translations for 76119445a3
2023-12-23 08:55:28 -06:00
JBlond
47bf9f7836 Follow up Translations for 76119445a3
* Update:Listening sessions table for multi-select, sorting and rows per page
- Updated get all sessions API endpoint to include sorting
- Added sessions API endpoint for batch deleting
2023-12-23 14:42:56 +01:00
advplyr
2738402aac Add:Year in review card for server stats #2373 2023-12-22 17:01:07 -06:00
advplyr
68d36522b1 Update:Listening sessions table UI for mobile 2023-12-21 14:36:51 -06:00
advplyr
24a587b944 Update:Remove playback sessions that are 3s or less on startup 2023-12-21 14:29:36 -06:00
advplyr
76119445a3 Update:Listening sessions table for multi-select, sorting and rows per page
- Updated get all sessions API endpoint to include sorting
- Added sessions API endpoint for batch deleting
2023-12-21 13:52:42 -06:00
advplyr
46ec59c74e Update:Year in review card prevent text overflow for narrator, author and genre #2373 2023-12-21 09:44:37 -06:00
advplyr
2b7122c744 Update:Year stats API endpoint & generate year in review image #2373 2023-12-20 17:18:21 -06:00
Pablo
52f0a5432b feat: enable zoom through the arrow buttons 2023-12-20 11:45:21 +01:00
advplyr
7391b4d0ec Add:User stats API for year stats 2023-12-19 17:19:33 -06:00
Pablo
aa7ee3e8ff fix: zoom buttons were showing when loading the image 2023-12-19 18:45:11 +01:00
Pablo
bef0f3709f feat: add basic zoom functionality to comic reader 2023-12-19 18:39:02 +01:00
advplyr
f33b011847 Merge pull request #2420 from treyg/synology-reverse-proxy-docs
docs: update synology reverse proxy to use the latest DSM settings
2023-12-17 16:42:43 -06:00
Trey Gordon
2d8d11d4da docs: update synology reverse proxy to use the latest DSM settings 2023-12-17 15:56:14 -05:00
advplyr
10b1784f6d Fix:Library search API endpoint /libraries/:id/search to check that query param q is a valid string 2023-12-17 12:23:55 -06:00
advplyr
f2f2ea161c Update:API endpoint /podcasts/feed validates rssFeed URL and uses SSRF req filter 2023-12-17 12:00:11 -06:00
advplyr
dc67a52000 Update:API endpoint /search/podcast throw 400 error if term query param is not supplied 2023-12-17 11:18:21 -06:00
advplyr
05820aa820 Update:API endpoints /podcasts/feed and /podcasts/opml restricted to admin users 2023-12-17 11:17:35 -06:00
advplyr
8966dbbcd1 Fix:Restrict podcast search page to admins 2023-12-17 11:06:03 -06:00
advplyr
cf32819c01 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-12-17 10:41:55 -06:00
advplyr
728496010c Update:/auth/openid/config API endpoint to require admin user and validate issuer URL 2023-12-17 10:41:39 -06:00
advplyr
0a08f47942 Merge pull request #2417 from springsunx/master
Update zh-cn.json
2023-12-15 12:14:15 -06:00
SunX
39ceb02500 Update zh-cn.json 2023-12-15 19:04:56 +08:00
advplyr
4336714248 Merge pull request #2415 from nichwall/docker_compose_update
Added comments to the Docker Compose file
2023-12-15 04:14:16 -06:00
nichwall
1d41904fc3 Added comments to the Docker Compose file 2023-12-14 21:04:37 -07:00
advplyr
fae383a045 Fix:RSS feeds for collections not updating #2414 2023-12-14 15:45:34 -06:00
mikiher
8f7a420cca Fix directory writable check (fs.access not working on Windows) 2023-12-14 09:47:18 +02:00
advplyr
9720ba3eed Merge pull request #2413 from JBlond/master
More german translations
2023-12-13 13:40:36 -06:00
JBlond
d3256d59d5 - Translate more strings
- Add missing least empty line
2023-12-13 20:12:25 +01:00
advplyr
fa5f7ab7a5 Merge pull request #2411 from Nab0y/master
Update Russian localization
2023-12-12 16:57:35 -06:00
Dmitry Naboychenko
6f26fd7238 Update Russian localization 2023-12-12 22:56:05 +03:00
advplyr
6abc0819d9 Merge pull request #2400 from mikiher/bookfinder-improvements
A few BookFinder improvements (including a fix for #2238)
2023-12-10 10:36:21 -06:00
advplyr
b580a23e7e BookFinder formatting update 2023-12-10 10:35:21 -06:00
advplyr
f659c3f11c Fix:Podcast RSS feed request header to include application/rss+xml #2401 2023-12-09 13:51:28 -06:00
mikiher
0282a0521b Sort audible match results by duration difference 2023-12-09 00:33:06 +02:00
advplyr
75637e4b94 Merge pull request #2397 from JBlond/master
Follow up for sso-redirecturi and #2305 and #2333
2023-12-08 15:42:28 -06:00
mikiher
b6c789dee6 Merge branch 'advplyr:master' into bookfinder-improvements 2023-12-08 14:07:25 +02:00
JBlond
8d3d636329 Follow up for sso-redirecturi and #2305 #2333
8f4c65ec8c / 7c9c278cc4 sso-redirecturi
2f6756eddf #2333
2e5822b7c8 #2305
2023-12-08 09:39:04 +01:00
advplyr
6f6395bad7 Only log update binary env path if it was updated 2023-12-07 17:32:06 -06:00
advplyr
b8c8d2a02e Merge pull request #2386 from Sapd/sso-redirecturi
SSO/OpenID: Use a mobile-redirect route (Fixes #2379 and #2381)
2023-12-07 17:12:36 -06:00
advplyr
98104a3c03 Map new translations to other files 2023-12-07 17:05:52 -06:00
advplyr
8f4c65ec8c Merge branch 'master' into sso-redirecturi 2023-12-07 17:04:59 -06:00
advplyr
341a0452da Update auth settings endpoint to return updated flag and show whether updates were made in client toast 2023-12-07 17:01:33 -06:00
mikiher
6afb8de3dd Remove ffbinaries local cache 2023-12-08 00:53:53 +02:00
mikiher
0e62ccc7aa Merge branch 'binary-manager' of https://github.com/mikiher/audiobookshelf into binary-manager 2023-12-07 23:51:33 +02:00
mikiher
09282a9a62 Remove all callbacks and refactor spaghetti code in downloadUrls 2023-12-07 23:49:46 +02:00
advplyr
18b3ab5610 Revert package-lock updates 2023-12-07 15:12:49 -06:00
mikiher
699a658df9 Remove debug printing from libs/ffbinaries 2023-12-07 08:50:45 +02:00
advplyr
b5e255a384 Update:Clean series sequence response from audible provider #2380
- Removes Book prefix
- Splits on spaces and takes first, removes trailing comma
2023-12-06 17:31:36 -06:00
mikiher
67ccd2c1fb Fix test after switching to libs/ffbinaries 2023-12-06 13:45:28 +02:00
mikiher
898b072e68 Merge branch 'advplyr:master' into binary-manager 2023-12-06 09:27:17 +02:00
advplyr
34156af403 Fix:Updating media progress not clearing cache #2392 2023-12-05 17:58:54 -06:00
advplyr
61a0126278 Remove ffbinaries dependency 2023-12-05 17:35:57 -06:00
advplyr
1ce1904c89 Add ffbinaries lib 2023-12-05 17:35:15 -06:00
advplyr
7c9c278cc4 Merge pull request #2293 from Dr-Blank/gujarati-translation
More Gujarati translations
2023-12-05 15:42:05 -06:00
advplyr
450507a812 Map english translations and merge with gu 2023-12-05 15:41:12 -06:00
mikiher
c074c835d4 Remove semicolons from test 2023-12-05 22:18:37 +02:00
mikiher
2e989fbe83 Add BinaryManager 2023-12-05 21:19:17 +02:00
mikiher
b1b325d00b Add ffbinaries dependency 2023-12-05 21:18:30 +02:00
Denis Arnst
cf00650c6d SSO/OpenID: Also fix possible race condition
- We need to define redirect_uri in the callback again, because the global params of passport can change between calls to the first route (ie. if multiple users log in at same time)
- Removed is_rest parameter as requirement for mobile flow (to maximise compatibility with possible oauth libraries)
- Also renamed some variables for clarity
2023-12-05 09:43:06 +01:00
Denis Arnst
e6ab28365f SSO/OpenID: Remove modifying redirect_uri in the callback
The redirect URI will be now correctly set to either /callback or /mobile-redirect in the /auth/openid route
2023-12-05 00:18:58 +01:00
Denis Arnst
80fd2a1a18 SSO/OpenID: Use a mobile-redirect route (Fixes #2379 and #2381)
- Implement /auth/openid/mobile-redirect this will redirect to an app-link like audiobookshelf://oauth
- An app must provide an `redirect_uri` parameter with the app-link in the authorization request to /auth/openid
- The user will have to whitelist possible URLs, or explicitly allow all
- Also modified MultiSelect to allow to hide the menu/popup
2023-12-04 22:36:34 +01:00
advplyr
84160b2f07 Fix:Server crash when user without a password attempts to login with a password #2378 2023-12-02 16:17:52 -06:00
advplyr
fbc2c2b481 Merge pull request #2333 from kieraneglin/ke/feature/upload-auto-fetch-data
Add ability to fetch book data on upload
2023-12-02 15:56:39 -06:00
Kieran Eglin
57a5005197 Addressed feedback changes 2023-12-01 21:42:54 -08:00
Kieran Eglin
9350c5513e Removed unneeded mixin 2023-12-01 15:19:50 -08:00
advplyr
f59516cc6e Fix:Hide change password form when password auth is disabled #2367 2023-12-01 17:10:33 -06:00
advplyr
88078ff813 Fix undefined series string when match has no series, minor ui updates 2023-12-01 16:44:04 -06:00
mikiher
281de48ed4 Fix "et al" cleanup 2023-11-30 21:49:24 +02:00
mikiher
3c6d6bf688 Merge branch 'advplyr:master' into bookfinder-improvements 2023-11-30 21:37:01 +02:00
mikiher
8ac0ce399f Remove "et al[.]" in author cleanup 2023-11-30 21:17:13 +02:00
mikiher
80458e24bd "[un]abridged" in title candidate generation 2023-11-30 21:15:25 +02:00
advplyr
6ab966ee2f Merge pull request #2365 from Sapd/sso-errorhandling
SSO/OpenID: Provide error messages to logs
2023-11-28 16:39:01 -06:00
advplyr
166477ae27 Fix:Narrators page 404 on reload #2359 2023-11-28 16:39:52 -06:00
advplyr
a719065b8d Auto formatting 2023-11-28 16:37:19 -06:00
Denis Arnst
36599a2984 SSO/OpenID: Rename probably misleading message 2023-11-28 21:16:39 +01:00
Kieran Eglin
d9c9289d65 Added error handling; Made querystring helper 2023-11-28 12:11:14 -08:00
Kieran Eglin
e5579b2c33 Improved UI; Added tooltips; Fixed unrelated layout issues 2023-11-28 11:45:44 -08:00
Denis Arnst
618028503b SSO/OpenID: Also Log token header 2023-11-28 20:07:49 +01:00
Kieran Eglin
2f6756eddf Merged parent 2023-11-28 10:21:41 -08:00
Denis Arnst
ad53894ea1 SSO/OpenID: Provide detailed error messages 2023-11-28 17:29:22 +01:00
advplyr
086954fb9c Version bump v2.6.0 2023-11-27 17:41:47 -06:00
advplyr
f243ad14e0 Add help link to oidc guide 2023-11-27 17:10:31 -06:00
advplyr
2e5822b7c8 Merge pull request #2305 from mikiher/nfo-metadata
Add NFO metadata source
2023-11-26 14:49:04 -06:00
advplyr
3d468339b3 Update parse nfo metadata test for description 2023-11-26 14:41:19 -06:00
advplyr
b4c14fc78d Parse NFO comma separated strings remove empty strings 2023-11-26 14:38:25 -06:00
advplyr
d9584174ff Parse NFO trim final parsed description 2023-11-26 14:33:35 -06:00
advplyr
36e00e8d6a Merge master 2023-11-26 13:54:06 -06:00
advplyr
5e69b54eb0 Reverse order of metadata precedence in UI, add translations 2023-11-26 13:45:43 -06:00
advplyr
5a8c60a8bc Merge pull request #2343 from mikiher/caching
Simple API Caching for /libraries* requests
2023-11-26 12:33:54 -06:00
mikiher
3ff41f2b43 Cache HTTP headers and status 2023-11-25 23:49:56 +02:00
advplyr
17cab0d3a8 Merge pull request #2351 from JBlond/master
de translation follow up
2023-11-25 12:18:08 -06:00
JBlond
0fac9e367d de translation follow up
for 2e06ae01a1
2023-11-25 19:10:26 +01:00
advplyr
bf0bcf8967 Merge pull request #2336 from JBlond/master
de language translation follow up
2023-11-25 11:31:59 -06:00
advplyr
2e06ae01a1 Merge pull request #2326 from lkiesow/hide-dev-logs
Allow enabling dev logs
2023-11-25 10:36:50 -06:00
mikiher
288a32cc1e Merge branch 'caching' of https://github.com/mikiher/audiobookshelf into caching 2023-11-25 08:14:54 +02:00
mikiher
26fc3a1966 Remove currently unused time measurement utils 2023-11-25 08:14:45 +02:00
advplyr
9d257ebecd Update:Home page shelf bulk items added socket event only adds new items to the recently added shelf instead of refreshing all shelves #2323 2023-11-24 15:36:42 -06:00
advplyr
1a046a9bcb Merge branch 'master' into caching 2023-11-24 14:38:27 -06:00
advplyr
7a9c869ac5 Ignore sequelize hooks when updating user lastSeen on socket authentication 2023-11-24 14:27:32 -06:00
advplyr
572fb0993c Rename ApiCacheManager to add .js file extension 2023-11-24 14:20:14 -06:00
advplyr
9beee3ed65 Fix:Change password api endpoint 2023-11-23 15:14:49 -06:00
mikiher
ab19e25586 Remove unnecessary timing measurements 2023-11-23 09:56:37 +02:00
mikiher
07d7d16418 Use a single router.get for API cache middleware 2023-11-23 09:55:55 +02:00
mikiher
5e1e748c71 Add ApiCacheManager unit test 2023-11-23 09:53:52 +02:00
advplyr
6651ad0d45 Update:Added translation strings for OIDC auth 2023-11-22 12:55:01 -06:00
advplyr
288beae874 Update:OIDC auth auto launch setting description to include manual override path 2023-11-22 12:38:11 -06:00
advplyr
32ce771911 Allow cors while in development 2023-11-22 12:37:18 -06:00
mikiher
d944ecaa21 Merge branch 'caching' of https://github.com/mikiher/audiobookshelf into caching 2023-11-22 19:10:29 +02:00
mikiher
5aeb6ade72 Merge branch 'caching' of https://github.com/mikiher/audiobookshelf into caching 2023-11-22 19:00:11 +02:00
mikiher
107b4b83c1 Add cache middleware to most /libraries get requests 2023-11-22 18:40:42 +02:00
JBlond
0d61e29ecf de language translation follow up for 27497451d9 2023-11-21 20:30:48 +01:00
mikiher
781d4f570f Add test for parseNfoMetadata 2023-11-21 09:12:37 +02:00
mikiher
a4d4f1bc2e Merge branch 'advplyr:master' into nfo-metadata 2023-11-21 09:09:12 +02:00
advplyr
048e27f03f Update:Openid auth endpoint sets the mobile flag on session to be used in the callback
Co-authored-by: Denis Arnst <git@sapd.eu>
2023-11-20 15:41:38 -06:00
Kieran Eglin
8c434703fb Added computed metadata check to UI dropdown 2023-11-20 09:18:50 -08:00
Kieran Eglin
3cc900ffbf Adds fetching book data on upload 2023-11-20 08:51:00 -08:00
Lars Kiesow
7b6aa3ba5a Allow enabling dev logs
This patch allows users to enable dev logs on production systems by
setting the `HIDE_DEV_LOGS` environment variable.

Before, you could only use this on a non-production environment. On
production, the logs would be disabled. This patch changes the behavior
and uses the `NODE_ENV` only as default. On production they are disabled
if `HIDE_DEV_LOGS` is undefined but can be enabled by setting
`HIDE_DEV_LOGS=0` on dev, they are enabled if undefined, but can be
disabled by setting `HIDE_DEV_LOGS=1`.
2023-11-19 21:00:54 +01:00
advplyr
aa933df525 Update oidc redirect_uri to check x-forwarded-proto header for proxies 2023-11-19 14:00:39 -06:00
advplyr
a0f137936d Merge pull request #2325 from lkiesow/milliseconds
Add milliseconds to logging
2023-11-19 13:41:10 -06:00
advplyr
dcbfc963c1 Update protocol for redirect_uri in openid strategy to work for reverse proxies 2023-11-19 13:38:09 -06:00
Lars Kiesow
91fa78d740 Add milliseconds to logging
This patch adds milliseconds to the time string used for logging. This
helps when debugging some timing issues and should have no real negative
side effect.
2023-11-19 20:36:04 +01:00
advplyr
89eb857c14 Fix initialize openid auth strategy 2023-11-19 12:57:17 -06:00
advplyr
e07d17c472 Merge pull request #1636 from lukeIam/auth_passportjs
Integrate passportjs for muti-strategy authentication and SSO
2023-11-19 11:46:52 -06:00
advplyr
4c2c320b9d Remove global CORS for api endpoints and setup temp CORS check for ebook endpoint 2023-11-19 11:32:48 -06:00
advplyr
56c574c928 Update package-lock 2023-11-19 08:29:58 -06:00
advplyr
d2aea86957 Merge pull request #2300 from mikiher/bookfinder-testing-mocha
Bookfinder.js unit testing with mocha
2023-11-18 13:55:18 -06:00
advplyr
80e061115f Add remove semicolons to .vscode settings, update BookFinder.test formatting 2023-11-18 13:41:08 -06:00
mikiher
4299627f5f Add lru-cache dependency 2023-11-17 08:54:16 +02:00
mikiher
6a722102c5 Use ApiCacheManager & timing middleware 2023-11-17 08:49:40 +02:00
mikiher
f22f3361d5 Add timing utils 2023-11-17 08:48:09 +02:00
mikiher
4dec8c265d Add ApiCacheManager 2023-11-17 08:47:40 +02:00
mikiher
d990e5b909 Add NFO metadata source 2023-11-12 13:30:23 +00:00
advplyr
fb48636510 Openid auth failures redirect to login page with error message.
Remove remaining google oauth server settings
2023-11-11 13:10:24 -06:00
advplyr
1ad6722e6d Remove google-oauth passport strategy 2023-11-11 11:29:59 -06:00
advplyr
557ef2ef79 Update /auth/openid endpoints for correct PKCE handling
- Provide error handling for /auth/openid
- Add session.mobile inside /auth/openid
- Proper PKCE handling for /auth/openid/callback
- redirect_uri handling for the token url in /auth/openid/callback

Co-authored-by: Denis Arnst <git@sapd.eu>
2023-11-11 10:52:05 -06:00
advplyr
cff2caa07a Update:Rename podcast search page to add #2301 2023-11-10 16:32:14 -06:00
advplyr
237fe84c54 Add new API endpoint for updating auth-settings and update passport auth strategies 2023-11-10 16:11:51 -06:00
advplyr
078cb0855f Merge branch 'master' into auth_passportjs 2023-11-10 07:26:07 -06:00
mikiher
ecba67da6d Add Istanbul coverage (nyc) 2023-11-10 10:02:02 +00:00
mikiher
ea05e1f559 Remove test/ from .gitigore (now contains unit tests) 2023-11-10 09:58:30 +00:00
advplyr
d3a55c8b1a Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-11-09 16:36:37 -06:00
advplyr
d6b17678ec Update:Persist soft/hard delete checkbox option #1689 2023-11-09 16:36:28 -06:00
advplyr
33e287a543 Update:Persist show full path option for tables #2285 2023-11-09 16:26:49 -06:00
advplyr
08f045a02b Merge pull request #2299 from burghy86/patch-11
Update it.json
2023-11-09 16:15:16 -06:00
mikiher
e8c14dbb58 Test BookFinder.js using mocha 2023-11-09 19:58:51 +00:00
burghy86
bf48eee705 Update it.json
arrange the additional lines.
how the hell did we get to over 700 lines in less than two months?
2023-11-09 15:46:25 +01:00
advplyr
8f4c75ff2b Update:Author card books translation string #2284 2023-11-08 16:28:05 -06:00
advplyr
ee75d672e6 Matching user by openid sub, email or username based on server settings. Auto register user. Persist sub on User records 2023-11-08 16:14:57 -06:00
advplyr
e140897313 Add match existing user by and auto register settings and UI 2023-11-08 14:45:29 -06:00
mikiher
d1671f0ddc Cleanup commented out tests 2023-11-08 16:37:12 +00:00
mikiher
2730486ba5 Add tests for AuthorCandidates and search() in BookFinder 2023-11-08 16:24:08 +00:00
mikiher
49e4515785 Add stripRedudantSpaces 2023-11-08 16:21:20 +00:00
mikiher
819c524f51 Pass audnexus to AuthorCandidates constructor directly 2023-11-08 16:19:24 +00:00
Dr-Blank
6d968f9044 Update gu.json 2023-11-06 18:16:03 -05:00
Dr-Blank
23fa9e8d7f Update gu.json 2023-11-06 18:15:18 -05:00
Dr-Blank
59a428d549 more gu translations 2023-11-06 18:10:57 -05:00
advplyr
70c213ad22 Merge pull request #2291 from brianjaustin/fix/collection-duration
Hide collection duration if 0
2023-11-06 16:21:25 -06:00
advplyr
aad6402fdb Update client/components/tables/collection/BookTableRow.vue 2023-11-06 16:18:35 -06:00
advplyr
5ce1cda2d0 Merge pull request #2283 from mikiher/watcher-single-file-update
Fix handling of single media file updates
2023-11-06 16:08:23 -06:00
mikiher
ba60fc7581 Add tests for TitleCanidates 2023-11-06 05:33:06 +00:00
Brian Austin
0344e8cf1b Hide collection duration if 0 2023-11-05 19:13:26 -05:00
advplyr
f840aa80f8 Add button to populate openid URLs using the issuer URL 2023-11-05 14:11:37 -06:00
advplyr
c17540e191 Add app and serverVersion properties to response from /status 2023-11-05 13:06:26 -06:00
advplyr
309ef807ab Update /auth/openid endpoint to work with PKCE from mobile
Co-authored-by: Denis Arnst <git@sapd.eu>
2023-11-05 13:05:16 -06:00
advplyr
61e05e92a8 Add Swedish language option 2023-11-05 13:05:16 -06:00
Gustav Almstrom
1e5d6a5d52 Added swedish translation of strings 2023-11-05 13:05:15 -06:00
advplyr
ff831678e8 Merge pull request #2288 from ScuttleSE/master
Added swedish translation of strings
2023-11-05 10:18:45 -06:00
advplyr
910be21e93 Add Swedish language option 2023-11-05 10:16:40 -06:00
mikiher
89055f8655 Remove unnecessary includesAuthorDiff from sorting 2023-11-05 16:14:26 +00:00
Gustav Almstrom
b9ccc28baa Added swedish translation of strings 2023-11-05 16:51:45 +01:00
mikiher
5a3d450482 Refactor diff declarations in title candidate sorting 2023-11-05 15:13:42 +00:00
mikiher
047e7a72f2 Make position an internal property of titleCandidates 2023-11-05 14:56:20 +00:00
mikiher
3a9d09ea63 Add jest to dev dependencies 2023-11-05 14:33:56 +00:00
mikiher
ee3d3808ef Refactor removing author from title candidate 2023-11-05 14:31:36 +00:00
mikiher
8f5a6b7c95 Move utility functions to module scope 2023-11-05 14:17:26 +00:00
advplyr
840811b464 Replace passport openidconnect plugin with openid-client, add JWKS and logout URL server settings, use email and email_verified instead of username 2023-11-04 15:36:43 -05:00
mikiher
567e1c46db Fix handling of single mefia file updates 2023-11-04 11:06:54 +00:00
advplyr
cfe0c2a986 Merge branch 'master' into auth_passportjs 2023-11-03 08:29:05 -05:00
advplyr
68546acf2a Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-11-03 07:08:04 -05:00
advplyr
5220361151 Fix:Podcast episode cron not adding/removing library items correctly #2277 2023-11-03 07:07:58 -05:00
advplyr
076e01dbfe Merge pull request #2276 from Plazec/patch-1
Update cs.json
2023-11-02 14:19:45 -05:00
advplyr
f15ed08b6a Merge branch 'master' into auth_passportjs 2023-11-02 13:55:16 -05:00
advplyr
828b96b2d9 Add server settings for changing openid button text and auto launching openid 2023-11-02 13:55:01 -05:00
Plazec
3100437651 Update cs.json
Corrected some translation errors and made the translation more consistent.
2023-11-02 19:44:32 +01:00
advplyr
20880a6bf6 Merge pull request #2274 from radekmuhlfeit2/patch-1
Create cs-CZ.json
2023-11-01 15:48:05 -05:00
advplyr
2eff69fe9f Add czech translation to dropdown 2023-11-01 15:42:54 -05:00
advplyr
5f035db0a9 Rename cs-CZ.json to cs.json 2023-11-01 15:29:58 -05:00
radekmuhlfeit2
e4a7e9d6b5 Create cs-CZ.json
Czech strings.
2023-11-01 20:21:45 +01:00
advplyr
ab14b561f5 Merge master 2023-11-01 08:58:48 -05:00
advplyr
5ce4734a70 Merge pull request #2272 from clement-dufour/master
Add support for the old Apple Podcasts iOS app
2023-11-01 07:43:48 -05:00
clement.dufour
1ae2089253 Update:Add cover file extension in RSS feeds 2023-11-01 12:11:24 +01:00
clement.dufour
3c21e9d413 Update:Simpler content URL in RSS feeds 2023-11-01 12:10:44 +01:00
advplyr
9616d99640 Fix:Crash when matching with author names ending in ??? by escaping regex strings #2265 2023-10-30 16:35:41 -05:00
advplyr
2662e8f715 Merge branch 'master' into auth_passportjs 2023-10-02 16:21:47 -05:00
advplyr
0d5a30b214 Update JWT auth extractors, add state in openid redirect, add back cors for api router 2023-09-25 17:05:58 -05:00
advplyr
e282142d3f Add authentication page in config, add /auth-settings GET endpoint, remove authOpenIDCallbackURL server setting 2023-09-24 15:36:35 -05:00
advplyr
7ba10db7d4 Update login button openid and google urls 2023-09-24 12:39:38 -05:00
advplyr
f6de373388 Update /status endpoint to return available auth methods, fix socket auth, update openid to use username instead of email 2023-09-24 12:36:36 -05:00
advplyr
9922294507 Fix setting tokenSecret on init 2023-09-23 13:42:28 -05:00
advplyr
f42ab45e1b Update passwordless root user check to user user.type instead of user.id 2023-09-23 13:30:28 -05:00
lukeIam
7a131880e5 show/hide of login buttons 2023-09-23 17:02:27 +01:00
lukeIam
0e75c80627 prepare show/hide of login buttons 2023-09-20 19:45:32 +01:00
lukeIam
2c25f64652 Add /auth_methods route 2023-09-20 19:16:08 +01:00
lukeIam
45cf00bd04 fix openid + jwt auth 2023-09-20 19:06:16 +01:00
lukeIam
f6113e85c7 cookie lifetime 2023-09-20 18:48:57 +01:00
lukeIam
2c90bba774 small refactorings 2023-09-20 18:37:55 +01:00
lukeIam
51b0750a3f Merge remote-tracking branch 'origin/master' into auth_passportjs 2023-09-20 17:34:29 +01:00
lukeIam
0a6cd89090 Allow rest mode login (?isRest=true) 2023-09-17 18:42:42 +01:00
lukeIam
942aa93f57 Fix: local login not possible 2023-09-16 19:45:04 +00:00
lukeIam
763c0f4a3d add missing await 2023-09-16 18:51:29 +00:00
lukeIam
7af3033f8d Fix: ci error - no token sercret 2023-09-16 18:42:48 +00:00
lukeIam
91d8451ab3 Remove log messages 2023-09-16 18:22:23 +00:00
lukeIam
6aaf3f0f02 Fix bug with undefined property 2023-09-16 18:22:11 +00:00
lukeIam
226a774ab9 Merge remote-tracking branch 'origin/master' into auth_passportjs 2023-09-16 18:02:51 +00:00
lukeIam
af4c35069b Use a short-time cookie to remember where to callback to 2023-09-14 18:49:19 +01:00
lukeIam
405c954b65 Updated + first rough implementation 2023-09-13 16:35:39 +00:00
lukeIam
f0f03efe17 Merge remote-tracking branch 'origin/master' into auth_passportjs 2023-09-10 13:11:35 +00:00
lukeIam
dd9a3858d7 Merge remote-tracking branch 'origin/master' into auth_passportjs 2023-08-12 16:44:44 +02:00
Jorge
679bdf36b1 Add CodeQL workflow 2023-07-03 09:15:04 +02:00
lukeIam
95e6fef3d1 Merge remote-tracking branch 'origin/master' into auth_passportjs 2023-05-27 10:56:05 +02:00
advplyr
4359ca28df Fix XAccel issue 2023-04-29 16:05:05 -05:00
advplyr
8b685436de Merge 2023-04-29 15:49:04 -05:00
advplyr
8d0064763c Merge branch 'master' into auth_passportjs 2023-04-16 10:08:17 -05:00
advplyr
7010a13648 Fixes for passport local and allow empty password 2023-04-16 10:08:13 -05:00
lukeIam
812395b21b Merge remote-tracking branch 'origin/master' into auth_passportjs 2023-04-14 20:27:43 +02:00
lukeIam
62b0940766 Added passport-openidconnect implementation 2023-04-14 20:26:29 +02:00
lukeIam
08676a675a Fix: small problem with this context in Auth.js 2023-03-24 18:31:58 +01:00
lukeIam
be53b31712 Merge remote-tracking branch 'origin/master' into auth_passportjs 2023-03-24 18:23:08 +01:00
lukeIam
e1ddb95250 Inital passportjs integration 2023-03-24 18:21:25 +01:00
275 changed files with 30972 additions and 25053 deletions

View File

@@ -1,5 +1,5 @@
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
ARG VARIANT=16
ARG VARIANT=20
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
# Setup the node environment

View File

@@ -5,5 +5,6 @@ module.exports.config = {
ConfigPath: Path.resolve('config'),
MetadataPath: Path.resolve('metadata'),
FFmpegPath: '/usr/bin/ffmpeg',
FFProbePath: '/usr/bin/ffprobe'
FFProbePath: '/usr/bin/ffprobe',
SkipBinariesCheck: false
}

View File

@@ -8,7 +8,7 @@
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "16"
"VARIANT": "20"
}
},
"mounts": [

View File

@@ -11,7 +11,7 @@ body:
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."
value: "### Join the [discord server](https://discord.gg/HQgCbd6E75) 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."
@@ -44,6 +44,7 @@ body:
options:
- Docker
- Debian/PPA
- Windows Tray App
- Built from source
- Other
validations:

View File

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

65
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: "CodeQL"
on:
push:
branches: [ 'master' ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ 'master' ]
schedule:
- cron: '16 5 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View File

@@ -71,7 +71,7 @@ jobs:
with:
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

30
.github/workflows/i18n-integration.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Verify all i18n files are alphabetized
on:
pull_request:
paths:
- client/strings/** # Should only check if any strings changed
push:
paths:
- client/strings/** # Should only check if any strings changed
jobs:
update_translations:
runs-on: ubuntu-latest
steps:
# Check out the repository
- name: Checkout repository
uses: actions/checkout@v4
# Set up node to run the javascript
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: "20"
# The only argument is the `directory`, which is where the i18n files are
# stored.
- name: Run Update JSON Files action
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.2.0
with:
directory: "client/strings/" # Adjust the directory path as needed

View File

@@ -16,7 +16,7 @@ jobs:
- name: setup nade
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
- name: install pkg
run: npm install -g pkg

View File

@@ -0,0 +1,17 @@
name: Dispatch an abs-windows event
on:
release:
types: [published]
workflow_dispatch:
jobs:
abs-windows-dispatch:
runs-on: ubuntu-latest
steps:
- name: Send a remote repository dispatch event
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.ABS_WINDOWS_PAT }}
repository: mikiher/audiobookshelf-windows
event-type: build-windows

37
.github/workflows/unit-tests.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Run Unit Tests
on:
workflow_dispatch:
inputs:
ref:
description: 'Branch/Tag/SHA to test'
required: true
pull_request:
push:
jobs:
run-unit-tests:
name: Run Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout (push/pull request)
uses: actions/checkout@v4
if: github.event_name != 'workflow_dispatch'
- name: Checkout (workflow_dispatch)
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
if: github.event_name == 'workflow_dispatch'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test

5
.gitignore vendored
View File

@@ -7,11 +7,14 @@
/podcasts/
/media/
/metadata/
test/
/client/.nuxt/
/client/dist/
/dist/
/deploy/
/coverage/
/.nyc_output/
/ffmpeg*
/ffprobe*
sw.*
.DS_STORE

View File

@@ -16,5 +16,6 @@
},
"editor.formatOnSave": true,
"editor.detectIndentation": true,
"editor.tabSize": 2
"editor.tabSize": 2,
"javascript.format.semicolons": "remove"
}

View File

@@ -1,5 +1,5 @@
### STAGE 0: Build client ###
FROM node:16-alpine AS build
FROM node:20-alpine AS build
WORKDIR /client
COPY /client /client
RUN npm ci && npm cache clean --force
@@ -7,7 +7,7 @@ RUN npm run generate
### STAGE 1: Build server ###
FROM sandreas/tone:v0.1.5 AS tone
FROM node:16-alpine
FROM node:20-alpine
ENV NODE_ENV=production
@@ -18,7 +18,8 @@ RUN apk update && \
ffmpeg \
make \
python3 \
g++
g++ \
tini
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist
@@ -31,4 +32,5 @@ RUN apk del make python3 g++
EXPOSE 80
ENTRYPOINT ["tini", "--"]
CMD ["node", "index.js"]

View File

@@ -48,11 +48,10 @@ Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control;
# Package debian
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
pkg -t node18-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
fakeroot dpkg-deb --build dist/debian
fakeroot dpkg-deb -Zxz --build dist/debian
mv dist/debian.deb "dist/$OUTPUT_FILE"
chmod +x "dist/$OUTPUT_FILE"
echo "Finished! Filename: $OUTPUT_FILE"

View File

@@ -30,8 +30,7 @@
}
.bookshelf-row {
/* Sidebar width + scrollbar width */
width: calc(100vw - 88px);
width: calc(100vw - (100vw - 100%));
}
@media (max-width: 768px) {
@@ -217,36 +216,6 @@ Bookshelf Label
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;
}
.episode-subtitle-long {
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-height: 16px;
/* fallback */
max-height: 72px;
/* fallback */
-webkit-line-clamp: 6;
/* number of lines to show */
-webkit-box-orient: vertical;
}
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
padding-top: 104px;
@@ -258,4 +227,24 @@ Bookshelf Label
.no-bars .Vue-Toastification__container.top-right {
padding-top: 8px;
}
.abs-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.abs-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
.abs-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}

View File

@@ -24,6 +24,7 @@
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
vertical-align: top;
}
.material-icons:not([class*="text-"]) {

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -320,9 +320,11 @@ export default {
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error',
checkboxDefaultValue: true,
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
callback: (confirmed, hardDelete) => {
if (confirmed) {
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
this.$store.commit('setProcessingBatch', true)
this.$axios

View File

@@ -1,7 +1,7 @@
<template>
<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-50" />
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
<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>
@@ -94,6 +94,9 @@ export default {
},
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems || []
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {
@@ -338,9 +341,15 @@ export default {
libraryItemsAdded(libraryItems) {
console.log('libraryItems added', libraryItems)
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
if (!this.search && isThisLibrary) {
this.fetchCategories()
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
if (!recentlyAddedShelf) return
// Add new library item to the recently added shelf
for (const libraryItem of libraryItems) {
if (libraryItem.libraryId === this.currentLibraryId && !recentlyAddedShelf.entities.some((ent) => ent.id === libraryItem.id)) {
// Add to front of array
recentlyAddedShelf.entities.unshift(libraryItem)
}
}
},
libraryItemsUpdated(items) {

View File

@@ -4,7 +4,7 @@
<div class="w-full h-full pt-6">
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities">
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
<cards-lazy-book-card :key="`${entity.id}-${index}`" :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">

View File

@@ -22,6 +22,10 @@
<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="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="flex-grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
<span v-else class="material-icons-outlined text-lg">queue_music</span>
</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>
@@ -36,7 +40,7 @@
</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>
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
</nuxt-link>
</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">
@@ -94,6 +98,9 @@
<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>
<!-- author sort select -->
<controls-sort-select v-if="authors && authors.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
</template>
</div>
</div>
@@ -179,6 +186,30 @@ export default {
}
]
},
authorSortItems() {
return [
{
text: this.$strings.LabelAuthorFirstLast,
value: 'name'
},
{
text: this.$strings.LabelAuthorLastFirst,
value: 'lastFirst'
},
{
text: this.$strings.LabelNumberOfBooks,
value: 'numBooks'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelUpdatedAt,
value: 'updatedAt'
}
]
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
@@ -293,6 +324,9 @@ export default {
}
return items
},
showPlaylists() {
return this.$store.state.libraries.numUserPlaylists > 0
}
},
methods: {
@@ -448,6 +482,9 @@ export default {
updateCollapseBookSeries() {
this.saveSettings()
},
updateAuthorSort() {
this.saveSettings()
},
saveSettings() {
this.$store.dispatch('user/updateUserSettings', this.settings)
},

View File

@@ -104,6 +104,11 @@ export default {
id: 'config-rss-feeds',
title: this.$strings.HeaderRSSFeeds,
path: '/config/rss-feeds'
},
{
id: 'config-authentication',
title: this.$strings.HeaderAuthentication,
path: '/config/authentication'
}
]

View File

@@ -21,7 +21,7 @@
</div>
</div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
</div>
</template>
@@ -205,6 +205,9 @@ export default {
sizeMultiplier() {
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {

View File

@@ -1,10 +1,10 @@
<template>
<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 v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
<div id="videoDock" />
<div class="absolute left-2 top-2 md:left-4 cursor-pointer">
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</div>
<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="flex items-start mb-6 lg: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 }}
@@ -29,7 +29,7 @@
</div>
<div class="flex-grow" />
<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>
<button :aria-label="$strings.LabelClosePlayer" class="material-icons sm:px-2 py-1 lg:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</button>
</ui-tooltip>
</div>
<player-ui
@@ -349,7 +349,7 @@ export default {
}
if ('mediaSession' in navigator) {
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
const artwork = [
{
src: coverImageSrc
@@ -380,7 +380,7 @@ export default {
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
console.log(`[MediaPlayerContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
@@ -397,17 +397,17 @@ export default {
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
},
streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session)
console.log(`[MediaPlayerContainer] Stream session open`, session)
},
streamClosed(streamId) {
// Stream was closed from the server
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to request from server')
console.warn('[MediaPlayerContainer] Closing stream due to request from server')
this.playerHandler.closePlayer()
}
},
streamReady() {
console.log(`[StreamContainer] Stream Ready`)
console.log(`[MediaPlayerContainer] Stream Ready`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setStreamReady()
} else {
@@ -417,7 +417,7 @@ export default {
streamError(streamId) {
// Stream had critical error from the server
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to stream error from server')
console.warn('[MediaPlayerContainer] Closing stream due to stream error from server')
this.playerHandler.closePlayer()
}
},
@@ -496,7 +496,7 @@ export default {
</script>
<style>
#streamContainer {
#mediaPlayerContainer {
box-shadow: 0px -6px 8px #1111113f;
}
</style>

View File

@@ -1,6 +1,7 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-8">
<div class="flex items-center mb-2">
<slot name="header-prefix"></slot>
<h1 class="text-xl">{{ headerText }}</h1>
<slot name="header-items"></slot>

View File

@@ -82,7 +82,7 @@
<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>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAdd }}</p>
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>

View File

@@ -8,7 +8,7 @@
<!-- 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>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
</div>
<!-- Search icon btn -->

View File

@@ -15,24 +15,33 @@
<div class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
<ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
<div v-if="!isPodcast" class="flex items-end">
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
<div
class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer"
@click="fetchMetadata">
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
</div>
</ui-tooltip>
</div>
<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" />
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
</div>
</div>
</div>
<div v-if="!isPodcast" class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" />
<ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
</div>
<div class="w-1/2 px-2">
<div 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" />
<label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
</div>
</div>
</div>
@@ -48,8 +57,8 @@
<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="$strings.MessageUploading" />
<div v-if="isNonInteractable" 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="nonInteractionLabel" />
</div>
</div>
</template>
@@ -61,10 +70,11 @@ export default {
props: {
item: {
type: Object,
default: () => {}
default: () => { }
},
mediaType: String,
processing: Boolean
processing: Boolean,
provider: String
},
data() {
return {
@@ -76,7 +86,8 @@ export default {
error: '',
isUploading: false,
uploadFailed: false,
uploadSuccess: false
uploadSuccess: false,
isFetchingMetadata: false
}
},
computed: {
@@ -87,12 +98,19 @@ export default {
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.itemData.title
const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part))
return Path.join(...cleanedOutputPathParts)
},
isNonInteractable() {
return this.isUploading || this.isFetchingMetadata
},
nonInteractionLabel() {
if (this.isUploading) {
return this.$strings.MessageUploading
} else if (this.isFetchingMetadata) {
return this.$strings.LabelFetchingMetadata
}
}
},
@@ -105,9 +123,42 @@ export default {
titleUpdated() {
this.error = ''
},
async fetchMetadata() {
if (!this.itemData.title.trim().length) {
return
}
this.isFetchingMetadata = true
this.error = ''
try {
const searchQueryString = new URLSearchParams({
title: this.itemData.title,
author: this.itemData.author,
provider: this.provider
})
const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`)
if (bestCandidate) {
this.itemData = {
...this.itemData,
title: bestCandidate.title,
author: bestCandidate.author,
series: (bestCandidate.series || [])[0]?.series
}
} else {
this.error = this.$strings.ErrorUploadFetchMetadataNoResults
}
} catch (e) {
console.error('Failed', e)
this.error = this.$strings.ErrorUploadFetchMetadataAPI
} finally {
this.isFetchingMetadata = false
}
},
getData() {
if (!this.itemData.title) {
this.error = 'Must have a title'
this.error = this.$strings.ErrorUploadLacksTitle
return null
}
this.error = ''
@@ -128,4 +179,4 @@ export default {
}
}
}
</script>
</script>

View File

@@ -6,12 +6,12 @@
</div>
<!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" 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>
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<p ref="displayTitle" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator :explicit="isExplicit" />
</div>
</ui-tooltip>
</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>
@@ -164,6 +164,7 @@ export default {
imageReady: false,
selected: false,
isSelectionMode: false,
displayTitleTruncated: false,
showCoverBg: false
}
},
@@ -357,7 +358,7 @@ export default {
},
showError() {
if (this.recentEpisode) return false // Dont show podcast error on episode card
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
return this.isMissing || this.isInvalid
},
libraryItemIdStreaming() {
return this.store.getters['getLibraryItemIdStreaming']
@@ -387,29 +388,13 @@ export default {
isInvalid() {
return this._libraryItem.isInvalid
},
numMissingParts() {
if (this.isPodcast) return 0
return this.media.numMissingParts
},
numInvalidAudioFiles() {
if (this.isPodcast) return 0
return this.media.numInvalidAudioFiles
},
errorText() {
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'
}
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'
return 'Unknown Error'
},
overlayWrapperClasslist() {
const classes = []
@@ -642,6 +627,12 @@ export default {
}
this.libraryItem = libraryItem
this.$nextTick(() => {
if (this.$refs.displayTitle) {
this.displayTitleTruncated = this.$refs.displayTitle.scrollWidth > this.$refs.displayTitle.clientWidth
}
})
},
clickCard(e) {
if (this.processing) return
@@ -848,9 +839,11 @@ export default {
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error',
checkboxDefaultValue: true,
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
callback: (confirmed, hardDelete) => {
if (confirmed) {
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
this.processing = true
const axios = this.$axios || this.$nuxt.$axios
axios

View File

@@ -89,6 +89,14 @@
</template>
</div>
</div>
<div v-if="language" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelLanguage }}</span>
</div>
<div>
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
</div>
</div>
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
@@ -182,6 +190,9 @@ export default {
narrators() {
return this.mediaMetadata.narrators || []
},
language() {
return this.mediaMetadata.language || null
},
durationPretty() {
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)

View File

@@ -1,13 +1,15 @@
<template>
<div class="sm:w-80 w-full relative">
<form @submit.prevent="submitSearch">
<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 class="">
<div class="w-full relative sm:w-80">
<form @submit.prevent="submitSearch">
<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>
<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">
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 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>{{ $strings.MessageThinking }}</p>

View File

@@ -235,6 +235,11 @@ export default {
value: 'tags',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
@@ -368,9 +373,17 @@ export default {
id: 'ebook',
name: this.$strings.LabelHasEbook
},
{
id: 'no-ebook',
name: this.$strings.LabelMissingEbook
},
{
id: 'supplementary',
name: this.$strings.LabelHasSupplementaryEbook
},
{
id: 'no-supplementary',
name: this.$strings.LabelMissingSupplementaryEbook
}
]
},

View File

@@ -1,8 +1,8 @@
<template>
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<button :aria-label="$strings.LabelVolume" class="text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
</div>
</button>
<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">
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
@@ -38,8 +38,8 @@ export default {
},
set(val) {
try {
localStorage.setItem("volume", val);
} catch(error) {
localStorage.setItem('volume', val)
} catch (error) {
console.error('Failed to store volume', err)
}
this.$emit('input', val)
@@ -146,7 +146,7 @@ export default {
if (this.value === 0) {
this.isMute = true
}
const storageVolume = localStorage.getItem("volume")
const storageVolume = localStorage.getItem('volume')
if (storageVolume) {
this.volume = parseFloat(storageVolume)
}

View File

@@ -111,7 +111,8 @@
</div>
<div class="flex pt-4 px-2">
<ui-btn v-if="isEditingRoot" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
<ui-btn v-if="hasOpenIDLink" small :loading="unlinkingFromOpenID" color="primary" type="button" class="mr-2" @click.stop="unlinkOpenID">Unlink OpenID</ui-btn>
<ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
@@ -136,7 +137,8 @@ export default {
newUser: {},
isNew: true,
tags: [],
loadingTags: false
loadingTags: false,
unlinkingFromOpenID: false
}
},
watch: {
@@ -180,7 +182,7 @@ export default {
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
},
isEditingRoot() {
return this.account && this.account.type === 'root'
return this.account?.type === 'root'
},
libraries() {
return this.$store.state.libraries.libraries
@@ -198,6 +200,9 @@ export default {
},
tagsSelectionText() {
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
},
hasOpenIDLink() {
return !!this.account?.hasOpenIDLink
}
},
methods: {
@@ -205,6 +210,31 @@ export default {
// Force close when navigating - used in UsersTable
if (this.$refs.modal) this.$refs.modal.setHide()
},
unlinkOpenID() {
const payload = {
message: 'Are you sure you want to unlink this user from OpenID?',
callback: (confirmed) => {
if (confirmed) {
this.unlinkingFromOpenID = true
this.$axios
.$patch(`/api/users/${this.account.id}/openid-unlink`)
.then(() => {
this.$toast.success('User unlinked from OpenID')
this.show = false
})
.catch((error) => {
console.error('Failed to unlink user from OpenID', error)
this.$toast.error('Failed to unlink user from OpenID')
})
.finally(() => {
this.unlinkingFromOpenID = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
accessAllTagsToggled(val) {
if (val) {
if (this.newUser.itemTagsSelected?.length) {

View File

@@ -0,0 +1,105 @@
<template>
<modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :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">Add custom metadata provider</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="px-4 w-full flex items-center text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
<div class="w-full p-8">
<div class="flex mb-2">
<div class="w-3/4 p-1">
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" />
</div>
<div class="w-1/4 p-1">
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
</div>
</div>
<div class="w-full mb-2 p-1">
<ui-text-input-with-label v-model="newUrl" label="URL" />
</div>
<div class="w-full mb-2 p-1">
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="'Authorization Header Value'" type="password" />
</div>
<div class="flex px-1 pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonAdd }}</ui-btn>
</div>
</div>
</div>
</form>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean
},
data() {
return {
processing: false,
newName: '',
newUrl: '',
newAuthHeaderValue: ''
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
submitForm() {
if (!this.newName || !this.newUrl) {
this.$toast.error('Must add name and url')
return
}
this.processing = true
this.$axios
.$post('/api/custom-metadata-providers', {
name: this.newName,
url: this.newUrl,
mediaType: 'book', // Currently only supporting book mediaType
authHeaderValue: this.newAuthHeaderValue
})
.then((data) => {
this.$emit('added', data.provider)
this.$toast.success('New provider added')
this.show = false
})
.catch((error) => {
const errorMsg = error.response?.data || 'Unknown error'
console.error('Failed to add provider', error)
this.$toast.error('Failed to add provider: ' + errorMsg)
})
.finally(() => {
this.processing = false
})
},
init() {
this.processing = false
this.newName = ''
this.newUrl = ''
this.newAuthHeaderValue = ''
}
},
mounted() {}
}
</script>

View File

@@ -34,11 +34,6 @@ export default {
data() {
return {}
},
watch: {
value(newVal) {
this.$nextTick(this.scrollToChapter)
}
},
computed: {
show: {
get() {
@@ -53,7 +48,7 @@ export default {
return this.playbackRate
},
currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null
return this.currentChapter?.id || null
},
currentChapterStart() {
return (this.currentChapter?.start || 0) / this._playbackRate
@@ -74,6 +69,11 @@ export default {
}
}
}
},
updated() {
if (this.value) {
this.$nextTick(this.scrollToChapter)
}
}
}
</script>

View File

@@ -88,10 +88,11 @@
<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="clientDisplayName" class="mb-1">{{ clientDisplayName }}</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="deviceDisplayName" class="mb-1">{{ deviceDisplayName }}</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>
@@ -141,10 +142,14 @@ export default {
if (!this.deviceInfo.osName) return null
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
},
clientDisplayName() {
deviceDisplayName() {
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
},
clientDisplayName() {
if (!this.deviceInfo.clientName) return null
return `${this.deviceInfo.clientName} ${this.deviceInfo.clientVersion || ''}`
},
playMethodName() {
const playMethod = this._session.playMethod
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap mb-4">
<div class="relative">
<div class="flex flex-col sm:flex-row mb-4">
<div class="relative self-center">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay -->
@@ -14,7 +14,7 @@
</div>
</div>
</div>
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-6 md:mt-0">
<div class="flex items-center">
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected">
@@ -49,20 +49,20 @@
</div>
</div>
<form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-48 px-1">
<div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
<div class="w-48 flex-grow p-1">
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
</div>
<div class="w-72 px-1">
<div class="w-72 flex-grow p-1">
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
</div>
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-1">
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 flex-grow p-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>
<ui-btn class="mt-5 ml-1 md:min-w-24" :padding-x="4" 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">
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
<template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">

View File

@@ -29,7 +29,7 @@
<td class="text-center w-20 min-w-20">
<p>{{ episode.episode }}</p>
</td>
<td>
<td dir="auto">
{{ episode.title }}
</td>
<td class="font-mono text-center">

View File

@@ -14,8 +14,7 @@ export default {
},
data() {
return {
tracks: [],
showFullPath: false
tracks: []
}
},
watch: {

View File

@@ -49,8 +49,8 @@
</div>
<div v-if="media.coverPath">
<p class="text-center text-gray-200">Current</p>
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<a :href="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" target="_blank" class="bg-primary">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</a>
</div>
</div>
@@ -328,10 +328,22 @@ export default {
console.error('PersistProvider', error)
}
},
getDefaultBookProvider() {
let provider = localStorage.getItem('book-provider')
if (!provider) return 'google'
// Validate book provider
if (!this.$store.getters['scanners/checkBookProviderExists'](provider)) {
console.error('Stored book provider does not exist', provider)
localStorage.removeItem('book-provider')
return 'google'
}
return provider
},
getSearchQuery() {
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`
if (this.libraryItemId) searchQuery += `&id=${this.libraryItemId}`
return searchQuery
},
submitSearch() {
@@ -433,7 +445,9 @@ export default {
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'
else {
this.provider = this.getDefaultBookProvider()
}
// Prefer using ASIN if set and using audible provider
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
@@ -494,7 +508,10 @@ export default {
} else if (key === 'author' && !this.isPodcast) {
var authors = this.selectedMatch[key]
if (!Array.isArray(authors)) {
authors = authors.split(',').map((au) => au.trim())
authors = authors
.split(',')
.map((au) => au.trim())
.filter((au) => !!au)
}
var authorPayload = []
authors.forEach((authorName) =>
@@ -532,24 +549,11 @@ export default {
// 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) {
if (updatePayload.metadata.cover) {
updatePayload.url = updatePayload.metadata.cover
delete updatePayload.metadata.cover
}
const mediaUpdatePayload = updatePayload
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
console.error('Failed to update', error)

View File

@@ -20,7 +20,7 @@
</ui-tooltip>
</div>
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
<ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded.">
<p class="pl-4 text-base">
Max new episodes to download per check
@@ -129,9 +129,12 @@ export default {
return
}
}
if (this.$refs.maxEpisodesInput && this.$refs.maxEpisodesInput.isFocused) {
if (this.$refs.maxEpisodesInput?.isFocused) {
this.$refs.maxEpisodesInput.blur()
return
}
if (this.$refs.maxEpisodesToDownloadInput?.isFocused) {
this.$refs.maxEpisodesToDownloadInput.blur()
}
const updatePayload = {
@@ -140,9 +143,11 @@ export default {
if (this.enableAutoDownloadEpisodes) {
updatePayload.autoDownloadSchedule = this.cronExpression
}
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
}
this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)
if (this.newMaxNewEpisodesToDownload !== this.maxNewEpisodesToDownload) {
updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload
}

View File

@@ -2,8 +2,11 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-xl font-semibold mb-2">{{ $strings.HeaderAudiobookTools }}</p>
<!-- alert for windows install -->
<widgets-alert v-if="isWindowsInstall" type="warning" class="my-8 text-base">Not supported for the Windows install yet</widgets-alert>
<!-- Merge to m4b -->
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div v-if="showM4bDownload && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
@@ -19,22 +22,8 @@
</div>
</div>
<!-- Split to mp3 -->
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsSplitM4bDescription }}</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
</div>
</div>
</div> -->
<!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
<div v-if="mediaTracks.length && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
@@ -122,6 +111,12 @@ export default {
},
isEncodeTaskRunning() {
return this.encodeTask && !this.encodeTask?.isFinished
},
isWindowsInstall() {
return this.Source == 'windows'
},
Source() {
return this.$store.state.Source
}
},
methods: {

View File

@@ -31,7 +31,7 @@
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
</div>
</div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
<modals-libraries-lazy-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
</div>
</template>

View File

@@ -127,7 +127,8 @@ export default {
skipMatchingMediaWithIsbn: false,
autoScanCronExpression: null,
hideSingleBookSeries: false,
metadataPrecedence: ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata']
onlyShowLaterBooksInContinueSeries: false,
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
}
}
},

View File

@@ -4,35 +4,37 @@
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
<p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p>
</div>
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
<div v-if="rootDirs.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
<p class="font-mono truncate">{{ selectedPath || '/' }}</p>
</div>
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4 folder-container">
<div v-if="rootDirs.length" class="relative flex bg-primary bg-opacity-50 p-4 folder-container">
<div class="w-1/2 border-r border-bg h-full overflow-y-auto">
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center hover:bg-white/10" @click="goBack">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2">..</p>
</div>
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)">
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" :class="dir.className" @click="selectDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
<span v-if="dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
</div>
</div>
<div class="w-1/2 h-full overflow-y-auto">
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" @click="selectSubDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
</div>
</div>
<div v-if="loadingDirs" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/10">
<ui-loading-indicator />
</div>
</div>
<div v-else-if="loadingFolders" class="py-12 text-center">
<div v-else-if="initialLoad" class="py-12 text-center">
<p>{{ $strings.MessageLoadingFolders }}</p>
</div>
<div v-else class="py-12 text-center max-w-sm mx-auto">
<p class="text-lg mb-2">{{ $strings.MessageNoFoldersAvailable }}</p>
<p class="text-gray-300 mb-2">{{ $strings.NoteFolderPicker }}</p>
<p v-if="isDebian" class="text-red-400">{{ $strings.NoteFolderPickerDebian }}</p>
</div>
<div class="w-full py-2">
@@ -51,11 +53,12 @@ export default {
},
data() {
return {
loadingFolders: false,
allFolders: [],
initialLoad: false,
loadingDirs: false,
isPosix: true,
rootDirs: [],
directories: [],
selectedPath: '',
selectedFullPath: '',
subdirs: [],
level: 0,
currentDir: null,
@@ -89,68 +92,91 @@ export default {
...d
}
})
},
isDebian() {
return this.Source == 'debian'
},
Source() {
return this.$store.state.Source
}
},
methods: {
goBack() {
var splitPaths = this.selectedPath.split('\\').slice(1)
var prev = splitPaths.slice(0, -1).join('\\')
async goBack() {
let selPath = this.selectedPath.replace(/^\//, '')
var splitPaths = selPath.split('/')
var currDirs = this.allFolders
for (let i = 0; i < splitPaths.length; i++) {
var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i])
if (_dir && _dir.path.slice(1) === prev) {
this.directories = currDirs
this.selectDir(_dir)
return
} else if (_dir) {
currDirs = _dir.dirs
}
let previousPath = ''
let lookupPath = ''
if (splitPaths.length > 2) {
lookupPath = splitPaths.slice(0, -2).join('/')
}
previousPath = splitPaths.slice(0, -1).join('/')
if (!this.isPosix) {
// For windows drives add a trailing slash. e.g. C:/
if (!this.isPosix && lookupPath.endsWith(':')) {
lookupPath += '/'
}
if (!this.isPosix && previousPath.endsWith(':')) {
previousPath += '/'
}
} else {
// Add leading slash
if (previousPath) previousPath = '/' + previousPath
if (lookupPath) lookupPath = '/' + lookupPath
}
this.level--
this.subdirs = this.directories
this.selectedPath = previousPath
this.directories = await this.fetchDirs(lookupPath, this.level)
},
selectDir(dir) {
async selectDir(dir) {
if (dir.isUsed) return
this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level
this.subdirs = dir.dirs
this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)
},
selectSubDir(dir) {
async selectSubDir(dir) {
if (dir.isUsed) return
this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level
this.directories = this.subdirs
this.subdirs = dir.dirs
this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)
},
selectFolder() {
if (!this.selectedPath) {
console.error('No Selected path')
return
}
if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) {
if (this.paths.find((p) => p.startsWith(this.selectedPath))) {
this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)
return
}
this.$emit('select', this.selectedFullPath)
this.$emit('select', this.selectedPath)
this.selectedPath = ''
this.selectedFullPath = ''
},
fetchDirs(path, level) {
this.loadingDirs = true
return this.$axios
.$get(`/api/filesystem?path=${path}&level=${level}`)
.then((data) => {
console.log('Fetched directories', data.directories)
this.isPosix = !!data.posix
return data.directories
})
.catch((error) => {
console.error('Failed to get filesystem paths', error)
this.$toast.error('Failed to get filesystem paths')
return []
})
.finally(() => {
this.loadingDirs = false
})
},
async init() {
this.loadingFolders = true
this.allFolders = await this.$store.dispatch('libraries/loadFolders')
this.loadingFolders = false
this.initialLoad = true
this.rootDirs = await this.fetchDirs('', 0)
this.initialLoad = false
this.directories = this.allFolders
this.directories = this.rootDirs
this.subdirs = []
this.selectedPath = ''
this.selectedFullPath = ''
}
},
mounted() {

View File

@@ -19,9 +19,11 @@
<li v-for="(source, index) in metadataSourceMapped" :key="source.id" :class="source.include ? 'item' : 'opacity-50'" class="w-full px-2 flex items-center relative border border-white/10">
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4">reorder</span>
<div class="text-center py-1 w-8 min-w-8">
{{ source.include ? index + 1 : '' }}
{{ source.include ? getSourceIndex(source.id) : '' }}
</div>
<div class="flex-grow inline-flex justify-between px-4 py-3">
{{ source.name }} <span v-if="source.include && (index === firstActiveSourceIndex || index === lastActiveSourceIndex)" class="px-2 italic font-semibold text-xs text-gray-400">{{ index === firstActiveSourceIndex ? $strings.LabelHighestPriority : $strings.LabelLowestPriority }}</span>
</div>
<div class="flex-grow px-4 py-3">{{ source.name }}</div>
<div class="px-2 opacity-100">
<ui-toggle-switch v-model="source.include" :off-color="'error'" @input="includeToggled(source)" />
</div>
@@ -61,7 +63,12 @@ export default {
},
audioMetatags: {
id: 'audioMetatags',
name: 'Audio file meta tags',
name: 'Audio file meta tags OR ebook metadata',
include: true
},
nfoFile: {
id: 'nfoFile',
name: 'NFO file',
include: true
},
txtFiles: {
@@ -92,20 +99,34 @@ export default {
},
isBookLibrary() {
return this.mediaType === 'book'
},
firstActiveSourceIndex() {
return this.metadataSourceMapped.findIndex((source) => source.include)
},
lastActiveSourceIndex() {
return this.metadataSourceMapped.findLastIndex((source) => source.include)
}
},
methods: {
getSourceIndex(source) {
const activeSources = (this.librarySettings.metadataPrecedence || []).map((s) => s).reverse()
return activeSources.findIndex((s) => s === source) + 1
},
resetToDefault() {
this.metadataSourceMapped = []
for (const key in this.metadataSourceData) {
this.metadataSourceMapped.push({ ...this.metadataSourceData[key] })
}
this.metadataSourceMapped.reverse()
this.$emit('update', this.getLibraryData())
},
getLibraryData() {
const metadataSourceIds = this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s)
metadataSourceIds.reverse()
return {
settings: {
metadataPrecedence: this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s)
metadataPrecedence: metadataSourceIds
}
}
},
@@ -120,15 +141,16 @@ export default {
},
init() {
const metadataPrecedence = this.librarySettings.metadataPrecedence || []
this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s)
for (const sourceKey in this.metadataSourceData) {
if (!metadataPrecedence.includes(sourceKey)) {
const unusedSourceData = { ...this.metadataSourceData[sourceKey], include: false }
this.metadataSourceMapped.push(unusedSourceData)
this.metadataSourceMapped.unshift(unusedSourceData)
}
}
this.metadataSourceMapped.reverse()
}
},
mounted() {

View File

@@ -49,6 +49,20 @@
</ui-tooltip>
</div>
</div>
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="onlyShowLaterBooksInContinueSeries" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div>
<div v-if="isPodcastLibrary" class="py-3">
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-52" @input="formUpdated" />
</div>
</div>
</template>
@@ -69,7 +83,9 @@ export default {
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
audiobooksOnly: false,
hideSingleBookSeries: false
hideSingleBookSeries: false,
onlyShowLaterBooksInContinueSeries: false,
podcastSearchRegion: 'us'
}
},
computed: {
@@ -85,6 +101,9 @@ export default {
isBookLibrary() {
return this.mediaType === 'book'
},
isPodcastLibrary() {
return this.mediaType === 'podcast'
},
providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
@@ -99,7 +118,9 @@ export default {
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
audiobooksOnly: !!this.audiobooksOnly,
hideSingleBookSeries: !!this.hideSingleBookSeries
hideSingleBookSeries: !!this.hideSingleBookSeries,
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
podcastSearchRegion: this.podcastSearchRegion
}
}
},
@@ -113,6 +134,8 @@ export default {
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
}
},
mounted() {

View File

@@ -33,7 +33,7 @@
<div class="break-words">{{ episode.title }}</div>
<widgets-podcast-type-indicator :type="episode.episodeType" />
</div>
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
</div>
</div>
@@ -68,7 +68,9 @@ export default {
selectAll: false,
search: null,
searchTimeout: null,
searchText: null
searchText: null,
downloadedEpisodeGuidMap: {},
downloadedEpisodeUrlMap: {}
}
},
watch: {
@@ -122,11 +124,13 @@ export default {
},
methods: {
getIsEpisodeDownloaded(episode) {
return this.itemEpisodes.some((downloadedEpisode) => {
if (episode.guid && downloadedEpisode.guid === episode.guid) return true
if (!downloadedEpisode.enclosure?.url) return false
return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl
})
if (episode.guid && !!this.downloadedEpisodeGuidMap[episode.guid]) {
return true
}
if (this.downloadedEpisodeUrlMap[episode.cleanUrl]) {
return true
}
return false
},
/**
* UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed.
@@ -219,6 +223,14 @@ export default {
})
},
init() {
this.downloadedEpisodeGuidMap = {}
this.downloadedEpisodeUrlMap = {}
this.itemEpisodes.forEach((episode) => {
if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id
if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id
})
this.episodesCleaned = this.episodes
.filter((ep) => ep.enclosure?.url)
.map((_ep) => {

View File

@@ -15,8 +15,8 @@
<p class="text-xs text-gray-300">{{ podcastAuthor }}</p>
</div>
</div>
<p class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" class="default-style" v-html="description" />
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" dir="auto" class="default-style" v-html="description" />
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
</div>
</modals-modal>

View File

@@ -19,7 +19,7 @@
<div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.subtitle" :label="$strings.LabelSubtitle" :rows="3" />
</div>
<div class="w-full p-1 default-style">
<div class="w-full p-1">
<ui-rich-text-editor :label="$strings.LabelDescription" v-model="newEpisode.description" />
</div>
</div>

View File

@@ -18,7 +18,7 @@
<div v-for="(episode, index) in episodesFound" :key="index" class="w-full py-4 border-b border-white border-opacity-5 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer px-2" @click.stop="selectEpisode(episode)">
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
<p class="break-words mb-1">{{ episode.title }}</p>
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
<p class="text-xs text-gray-400">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
</div>
</template>

View File

@@ -1,22 +1,30 @@
<template>
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
<div class="flex items-center pt-4 pb-2 lg:pt-0 lg:pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
<span class="material-icons text-2xl sm: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-2xl sm: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-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<ui-tooltip direction="top" :text="$strings.ButtonPreviousChapter" class="mr-4 lg:mr-8">
<button :aria-label="$strings.ButtonPreviousChapter" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
</button>
</ui-tooltip>
<ui-tooltip direction="top" :text="$strings.ButtonJumpBackward">
<button :aria-label="$strings.ButtonJumpBackward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
</button>
</ui-tooltip>
<button :aria-label="paused ? $strings.ButtonPlay : $strings.ButtonPause" class="p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 lg:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons text-2xl">{{ 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-2xl sm:text-3xl">forward_10</span>
</div>
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
</div>
</button>
<ui-tooltip direction="top" :text="$strings.ButtonJumpForward">
<button :aria-label="$strings.ButtonJumpForward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
</button>
</ui-tooltip>
<ui-tooltip direction="top" :text="$strings.ButtonNextChapter" class="ml-4 lg:ml-8">
<button :aria-label="$strings.ButtonNextChapter" :disabled="!hasNextChapter" :class="hasNextChapter ? 'text-gray-300' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
</button>
</ui-tooltip>
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template>
<template v-else>

View File

@@ -57,7 +57,6 @@ export default {
},
watch: {
duration: {
immediate: true,
handler() {
this.setChapterTicks()
}
@@ -205,10 +204,14 @@ export default {
},
windowResize() {
this.setTrackWidth()
this.setChapterTicks()
this.updatePlayedTrackWidth()
this.updateBufferTrack()
}
},
mounted() {
this.setTrackWidth()
this.setChapterTicks()
window.addEventListener('resize', this.windowResize)
},
beforeDestroy() {

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full -mt-6">
<div class="w-full relative mb-1">
<div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
<ui-tooltip direction="top" :text="$strings.LabelVolume">
@@ -9,37 +9,37 @@
</ui-tooltip>
<ui-tooltip direction="top" :text="$strings.LabelSleepTimer">
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
<button :aria-label="$strings.LabelSleepTimer" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
<span v-if="!sleepTimerSet" class="material-icons text-2xl">snooze</span>
<div v-else class="flex items-center">
<span class="material-icons text-lg text-warning">snooze</span>
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
</div>
</div>
</button>
</ui-tooltip>
<ui-tooltip v-if="!isPodcast" direction="top" :text="$strings.LabelViewBookmarks">
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
<button :aria-label="$strings.LabelViewBookmarks" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
<span class="material-icons text-2xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div>
</button>
</ui-tooltip>
<ui-tooltip v-if="chapters.length" direction="top" :text="$strings.LabelViewChapters">
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<button :aria-label="$strings.LabelViewChapters" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-2xl">format_list_bulleted</span>
</div>
</button>
</ui-tooltip>
<ui-tooltip v-if="playerQueueItems.length" direction="top" :text="$strings.LabelViewQueue">
<button class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
<button :aria-label="$strings.LabelViewQueue" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
<span class="material-icons text-2.5xl sm:text-3xl">playlist_play</span>
</button>
</ui-tooltip>
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack">
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
<button :aria-label="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack" class="text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
</div>
</button>
</ui-tooltip>
</div>

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index + 1 ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
@@ -14,34 +14,40 @@
</div>
</div>
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'">
<span class="material-icons text-xl">download</span>
</a>
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
<div v-if="numPages" class="absolute top-0 left-4 sm:left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-xl">menu</span>
</div>
<div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<div v-if="comicMetadata" class="absolute top-0 left-16 sm:left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-28 sm:left-32' : 'left-16 sm:left-20'">
<span class="material-icons text-xl">download</span>
</a>
<div v-if="numPages" class="absolute top-0 right-14 sm:right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div v-if="mainImg" class="absolute top-0 right-36 sm:right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
</div>
<div class="overflow-hidden w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="w-full h-full relative">
<div v-show="canGoPrev" ref="prevButton" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div v-show="canGoNext" ref="nextButton" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="h-full flex justify-center">
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
<div ref="imageContainer" class="w-full h-full relative overflow-auto">
<div class="h-full flex" :class="scale > 100 ? '' : 'justify-center'">
<img v-if="mainImg" :style="{ minWidth: scale + '%', width: scale + '%' }" :src="mainImg" class="object-contain m-auto" />
</div>
</div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator />
</div>
@@ -54,6 +60,10 @@ import Path from 'path'
import { Archive } from 'libarchive.js/main.js'
import { CompressedFile } from 'libarchive.js/src/compressed-file'
// This is % with respect to the screen width
const MAX_SCALE = 400
const MIN_SCALE = 10
Archive.init({
workerUrl: '/libarchive/worker-bundle.js'
})
@@ -81,7 +91,8 @@ export default {
showInfoMenu: false,
loadTimeout: null,
loadedFirstPage: false,
comicMetadata: null
comicMetadata: null,
scale: 80
}
},
watch: {
@@ -136,6 +147,12 @@ export default {
return p
}) || []
)
},
canScaleUp() {
return this.scale < MAX_SCALE
},
canScaleDown() {
return this.scale > MIN_SCALE
}
},
methods: {
@@ -317,7 +334,7 @@ export default {
}
},
parseFilenames(filenames) {
const acceptableImages = ['.jpeg', '.jpg', '.png']
const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp']
var imageFiles = filenames.filter((f) => {
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
})
@@ -331,10 +348,37 @@ export default {
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
this.pages = orderedImages
},
zoomIn() {
this.scale += 10
},
zoomOut() {
this.scale -= 10
},
scroll(event) {
const imageContainer = this.$refs.imageContainer
imageContainer.scrollBy({
top: event.deltaY,
left: event.deltaX,
behavior: 'auto'
})
}
},
mounted() {},
beforeDestroy() {}
mounted() {
const prevButton = this.$refs.prevButton
const nextButton = this.$refs.nextButton
prevButton.addEventListener('wheel', this.scroll, { passive: false })
nextButton.addEventListener('wheel', this.scroll, { passive: false })
},
beforeDestroy() {
const prevButton = this.$refs.prevButton
const nextButton = this.$refs.nextButton
prevButton.removeEventListener('wheel', this.scroll, { passive: false })
nextButton.removeEventListener('wheel', this.scroll, { passive: false })
}
}
</script>

View File

@@ -316,6 +316,7 @@ export default {
reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth,
height: this.readerHeight * 0.8,
allowScriptedContent: true,
spread: 'auto',
snap: true,
manager: 'continuous',

View File

@@ -1,45 +1,45 @@
<template>
<div class="flex flex-wrap justify-center mt-6">
<div class="flex px-2">
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
<div class="flex p-2">
<svg class="h-14 w-14" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
</svg>
<div class="px-2">
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
<div class="px-1">
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalItems) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
</div>
</div>
<div class="flex px-4">
<span class="material-icons text-7xl">show_chart</span>
<div class="flex p-2">
<span class="material-icons text-5xl py-1">show_chart</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalTime) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
</div>
</div>
<div v-if="isBookLibrary" class="flex px-4">
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
<div v-if="isBookLibrary" class="flex p-2">
<svg class="h-14 w-14" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
</svg>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalAuthors }}</p>
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalAuthors) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
</div>
</div>
<div class="flex px-4">
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
<div class="flex p-2">
<span class="material-icons-outlined text-5xl pt-1">insert_drive_file</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalSizeNum }}</p>
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalSizeNum) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
</div>
</div>
<div class="flex px-4">
<span class="material-icons-outlined text-6xl pt-1">audio_file</span>
<div class="flex p-2">
<span class="material-icons-outlined text-5xl pt-1">audio_file</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ numAudioTracks }}</p>
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(numAudioTracks) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
</div>
</div>

View File

@@ -0,0 +1,285 @@
<template>
<div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
</div>
</template>
<script>
export default {
props: {
variant: {
type: Number,
default: 0
},
year: Number,
processing: Boolean
},
data() {
return {
canvas: null,
dataUrl: null,
yearStats: null
}
},
watch: {
variant() {
this.init()
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 800
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444455')
grd1.addColorStop(1, '#ffffff11')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C088'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color
ctx.font = `${fontSize} Material Icons Outlined`
ctx.fillText(icon, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
const bookCovers = this.yearStats.finishedBooksWithCovers
bookCovers.push(...this.yearStats.booksWithCovers)
let finishedBookCoverImgs = {}
if (bookCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-130, -120)
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const coverIndex = index % bookCovers.length
let libraryItemId = bookCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
resolve()
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
finishedBookCoverImgs[libraryItemId] = {
img,
sx,
sy,
sw
}
}
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(50, 100, 340, 160)
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
const readIconPath = new Path2D()
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 })
ctx.fillStyle = '#ffffff'
ctx.fill(readIconPath)
// Box top right
createRoundedRect(410, 100, 340, 160)
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
addIcon('watch_later', 'white', '52px', 440, 180)
// Box bottom left
createRoundedRect(50, 280, 340, 160)
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
addIcon('headphones', 'white', '52px', 95, 360)
// Box bottom right
createRoundedRect(410, 280, 340, 160)
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
addIcon('local_library', 'white', '52px', 440, 360)
if (!this.variant) {
// Text stats
const topNarrator = this.yearStats.mostListenedNarrator
if (topNarrator) {
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
}
const topGenre = this.yearStats.topGenres[0]
if (topGenre) {
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
}
const topAuthor = this.yearStats.topAuthors[0]
if (topAuthor) {
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
}
if (this.yearStats.mostListenedMonth?.time) {
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
const monthName = this.$formatJsDate(jsdate, 'LLLL')
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
}
} else if (this.variant === 1) {
// Bottom images
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
if (finishedBookCoverImgs.length > 0) {
ctx.textAlign = 'center'
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
let imgToAdd = finishedBookCoverImgs[i]
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
}
}
} else if (this.variant === 2) {
// Text stats
if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
}
}
if (this.yearStats.topGenres.length) {
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
}
}
}
this.canvas = canvas
this.dataUrl = canvas.toDataURL('png')
},
refresh() {
this.init()
},
share() {
this.canvas.toBlob((blob) => {
const file = new File([blob], 'yearinreview.png', { type: blob.type })
const shareData = {
files: [file]
}
if (navigator.canShare(shareData)) {
navigator
.share(shareData)
.then(() => {
console.log('Share success')
})
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
}
})
} else {
this.$toast.error('Cannot share natively on this device')
}
})
},
async init() {
this.$emit('update:processing', true)
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats')
return null
})
await this.initCanvas()
this.$emit('update:processing', false)
}
},
mounted() {
this.init()
}
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-1 sm:p-4 mb-4">
<!-- hack to get icon fonts loaded on init -->
<div class="h-0 w-0 overflow-hidden opacity-0">
<span class="material-icons-outlined">close</span>
<span class="abs-icons icon-audiobookshelf" />
</div>
<div class="flex items-center">
<p class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</p>
<div class="hidden md:block flex-grow" />
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide :
$strings.LabelYearReviewShow }}</ui-btn>
</div>
<!-- your year in review -->
<div v-if="showYearInReview">
<div class="w-full h-px bg-slate-200/10 my-4" />
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
<!-- previous button -->
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn>
<!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{
$strings.ButtonShare }}
</ui-btn>
<div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}
</p>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
<div class="flex-grow" />
<!-- refresh button -->
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
<span class="hidden sm:inline-block">{{ $strings.ButtonRefresh }}</span>
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- next button -->
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
</div>
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
<!-- your year in review short -->
<div class="w-full max-w-[800px] mx-auto my-4">
<!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn>
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
</div>
<!-- your server in review -->
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
<div class="flex items-center justify-center mb-2">
<!-- previous button -->
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn>
<!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }}
</ui-btn>
<div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</p>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
<div class="flex-grow" />
<!-- refresh button -->
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
<span class="hidden sm:inline-block">{{ $strings.ButtonRefresh }}</span>
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- next button -->
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
</div>
</div>
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
</div>
</div>
</template>
<script>
export default {
data() {
return {
showYearInReview: false,
yearInReviewYear: 0,
yearInReviewVariant: 0,
yearInReviewServerVariant: 0,
processingYearInReview: false,
processingYearInReviewShort: false,
processingYearInReviewServer: false,
showShareButton: false
}
},
computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
}
},
methods: {
shareYearInReviewServer() {
this.$refs.yearInReviewServer.share()
},
shareYearInReview() {
this.$refs.yearInReview.share()
},
shareYearInReviewShort() {
this.$refs.yearInReviewShort.share()
},
refreshYearInReviewServer() {
this.$refs.yearInReviewServer.refresh()
},
refreshYearInReview() {
this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh()
},
clickShowYearInReview() {
this.showYearInReview = !this.showYearInReview
}
},
beforeMount() {
this.yearInReviewYear = new Date().getFullYear()
// When not December show previous year
if (new Date().getMonth() < 11) {
this.yearInReviewYear--
}
},
mounted() {
if (typeof navigator.share !== 'undefined' && navigator.share) {
this.showShareButton = true
} else {
console.warn('Navigator.share not supported')
}
}
}
</script>

View File

@@ -0,0 +1,264 @@
<template>
<div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
</div>
</template>
<script>
export default {
props: {
variant: {
type: Number,
default: 0
},
processing: Boolean,
year: Number
},
data() {
return {
canvas: null,
dataUrl: null,
yearStats: null
}
},
watch: {
variant() {
this.init()
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 800
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444455')
grd1.addColorStop(1, '#ffffff11')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C088'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
let imgsToAdd = {}
if (this.yearStats.booksAddedWithCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-130, -120)
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
if (!imgsToAdd[libraryItemId]) {
imgsToAdd[libraryItemId] = {
img,
sx,
sy,
sw
}
}
resolve()
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(40, 100, 230, 100)
ctx.textAlign = 'center'
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
// Box top right
createRoundedRect(285, 100, 230, 100)
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
// Box bottom left
createRoundedRect(530, 100, 230, 100)
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
// Text stats
if (this.yearStats.totalBooksAddedSize) {
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
}
if (this.yearStats.totalBooksAddedDuration) {
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
}
if (!this.variant) {
// Bottom images
imgsToAdd = Object.values(imgsToAdd)
if (imgsToAdd.length > 0) {
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
let imgToAdd = imgsToAdd[i]
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
}
}
} else if (this.variant === 1) {
// Text stats
ctx.textAlign = 'left'
if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
}
}
if (this.yearStats.topNarrators.length) {
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
}
}
} else if (this.variant === 2) {
// Text stats
ctx.textAlign = 'left'
if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
}
}
if (this.yearStats.topGenres.length) {
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
}
}
}
this.canvas = canvas
this.dataUrl = canvas.toDataURL('png')
},
share() {
this.canvas.toBlob((blob) => {
const file = new File([blob], 'yearinreviewserver.png', { type: blob.type })
const shareData = {
files: [file]
}
if (navigator.canShare(shareData)) {
navigator
.share(shareData)
.then(() => {
console.log('Share success')
})
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
}
})
} else {
this.$toast.error('Cannot share natively on this device')
}
})
},
refresh() {
this.init()
},
async init() {
this.$emit('update:processing', true)
this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {
console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats')
return null
})
await this.initCanvas()
this.$emit('update:processing', false)
}
},
mounted() {
this.init()
}
}
</script>

View File

@@ -0,0 +1,194 @@
<template>
<div>
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" />
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
year: Number
},
data() {
return {
canvas: null,
dataUrl: null,
yearStats: null
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 600
canvas.height = 200
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444455')
grd1.addColorStop(1, '#ffffff11')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C088'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color
ctx.font = `${fontSize} Material Icons Outlined`
ctx.fillText(icon, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
const bookCovers = this.yearStats.finishedBooksWithCovers
bookCovers.push(...this.yearStats.booksWithCovers)
if (bookCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-10, -90)
for (let x = 0; x < 4; x++) {
for (let y = 0; y < 3; y++) {
const coverIndex = index % bookCovers.length
let libraryItemId = bookCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
resolve()
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(15, 75, 280, 110)
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
const readIconPath = new Path2D()
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 })
ctx.fillStyle = '#ffffff'
ctx.fill(readIconPath)
createRoundedRect(305, 75, 280, 110)
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
addIcon('local_library', 'white', '42px', 345, 130)
this.canvas = canvas
this.dataUrl = canvas.toDataURL('png')
},
share() {
this.canvas.toBlob((blob) => {
const file = new File([blob], 'yearinreviewshort.png', { type: blob.type })
const shareData = {
files: [file]
}
if (navigator.canShare(shareData)) {
navigator
.share(shareData)
.then(() => {
console.log('Share success')
})
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
}
})
} else {
this.$toast.error('Cannot share natively on this device')
}
})
},
refresh() {
this.init()
},
async init() {
this.$emit('update:processing', true)
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats')
return null
})
await this.initCanvas()
this.$emit('update:processing', false)
}
},
mounted() {
this.init()
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="text-center mt-4">
<div class="text-center mt-4 relative">
<div class="flex py-4">
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input>
<div class="flex-grow" />
@@ -54,6 +54,10 @@
</div>
</div>
</prompt-dialog>
<div v-if="isApplyingBackup" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/20 rounded-md">
<ui-loading-indicator />
</div>
</div>
</template>
@@ -64,6 +68,7 @@ export default {
showConfirmApply: false,
selectedBackup: null,
isBackingUp: false,
isApplyingBackup: false,
processing: false,
backups: []
}
@@ -85,19 +90,21 @@ export default {
},
confirm() {
this.showConfirmApply = false
this.isApplyingBackup = true
this.$axios
.$get(`/api/backups/${this.selectedBackup.id}/apply`)
.then(() => {
this.isBackingUp = false
location.replace('/config/backups?backup=1')
})
.catch((error) => {
this.isBackingUp = false
console.error('Failed to apply backup', error)
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
this.$toast.error(errorMsg)
})
.finally(() => {
this.isApplyingBackup = false
})
},
deleteBackupClick(backup) {
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
@@ -180,7 +187,6 @@ export default {
this.loadBackups()
if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully')
this.$router.replace('/config')
}
}
}

View File

@@ -21,7 +21,7 @@
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td>
<td dir="auto">
{{ chapter.title }}
</td>
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">

View File

@@ -0,0 +1,127 @@
<template>
<div class="min-h-40">
<table v-if="providers.length" id="providers">
<tr>
<th>{{ $strings.LabelName }}</th>
<th>URL</th>
<th>Authorization Header Value</th>
<th class="w-12"></th>
</tr>
<tr v-for="provider in providers" :key="provider.id">
<td class="text-sm">{{ provider.name }}</td>
<td class="text-sm">{{ provider.url }}</td>
<td class="text-sm">
<span v-if="provider.authHeaderValue" class="custom-provider-api-key">{{ provider.authHeaderValue }}</span>
</td>
<td class="py-0">
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="removeProvider(provider)">
<button type="button" :aria-label="$strings.ButtonDelete" class="material-icons text-base">delete</button>
</div>
</td>
</tr>
</table>
<div v-else-if="!processing" class="text-center py-8">
<p class="text-lg">No custom metadata providers</p>
</div>
<div v-if="processing" class="absolute inset-0 h-full flex items-center justify-center bg-black/40 rounded-md">
<ui-loading-indicator />
</div>
</div>
</template>
<script>
export default {
props: {
providers: {
type: Array,
default: () => []
},
processing: Boolean
},
data() {
return {}
},
methods: {
removeProvider(provider) {
const payload = {
message: `Are you sure you want remove custom metadata provider "${provider.name}"?`,
callback: (confirmed) => {
if (confirmed) {
this.$emit('update:processing', true)
this.$axios
.$delete(`/api/custom-metadata-providers/${provider.id}`)
.then(() => {
this.$toast.success('Provider removed')
this.$emit('removed', provider.id)
})
.catch((error) => {
console.error('Failed to remove provider', error)
this.$toast.error('Failed to remove provider')
})
.finally(() => {
this.$emit('update:processing', false)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}
}
}
</script>
<style>
#providers {
table-layout: fixed;
border-collapse: collapse;
border: 1px solid #474747;
width: 100%;
}
#providers td,
#providers th {
/* border: 1px solid #2e2e2e; */
padding: 8px 8px;
text-align: left;
}
#providers td.py-0 {
padding: 0px 8px;
}
#providers tr:nth-child(even) {
background-color: #373838;
}
#providers tr:nth-child(odd) {
background-color: #2f2f2f;
}
#providers tr:hover {
background-color: #444;
}
#providers th {
font-size: 0.8rem;
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #272727;
}
.custom-provider-api-key {
padding: 1px;
background-color: #272727;
border-radius: 4px;
color: transparent;
transition: color, background-color 0.5s ease;
}
.custom-provider-api-key:hover {
background-color: transparent;
color: white;
}
</style>

View File

@@ -6,7 +6,7 @@
<span class="text-sm font-mono">{{ ebookFiles.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
@@ -75,6 +75,10 @@ export default {
}
},
methods: {
toggleFullPath() {
this.showFullPath = !this.showFullPath
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
},
readEbook(fileIno) {
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
},
@@ -82,6 +86,10 @@ export default {
this.showFiles = !this.showFiles
}
},
mounted() {}
mounted() {
if (this.userIsAdmin) {
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
}
}
}
</script>

View File

@@ -6,13 +6,13 @@
<span class="text-sm font-mono">{{ files.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showFiles">
<div class="w-full" v-if="showFiles">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
@@ -70,7 +70,7 @@ export default {
},
audioFiles() {
if (this.libraryItem.mediaType === 'podcast') {
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile).filter((af) => af) || []
}
return this.libraryItem.media?.audioFiles || []
},
@@ -84,6 +84,10 @@ export default {
}
},
methods: {
toggleFullPath() {
this.showFullPath = !this.showFullPath
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
},
clickBar() {
this.showFiles = !this.showFiles
},
@@ -93,6 +97,9 @@ export default {
}
},
mounted() {
if (this.userIsAdmin) {
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
}
this.showFiles = this.expanded
}
}

View File

@@ -6,7 +6,7 @@
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
</nuxt-link>
@@ -74,6 +74,10 @@ export default {
}
},
methods: {
toggleFullPath() {
this.showFullPath = !this.showFullPath
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
},
clickBar() {
this.showTracks = !this.showTracks
},
@@ -82,6 +86,10 @@ export default {
this.showAudioFileDataModal = true
}
},
mounted() {}
mounted() {
if (this.userIsAdmin) {
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
}
}
}
</script>

View File

@@ -30,7 +30,7 @@
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template>
</div>
<p class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p>
<p v-if="media.duration" class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p>
</div>
</div>
</div>

View File

@@ -30,7 +30,7 @@
<widgets-podcast-type-indicator :type="downloadQueued.episodeType" />
</div>
</td>
<td class="px-4">
<td dir="auto" class="px-4">
{{ downloadQueued.episodeDisplayTitle }}
</td>
<td class="text-xs">

View File

@@ -1,18 +1,22 @@
<template>
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode">
<div class="flex-grow px-2">
<div class="flex items-center">
<span class="text-sm font-semibold">{{ title }}</span>
<widgets-podcast-type-indicator :type="episode.episodeType" />
<div :id="`lazy-episode-${index}`" class="w-full h-full cursor-pointer" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="flex" @click="clickedEpisode">
<div class="flex-grow">
<div dir="auto" class="flex items-center">
<span class="text-sm font-semibold">{{ episodeTitle }}</span>
<widgets-podcast-type-indicator :type="episodeType" />
</div>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p>
<div class="flex justify-between pt-2 max-w-xl">
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
<div class="h-10 flex items-center mt-1.5 mb-0.5 overflow-hidden">
<p class="text-sm text-gray-200 line-clamp-2" v-html="episodeSubtitle"></p>
</div>
<div class="h-8 flex items-center">
<div class="w-full inline-flex justify-between max-w-xl">
<p v-if="episode?.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode?.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
</div>
</div>
<div class="flex items-center pt-2">
@@ -37,10 +41,11 @@
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
</div>
</div>
<div v-if="isHovering || isSelected || selectionMode" class="hidden md:block w-12 min-w-12" />
<div v-if="isHovering || isSelected || isSelectionMode" class="hidden md:block w-12 min-w-12" />
</div>
<div v-if="isSelected || selectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !selectionMode ? 'translate-x-24' : 'translate-x-0'">
<div v-if="isSelected || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !isSelectionMode ? 'translate-x-24' : 'translate-x-0'">
<div class="flex h-full items-center">
<div class="mx-1">
<ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" />
@@ -55,84 +60,91 @@
<script>
export default {
props: {
index: Number,
libraryItemId: String,
episode: {
type: Object,
default: () => {}
},
selectionMode: Boolean
default: () => null
}
},
data() {
return {
isProcessingReadUpdate: false,
processingRemove: false,
isHovering: false,
isSelected: false
isSelected: false,
isSelectionMode: false
}
},
computed: {
store() {
return this.$store || this.$nuxt.$store
},
axios() {
return this.$axios || this.$nuxt.$axios
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
return this.store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
return this.store.getters['user/getUserCanDelete']
},
audioFile() {
return this.episode.audioFile
episodeId() {
return this.episode?.id || ''
},
title() {
return this.episode.title || ''
episodeTitle() {
return this.episode?.title || ''
},
subtitle() {
return this.episode.subtitle || this.description
episodeSubtitle() {
return this.episode?.subtitle || ''
},
description() {
return this.episode.description || ''
episodeType() {
return this.episode?.episodeType || ''
},
duration() {
return this.$secondsToTimestamp(this.episode.duration)
publishedAt() {
return this.episode?.publishedAt
},
libraryItemIdStreaming() {
return this.$store.getters['getLibraryItemIdStreaming']
},
isStreamingFromDifferentLibrary() {
return this.$store.getters['getIsStreamingFromDifferentLibrary']
},
isStreaming() {
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
},
isQueued() {
return this.$store.getters['getIsMediaQueued'](this.libraryItemId, this.episode.id)
},
streamIsPlaying() {
return this.$store.state.streamIsPlaying && this.isStreaming
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
itemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)
},
itemProgressPercent() {
return this.itemProgress ? this.itemProgress.progress : 0
return this.itemProgress?.progress || 0
},
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
return !!this.itemProgress?.isFinished
},
libraryItemIdStreaming() {
return this.store.getters['getLibraryItemIdStreaming']
},
isStreamingFromDifferentLibrary() {
return this.store.getters['getIsStreamingFromDifferentLibrary']
},
isStreaming() {
return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)
},
isQueued() {
return this.store.getters['getIsMediaQueued'](this.libraryItemId, this.episodeId)
},
streamIsPlaying() {
return this.store.state.streamIsPlaying && this.isStreaming
},
timeRemaining() {
if (this.streamIsPlaying) return 'Playing'
if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration)
if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0)
if (this.userIsFinished) return 'Finished'
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
const duration = this.itemProgress.duration || this.episode?.duration || 0
const remaining = Math.floor(duration - this.itemProgress.currentTime)
return `${this.$elapsedPretty(remaining)} left`
},
publishedAt() {
return this.episode.publishedAt
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
}
},
methods: {
clickAddToPlaylist() {
this.$emit('addToPlaylist', this.episode)
setSelectionMode(isSelectionMode) {
this.isSelectionMode = isSelectionMode
if (!this.isSelectionMode) this.isSelected = false
},
clickedEpisode() {
this.$emit('view', this.episode)
@@ -150,16 +162,23 @@ export default {
mouseleave() {
this.isHovering = false
},
clickEdit() {
this.$emit('edit', this.episode)
},
playClick() {
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
const eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('pause-item')
} else {
this.$emit('play', this.episode)
}
},
queueBtnClick() {
if (this.isQueued) {
// Remove from queue
this.store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episodeId })
} else {
// Add to queue
this.$emit('addToQueue', this.episode)
}
},
toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
const payload = {
@@ -171,37 +190,47 @@ export default {
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
this.store.commit('globals/setConfirmPrompt', payload)
return
}
var updatePayload = {
const updatePayload = {
isFinished: !this.userIsFinished
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
this.axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episodeId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
const toast = this.$toast || this.$nuxt.$toast
toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
},
clickAddToPlaylist() {
this.$emit('addToPlaylist', this.episode)
},
clickEdit() {
this.$emit('edit', this.episode)
},
removeClick() {
this.$emit('remove', this.episode)
},
queueBtnClick() {
if (this.isQueued) {
// Remove from queue
this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episode.id })
} else {
// Add to queue
this.$emit('addToQueue', this.episode)
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() {}
}
</script>
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="w-full py-6">
<div id="lazy-episodes-table" class="w-full py-6">
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
<div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0">
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
@@ -18,28 +18,41 @@
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
</template>
<template v-else>
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" @change="filterSortChanged" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="filterSortChanged" />
<div class="flex-grow md:hidden" />
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
</template>
</div>
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
<!-- <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> -->
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form>
</div>
<template v-for="episode in episodesList">
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" />
</template>
<div class="relative min-h-[176px]">
<template v-for="episode in totalEpisodes">
<div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
<!-- episode is mounted here -->
</div>
</template>
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
<ui-loading-indicator />
</div>
<div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center">
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
</div>
</div>
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
</div>
</template>
<script>
import Vue from 'vue'
import LazyEpisodeRow from './LazyEpisodeRow.vue'
export default {
props: {
libraryItem: {
@@ -60,13 +73,21 @@ export default {
processing: false,
search: null,
searchTimeout: null,
searchText: null
searchText: null,
isSearching: false,
totalEpisodes: 0,
episodesPerPage: null,
episodeIndexesMounted: [],
episodeComponentRefs: {},
windowHeight: 0,
episodesTableOffsetTop: 0,
episodeRowHeight: 176
}
},
watch: {
libraryItem: {
handler() {
this.init()
this.refresh()
}
}
},
@@ -194,13 +215,19 @@ export default {
submit() {},
inputUpdate() {
clearTimeout(this.searchTimeout)
this.isSearching = true
let searchStart = this.searchText
this.searchTimeout = setTimeout(() => {
if (!this.search || !this.search.trim()) {
this.isSearching = false
if (!this.search?.trim()) {
this.searchText = ''
return
} else {
this.searchText = this.search.toLowerCase().trim()
}
this.searchText = this.search.toLowerCase().trim()
}, 500)
if (searchStart !== this.searchText) {
this.init()
}
}, 750)
},
contextMenuAction({ action }) {
if (action === 'quick-match-episodes') {
@@ -304,24 +331,30 @@ export default {
if (!val) this.episodesToRemove = []
},
clearSelected() {
const episodeRows = this.$refs.episodeRow
if (episodeRows && episodeRows.length) {
for (const epRow of episodeRows) {
if (epRow) epRow.isSelected = false
}
}
this.selectedEpisodes = []
this.setSelectionModeForEpisodes()
},
removeSelectedEpisodes() {
this.episodesToRemove = this.selectedEpisodes
this.showPodcastRemoveModal = true
},
episodeSelected({ isSelected, episode }) {
let isSelectionModeBefore = this.isSelectionMode
if (isSelected) {
this.selectedEpisodes.push(episode)
} else {
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
}
if (this.isSelectionMode !== isSelectionModeBefore) {
this.setSelectionModeForEpisodes()
}
},
setSelectionModeForEpisodes() {
for (const key in this.episodeComponentRefs) {
if (this.episodeComponentRefs[key]?.setSelectionMode) {
this.episodeComponentRefs[key].setSelectionMode(this.isSelectionMode)
}
}
},
playEpisode(episode) {
const queueItems = []
@@ -367,12 +400,147 @@ export default {
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
},
init() {
destroyEpisodeComponents() {
for (const key in this.episodeComponentRefs) {
if (this.episodeComponentRefs[key]?.destroy) {
this.episodeComponentRefs[key].destroy()
}
}
this.episodeComponentRefs = {}
this.episodeIndexesMounted = []
},
mountEpisode(index) {
const episodeEl = document.getElementById(`episode-${index}`)
if (!episodeEl) {
console.warn('Episode row el not found at ' + index)
return
}
this.episodeIndexesMounted.push(index)
if (this.episodeComponentRefs[index]) {
const episodeComponent = this.episodeComponentRefs[index]
episodeEl.appendChild(episodeComponent.$el)
if (this.isSelectionMode) {
episodeComponent.setSelectionMode(true)
if (this.selectedEpisodes.some((i) => i.id === episodeComponent.episodeId)) {
episodeComponent.isSelected = true
} else {
episodeComponent.isSelected = false
}
} else {
episodeComponent.setSelectionMode(false)
}
} else {
const _this = this
const ComponentClass = Vue.extend(LazyEpisodeRow)
const instance = new ComponentClass({
propsData: {
index,
libraryItemId: this.libraryItem.id,
episode: this.episodesList[index]
},
created() {
this.$on('selected', (payload) => {
_this.episodeSelected(payload)
})
this.$on('view', (payload) => {
_this.viewEpisode(payload)
})
this.$on('play', (payload) => {
_this.playEpisode(payload)
})
this.$on('addToQueue', (payload) => {
_this.addEpisodeToQueue(payload)
})
this.$on('remove', (payload) => {
_this.removeEpisode(payload)
})
this.$on('edit', (payload) => {
_this.editEpisode(payload)
})
this.$on('addToPlaylist', (payload) => {
_this.addToPlaylist(payload)
})
}
})
this.episodeComponentRefs[index] = instance
instance.$mount()
episodeEl.appendChild(instance.$el)
if (this.isSelectionMode) {
instance.setSelectionMode(true)
if (this.selectedEpisodes.some((i) => i.id === this.episodesList[index].id)) {
instance.isSelected = true
}
}
}
},
mountEpisodes(startIndex, endIndex) {
for (let i = startIndex; i < endIndex; i++) {
if (!this.episodeIndexesMounted.includes(i)) {
this.mountEpisode(i)
}
}
},
scroll(evt) {
if (!evt?.target?.scrollTop) return
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
this.episodeIndexesMounted = this.episodeIndexesMounted.filter((_index) => {
if (_index < firstEpisodeIndex || _index >= lastEpisodeIndex) {
const el = document.getElementById(`lazy-episode-${_index}`)
if (el) el.remove()
return false
}
return true
})
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
},
initListeners() {
const itemPageWrapper = document.getElementById('item-page-wrapper')
if (itemPageWrapper) {
itemPageWrapper.addEventListener('scroll', this.scroll)
}
},
removeListeners() {
const itemPageWrapper = document.getElementById('item-page-wrapper')
if (itemPageWrapper) {
itemPageWrapper.removeEventListener('scroll', this.scroll)
}
},
filterSortChanged() {
this.init()
},
refresh() {
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.init()
},
init() {
this.destroyEpisodeComponents()
this.totalEpisodes = this.episodesList.length
const lazyEpisodesTableEl = document.getElementById('lazy-episodes-table')
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
this.windowHeight = window.innerHeight
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
this.$nextTick(() => {
this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes))
})
}
},
mounted() {
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.initListeners()
this.init()
},
beforeDestroy() {
this.removeListeners()
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
@@ -7,7 +7,7 @@
</svg>
</div>
</nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<button v-else class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
@@ -72,23 +72,3 @@ export default {
mounted() {}
}
</script>
<style scoped>
.btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -2,7 +2,8 @@
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
<span v-if="partial" class="material-icons text-base leading-none text-gray-400">remove</span>
<svg v-else-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
</label>
@@ -31,7 +32,8 @@ export default {
type: String,
default: ''
},
disabled: Boolean
disabled: Boolean,
partial: Boolean
},
data() {
return {}

View File

@@ -1,6 +1,6 @@
<template>
<div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
@@ -64,7 +64,7 @@ export default {
},
itemsToShow() {
return this.items.map((i) => {
if (typeof i === 'string') {
if (typeof i === 'string' || typeof i === 'number') {
return {
text: i,
value: i

View File

@@ -1,7 +1,7 @@
<template>
<div>
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickUpload" color="primary" class="hidden md:block" type="text"><slot /></ui-btn>
<ui-btn @click="clickUpload" color="primary" class="hidden md:block w-full" type="text"><slot /></ui-btn>
<ui-icon-btn @click="clickUpload" icon="upload" class="block md:hidden" />
</div>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full">
<label class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
<label v-if="label" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">

View File

@@ -11,13 +11,13 @@
</div>
{{ item }}
</div>
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
</div>
</form>
<ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item }}</span>
</div>
@@ -50,7 +50,11 @@ export default {
label: String,
disabled: Boolean,
readonly: Boolean,
showEdit: Boolean
showEdit: Boolean,
menuDisabled: {
type: Boolean,
default: false
}
},
data() {
return {
@@ -58,7 +62,9 @@ export default {
currentSearch: null,
typingTimeout: null,
isFocused: false,
menu: null
menu: null,
filteredItems: null,
selectedMenuItemIndex: null
}
},
watch: {
@@ -77,7 +83,7 @@ export default {
}
},
showMenu() {
return this.isFocused
return this.isFocused && !this.menuDisabled
},
wrapperClass() {
var classes = []
@@ -87,24 +93,63 @@ export default {
return classes.join(' ')
},
itemsToShow() {
if (!this.currentSearch || !this.textInput) {
if (!this.currentSearch || !this.textInput || !this.filteredItems) {
return this.items
}
return this.items.filter((i) => {
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
return this.filteredItems
}
},
methods: {
editItem(item) {
this.$emit('edit', item)
},
keydownInput() {
search() {
if (!this.textInput) {
this.filteredItems = null
return
}
this.currentSearch = this.textInput
const results = this.items.filter((i) => {
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
this.filteredItems = results || []
},
keydownInput(event) {
let items = this.itemsToShow
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!items.length) return
if (event.key === 'ArrowDown') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = 0
} else {
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
}
} else if (event.key === 'ArrowUp') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = items.length - 1
} else {
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
}
}
this.recalcScroll()
return
} else if (event.key === 'Enter') {
if (this.selectedMenuItemIndex !== null) {
this.clickedOption(event, items[this.selectedMenuItemIndex])
} else {
this.submitForm()
}
return
}
this.selectedMenuItemIndex = null
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput
this.search()
}, 100)
this.setInputWidth()
},
@@ -116,6 +161,24 @@ export default {
this.recalcMenuPos()
}, 50)
},
recalcScroll() {
if (!this.menu) return
var menuItems = this.menu.querySelectorAll('li')
if (!menuItems.length) return
var selectedItem = menuItems[this.selectedMenuItemIndex]
if (!selectedItem) return
var menuHeight = this.menu.offsetHeight
var itemHeight = selectedItem.offsetHeight
var itemTop = selectedItem.offsetTop
var itemBottom = itemTop + itemHeight
if (itemBottom > this.menu.scrollTop + menuHeight) {
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
} else if (itemTop < this.menu.scrollTop) {
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
this.menu.scrollTop = itemTop - menuPaddingTop
}
},
recalcMenuPos() {
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@@ -204,7 +267,10 @@ export default {
e.stopPropagation()
e.preventDefault()
}
if (this.$refs.input) this.$refs.input.focus()
if (this.$refs.input) {
this.$refs.input.style.width = '24px'
this.$refs.input.focus()
}
var newSelected = null
if (this.selected.includes(itemValue)) {
@@ -215,6 +281,7 @@ export default {
}
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
@@ -241,6 +308,7 @@ export default {
this.$emit('newItem', item)
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$nextTick(() => {
this.blur()
})
@@ -257,6 +325,7 @@ export default {
} else {
this.insertNewItem(this.textInput)
}
if (this.$refs.input) this.$refs.input.style.width = '24px'
},
scroll() {
this.recalcMenuPos()

View File

@@ -14,13 +14,13 @@
<div v-if="showEdit && !disabled" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
</div>
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
</div>
</form>
<ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
</div>
@@ -63,7 +63,8 @@ export default {
typingTimeout: null,
isFocused: false,
menu: null,
items: []
items: [],
selectedMenuItemIndex: null
}
},
watch: {
@@ -122,7 +123,35 @@ export default {
this.items = results || []
},
keydownInput() {
keydownInput(event) {
let items = this.itemsToShow
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!items.length) return
if (event.key === 'ArrowDown') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = 0
} else {
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
}
} else if (event.key === 'ArrowUp') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = items.length - 1
} else {
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
}
}
this.recalcScroll()
return
} else if (event.key === 'Enter') {
if (this.selectedMenuItemIndex !== null) {
this.clickedOption(event, items[this.selectedMenuItemIndex])
} else {
this.submitForm()
}
return
}
this.selectedMenuItemIndex = null
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.search()
@@ -137,6 +166,24 @@ export default {
this.recalcMenuPos()
}, 50)
},
recalcScroll() {
if (!this.menu) return
var menuItems = this.menu.querySelectorAll('li')
if (!menuItems.length) return
var selectedItem = menuItems[this.selectedMenuItemIndex]
if (!selectedItem) return
var menuHeight = this.menu.offsetHeight
var itemHeight = selectedItem.offsetHeight
var itemTop = selectedItem.offsetTop
var itemBottom = itemTop + itemHeight
if (itemBottom > this.menu.scrollTop + menuHeight) {
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
} else if (itemTop < this.menu.scrollTop) {
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
this.menu.scrollTop = itemTop - menuPaddingTop
}
},
recalcMenuPos() {
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@@ -228,7 +275,10 @@ export default {
e.stopPropagation()
e.preventDefault()
}
if (this.$refs.input) this.$refs.input.focus()
if (this.$refs.input) {
this.$refs.input.style.width = '24px'
this.$refs.input.focus()
}
let newSelected = null
if (this.getIsSelected(item.id)) {
@@ -244,6 +294,7 @@ export default {
}
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$emit('input', newSelected)
this.$nextTick(() => {
@@ -271,6 +322,7 @@ export default {
this.$emit('newItem', item)
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$nextTick(() => {
this.blur()
})
@@ -291,6 +343,7 @@ export default {
name: this.textInput
})
}
if (this.$refs.input) this.$refs.input.style.width = '24px'
},
scroll() {
this.recalcMenuPos()

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="default-style">
<p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
{{ label }}
</p>
@@ -29,31 +29,31 @@ export default {
config() {
return {
toolbar: {
getDefaultHTML: () => ` <div class="trix-button-row">
getDefaultHTML: () => `<div class="trix-button-row">
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="#{lang.bold}" tabindex="-1">#{lang.bold}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="#{lang.italic}" tabindex="-1">#{lang.italic}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="#{lang.strike}" tabindex="-1">#{lang.strike}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="#{lang.link}" tabindex="-1">#{lang.link}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="${this.$strings.LabelFontBold}" tabindex="-1">${this.$strings.LabelFontBold}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="${this.$strings.LabelFontItalic}" tabindex="-1">${this.$strings.LabelFontItalic}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="${this.$strings.LabelFontStrikethrough}" tabindex="-1">${this.$strings.LabelFontStrikethrough}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="${this.$strings.LabelTextEditorLink}" tabindex="-1">${this.$strings.LabelTextEditorLink}</button>
</span>
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="#{lang.bullets}" tabindex="-1">#{lang.bullets}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="#{lang.numbers}" tabindex="-1">#{lang.numbers}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="${this.$strings.LabelTextEditorBulletedList}" tabindex="-1">${this.$strings.LabelTextEditorBulletedList}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="${this.$strings.LabelTextEditorNumberedList}" tabindex="-1">${this.$strings.LabelTextEditorNumberedList}</button>
</span>
<span class="trix-button-group-spacer"></span>
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="#{lang.undo}" tabindex="-1">#{lang.undo}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="#{lang.redo}" tabindex="-1">#{lang.redo}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="${this.$strings.LabelUndo}" tabindex="-1">${this.$strings.LabelUndo}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="${this.$strings.LabelRedo}" tabindex="-1">${this.$strings.LabelRedo}</button>
</span>
</div>
<div class="trix-dialogs" data-trix-dialogs>
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
<div class="trix-dialog__link-fields">
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="#{lang.urlPlaceholder}" aria-label="#{lang.url}" required data-trix-input>
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="" aria-label="URL" required data-trix-input>
<div class="trix-button-group">
<input type="button" class="trix-button trix-button--dialog" value="#{lang.link}" data-trix-method="setAttribute">
<input type="button" class="trix-button trix-button--dialog" value="#{lang.unlink}" data-trix-method="removeAttribute">
<input type="button" class="trix-button trix-button--dialog" value="${this.$strings.LabelTextEditorLink}" data-trix-method="setAttribute">
<input type="button" class="trix-button trix-button--dialog" value="${this.$strings.LabelTextEditorUnlink}" data-trix-method="removeAttribute">
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative">
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div>
@@ -33,6 +33,7 @@ export default {
textCenter: Boolean,
clearable: Boolean,
inputId: String,
inputName: String,
step: [String, Number],
min: [String, Number]
},
@@ -117,4 +118,4 @@ input:read-only {
input::-webkit-calendar-picker-indicator {
filter: invert(1);
}
</style>
</style>

View File

@@ -5,7 +5,7 @@
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
>
</slot>
<ui-text-input :placeholder="label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
</div>
</template>
@@ -14,6 +14,7 @@ export default {
props: {
value: [String, Number],
label: String,
placeholder: String,
note: String,
type: {
type: String,

View File

@@ -1,5 +1,5 @@
<template>
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
</template>
<script>

View File

@@ -15,6 +15,13 @@ export default {
type: String,
default: 'right'
},
/**
* Delay showing the tooltip after X milliseconds of hovering
*/
delayOnShow: {
type: Number,
default: 0
},
disabled: Boolean
},
data() {
@@ -22,7 +29,8 @@ export default {
tooltip: null,
tooltipId: null,
isShowing: false,
hideTimeout: null
hideTimeout: null,
delayOnShowTimeout: null
}
},
watch: {
@@ -59,29 +67,44 @@ export default {
this.tooltip = tooltip
},
setTooltipPosition(tooltip) {
var boxChow = this.$refs.box.getBoundingClientRect()
const boxRect = this.$refs.box.getBoundingClientRect()
const shouldMount = !tooltip.isConnected
var shouldMount = !tooltip.isConnected
// Calculate size of tooltip
if (shouldMount) document.body.appendChild(tooltip)
var { width, height } = tooltip.getBoundingClientRect()
const tooltipRect = tooltip.getBoundingClientRect()
if (shouldMount) tooltip.remove()
var top = 0
var left = 0
// Subtracting scrollbar size
const windowHeight = window.innerHeight - 8
const windowWidth = window.innerWidth - 8
let top = 0
let left = 0
if (this.direction === 'right') {
top = boxChow.top - height / 2 + boxChow.height / 2
left = boxChow.left + boxChow.width + 4
top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)
left = Math.max(0, boxRect.left + boxRect.width + 4)
} else if (this.direction === 'bottom') {
top = boxChow.top + boxChow.height + 4
left = boxChow.left - width / 2 + boxChow.width / 2
top = Math.max(0, boxRect.top + boxRect.height + 4)
left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)
} else if (this.direction === 'top') {
top = boxChow.top - height - 4
left = boxChow.left - width / 2 + boxChow.width / 2
top = Math.max(0, boxRect.top - tooltipRect.height - 4)
left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)
} else if (this.direction === 'left') {
top = boxChow.top - height / 2 + boxChow.height / 2
left = boxChow.left - width - 4
top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)
left = Math.max(0, boxRect.left - tooltipRect.width - 4)
}
// Shift left if tooltip would overflow the window on the right
if (left + tooltipRect.width > windowWidth) {
left -= left + tooltipRect.width - windowWidth
}
// Shift up if tooltip would overflow the window on the bottom
if (top + tooltipRect.height > windowHeight) {
top -= top + tooltipRect.height - windowHeight
}
tooltip.style.top = top + 'px'
tooltip.style.left = left + 'px'
},
@@ -107,15 +130,33 @@ export default {
this.isShowing = false
},
cancelHide() {
if (this.hideTimeout) clearTimeout(this.hideTimeout)
clearTimeout(this.hideTimeout)
},
mouseover() {
if (!this.isShowing) this.showTooltip()
if (this.isShowing || this.disabled) return
if (this.delayOnShow) {
if (this.delayOnShowTimeout) {
// Delay already running
return
}
this.delayOnShowTimeout = setTimeout(() => {
this.showTooltip()
this.delayOnShowTimeout = null
}, this.delayOnShow)
} else {
this.showTooltip()
}
},
mouseleave() {
if (this.isShowing) {
this.hideTimeout = setTimeout(this.hideTooltip, 100)
if (!this.isShowing) {
clearTimeout(this.delayOnShowTimeout)
this.delayOnShowTimeout = null
return
}
this.hideTimeout = setTimeout(this.hideTooltip, 100)
}
},
beforeDestroy() {

View File

@@ -1,84 +0,0 @@
<template>
<div class="w-full">
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
{{ $strings.LabelMissingParts }} <span class="text-sm">({{ missingParts.length }})</span>
</p>
<p class="text-sm font-mono">{{ missingPartChunks.join(', ') }}</p>
</div>
<div v-if="invalidParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
{{ $strings.LabelInvalidParts }} <span class="text-sm">({{ invalidParts.length }})</span>
</p>
<div>
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
</div>
</div>
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
</div>
</template>
<script>
export default {
props: {
libraryItemId: String,
media: {
type: Object,
default: () => {}
},
isFile: Boolean
},
data() {
return {}
},
computed: {
tracksWithAudioFile() {
return this.media.tracks.map((track) => {
track.audioFile = this.media.audioFiles.find((af) => af.metadata.path === track.metadata.path)
return track
})
},
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
var currentIndex = this.missingParts[0]
var currentChunk = [this.missingParts[0]]
for (let i = 1; i < this.missingParts.length; i++) {
var partIndex = this.missingParts[i]
if (currentIndex === partIndex - 1) {
currentChunk.push(partIndex)
currentIndex = partIndex
} else {
// console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex)
if (currentChunk.length === 0) {
console.error('How is current chunk 0?', currentChunk.join(', '))
}
chunks.push(currentChunk)
currentChunk = [partIndex]
currentIndex = partIndex
}
}
if (currentChunk.length) {
chunks.push(currentChunk)
}
chunks = chunks.map((chunk) => {
if (chunk.length === 1) return chunk[0]
else return `${chunk[0]}-${chunk[chunk.length - 1]}`
})
return chunks
},
missingParts() {
return this.media.missingParts || []
},
invalidParts() {
return this.media.invalidParts || []
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -10,10 +10,10 @@
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex space-x-4" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @edit="editAuthor" @hook:updated="setScrollVars" />
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative" @edit="editAuthor" @hook:updated="setScrollVars" />
</template>
</div>
</div>

View File

@@ -10,8 +10,8 @@
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex space-x-4" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-book-card
:key="item.recentEpisode.id"
@@ -23,7 +23,7 @@
:book-cover-aspect-ratio="bookCoverAspectRatio"
:bookshelf-view="bookshelfView"
:continue-listening-shelf="continueListeningShelf"
class="relative mx-2"
class="relative"
@edit="editEpisode"
@editPodcast="editPodcast"
@select="selectItem"

View File

@@ -10,10 +10,24 @@
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex space-x-4" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-book-card :key="item.id + '-' + shelfId" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
<cards-lazy-book-card
:key="item.id + '-' + shelfId + '-' + index"
:ref="`slider-item-${item.id}`"
:index="index"
:book-mount="item"
:height="cardHeight"
:width="cardWidth"
:book-cover-aspect-ratio="bookCoverAspectRatio"
:bookshelf-view="bookshelfView"
:continue-listening-shelf="continueListeningShelf"
class="relative"
@edit="editItem"
@select="selectItem"
@hook:updated="setScrollVars"
/>
</template>
</div>
</div>

View File

@@ -10,10 +10,10 @@
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex space-x-4" :style="{ height: height + 'px' }">
<template v-for="item in items">
<cards-narrator-card :key="item.name" :ref="`slider-item-${item.name}`" :narrator="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @hook:updated="setScrollVars" />
<cards-narrator-card :key="item.name" :ref="`slider-item-${item.name}`" :narrator="item" :height="cardHeight" :width="cardWidth" class="relative" @hook:updated="setScrollVars" />
</template>
</div>
</div>

View File

@@ -10,10 +10,10 @@
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex space-x-4" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.DETAIL" class="relative mx-2" @hook:updated="setScrollVars" />
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.DETAIL" class="relative" @hook:updated="setScrollVars" />
</template>
</div>
</div>

View File

@@ -7,7 +7,7 @@
<Nuxt :key="currentLang" />
</div>
<app-stream-container ref="streamContainer" />
<app-media-player-container ref="mediaPlayerContainer" />
<modals-item-edit-modal />
<modals-collections-add-create-modal />
@@ -129,23 +129,23 @@ export default {
this.$eventBus.$emit('socket_init')
},
streamOpen(stream) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
},
streamClosed(streamId) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamClosed(streamId)
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamClosed(streamId)
},
streamProgress(data) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamProgress(data)
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamProgress(data)
},
streamReady() {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReady()
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamReady()
},
streamReset(payload) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReset(payload)
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamReset(payload)
},
streamError({ id, errorMessage }) {
this.$toast.error(`Stream Failed: ${errorMessage}`)
if (this.$refs.streamContainer) this.$refs.streamContainer.streamError(id)
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamError(id)
},
libraryAdded(library) {
this.$store.commit('libraries/addUpdate', library)
@@ -247,7 +247,7 @@ export default {
this.multiSessionCurrentSessionId = null
this.$toast.dismiss('multiple-sessions')
}
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.sessionClosedEvent(sessionId)
},
userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload)
@@ -328,6 +328,14 @@ export default {
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
},
customMetadataProviderAdded(provider) {
if (!provider?.id) return
this.$store.commit('scanners/addCustomMetadataProvider', provider)
},
customMetadataProviderRemoved(provider) {
if (!provider?.id) return
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -406,6 +414,10 @@ export default {
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
this.socket.on('admin_message', this.adminMessageEvt)
// Custom metadata provider Listeners
this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded)
this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved)
},
showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion')

View File

@@ -25,11 +25,13 @@ module.exports = {
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: '' }
{ hid: 'description', name: 'description', content: '' },
{ hid: 'robots', name: 'robots', content: 'noindex' }
],
script: [],
link: [
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' },
{ rel: 'apple-touch-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/ios_icon.png' }
]
},
@@ -39,6 +41,7 @@ module.exports = {
// Global CSS: https://go.nuxtjs.dev/config-css
css: [
'@/assets/tailwind.css',
'@/assets/app.css'
],
@@ -58,9 +61,7 @@ module.exports = {
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
buildModules: [
// https://go.nuxtjs.dev/tailwindcss
'@nuxtjs/tailwindcss',
'@nuxtjs/pwa',
'@nuxt/postcss8'
'@nuxtjs/pwa'
],
// Modules: https://go.nuxtjs.dev/config-modules
@@ -96,7 +97,7 @@ module.exports = {
meta: {
appleStatusBarStyle: 'black',
name: 'Audiobookshelf',
theme_color: '#373838',
theme_color: '#232323',
mobileAppIOS: true,
nativeUI: true
},
@@ -104,16 +105,16 @@ module.exports = {
name: 'Audiobookshelf',
short_name: 'Audiobookshelf',
display: 'standalone',
background_color: '#373838',
background_color: '#232323',
icons: [
{
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
sizes: "any"
sizes: 'any'
},
{
src: (process.env.ROUTER_BASE_PATH || '') + '/icon64.png',
type: "image/png",
sizes: "64x64"
src: (process.env.ROUTER_BASE_PATH || '') + '/icon192.png',
type: 'image/png',
sizes: 'any'
}
]
},

26967
client/package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.5.0",
"version": "2.9.0",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
@@ -21,9 +21,9 @@
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0",
"epubjs": "^0.3.88",
"hls.js": "^1.0.7",
"hls.js": "^1.5.7",
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.8",
"nuxt": "^2.17.3",
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
@@ -31,11 +31,9 @@
"vuedraggable": "^2.24.3"
},
"devDependencies": {
"@nuxt/postcss8": "^1.1.3",
"@nuxtjs/pwa": "^3.3.5",
"@nuxtjs/tailwindcss": "^4.2.1",
"autoprefixer": "^10.4.7",
"postcss": "^8.3.6",
"tailwindcss": "^3.1.4"
"tailwindcss": "^3.4.1"
}
}

View File

@@ -19,8 +19,8 @@
<div class="w-full h-px bg-white/10 my-4" />
<p v-if="!isGuest" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
<form v-if="!isGuest" @submit.prevent="submitChangePassword">
<p v-if="showChangePasswordForm" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
<form v-if="showChangePasswordForm" @submit.prevent="submitChangePassword">
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" />
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" />
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" />
@@ -68,6 +68,13 @@ export default {
},
isGuest() {
return this.usertype === 'guest'
},
isPasswordAuthEnabled() {
const activeAuthMethods = this.$store.getters['getServerSetting']('authActiveAuthMethods') || []
return activeAuthMethods.includes('local')
},
showChangePasswordForm() {
return !this.isGuest && this.isPasswordAuthEnabled
}
},
methods: {
@@ -75,19 +82,33 @@ export default {
this.$setLanguageCode(lang)
},
logout() {
var rootSocket = this.$root.socket || {}
const logoutPayload = {
socketId: rootSocket.id
// Disconnect from socket
if (this.$root.socket) {
console.log('Disconnecting from socket', this.$root.socket.id)
this.$root.socket.removeAllListeners()
this.$root.socket.disconnect()
}
this.$axios.$post('/logout', logoutPayload).catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$store.commit('libraries/setUserPlaylists', [])
this.$store.commit('libraries/setCollections', [])
this.$router.push('/login')
this.$axios
.$post('/logout')
.then((logoutPayload) => {
const redirect_url = logoutPayload.redirect_url
if (redirect_url) {
window.location.href = redirect_url
} else {
this.$router.push('/login')
}
})
.catch((error) => {
console.error(error)
})
},
resetForm() {
this.password = null

View File

@@ -142,7 +142,7 @@
</template>
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
<div v-if="!chapterData" class="flex p-20">
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
<ui-text-input-with-label v-model.trim="asinInput" label="ASIN" />
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-32 mx-1" />
<ui-btn small color="primary" class="mt-5" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
</div>
@@ -281,7 +281,7 @@ export default {
return this.media.audioFiles || []
},
audioTracks() {
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
return this.audioFiles.filter((af) => !af.exclude)
},
selectedChapterId() {
return this.selectedChapter ? this.selectedChapter.id : null

View File

@@ -137,9 +137,6 @@ export default {
})
return count
},
missingParts() {
return this.media.missingParts || []
},
libraryItemId() {
return this.libraryItem.id
},

View File

@@ -249,7 +249,7 @@ export default {
return this.media.metadata || {}
},
audioFiles() {
return (this.media.audioFiles || []).filter((af) => !af.exclude && !af.invalid)
return (this.media.audioFiles || []).filter((af) => !af.exclude)
},
isSingleM4b() {
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'

View File

@@ -17,7 +17,10 @@
</div>
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
<p class="text-white max-w-3xl text-sm leading-5 whitespace-pre-wrap">{{ author.description }}</p>
<p ref="description" id="author-description" class="text-white max-w-3xl text-base whitespace-pre-wrap" :class="{ 'show-full': showFullDescription }">{{ author.description }}</p>
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">
{{ showFullDescription ? 'Read less' : 'Read more' }} <span class="material-icons text-xl pl-1">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
</button>
</div>
</div>
@@ -62,7 +65,10 @@ export default {
}
},
data() {
return {}
return {
isDescriptionClamped: false,
showFullDescription: false
}
},
computed: {
streamLibraryItem() {
@@ -82,6 +88,10 @@ export default {
}
},
methods: {
checkDescriptionClamped() {
if (!this.$refs.description) return
this.isDescriptionClamped = this.$refs.description.scrollHeight > this.$refs.description.clientHeight
},
editAuthor() {
this.$store.commit('globals/showEditAuthorModal', this.author)
},
@@ -93,6 +103,7 @@ export default {
series: this.authorSeries,
libraryItems: this.libraryItems
}
this.$nextTick(this.checkDescriptionClamped)
}
},
authorRemoved(author) {
@@ -104,6 +115,7 @@ export default {
},
mounted() {
if (!this.author) this.$router.replace('/')
this.checkDescriptionClamped()
this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('author_removed', this.authorRemoved)
@@ -113,4 +125,19 @@ export default {
this.$root.socket.off('author_removed', this.authorRemoved)
}
}
</script>
</script>
<style scoped>
#author-description {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
max-height: 6.25rem;
transition: all 0.3s ease-in-out;
}
#author-description.show-full {
-webkit-line-clamp: unset;
max-height: 999rem;
}
</style>

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