Compare commits

...

125 Commits

Author SHA1 Message Date
Aram Becker
7d0401f463 CI: update docker image for nuxt server target 2023-05-01 21:33:01 +02:00
Aram Becker
eecd8be78d Update: run client from server and proxy requests 2023-05-01 21:32:56 +02:00
Aram Becker
31dc10ba51 Add: build client as server instead of static 2023-05-01 21:32:51 +02:00
Aram Becker
883cb46481 Merge commit '5286b533347477ae47d4e56f086285019e8095c1' into feature/nuxt-target-server 2023-04-30 16:15:01 +02:00
advplyr
5286b53334 Add:Progress bar on series covers #1734 2023-04-29 16:26:56 -05:00
advplyr
4db26f9f79 Add:Log user and ip on successful login #1740 2023-04-28 16:16:47 -05:00
advplyr
ff8a58c7bc Remove log about not modifying permissions 2023-04-28 16:08:57 -05:00
advplyr
6f67c7bfa2 Merge pull request #1686 from divyangbw/feat-user-access-by-tag-enhancement
Invert Tag Selection
2023-04-27 17:20:16 -05:00
advplyr
e9f5bd9bfe Merge branch 'master' into feat-user-access-by-tag-enhancement 2023-04-27 17:18:56 -05:00
advplyr
56e213d654 Update itemTagsSelected migration 2023-04-27 17:18:54 -05:00
advplyr
98323de64c Merge pull request #1736 from divyangbw/fix-fr-json-corrupted
Remove what seems to be a paste error in the french translations
2023-04-27 16:44:13 -05:00
Divyang Joshi
4a13712b1c fix: Remove what seems to be a paste error in the french translations 2023-04-27 17:07:09 -04:00
Divyang Joshi
0387436111 feat: add support for inverting the selection on libraries and tags 2023-04-27 17:02:15 -04:00
advplyr
7685ead000 Merge pull request #1729 from apineiro97/master
update es translation
2023-04-27 04:46:39 -05:00
advplyr
8665d66923 Merge pull request #1730 from Hallo951/master
Update german strings
2023-04-27 04:46:15 -05:00
Hallo951
9a808602c4 Update german strings 2023-04-27 10:41:06 +02:00
Arturo Pineiro
813e553dbb update es translation 2023-04-26 18:20:46 -07:00
advplyr
be050a7d57 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-26 18:15:55 -05:00
advplyr
065675697d Fix:Catch exception when failing to download podcast episodes 2023-04-26 18:15:50 -05:00
advplyr
8f6832fc2e Merge pull request #1727 from tomazed/translation-fr
update on pending translation
2023-04-26 10:50:54 -05:00
Tomazed
bdb154a6e5 update on pending translation 2023-04-26 17:39:15 +02:00
advplyr
f557289274 Update:Set lang in HTML tag #1399 2023-04-25 19:00:57 -05:00
advplyr
a5627a1b52 Add:Search for narrators #1495 2023-04-24 18:25:30 -05:00
advplyr
33f20d54cc Updated docker template xml 2023-04-23 17:08:36 -05:00
advplyr
dadd41cb5c Fix:Podcast episode quick match crash #1711 2023-04-21 17:49:25 -05:00
advplyr
35e27e4f61 Merge pull request #1710 from Weldawadyathink/audiobook-covers-2
Add AudiobookCovers.com metadata provider
2023-04-21 16:17:48 -05:00
advplyr
84839bea44 Cleanup audiobookcovers.com addition 2023-04-21 16:17:52 -05:00
Spenser Bushey
1342897858 Removed useless comments 2023-04-20 16:39:04 -07:00
advplyr
c32efb8db8 Fix:Podcast episode search modal search filter #1699 2023-04-20 17:51:06 -05:00
Spenser Bushey
f9ed412e4e Add AudiobookCovers.com metadata provider
AudiobookCovers.com acts as a cover-only metadata provider, therefore will only show up in the covers selector.
2023-04-19 22:13:52 -07:00
advplyr
6ae3ad508e Readme update 2023-04-19 18:03:43 -05:00
advplyr
24af702b41 Merge pull request #1707 from coldshouldermedia/master
Updated SWAG Reverse Proxy guide
2023-04-19 17:59:59 -05:00
advplyr
a57ff20f35 Merge pull request #1692 from divyangbw/fix-show-all-genres-on-match-tab
fix: Make sure all existing genres also show up on the match tab
2023-04-19 17:39:04 -05:00
advplyr
39e710deb1 Merge pull request #1706 from ajaxbits/master
Update:Fix filename issue in tone, bump to v0.1.5
2023-04-19 17:22:24 -05:00
advplyr
3b6fa73ac0 Update tone in debian package to v0.1.5 2023-04-19 17:22:25 -05:00
coldshouldermedia
e2dd66d450 Updated SWAG Reverse Proxy guide 2023-04-19 16:19:29 -06:00
Alex Jackson
b1b53a1eae Update:Bump tone version in devcontainer to v0.1.5 2023-04-19 16:40:46 -05:00
Alex Jackson
6f73345f39 Merge branch 'advplyr:master' into master 2023-04-19 16:24:21 -05:00
Alex Jackson
c7b4b3bd3e Update:Bump tone version
Addresses #1703. Paths with quotations were not handled by tone<v0.1.3.
2023-04-19 16:22:15 -05:00
advplyr
98d543e3e5 Merge pull request #1695 from jkuehnemundt/de-lang-fix
Improve German (de.json) translation
2023-04-18 18:07:43 -05:00
advplyr
4de4e958a0 Merge pull request #1684 from ThinkSalat/bugfix/notification-url-blur
blur the url input when clicking submit to add info currently in input
2023-04-18 18:06:28 -05:00
advplyr
cc5e92ec8e Update client/components/modals/notification/NotificationEditModal.vue 2023-04-18 18:06:22 -05:00
Jannik Kühnemundt
6cb9dfaa85 Update de.json 2023-04-18 17:55:59 +02:00
Jannik Kühnemundt
8790166ac1 Fix further translation 2023-04-18 16:37:34 +02:00
Divyang Joshi
3b97e2146d fix: Make sure all existing genres also show up on the match tab 2023-04-17 20:09:59 -05:00
advplyr
0bb1cf002d Fix:Crash when podcasts put empty spaces with episode file path in RSS feed #1650 2023-04-17 17:03:58 -05:00
advplyr
307c7ebc9d Merge pull request #1688 from Machou/patch-1
Update fr.json
2023-04-17 16:35:31 -05:00
Jannik Kühnemundt
cc1b41995d Fixed german translation 2023-04-17 23:17:40 +02:00
Machou
730d60575e Update fr.json 2023-04-17 22:42:48 +02:00
Shawn Salat
1b96297cc7 blur the url input when clicking submit to add info currently in input 2023-04-17 08:25:20 -06:00
advplyr
128c554543 Version bump 2.2.19 2023-04-16 16:34:09 -05:00
advplyr
1b5ab6c378 Update xml2js 0.5.0 2023-04-16 16:33:28 -05:00
advplyr
e4961feffb Update:Remove item metadata path when removing item #1561 2023-04-16 16:23:13 -05:00
advplyr
eb5f257b8c Merge pull request #1680 from lukeIam/region_authors
Use region for author queries
2023-04-16 15:54:49 -05:00
advplyr
e271e89835 Author API requests to use region from library provider 2023-04-16 15:53:46 -05:00
advplyr
f5009f76f4 Update proper lockfile settings #1326 2023-04-16 15:21:04 -05:00
lukeIam
a3e63e03d2 Use region for author queries 2023-04-16 13:36:50 +02:00
advplyr
2ae3ea346f Update:Show abridged icon next to title #1656 2023-04-15 18:28:06 -05:00
advplyr
8542d433a2 Add:Audio file info modal #1667 2023-04-15 18:09:49 -05:00
advplyr
03984f96d4 Remove experimental tone probe 2023-04-15 16:21:16 -05:00
advplyr
eab019c577 Use horizontal kebab icon 2023-04-14 16:48:24 -05:00
advplyr
179f11f55d Add:Delete library items from file system #1439 2023-04-14 16:44:41 -05:00
advplyr
5a21e63d0b Add:Delete library files, condense item options in more menu #1439 2023-04-13 18:03:39 -05:00
advplyr
24ef105732 Fix:Empty podcasts marked as missing & removing episodes when deleted in folder #1671 2023-04-12 17:20:11 -05:00
advplyr
589c4f73d2 Cleanup scanner 2023-04-12 16:45:52 -05:00
advplyr
55fdc48d5d Merge pull request #1670 from divyangbw/feat-new-sortBy-last-book
Add sortBy Last Book Added and Updated to series
2023-04-12 16:23:52 -05:00
advplyr
4d45a902bb Add translations to other languages 2023-04-12 16:25:02 -05:00
Divyang Joshi
69bac2ec1e Add sorted by value to the series card 2023-04-12 12:55:59 -04:00
Divyang Joshi
122ec140e8 Add sortBy Last Book Added and Updated to series 2023-04-11 23:18:25 -04:00
advplyr
6a0adf7433 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-11 16:55:28 -05:00
advplyr
c1b2aaec9f Fix:Set tone path for debian tone usage #1643 2023-04-11 16:55:22 -05:00
advplyr
a49acdb2e4 Merge pull request #1669 from Nab0y/master
Fix Russian localization
2023-04-11 15:06:50 -05:00
Dmitry Naboychenko
9b67fbe8d9 Fix Russian localization 2023-04-11 21:09:18 +03:00
advplyr
2d13215f1f Merge pull request #1665 from tomazed/translation-fr
MessageConfirmRemoveAllChapters translation fr
2023-04-11 09:32:45 -05:00
Tomazed
a77c3aae93 MessageConfirmRemoveAllChapters translation fr 2023-04-11 09:41:38 +02:00
advplyr
164937b454 Merge pull request #1659 from Dr-Blank/gujarati-translation
Kick off Gujarati translation.
2023-04-10 17:24:59 -05:00
Dr-Blank
b0a8f3d207 Kick off Gujarati translation. 2023-04-09 23:58:51 -04:00
advplyr
77cc0934be Update:Episodes table sort by pub date treats episodes with no pub date as the oldest #1454 2023-04-09 17:20:56 -05:00
advplyr
718890cfad Add:Download button to download full library item #580 2023-04-09 17:05:35 -05:00
advplyr
418adcf891 Update:Only admin users can see full file path #1411 2023-04-09 16:10:03 -05:00
advplyr
b96f878d69 Update:Sleep timer presets and add custom time input #1357 2023-04-09 15:37:49 -05:00
advplyr
22b8622c67 Fix:Crash for invalid payload to update cover endpoint #1644 2023-04-09 15:01:14 -05:00
advplyr
3dc9416da6 Add:Chapters to podcast episodes #1646 2023-04-09 14:32:51 -05:00
advplyr
5e5b674c17 Add:Remove all chapters button in chapter editor #1603 2023-04-09 12:47:36 -05:00
advplyr
3656eab8bf Update:Add audible_asin meta tag #1640 2023-04-09 11:23:02 -05:00
advplyr
25ca950dd0 Update listening sessions per device and show open sessions 2023-04-08 18:01:24 -05:00
advplyr
8fca84e4bd Fix:Chapter editor show save button when shifting times #1648 2023-04-08 14:32:12 -05:00
advplyr
56579f440b Update playback rate hotkey adjustment 2023-04-08 11:17:17 -05:00
advplyr
a59311f795 Update:Adjust timestamps in player for playback speed #1647 2023-04-07 18:05:23 -05:00
advplyr
042c89039c Merge pull request #1654 from Dr-Blank/hindi-translation
Kicked off Hindi Translation.
2023-04-07 16:10:44 -05:00
Dr-Blank
d94482827a Kicked of Hindi Translation. 2023-04-07 04:41:38 -04:00
advplyr
a8dab5653b Merge pull request #1651 from Demian98/patch-1
Added german translation for abridged
2023-04-06 09:56:07 -05:00
Demian98
1d1200a3f2 Added german translation for abridged 2023-04-06 16:04:56 +02:00
advplyr
4d110ebe7e Fix:Podcast RSS feed parse when element has attributes #1650 2023-04-05 17:40:40 -05:00
advplyr
b300f0d10c Merge pull request #1107 from ruoti/dev-documentation
Development documentation
2023-04-04 16:47:36 -05:00
Scott Ruoti
6dc4dc8f49 Updating devcontainer setup and related documentation 2023-04-04 12:10:45 -04:00
advplyr
dfae6cf89f Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-03 17:50:47 -05:00
advplyr
d7f18bdd8b Remove deprecated user settings 2023-04-03 17:41:03 -05:00
advplyr
05b102722b Remove unused ebook routes 2023-04-03 17:33:02 -05:00
advplyr
ef954ee68f Remove downloads folder in metadata dir 2023-04-03 17:28:55 -05:00
advplyr
dbaea9f87d Merge pull request #1645 from tomazed/translation-fr
update fr strings
2023-04-03 08:15:22 -05:00
Tomazed
64768ec2f9 update fr strings 2023-04-03 10:21:15 +02:00
advplyr
14ee17de47 Version bump 2.2.18 2023-04-02 17:40:55 -05:00
advplyr
034b8956a2 Add:Batch embed metadata and queue system for metadata embedding #700 2023-04-02 16:13:18 -05:00
advplyr
1a3f0e332e Fix download podcast episode that is not mp3 2023-04-01 16:31:04 -05:00
advplyr
fc36e86db7 Update:Match tab show current cover and include resolutions #1605 2023-03-31 18:00:45 -05:00
advplyr
60b4bc1a7e Update:Show resolution under cover in book details modal #1547 2023-03-31 17:42:52 -05:00
advplyr
9fdc8df8bc Update:API endpoint for updating book media does not require an id for new series/authors #1540 2023-03-31 17:04:26 -05:00
advplyr
212b97fa20 Add:Parsing meta tags from podcast episode audio file #1488 2023-03-30 18:04:21 -05:00
advplyr
704fbaced8 Update:Download podcast episodes and embed meta tags #1488 2023-03-29 18:05:53 -05:00
advplyr
575a162f8b Update:API endpoint for get all users to use minimal payload 2023-03-29 14:56:50 -05:00
advplyr
d2e0844493 Epub reader updates for mobile 2023-03-26 14:44:59 -05:00
advplyr
f2baf3fafd Update epub media progress update 2023-03-26 13:50:44 -05:00
advplyr
916fd039ca Remove keydown event listener in epub reader 2023-03-26 13:40:47 -05:00
advplyr
e248b6d8d8 Fix:Parsing id3 tags case insensitive 2023-03-25 16:09:41 -05:00
advplyr
936de68622 Update epub reader only store up to 3MB of locations cache 2023-03-25 15:53:19 -05:00
advplyr
a99257e758 Fix getAllLibraryItemsInProgress route 2023-03-25 14:07:35 -05:00
advplyr
c89d77dd06 Merge pull request #1627 from vincentscode/epub-reader
Save Progress for EPUBs
2023-03-24 18:01:13 -05:00
advplyr
3138865d69 Update toc menu and media progress display 2023-03-24 17:57:41 -05:00
Vincent Schmandt
4d29ebd647 Save Locations locally, add separate progress tracker 2023-03-23 08:45:00 +01:00
advplyr
fd58df4729 Add:Abridged book detail, parse from audible, abridged book filter #1408 2023-03-22 18:05:43 -05:00
Vincent Schmandt
5078818295 Add MediaProgress fields
Add Table of Contents
2023-03-22 11:16:01 +01:00
Vincent Schmandt
6c618d7760 Adjust height to fit metadata 2023-03-21 13:36:06 +01:00
Vincent Schmandt
17b8cf19b7 Add Location Storage 2023-03-21 13:34:21 +01:00
Vincent Schmandt
e018f8341e EPUB progress persistence 2023-03-21 13:27:21 +01:00
145 changed files with 5766 additions and 1591 deletions

View File

@@ -1,4 +1,15 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get install ffmpeg gnupg2 -y
# [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
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
# Setup the node environment
ENV NODE_ENV=development
# Install additional OS packages.
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
curl tzdata ffmpeg && \
rm -rf /var/lib/apt/lists/*
# Move tone executable to appropriate directory
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/

9
.devcontainer/dev.js Normal file
View File

@@ -0,0 +1,9 @@
// Using port 3333 is important when running the client web app separately
const Path = require('path')
module.exports.config = {
Port: 3333,
ConfigPath: Path.resolve('config'),
MetadataPath: Path.resolve('metadata'),
FFmpegPath: '/usr/bin/ffmpeg',
FFProbePath: '/usr/bin/ffprobe'
}

View File

@@ -1,12 +1,40 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"build": { "dockerfile": "Dockerfile" },
"mounts": [
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
],
"features": {
"fish": "latest"
"name": "Audiobookshelf",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "16"
}
},
"extensions": [
"eamodio.gitlens"
]
"mounts": [
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
],
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
3000,
3333
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"octref.vetur"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@@ -0,0 +1,29 @@
#!/bin/sh
# Mark the working directory as safe for use with git
git config --global --add safe.directory $PWD
# If there is no dev.js file, create it
if [ ! -f dev.js ]; then
cp .devcontainer/dev.js .
fi
# Update permissions for node_modules folders
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
if [ -d node_modules ]; then
sudo chown $(id -u):$(id -g) node_modules
fi
if [ -d client/node_modules ]; then
sudo chown $(id -u):$(id -g) client/node_modules
fi
# Install packages for the server
if [ -f package.json ]; then
npm ci
fi
# Install packages and build the client
if [ -f client/package.json ]; then
(cd client; npm ci; npm run generate)
fi

View File

@@ -3,6 +3,9 @@ node_modules
npm-debug.log
.git
.gitignore
.devcontainer/
.github/
.vscode/
/config
/audiobooks
/audiobooks2
@@ -13,4 +16,5 @@ test/
/client/.nuxt/
/client/dist/
/dist/
/build/
/deploy/

5
.gitattributes vendored Normal file
View File

@@ -0,0 +1,5 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
# Declare files that will always have CRLF line endings on checkout.
.devcontainer/post-create.sh text eol=lf

4
.gitignore vendored
View File

@@ -1,6 +1,6 @@
.env
dev.js
node_modules/
/dev.js
**/node_modules/
/config/
/audiobooks/
/audiobooks2/

44
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,44 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug server",
"runtimeExecutable": "npm",
"args": [
"run",
"dev"
],
"skipFiles": [
"<node_internals>/**"
]
},
{
"type": "node",
"request": "launch",
"name": "Debug client (nuxt)",
"runtimeExecutable": "npm",
"args": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/client",
"skipFiles": [
"${workspaceFolder}/<node_internals>/**"
]
}
],
"compounds": [
{
"name": "Debug server and client (nuxt)",
"configurations": [
"Debug server",
"Debug client (nuxt)"
]
}
]
}

40
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,40 @@
{
"version": "2.0.0",
"tasks": [
{
"path": "client",
"type": "npm",
"script": "generate",
"detail": "nuxt generate",
"label": "Build client",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"dependsOn": [
"Build client"
],
"type": "npm",
"script": "dev",
"detail": "nodemon --watch server index.js",
"label": "Run server",
"group": {
"kind": "test",
"isDefault": true
}
},
{
"path": "client",
"type": "npm",
"script": "dev",
"detail": "nuxt",
"label": "Run Live-reload client",
"group": {
"kind": "test",
"isDefault": false
}
}
]
}

View File

@@ -1,14 +1,19 @@
### STAGE 0: Build client ###
FROM node:16-alpine AS build
WORKDIR /client
COPY /client /client
COPY /client/package*.json /client/
RUN npm ci && npm cache clean --force
RUN npm run generate
COPY /client /client
RUN npm run build
### STAGE 1: Build server ###
FROM sandreas/tone:v0.1.2 AS tone
FROM sandreas/tone:v0.1.5 AS tone
FROM node:16-alpine
WORKDIR /app
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
@@ -17,11 +22,17 @@ RUN apk update && \
ffmpeg
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist
COPY index.js package* /
COPY server server
COPY package* ./
COPY --from=build /client/package*.json client/
RUN npm ci --only=production
RUN npm ci --omit=dev
RUN cd client && npm ci --omit=dev && cd ..
COPY index.js package* ./
COPY server server
COPY --from=build /client/nuxt.config.js* client/
COPY --from=build /client/.nuxt client/.nuxt
COPY --from=build /client/modules client/modules
EXPOSE 80
HEALTHCHECK \

View File

@@ -50,7 +50,7 @@ install_ffmpeg() {
echo "Starting FFMPEG Install"
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.2/tone-0.1.2-linux-x64.tar.gz --output-document=tone-0.1.2-linux-x64.tar.gz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
if ! cd "$FFMPEG_INSTALL_DIR"; then
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
@@ -66,8 +66,8 @@ install_ffmpeg() {
# Temp downloading tone library to the ffmpeg dir
echo "Getting tone.."
$WGET_TONE
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
rm tone-0.1.2-linux-x64.tar.gz
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
rm tone-0.1.5-linux-x64.tar.gz
echo "Good to go on Ffmpeg (& tone)... hopefully"
}

View File

@@ -5,7 +5,7 @@
@import './absicons.css';
:root {
--bookshelf-texture-img: url(/textures/wood_default.jpg);
--bookshelf-texture-img: url(~static//textures/wood_default.jpg);
--bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
}

View File

@@ -58,9 +58,6 @@
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }}
</ui-btn>
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
</ui-tooltip>
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip>
@@ -75,8 +72,11 @@
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
</ui-tooltip>
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
<span class="material-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</ui-tooltip>
</div>
</div>
@@ -160,9 +160,59 @@ export default {
},
isHttps() {
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
},
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
const options = [
{
text: this.$strings.ButtonQuickMatch,
action: 'quick-match'
}
]
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
options.push({
text: 'Quick Embed Metadata',
action: 'quick-embed'
})
}
return options
}
},
methods: {
requestBatchQuickEmbed() {
const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/tools/batch/embed-metadata`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
console.log('Audio metadata embed started')
this.cancelSelectionMode()
})
.catch((error) => {
console.error('Audio metadata embed failed', error)
const errorMsg = error.response.data || 'Failed to embed metadata'
this.$toast.error(errorMsg)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction(action) {
if (action === 'quick-embed') {
this.requestBatchQuickEmbed()
} else if (action === 'quick-match') {
this.batchAutoMatchClick()
}
},
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)
@@ -237,26 +287,37 @@ export default {
})
},
batchDeleteClick() {
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
if (confirm(confirmMsg)) {
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/delete`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error('Batch delete failed')
console.error('Failed to batch delete', error)
this.$store.commit('setProcessingBatch', false)
})
const payload = {
message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`,
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error',
checkboxDefaultValue: true,
callback: (confirmed, hardDelete) => {
if (confirmed) {
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success')
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
console.error('Batch delete failed', error)
this.$toast.error('Batch delete failed')
})
.finally(() => {
this.$store.commit('setProcessingBatch', false)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
batchEditClick() {
this.$router.push('/batch')

View File

@@ -28,6 +28,9 @@
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-authors-slider>
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-narrators-slider>
</template>
</div>
<!-- Regular bookshelf view -->
@@ -185,8 +188,8 @@ export default {
this.shelves = categories
},
async setShelvesFromSearch() {
var shelves = []
if (this.results.books && this.results.books.length) {
const shelves = []
if (this.results.books?.length) {
shelves.push({
id: 'books',
label: 'Books',
@@ -196,7 +199,7 @@ export default {
})
}
if (this.results.podcasts && this.results.podcasts.length) {
if (this.results.podcasts?.length) {
shelves.push({
id: 'podcasts',
label: 'Podcasts',
@@ -206,7 +209,7 @@ export default {
})
}
if (this.results.series && this.results.series.length) {
if (this.results.series?.length) {
shelves.push({
id: 'series',
label: 'Series',
@@ -221,7 +224,7 @@ export default {
})
})
}
if (this.results.tags && this.results.tags.length) {
if (this.results.tags?.length) {
shelves.push({
id: 'tags',
label: 'Tags',
@@ -236,7 +239,7 @@ export default {
})
})
}
if (this.results.authors && this.results.authors.length) {
if (this.results.authors?.length) {
shelves.push({
id: 'authors',
label: 'Authors',
@@ -250,6 +253,20 @@ export default {
})
})
}
if (this.results.narrators?.length) {
shelves.push({
id: 'narrators',
label: 'Narrators',
labelStringKey: 'LabelNarrators',
type: 'narrators',
entities: this.results.narrators.map((n) => {
return {
...n,
type: 'narrator'
}
})
})
}
this.shelves = shelves
},
scan() {

View File

@@ -41,6 +41,11 @@
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
</template>
</div>
<div v-if="shelf.type === 'narrators'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
</template>
</div>
</div>
</div>
@@ -88,6 +93,7 @@ export default {
return this.bookCoverWidth * this.bookCoverAspectRatio
},
shelfHeight() {
if (this.shelf.type === 'narrators') return 148
return this.bookCoverHeight + 48
},
paddingLeft() {

View File

@@ -163,6 +163,14 @@ export default {
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelLastBookAdded,
value: 'lastBookAdded'
},
{
text: this.$strings.LabelLastBookUpdated,
value: 'lastBookUpdated'
},
{
text: this.$strings.LabelTotalDuration,
value: 'totalDuration'
@@ -181,6 +189,9 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
@@ -315,7 +326,11 @@ export default {
const payload = {}
if (author.asin) payload.asin = author.asin
else payload.q = author.name
console.log('Payload', payload, 'author', author)
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
this.$eventBus.$emit(`searching-author-${author.id}`, true)

View File

@@ -81,7 +81,7 @@ export default {
sleepTimerRemaining: 0,
sleepTimer: null,
displayTitle: null,
initialPlaybackRate: 1,
currentPlaybackRate: 1,
syncFailedToast: null
}
},
@@ -120,17 +120,22 @@ export default {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
streamEpisode() {
if (!this.$store.state.streamEpisodeId) return null
const episodes = this.streamLibraryItem.media.episodes || []
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
},
libraryItemId() {
return this.streamLibraryItem ? this.streamLibraryItem.id : null
return this.streamLibraryItem?.id || null
},
media() {
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
return this.streamLibraryItem?.media || {}
},
isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
return this.streamLibraryItem?.mediaType === 'podcast'
},
isMusic() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
return this.streamLibraryItem?.mediaType === 'music'
},
isExplicit() {
return this.mediaMetadata.explicit || false
@@ -139,6 +144,7 @@ export default {
return this.media.metadata || {}
},
chapters() {
if (this.streamEpisode) return this.streamEpisode.chapters || []
return this.media.chapters || []
},
title() {
@@ -152,7 +158,8 @@ export default {
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
// Adjusted by playback rate
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
},
podcastAuthor() {
if (!this.isPodcast) return null
@@ -255,7 +262,7 @@ export default {
this.playerHandler.setVolume(volume)
},
setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate
this.currentPlaybackRate = playbackRate
this.playerHandler.setPlaybackRate(playbackRate)
},
seek(time) {
@@ -384,7 +391,7 @@ export default {
libraryItem: session.libraryItem,
episodeId: session.episodeId
})
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
},
streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session)
@@ -451,7 +458,7 @@ export default {
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
},
pauseItem() {
this.playerHandler.pause()
@@ -459,6 +466,13 @@ export default {
showFailedProgressSyncs() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
},
sessionClosedEvent(sessionId) {
if (this.playerHandler.currentSessionId === sessionId) {
console.log('sessionClosedEvent closing current session', sessionId)
this.playerHandler.resetPlayer() // Closes player without reporting to server
this.$store.commit('setMediaPlaying', null)
}
}
},
mounted() {

View File

@@ -77,6 +77,12 @@ export default {
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
}
},
methods: {
@@ -92,6 +98,11 @@ export default {
if (this.asin) payload.asin = this.asin
else payload.q = this.name
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error)
return null

View File

@@ -10,7 +10,7 @@
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div>
</div>
</template>
@@ -67,12 +67,13 @@ export default {
// but with removing commas periods etc this is no longer plausible
const html = this.matchText
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
return `${html}`
}
},

View File

@@ -325,8 +325,13 @@ export default {
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
},
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
@@ -521,6 +526,14 @@ export default {
}
}
}
if (this.userCanDelete) {
items.push({
func: 'deleteLibraryItem',
text: this.$strings.ButtonDelete
})
}
return items
},
_socket() {
@@ -772,6 +785,35 @@ export default {
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
this.store.commit('globals/setShowPlaylistsModal', true)
},
deleteLibraryItem() {
const payload = {
message: 'This will delete the library item from the database and your file system. Are you sure?',
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error',
checkboxDefaultValue: true,
callback: (confirmed, hardDelete) => {
if (confirmed) {
this.processing = true
const axios = this.$axios || this.$nuxt.$axios
axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
this.$toast.success('Item deleted')
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.store.commit('globals/setConfirmPrompt', payload)
},
createMoreMenu() {
if (!this.$refs.moreIcon) return

View File

@@ -7,7 +7,7 @@
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
@@ -81,13 +81,20 @@ export default {
return this.title
},
displaySortLine() {
if (this.orderBy === 'addedAt') {
// return this.addedAt
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
} else if (this.orderBy === 'totalDuration') {
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
switch (this.orderBy) {
case 'addedAt':
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
case 'totalDuration':
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
case 'lastBookUpdated':
const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
case 'lastBookAdded':
const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
default:
return null
}
return null
},
books() {
return this.series ? this.series.books || [] : []
@@ -108,6 +115,14 @@ export default {
seriesBooksFinished() {
return this.seriesBookProgress.filter((p) => p.isFinished)
},
hasSeriesBookInProgress() {
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
},
seriesPercentInProgress() {
let totalFinishedAndInProgress = this.seriesBooksFinished.length
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
},
isSeriesFinished() {
return this.books.length === this.seriesBooksFinished.length
},

View File

@@ -0,0 +1,50 @@
<template>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-20">
<span class="material-icons-outlined text-8xl">record_voice_over</span>
</div>
<!-- Narrator name & num books overlay -->
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
</div>
</nuxt-link>
</template>
<script>
export default {
props: {
narrator: {
type: Object,
default: () => {}
},
width: Number,
height: Number,
sizeMultiplier: {
type: Number,
default: 1
}
},
data() {
return {}
},
computed: {
name() {
return this.narrator?.name || ''
},
numBooks() {
return this.narrator?.books?.length || 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {}
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
</div>
<div class="flex-grow px-2 narratorSearchCardContent h-full">
<p class="truncate text-sm">{{ narrator }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
narrator: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style scoped>
.narratorSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -63,6 +63,15 @@
</nuxt-link>
</li>
</template>
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
<template v-for="narrator in narratorResults">
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<cards-narrator-search-card :narrator="narrator.name" />
</nuxt-link>
</li>
</template>
</template>
</ul>
</div>
@@ -84,6 +93,7 @@ export default {
authorResults: [],
seriesResults: [],
tagResults: [],
narratorResults: [],
searchTimeout: null,
lastSearch: null
}
@@ -114,6 +124,7 @@ export default {
this.authorResults = []
this.seriesResults = []
this.tagResults = []
this.narratorResults = []
this.showMenu = false
this.isFetching = false
this.isTyping = false
@@ -142,7 +153,7 @@ export default {
}
this.isFetching = true
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
console.error('Search error', error)
return []
})
@@ -155,6 +166,7 @@ export default {
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || []
this.narratorResults = searchResults.narrators || []
this.isFetching = false
if (!this.showMenu) {

View File

@@ -185,6 +185,11 @@ export default {
value: 'tracks',
sublist: true
},
{
text: this.$strings.LabelAbridged,
value: 'abridged',
sublist: false
},
{
text: this.$strings.ButtonIssues,
value: 'issues',

View File

@@ -6,7 +6,7 @@
</div>
</template>
<form @submit.prevent="submitForm">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<div class="px-4 w-full 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 py-2">
<div class="w-1/2 px-2">
@@ -96,7 +96,14 @@
</div>
</div>
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" :label="$strings.LabelTagsAccessibleToUser" />
<div class="flex items-center">
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
<div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
</div>
</div>
</div>
</div>
@@ -185,6 +192,9 @@ export default {
value: t
}
})
},
tagsSelectionText() {
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
}
},
methods: {
@@ -193,8 +203,11 @@ export default {
if (this.$refs.modal) this.$refs.modal.setHide()
},
accessAllTagsToggled(val) {
if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = []
if (val) {
if (this.newUser.itemTagsSelected?.length) {
this.newUser.itemTagsSelected = []
}
this.newUser.permissions.selectedTagsNotAccessible = false
}
},
fetchAllTags() {
@@ -226,7 +239,7 @@ export default {
this.$toast.error('Must select at least one library')
return
}
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
this.$toast.error('Must select at least one tag')
return
}
@@ -307,12 +320,12 @@ export default {
delete: type === 'admin',
upload: type === 'admin',
accessAllLibraries: true,
accessAllTags: true
accessAllTags: true,
selectedTagsNotAccessible: false
}
},
init() {
this.fetchAllTags()
this.isNew = !this.account
if (this.account) {
this.newUser = {
@@ -322,9 +335,10 @@ export default {
isActive: this.account.isActive,
permissions: { ...this.account.permissions },
librariesAccessible: [...(this.account.librariesAccessible || [])],
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
}
} else {
this.fetchAllTags()
this.newUser = {
username: null,
password: null,
@@ -336,7 +350,8 @@ export default {
delete: false,
upload: false,
accessAllLibraries: true,
accessAllTags: true
accessAllTags: true,
selectedTagsNotAccessible: false
},
librariesAccessible: []
}

View File

@@ -0,0 +1,118 @@
<template>
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
<p class="text-base text-gray-200">{{ metadata.filename }}</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
<div class="flex flex-col sm:flex-row text-sm">
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelSize }}
</p>
<p>{{ $bytesPretty(metadata.size) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelDuration }}
</p>
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
<p>{{ audioFile.format }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChapters }}
</p>
<p>{{ audioFile.chapters?.length || 0 }}</p>
</div>
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelEmbeddedCover }}
</p>
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
</div>
</div>
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelCodec }}
</p>
<p>{{ audioFile.codec }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChannels }}
</p>
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelBitrate }}
</p>
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
<p>{{ audioFile.timeBase }}</p>
</div>
<div v-if="audioFile.language" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelLanguage }}
</p>
<p>{{ audioFile.language || '' }}</p>
</div>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
<p class="w-32 min-w-32 text-black-50 mb-1">
{{ key.replace('tag', '') }}
</p>
<p>{{ value }}</p>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
audioFile: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
metadata() {
return this.audioFile?.metadata || {}
},
metaTags() {
return this.audioFile?.metaTags || {}
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -2,13 +2,13 @@
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
<p class="chapter-title truncate text-sm md:text-base">
{{ chap.title }}
</p>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
<span class="flex-grow" />
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div>
@@ -28,7 +28,8 @@ export default {
currentChapter: {
type: Object,
default: () => null
}
},
playbackRate: Number
},
data() {
return {}
@@ -47,11 +48,15 @@ export default {
this.$emit('input', val)
}
},
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null
},
currentChapterStart() {
return this.currentChapter ? this.currentChapter.start : 0
return (this.currentChapter?.start || 0) / this._playbackRate
}
},
methods: {
@@ -61,13 +66,11 @@ export default {
scrollToChapter() {
if (!this.currentChapterId) return
var container = this.$refs.container
if (container) {
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (this.$refs.container) {
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop
var containerHeight = container.clientHeight
container.scrollTo({ top: offsetTop - containerHeight / 2 })
const containerHeight = this.$refs.container.clientHeight
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
}
}
}

View File

@@ -98,7 +98,8 @@
</div>
<div class="flex items-center">
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
</div>
</div>
</modals-modal>
@@ -157,6 +158,9 @@ export default {
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
},
isOpenSession() {
return !!this._session.open
}
},
methods: {
@@ -188,6 +192,24 @@ export default {
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
})
},
closeSessionClick() {
this.processing = true
this.$axios
.$post(`/api/session/${this._session.id}/close`)
.then(() => {
this.$toast.success('Session closed')
this.show = false
this.$emit('closedSession')
})
.catch((error) => {
console.error('Failed to close session', error)
const errMsg = error.response?.data || ''
this.$toast.error(errMsg || 'Failed to close open session')
})
.finally(() => {
this.processing = false
})
}
},
mounted() {}

View File

@@ -9,10 +9,14 @@
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="!timerSet" class="w-full">
<template v-for="time in sleepTimes">
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)">
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
<p class="text-xl text-center">{{ time.text }}</p>
</div>
</template>
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
<ui-btn color="success" type="submit" padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
</form>
</div>
<div v-else class="w-full p-4">
<div class="mb-4 flex items-center justify-center">
@@ -48,19 +52,28 @@ export default {
},
data() {
return {
customTime: null,
sleepTimes: [
{
seconds: 10,
text: '10 seconds'
},
{
seconds: 60 * 5,
text: '5 minutes'
},
{
seconds: 60 * 15,
text: '15 minutes'
},
{
seconds: 60 * 20,
text: '20 minutes'
},
{
seconds: 60 * 30,
text: '30 minutes'
},
{
seconds: 60 * 45,
text: '45 minutes'
},
{
seconds: 60 * 60,
text: '60 minutes'
@@ -72,10 +85,6 @@ export default {
{
seconds: 60 * 120,
text: '2 hours'
},
{
seconds: 60 * 180,
text: '3 hours'
}
]
}
@@ -97,8 +106,17 @@ export default {
}
},
methods: {
setTime(time) {
this.$emit('set', time.seconds)
submitCustomTime() {
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
this.customTime = null
return
}
const timeInSeconds = Math.round(Number(this.customTime) * 60)
this.setTime(timeInSeconds)
},
setTime(seconds) {
this.$emit('set', seconds)
},
increment(amount) {
this.$emit('increment', amount)

View File

@@ -85,6 +85,12 @@ export default {
},
title() {
return this.$strings.HeaderUpdateAuthor
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
}
},
methods: {
@@ -151,6 +157,11 @@ export default {
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
else payload.q = this.authorCopy.name
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error)
return null

View File

@@ -2,7 +2,8 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap">
<div class="relative">
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay -->
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
@@ -27,14 +28,14 @@
</form>
</div>
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
<div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
<div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center">
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
@@ -48,13 +49,13 @@
</div>
<form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<div class="w-48 px-1">
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
</div>
<div v-show="provider != 'itunes'" class="w-72 px-1">
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
@@ -127,7 +128,7 @@ export default {
},
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
return [...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
},
searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN

View File

@@ -7,11 +7,6 @@
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">{{ $strings.ButtonRemove }}</ui-btn>
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
<div class="flex-grow" />
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
</ui-tooltip>
@@ -20,6 +15,8 @@
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
</ui-tooltip>
<div class="flex-grow" />
<!-- desktop -->
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
@@ -77,9 +74,6 @@ export default {
mediaMetadata() {
return this.media.metadata || {}
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
libraryId() {
return this.libraryItem ? this.libraryItem.libraryId : null
},
@@ -184,23 +178,6 @@ export default {
}
return false
},
removeItem() {
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}`)
.then(() => {
console.log('Item removed')
this.$toast.success('Item Removed')
this.$emit('close')
this.isProcessing = false
})
.catch((error) => {
console.error('Remove item failed', error)
this.isProcessing = false
})
}
},
checkIsScrollable() {
this.$nextTick(() => {
var formWrapper = document.getElementById('formWrapper')

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
<tables-library-files-table expanded :library-item="libraryItem" :is-missing="isMissing" in-modal />
</div>
</template>
@@ -30,9 +30,6 @@ export default {
media() {
return this.libraryItem.media || {}
},
libraryFiles() {
return this.libraryItem.libraryFiles || []
},
userToken() {
return this.$store.getters['user/getToken']
},

View File

@@ -34,13 +34,25 @@
</div>
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
</a>
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
<div class="flex flex-grow items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
</div>
<div class="flex py-2">
<div>
<p class="text-center text-gray-200">New</p>
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</a>
</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>
</div>
</div>
</div>
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
@@ -103,7 +115,7 @@
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-multi-select v-model="selectedMatch.genres" :items="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
</div>
</div>
@@ -164,13 +176,20 @@
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
</div>
</div>
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center py-2">
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
</div>
</div>
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? 'Abridged (checked)' : 'Unabridged (unchecked)' }}</p>
</div>
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
@@ -216,6 +235,7 @@ export default {
explicit: true,
asin: true,
isbn: true,
abridged: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@@ -280,6 +300,12 @@ export default {
},
isPodcast() {
return this.mediaType == 'podcast'
},
genres() {
const filterData = this.$store.state.libraries.filterData || {}
const currentGenres = filterData.genres || []
const selectedMatchGenres = this.selectedMatch.genres || []
return [...new Set([...currentGenres ,...selectedMatchGenres])]
}
},
methods: {
@@ -360,6 +386,7 @@ export default {
explicit: true,
asin: true,
isbn: true,
abridged: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@@ -476,7 +503,6 @@ export default {
} else if (key === 'narrator') {
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'genres') {
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
updatePayload.metadata.genres = [...this.selectedMatch[key]]
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())

View File

@@ -46,8 +46,20 @@
>{{ $strings.ButtonOpenManager }}
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
</div>
</div>
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- processing alert -->
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
<p class="text-lg">Currently embedding metadata</p>
</widgets-alert>
</div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
@@ -71,10 +83,10 @@ export default {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
return this.libraryItem?.id || null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
return this.libraryItem?.media || {}
},
mediaTracks() {
return this.media.tracks || []
@@ -92,9 +104,49 @@ export default {
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []
},
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
},
tasks() {
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
},
embedTask() {
return this.tasks.find((t) => t.action === 'embed-metadata')
},
encodeTask() {
return this.tasks.find((t) => t.action === 'encode-m4b')
},
isEmbedTaskRunning() {
return this.embedTask && !this.embedTask?.isFinished
},
isEncodeTaskRunning() {
return this.encodeTask && !this.encodeTask?.isFinished
}
},
methods: {},
mounted() {}
methods: {
quickEmbed() {
const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)
.then(() => {
console.log('Audio metadata encode started')
})
.catch((error) => {
console.error('Audio metadata encode failed', error)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}
}
}
</script>

View File

@@ -10,7 +10,7 @@
<div class="w-full px-3 py-5 md:p-12">
<ui-dropdown v-model="newNotification.eventName" :label="$strings.LabelNotificationEvent" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
<ui-multi-select v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-multi-select ref="urlsInput" v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-text-input-with-label v-model="newNotification.titleTemplate" :label="$strings.LabelNotificationTitleTemplate" class="mb-2" />
@@ -103,6 +103,8 @@ export default {
if (this.$refs.modal) this.$refs.modal.setHide()
},
submitForm() {
this.$refs.urlsInput?.forceBlur()
if (!this.newNotification.urls.length) {
this.$toast.error('Must enter an Apprise URL')
return

View File

@@ -6,7 +6,7 @@
</div>
</template>
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<div v-if="episodesCleaned.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>
@@ -16,12 +16,12 @@
v-for="(episode, index) in episodesList"
:key="index"
class="relative"
:class="itemEpisodeMap[episode.enclosure.url?.split('?')[0]] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(index, episode)"
:class="itemEpisodeMap[episode.cleanUrl] ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(episode)"
>
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="itemEpisodeMap[episode.enclosure.url?.split('?')[0]]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
<span v-if="itemEpisodeMap[episode.cleanUrl]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
<div class="flex items-center font-semibold text-gray-200">
@@ -63,6 +63,7 @@ export default {
data() {
return {
processing: false,
episodesCleaned: [],
selectedEpisodes: {},
selectAll: false,
search: null,
@@ -92,7 +93,7 @@ export default {
return this.libraryItem.media.metadata.title || 'Unknown'
},
allDownloaded() {
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]])
return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl])
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
@@ -113,7 +114,7 @@ export default {
return map
},
episodesList() {
return this.episodes.filter((episode) => {
return this.episodesCleaned.filter((episode) => {
if (!this.searchText) return true
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
})
@@ -131,31 +132,29 @@ export default {
}, 500)
},
toggleSelectAll(val) {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) this.selectedEpisodes[String(i)] = false
else this.$set(this.selectedEpisodes, String(i), val)
for (const episode of this.episodesCleaned) {
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
}
},
checkSetIsSelectedAll() {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (!this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]] && !this.selectedEpisodes[String(i)]) {
for (const episode of this.episodesCleaned) {
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
this.selectAll = false
return
}
}
this.selectAll = true
},
toggleSelectEpisode(index, episode) {
toggleSelectEpisode(episode) {
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
this.checkSetIsSelectedAll()
},
submit() {
var episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
}
var payloadSize = JSON.stringify(episodesToDownload).length
@@ -185,7 +184,15 @@ export default {
})
},
init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.episodesCleaned = this.episodes
.filter((ep) => ep.enclosure?.url)
.map((_ep) => {
return {
..._ep,
cleanUrl: _ep.enclosure.url.split('?')[0]
}
})
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.selectAll = false
this.selectedEpisodes = {}
}

View File

@@ -31,9 +31,10 @@
<!-- mobile -->
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
</div>
<div v-if="enclosureUrl" class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
<div v-if="enclosureUrl" class="pb-4 pt-6">
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label>
</ui-text-input-with-label>
</div>
<div v-else class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>

View File

@@ -38,7 +38,8 @@ export default {
currentChapter: {
type: Object,
default: () => {}
}
},
playbackRate: Number
},
data() {
return {
@@ -63,6 +64,10 @@ export default {
}
},
computed: {
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterDuration() {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentChapter.start
@@ -81,8 +86,8 @@ export default {
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
const offsetX = e.offsetX
const perc = offsetX / this.trackWidth
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const time = baseTime + perc * duration
@@ -111,7 +116,7 @@ export default {
this.updateReadyTrack()
},
updateReadyTrack() {
var widthReady = Math.round(this.trackWidth * this.percentReady)
const widthReady = Math.round(this.trackWidth * this.percentReady)
if (this.readyTrackWidth === widthReady) return
this.readyTrackWidth = widthReady
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
@@ -124,7 +129,7 @@ export default {
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
var ptWidth = Math.round((time / duration) * this.trackWidth)
const ptWidth = Math.round((time / duration) * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
@@ -133,7 +138,7 @@ export default {
},
setChapterTicks() {
this.chapterTicks = this.chapters.map((chap) => {
var perc = chap.start / this.duration
const perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
@@ -141,7 +146,7 @@ export default {
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
const offsetX = e.offsetX
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
@@ -167,7 +172,7 @@ export default {
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(progressTime)
var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
if (chapter && chapter.title) {

View File

@@ -46,7 +46,7 @@
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
</div>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
<div class="flex">
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
@@ -59,7 +59,7 @@
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
</div>
</template>
@@ -92,6 +92,11 @@ export default {
useChapterTrack: false
}
},
watch: {
playbackRate() {
this.updateTimestamp()
}
},
computed: {
sleepTimerRemainingString() {
var rounded = Math.round(this.sleepTimerRemaining)
@@ -213,18 +218,14 @@ export default {
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
if (this.playbackRate >= 10) return
this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
this.setPlaybackRate(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
if (this.playbackRate <= 0.5) return
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
this.setPlaybackRate(this.playbackRate)
},
setPlaybackRate(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
@@ -289,14 +290,13 @@ export default {
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
},
updateTimestamp() {
var ts = this.$refs.currentTimestamp
const ts = this.$refs.currentTimestamp
if (!ts) {
console.error('No timestamp el')
return
}
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
var currTimeClean = this.$secondsToTimestamp(time)
ts.innerText = currTimeClean
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
},
setBufferTime(bufferTime) {
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
@@ -312,7 +312,7 @@ export default {
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate)
this.setPlaybackRate(this.playbackRate)
},
settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {

View File

@@ -3,11 +3,14 @@
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-lg mb-8 mt-2 px-1" v-html="message" />
<p class="text-lg mb-6 mt-2 px-1" v-html="message" />
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
<div class="flex px-1 items-center">
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
<div class="flex-grow" />
<ui-btn v-if="isYesNo" color="success" @click="confirm">{{ $strings.ButtonYes }}</ui-btn>
<ui-btn v-if="isYesNo" :color="yesButtonColor" @click="confirm">{{ yesButtonText }}</ui-btn>
<ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn>
</div>
</div>
@@ -21,7 +24,8 @@ export default {
data() {
return {
el: null,
content: null
content: null,
checkboxValue: false
}
},
watch: {
@@ -57,6 +61,18 @@ export default {
persistent() {
return !!this.confirmPromptOptions.persistent
},
checkboxLabel() {
return this.confirmPromptOptions.checkboxLabel
},
yesButtonText() {
return this.confirmPromptOptions.yesButtonText || this.$strings.ButtonYes
},
yesButtonColor() {
return this.confirmPromptOptions.yesButtonColor || 'success'
},
checkboxDefaultValue() {
return !!this.confirmPromptOptions.checkboxDefaultValue
},
isYesNo() {
return this.type === 'yesNo'
},
@@ -84,10 +100,11 @@ export default {
this.show = false
},
confirm() {
if (this.callback) this.callback(true)
if (this.callback) this.callback(true, this.checkboxValue)
this.show = false
},
setShow() {
this.checkboxValue = this.checkboxDefaultValue
this.$eventBus.$emit('showing-prompt', true)
document.body.appendChild(this.el)
setTimeout(() => {

View File

@@ -1,18 +1,14 @@
<template>
<div class="h-full w-full">
<div class="h-full flex items-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
<div class="h-full flex items-center justify-center">
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
<span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</div>
<div id="frame" class="w-full" style="height: 650px">
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
<div class="py-4 flex justify-center" style="height: 50px">
<p>{{ progress }}%</p>
</div>
<div id="frame" class="w-full" style="height: 80%">
<div id="viewer"></div>
</div>
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
<span v-if="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</div>
</div>
</div>
@@ -21,108 +17,252 @@
<script>
import ePub from 'epubjs'
/**
* @typedef {object} EpubReader
* @property {ePub.Book} book
* @property {ePub.Rendition} rendition
*/
export default {
props: {
url: String
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
windowWidth: 0,
/** @type {ePub.Book} */
book: null,
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
/** @type {ePub.Rendition} */
rendition: null
}
},
computed: {},
methods: {
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
}
computed: {
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
},
hasPrev() {
return !this.rendition?.location?.atStart
},
hasNext() {
return !this.rendition?.location?.atEnd
},
/** @returns {Array<ePub.NavItem>} */
chapters() {
return this.book ? this.book.navigation.toc : []
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
readerWidth() {
if (this.windowWidth < 640) return this.windowWidth
return this.windowWidth - 200
}
},
methods: {
prev() {
if (this.rendition) {
this.rendition.prev()
}
return this.rendition?.prev()
},
next() {
if (this.rendition) {
this.rendition.next()
return this.rendition?.next()
},
goToChapter(href) {
return this.rendition?.display(href)
},
keyUp(e) {
const rtl = this.book.package.metadata.direction === 'rtl'
if ((e.keyCode || e.which) == 37) {
return rtl ? this.next() : this.prev()
} else if ((e.keyCode || e.which) == 39) {
return rtl ? this.prev() : this.next()
}
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
/**
* @param {object} payload
* @param {string} payload.ebookLocation - CFI of the current location
* @param {string} payload.ebookProgress - eBook Progress Percentage
*/
updateProgress(payload) {
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
},
getAllEbookLocationData() {
const locations = []
let totalSize = 0 // Total in bytes
for (const key in localStorage) {
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
continue
}
try {
const ebookLocations = JSON.parse(localStorage[key])
if (!ebookLocations.locations) throw new Error('Invalid locations object')
ebookLocations.key = key
ebookLocations.size = (localStorage[key].length + key.length) * 2
locations.push(ebookLocations)
totalSize += ebookLocations.size
} catch (error) {
console.error('Failed to parse ebook locations', key, error)
localStorage.removeItem(key)
}
}
// Sort by oldest lastAccessed first
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
return {
locations,
totalSize
}
},
/** @param {string} locationString */
checkSaveLocations(locationString) {
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
// Too large overall
if (newLocationsSize > maxSizeInBytes) {
console.error('Epub locations are too large to store. Size =', newLocationsSize)
return
}
const ebookLocationsData = this.getAllEbookLocationData()
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
// Remove epub locations until there is room for locations
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
const oldestLocation = ebookLocationsData.locations.shift()
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
availableSpace += oldestLocation.size
localStorage.removeItem(oldestLocation.key)
}
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
this.saveLocations(locationString)
},
/** @param {string} locationString */
saveLocations(locationString) {
localStorage.setItem(
this.localStorageLocationsKey,
JSON.stringify({
lastAccessed: Date.now(),
locations: locationString
})
)
},
loadLocations() {
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
if (!locationsObjString) return null
const locationsObject = JSON.parse(locationsObjString)
// Remove invalid location objects
if (!locationsObject.locations) {
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
localStorage.removeItem(this.localStorageLocationsKey)
return null
}
// Update lastAccessed
this.saveLocations(locationsObject.locations)
return locationsObject.locations
},
/** @param {string} location - CFI of the new location */
relocated(location) {
if (this.userMediaProgress?.ebookLocation === location.start.cfi) {
return
}
if (location.end.percentage) {
this.updateProgress({
ebookLocation: location.start.cfi,
ebookProgress: location.end.percentage
})
} else {
this.updateProgress({
ebookLocation: location.start.cfi
})
}
},
initEpub() {
// var book = ePub(this.url, {
// requestHeaders: {
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
/** @type {EpubReader} */
const reader = this
this.rendition = book.renderTo('viewer', {
width: window.innerWidth - 200,
height: 600,
ignoreClass: 'annotator-hl',
manager: 'continuous',
spread: 'always'
/** @type {ePub.Book} */
reader.book = new ePub(reader.url, {
width: this.readerWidth,
height: window.innerHeight - 50
})
var displayed = this.rendition.display()
book.ready
.then(() => {
console.log('Book ready')
return book.locations.generate(1600)
/** @type {ePub.Rendition} */
reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth,
height: window.innerHeight * 0.8
})
// load saved progress
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
// load style
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
reader.book.ready.then(() => {
// set up event listeners
reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('keydown', reader.keyUp)
let touchStart = 0
let touchEnd = 0
reader.rendition.on('touchstart', (event) => {
touchStart = event.changedTouches[0].screenX
})
.then((locations) => {
// console.log('Loaded locations', locations)
// Wait for book to be rendered to get current page
displayed.then(() => {
// Get the current CFI
var currentLocation = this.rendition.currentLocation()
if (!currentLocation.start) {
console.error('No Start', currentLocation)
} else {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
// console.log('current page', currentPage)
}
reader.rendition.on('touchend', (event) => {
touchEnd = event.changedTouches[0].screenX
const touchDistanceX = Math.abs(touchEnd - touchStart)
if (touchStart < touchEnd && touchDistanceX > 120) {
this.next()
}
if (touchStart > touchEnd && touchDistanceX > 120) {
this.prev()
}
})
// load ebook cfi locations
const savedLocations = this.loadLocations()
if (savedLocations) {
reader.book.locations.load(savedLocations)
} else {
reader.book.locations.generate().then(() => {
this.checkSaveLocations(reader.book.locations.save())
})
})
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
})
book.loaded.metadata.then((metadata) => {
this.author = metadata.creator
this.title = metadata.title
})
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
this.progress = Math.floor(percent * 100)
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
}
})
},
resize() {
this.windowWidth = window.innerWidth
this.rendition?.resize(this.readerWidth, window.innerHeight * 0.8)
}
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.book?.destroy()
},
mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', this.resize)
this.initEpub()
}
}

View File

@@ -1,88 +0,0 @@
<template>
<div class="h-full w-full">
<div id="viewer" class="border border-gray-100 bg-white text-black shadow-md h-screen overflow-y-auto p-4" v-html="pageHtml"></div>
</div>
</template>
<script>
export default {
props: {
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
bookInfo: {},
page: 0,
numPages: 0,
pageHtml: '',
progress: 0
}
},
computed: {
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
hasPrev() {
return this.page > 0
},
hasNext() {
return this.page < this.numPages - 1
}
},
methods: {
prev() {
if (!this.hasPrev) return
this.page--
this.loadPage()
},
next() {
if (!this.hasNext) return
this.page++
this.loadPage()
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
loadPage() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/page/${this.page}?dev=${this.$isDev ? 1 : 0}`)
.then((html) => {
this.pageHtml = html
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load page')
})
},
loadInfo() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/info?dev=${this.$isDev ? 1 : 0}`)
.then((bookInfo) => {
this.bookInfo = bookInfo
this.numPages = bookInfo.pages
this.page = 0
this.loadPage()
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load info')
})
},
initEpub() {
if (!this.libraryItemId) return
this.loadInfo()
}
},
mounted() {
this.initEpub()
}
}
</script>

View File

@@ -1,24 +1,48 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
<div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
<div class="absolute top-4 left-4 z-20">
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
</div>
<div class="absolute top-4 left-4">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
<p v-if="abAuthor">by {{ abAuthor }}</p>
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
<h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
<span style="font-weight: 600">{{ abTitle }}</span>
<span v-if="abAuthor" style="display: inline"> </span>
<span v-if="abAuthor">{{ abAuthor }}</span>
</h1>
</div>
<div class="absolute top-4 right-4 z-20">
<span v-if="hasSettings" class="material-icons cursor-pointer text-2xl" @click="openSettings">settings</span>
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
<!-- TOC side nav -->
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
<div v-if="hasToC" class="w-72 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-72'" @click.stop.prevent="toggleToC">
<div class="p-4 h-full overflow-hidden">
<p class="text-lg font-semibold mb-2">Table of Contents</p>
<div class="tocContent">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
return {
chapters: [],
tocOpen: false
}
},
watch: {
show(newVal) {
@@ -37,13 +61,18 @@ export default {
}
},
componentName() {
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
else if (this.ebookType === 'epub') return 'readers-epub-reader'
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
hasToC() {
return this.isEpub
},
hasSettings() {
return false
},
abTitle() {
return this.mediaMetadata.title
},
@@ -111,18 +140,29 @@ export default {
}
},
methods: {
toggleToC() {
this.tocOpen = !this.tocOpen
this.chapters = this.$refs.readerComponent.chapters
},
openSettings() {},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
this.next()
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
this.prev()
} else if (action === this.$hotkeys.EReader.CLOSE) {
this.close()
}
},
next() {
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
},
prev() {
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
},
registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey)
},
@@ -151,4 +191,8 @@ export default {
.ebook-viewer {
height: calc(100% - 96px);
}
.tocContent {
height: calc(100% - 36px);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<tr>
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
<td v-if="!showFullPath" class="hidden lg:table-cell">
{{ track.audioFile.codec || '' }}
</td>
<td v-if="!showFullPath" class="hidden xl:table-cell">
{{ $bytesPretty(track.audioFile.bitRate || 0, 0) }}
</td>
<td class="hidden md:table-cell">
{{ $bytesPretty(track.metadata.size) }}
</td>
<td class="hidden sm:table-cell">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
track: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
contextMenuItems() {
const items = []
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
if (this.userIsAdmin) {
items.push({
text: this.$strings.LabelMoreInfo,
action: 'more'
})
}
return items
},
downloadUrl() {
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.track.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
}
},
methods: {
contextMenuAction(action) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'more') {
this.$emit('showMore', this.track.audioFile)
}
},
deleteLibraryFile() {
const payload = {
message: 'This will delete the file from your file system. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.track.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
}
},
mounted() {}
}
</script>

View File

@@ -6,7 +6,7 @@
<span class="text-sm font-mono">{{ files.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<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>
<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>
@@ -18,60 +18,76 @@
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
<th v-if="userCanDownload && !isMissing" class="text-center w-20">{{ $strings.LabelDownload }}</th>
<th v-if="userCanDelete || userCanDownload || (userIsAdmin && audioFiles.length && !inModal)" class="text-center w-16"></th>
</tr>
<template v-for="file in files">
<tr :key="file.path">
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<div class="flex items-center">
<p>{{ file.fileType }}</p>
</div>
</td>
<td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
<template v-for="file in filesWithAudioFile">
<tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" :inModal="inModal" @showMore="showMore" />
</template>
</table>
</div>
</transition>
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
</div>
</template>
<script>
export default {
props: {
files: {
type: Array,
default: () => []
libraryItem: {
type: Object,
default: () => {}
},
libraryItemId: String,
isMissing: Boolean,
expanded: Boolean // start expanded
expanded: Boolean, // start expanded
inModal: Boolean
},
data() {
return {
showFiles: false,
showFullPath: false
showFullPath: false,
showAudioFileDataModal: false,
selectedAudioFile: null
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
files() {
return this.libraryItem.libraryFiles || []
},
audioFiles() {
return this.libraryItem.media?.audioFiles || []
},
filesWithAudioFile() {
return this.files.map((file) => {
if (file.fileType === 'audio') {
file.audioFile = this.audioFiles.find((af) => af.ino === file.ino)
}
return file
})
}
},
methods: {
clickBar() {
this.showFiles = !this.showFiles
},
showMore(audioFile) {
this.selectedAudioFile = audioFile
this.showAudioFileDataModal = true
}
},
mounted() {

View File

@@ -0,0 +1,118 @@
<template>
<tr>
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td>
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<div class="flex items-center">
<p>{{ file.fileType }}</p>
</div>
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
},
inModal: Boolean
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.file.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
},
contextMenuItems() {
const items = []
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
// Currently not showing this option in the Files tab modal
if (this.userIsAdmin && this.file.audioFile && !this.inModal) {
items.push({
text: this.$strings.LabelMoreInfo,
action: 'more'
})
}
return items
}
},
methods: {
contextMenuAction(action) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'more') {
this.$emit('showMore', this.file.audioFile)
}
},
deleteLibraryFile() {
const payload = {
message: 'This will delete the file from your file system. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.file.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
}
},
mounted() {}
}
</script>

View File

@@ -5,9 +5,8 @@
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<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>
<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>
@@ -21,41 +20,20 @@
<tr>
<th class="w-10">#</th>
<th class="text-left">{{ $strings.LabelFilename }}</th>
<th class="text-left w-20">{{ $strings.LabelSize }}</th>
<th class="text-left w-20">{{ $strings.LabelDuration }}</th>
<th v-if="userCanDownload" class="text-center w-20">{{ $strings.LabelDownload }}</th>
<th v-if="showExperimentalFeatures" class="text-center w-20">
<div class="flex items-center">
<p>Tone</p>
<ui-tooltip text="Experimental feature for testing Tone library metadata scan results. Results logged in browser console." class="ml-2 w-2" direction="left">
<span class="material-icons-outlined text-sm">information</span>
</ui-tooltip>
</div>
</th>
<th v-if="!showFullPath" class="text-left w-20 hidden lg:table-cell">{{ $strings.LabelCodec }}</th>
<th v-if="!showFullPath" class="text-left w-20 hidden xl:table-cell">{{ $strings.LabelBitrate }}</th>
<th class="text-left w-20 hidden md:table-cell">{{ $strings.LabelSize }}</th>
<th class="text-left w-20 hidden sm:table-cell">{{ $strings.LabelDuration }}</th>
<th class="text-center w-16"></th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.metadata.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text pt-1">download</span></a>
</td>
<td v-if="showExperimentalFeatures" class="text-center">
<ui-icon-btn borderless :loading="toneProbing" icon="search" @click="toneProbe(track.index)" />
</td>
</tr>
<tables-audio-tracks-table-row :key="track.index" :track="track" :library-item-id="libraryItemId" :showFullPath="showFullPath" @showMore="showMore" />
</template>
</table>
</div>
</transition>
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
</div>
</template>
@@ -77,47 +55,31 @@ export default {
return {
showTracks: false,
showFullPath: false,
toneProbing: false
selectedAudioFile: null,
showAudioFileDataModal: false
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
}
},
methods: {
clickBar() {
this.showTracks = !this.showTracks
},
toneProbe(index) {
this.toneProbing = true
this.$axios
.$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`)
.then((data) => {
console.log('Tone probe data', data)
if (data.error) {
this.$toast.error('Tone probe error: ' + data.error)
} else {
this.$toast.success('Tone probe successful! Check browser console')
}
})
.catch((error) => {
console.error('Failed to tone probe', error)
this.$toast.error('Tone probe failed')
})
.finally(() => {
this.toneProbing = false
})
showMore(audioFile) {
this.selectedAudioFile = audioFile
this.showAudioFileDataModal = true
}
},
mounted() {}

View File

@@ -12,6 +12,7 @@
<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>

View File

@@ -54,7 +54,7 @@ export default {
quickMatchingEpisodes: false,
search: null,
searchTimeout: null,
searchText: null,
searchText: null
}
},
watch: {
@@ -139,19 +139,25 @@ export default {
return episodeProgress && !episodeProgress.isFinished
})
.sort((a, b) => {
if (this.sortDesc) {
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
let aValue = a[this.sortKey]
let bValue = b[this.sortKey]
// Sort episodes with no pub date as the oldest
if (this.sortKey === 'publishedAt') {
if (!aValue) aValue = Number.MAX_VALUE
if (!bValue) bValue = Number.MAX_VALUE
}
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
if (this.sortDesc) {
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
}
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
})
},
episodesList() {
return this.episodesSorted.filter((episode) => {
if (!this.searchText) return true
return (
(episode.title && episode.title.toLowerCase().includes(this.searchText)) ||
(episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
)
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
})
},
selectedIsFinished() {

View File

@@ -4,7 +4,7 @@
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
</label>
</template>

View File

@@ -1,11 +1,13 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
</slot>
<transition name="menu">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm" :style="{ width: menuWidth }">
<template v-for="(item, index) in items">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p>
@@ -27,6 +29,10 @@ export default {
iconClass: {
type: String,
default: ''
},
menuWidth: {
type: String,
default: '192px'
}
},
data() {

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative">
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :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" 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" />
<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>
@@ -32,7 +32,9 @@ export default {
noSpinner: Boolean,
textCenter: Boolean,
clearable: Boolean,
inputId: String
inputId: String,
step: [String, Number],
min: [String, Number]
},
data() {
return {

View File

@@ -0,0 +1,70 @@
<template>
<ui-tooltip :text="$strings.LabelAbridged" direction="top">
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
<path
fill="white"
d="M 89.00,40.12
C 89.00,40.12 127.00,40.12 127.00,40.12
127.00,40.12 198.00,40.12 198.00,40.12
198.00,40.12 416.00,40.12 416.00,40.12
446.58,40.05 472.95,66.42 473.00,97.00
473.00,97.00 473.00,303.00 473.00,303.00
473.00,303.00 473.00,418.00 473.00,418.00
472.65,447.55 445.06,472.95 416.00,473.00
416.00,473.00 210.00,473.00 210.00,473.00
210.00,473.00 95.00,473.00 95.00,473.00
65.45,472.65 40.05,445.06 40.00,416.00
40.00,416.00 40.00,136.00 40.00,136.00
40.00,136.00 40.00,109.00 40.00,109.00
40.00,109.00 40.00,96.00 40.00,96.00
40.07,81.58 46.89,67.14 57.01,57.01
61.17,52.86 64.86,50.13 70.00,47.31
77.25,43.33 81.02,42.18 89.00,40.12 Z
M 372.00,392.00
C 372.00,392.00 364.02,364.00 364.02,364.00
364.02,364.00 350.72,319.00 350.72,319.00
350.72,319.00 310.42,183.00 310.42,183.00
310.42,183.00 296.86,137.00 296.86,137.00
296.86,137.00 291.30,121.99 291.30,121.99
291.30,121.99 284.00,121.00 284.00,121.00
284.00,121.00 230.00,121.00 230.00,121.00
230.00,121.00 222.51,122.02 222.51,122.02
222.51,122.02 216.86,137.00 216.86,137.00
216.86,137.00 203.28,183.00 203.28,183.00
203.28,183.00 163.28,318.00 163.28,318.00
163.28,318.00 148.71,367.00 148.71,367.00
148.71,367.00 142.00,392.00 142.00,392.00
142.00,392.00 183.00,392.00 183.00,392.00
183.00,392.00 190.86,390.43 190.86,390.43
190.86,390.43 195.86,375.00 195.86,375.00
195.86,375.00 206.00,338.00 206.00,338.00
206.00,338.00 293.00,338.00 293.00,338.00
295.64,338.01 299.26,337.65 301.30,339.60
303.23,341.43 304.80,348.22 305.58,351.00
305.58,351.00 313.00,378.00 313.00,378.00
316.91,391.63 315.20,391.98 325.00,392.00
325.00,392.00 372.00,392.00 372.00,392.00 Z
M 254.00,170.00
C 254.00,170.00 256.00,170.00 256.00,170.00
256.00,170.00 263.12,197.00 263.12,197.00
263.12,197.00 282.88,268.00 282.88,268.00
282.88,268.00 290.00,296.00 290.00,296.00
290.00,296.00 219.00,296.00 219.00,296.00
219.00,296.00 230.58,253.00 230.58,253.00
230.58,253.00 254.00,170.00 254.00,170.00 Z"
/>
</svg>
</ui-tooltip>
</template>
<script>
export default {
props: {},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -16,7 +16,7 @@
</div>
</div>
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
</div>
</template>
@@ -34,6 +34,12 @@ export default {
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 = []

View File

@@ -50,7 +50,7 @@
</div>
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/2 px-1">
<div class="w-full md:w-1/4 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" />
</div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
@@ -61,6 +61,11 @@
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center">
<ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
</div>
</form>
</div>
@@ -89,7 +94,8 @@ export default {
isbn: null,
asin: null,
genres: [],
explicit: false
explicit: false,
abridged: false
},
newTags: []
}
@@ -271,6 +277,7 @@ export default {
this.details.isbn = this.mediaMetadata.isbn || null
this.details.asin = this.mediaMetadata.asin || null
this.details.explicit = !!this.mediaMetadata.explicit
this.details.abridged = !!this.mediaMetadata.abridged
this.newTags = [...(this.media.tags || [])]
},
submitForm() {

View File

@@ -1,6 +1,40 @@
<template>
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
<span class="material-icons ml-1" style="font-size: 0.8rem">explicit</span>
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
<path
fill="white"
d="M 89.00,40.12
C 89.00,40.12 127.00,40.12 127.00,40.12
127.00,40.12 198.00,40.12 198.00,40.12
198.00,40.12 416.00,40.12 416.00,40.12
446.58,40.05 472.95,66.42 473.00,97.00
473.00,97.00 473.00,303.00 473.00,303.00
473.00,303.00 473.00,418.00 473.00,418.00
472.65,447.55 445.06,472.95 416.00,473.00
416.00,473.00 210.00,473.00 210.00,473.00
210.00,473.00 95.00,473.00 95.00,473.00
65.45,472.65 40.05,445.06 40.00,416.00
40.00,416.00 40.00,136.00 40.00,136.00
40.00,136.00 40.00,109.00 40.00,109.00
40.00,109.00 40.00,96.00 40.00,96.00
40.07,81.58 46.89,67.14 57.01,57.01
61.17,52.86 64.86,50.13 70.00,47.31
77.25,43.33 81.02,42.18 89.00,40.12 Z
M 337.00,121.00
C 337.00,121.00 175.00,121.00 175.00,121.00
175.00,121.00 175.00,392.00 175.00,392.00
175.00,392.00 337.00,392.00 337.00,392.00
337.00,392.00 337.00,349.00 337.00,349.00
337.00,349.00 226.00,349.00 226.00,349.00
226.00,349.00 226.00,274.00 226.00,274.00
226.00,274.00 332.00,274.00 332.00,274.00
332.00,274.00 332.00,232.00 332.00,232.00
332.00,232.00 226.00,232.00 226.00,232.00
226.00,232.00 226.00,164.00 226.00,164.00
226.00,164.00 337.00,164.00 337.00,164.00
337.00,164.00 337.00,121.00 337.00,121.00 Z"
/>
</svg>
</ui-tooltip>
</template>

View File

@@ -0,0 +1,100 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<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' }">
<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" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
cardHeight() {
return this.height
},
cardWidth() {
return this.cardHeight * 1.5
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
}
},
methods: {
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -73,6 +73,8 @@ export default {
return `/library/${task.data.libraryId}/podcast/download-queue`
case 'encode-m4b':
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
case 'embed-metadata':
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
default:
return ''
}

View File

@@ -278,6 +278,13 @@ export default {
console.log('Task finished', task)
this.$store.commit('tasks/addUpdateTask', task)
},
metadataEmbedQueueUpdate(data) {
if (data.queued) {
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
} else {
this.$store.commit('tasks/removeQueuedEmbedLId', data.libraryItemId)
}
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user)
@@ -292,8 +299,17 @@ export default {
userStreamUpdate(user) {
this.$store.commit('users/updateUserOnline', user)
},
userSessionClosed(sessionId) {
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
},
userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload)
if (payload.data) {
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId)) {
// TODO: Update currently open session if being played from another device
}
}
},
collectionAdded(collection) {
if (this.currentLibraryId !== collection.libraryId) return
@@ -398,6 +414,7 @@ export default {
this.socket.on('user_online', this.userOnline)
this.socket.on('user_offline', this.userOffline)
this.socket.on('user_stream_update', this.userStreamUpdate)
this.socket.on('user_session_closed', this.userSessionClosed)
this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
// Collection Listeners
@@ -418,6 +435,7 @@ export default {
// Task Listeners
this.socket.on('task_started', this.taskStarted)
this.socket.on('task_finished', this.taskFinished)
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
this.socket.on('backup_applied', this.backupApplied)
@@ -531,12 +549,18 @@ export default {
},
loadTasks() {
this.$axios
.$get('/api/tasks')
.$get('/api/tasks?include=queue')
.then((payload) => {
console.log('Fetched tasks', payload)
if (payload.tasks) {
this.$store.commit('tasks/setTasks', payload.tasks)
}
if (payload.queuedTaskData?.embedMetadata?.length) {
this.$store.commit(
'tasks/setQueuedEmbedLIds',
payload.queuedTaskData.embedMetadata.map((td) => td.libraryItemId)
)
}
})
.catch((error) => {
console.error('Failed to load tasks', error)
@@ -545,6 +569,7 @@ export default {
changeLanguage(code) {
console.log('Changed lang', code)
this.currentLang = code
document.documentElement.lang = code
}
},
beforeMount() {
@@ -569,6 +594,11 @@ export default {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
// Set lang on HTML tag
if (this.$languageCodes?.current) {
document.documentElement.lang = this.$languageCodes.current
}
},
beforeDestroy() {
this.$eventBus.$off('change-lang', this.changeLanguage)

View File

@@ -0,0 +1,55 @@
const Path = require('path')
const { promises: { readFile, writeFile } } = require('fs')
const { glob } = require('glob')
/**
* Rewrite PWA manifest paths to include router base path
* @nuxtjs/pwa module does not support a dynamic router base path,
* so we have to rewrite the manifest paths manually
*
* @see https://github.com/nuxt/nuxt/pull/8520
* @see https://github.com/nuxt-community/pwa-module/issues/435
*/
export default function rewritePwaManifest () {
this.nuxt.hook('ready', async ({ options }) => {
// Do not run on build, this is where the manifest is generated
if (options._build) return
const routerBasePath = options.router.base
const clientDir = Path.join(options.buildDir, 'dist/client')
let rewritten = false
// Find manifest file generated to the build directory
const manifestPaths = await glob('manifest.*.json', { cwd: clientDir })
if (manifestPaths.length === 0) {
console.warn(`[PWA] No manifest not found under ${clientDir}/manifest.*.json`)
return
}
// Rewrite manifest paths for all found manifest files
for (const manifestPath of manifestPaths) {
const manifestJson = await readFile(Path.join(clientDir, manifestPath), 'utf8')
const manifest = JSON.parse(manifestJson)
const currentBasePath = manifest.start_url.split('?')[0]
if (currentBasePath !== routerBasePath) {
// Rewrite start_url and icons paths
manifest.start_url = `${routerBasePath || '/'}${manifest.start_url.slice(currentBasePath.length)}`
for (const icon of manifest.icons) {
icon.src = currentBasePath.startsWith('.')
? `${routerBasePath}${icon.src}` // Initially, the start_url is `./`
: `${routerBasePath}/${icon.src.slice(currentBasePath.length)}`
}
// Update manifest file
await writeFile(Path.join(clientDir, manifestPath), JSON.stringify(manifest), 'utf8')
rewritten = true
}
}
if (rewritten) {
console.info('[PWA] Manifest paths rewritten')
}
})
}

View File

@@ -1,9 +1,9 @@
const pkg = require('./package.json')
module.exports = {
module.exports = ({ command }) => ({
// Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode
ssr: false,
target: 'static',
target: 'server',
dev: process.env.NODE_ENV !== 'production',
env: {
serverUrl: process.env.NODE_ENV === 'production' ? process.env.ROUTER_BASE_PATH || '' : 'http://localhost:3333',
@@ -26,19 +26,12 @@ module.exports = {
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: '' }
],
script: [
{
src: (process.env.ROUTER_BASE_PATH || '') + '/libs/sortable.js'
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
]
},
router: {
base: process.env.ROUTER_BASE_PATH || ''
// We must specify `./` during build to support dynamic router base paths (https://github.com/nuxt/nuxt/issues/10088)
base: command == 'build' ? './' : process.env.ROUTER_BASE_PATH || ''
},
// Global CSS: https://go.nuxtjs.dev/config-css
@@ -46,10 +39,14 @@ module.exports = {
'@/assets/app.css'
],
favicon: '/favicon.ico',
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [
'@/plugins/constants.js',
'@/plugins/init.client.js',
'@/plugins/sortable.js',
'@/plugins/favicon.js',
'@/plugins/axios.js',
'@/plugins/toast.js',
'@/plugins/utils.js',
@@ -71,13 +68,21 @@ module.exports = {
modules: [
'nuxt-socket-io',
'@nuxtjs/axios',
'@nuxtjs/proxy'
'@nuxtjs/proxy',
'@/modules/rewritePwaManifest.js'
],
proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' + process.env : '/' },
[`${process.env.ROUTER_BASE_PATH || ''}/dev/`]: {
target: `http://localhost:3333${process.env.ROUTER_BASE_PATH || ''}`,
pathRewrite: { [`^${process.env.ROUTER_BASE_PATH || ''}/dev/`]: process.env.ROUTER_BASE_PATH || '' }
},
[`${process.env.ROUTER_BASE_PATH || ''}/ebook/`]: {
target: (process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '') + `${process.env.ROUTER_BASE_PATH || ''}/`
},
[`${process.env.ROUTER_BASE_PATH || ''}/s/`]: {
target: (process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '') + `${process.env.ROUTER_BASE_PATH || ''}/`
}
},
io: {
@@ -106,17 +111,18 @@ module.exports = {
nativeUI: true
},
manifest: {
publicPath: `${(process.env.ROUTER_BASE_PATH || '')}_nuxt`,
name: 'Audiobookshelf',
short_name: 'Audiobookshelf',
display: 'standalone',
background_color: '#373838',
icons: [
{
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
src: '/icon.svg',
sizes: "any"
},
{
src: (process.env.ROUTER_BASE_PATH || '') + '/icon64.png',
src: '/icon64.png',
type: "image/png",
sizes: "64x64"
}
@@ -146,7 +152,7 @@ module.exports = {
}
},
server: {
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
port: process.env.CLIENT_PORT || 3000,
host: '0.0.0.0'
},
@@ -157,4 +163,4 @@ module.exports = {
* See: [Issue tracker](https://github.com/nuxt-community/tailwindcss-module/issues/480)
*/
devServerHandlers: [],
}
})

636
client/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.2.17",
"version": "2.2.19",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.2.17",
"version": "2.2.19",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
@@ -15,6 +15,7 @@
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0",
"epubjs": "^0.3.88",
"glob": "^10.2.2",
"hls.js": "^1.0.7",
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.8",
@@ -1860,6 +1861,25 @@
"node": ">=10"
}
},
"node_modules/@npmcli/move-file/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@npmcli/move-file/node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
@@ -1937,6 +1957,25 @@
"upath": "^2.0.1"
}
},
"node_modules/@nuxt/builder/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@nuxt/cli": {
"version": "2.15.8",
"resolved": "https://registry.npmjs.org/@nuxt/cli/-/cli-2.15.8.tgz",
@@ -1994,6 +2033,25 @@
"consola": "*"
}
},
"node_modules/@nuxt/components/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@nuxt/config": {
"version": "2.15.8",
"resolved": "https://registry.npmjs.org/@nuxt/config/-/config-2.15.8.tgz",
@@ -2445,6 +2503,25 @@
"webpack": "^4.27.0 || ^5.0.0"
}
},
"node_modules/@nuxt/webpack/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@nuxt/webpack/node_modules/icss-utils": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz",
@@ -2761,6 +2838,26 @@
"node": ">=12"
}
},
"node_modules/@nuxtjs/tailwindcss/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@nuxtjs/tailwindcss/node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2973,6 +3070,15 @@
"stack-trace": "0.0.10"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.21",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
@@ -4588,6 +4694,25 @@
"node": ">= 10"
}
},
"node_modules/cacache/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/cacache/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -4977,7 +5102,6 @@
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
@@ -7084,6 +7208,32 @@
"node": ">=0.10.0"
}
},
"node_modules/foreground-child": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
"integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
"dependencies": {
"cross-spawn": "^7.0.0",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/foreground-child/node_modules/signal-exit": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.1.tgz",
"integrity": "sha512-uUWsN4aOxJAS8KOuf3QMyFtgm1pkb6I+KRZbRF/ghdf5T7sM+B1lLLzPDxswUjkmHyxQAVzEgG35E3NzDM9GVw==",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fraction.js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
@@ -7317,19 +7467,21 @@
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.2.2.tgz",
"integrity": "sha512-Xsa0BcxIC6th9UwNjZkhrMtNo/MnyRL8jGCP+uEwhA5oFOCY1f2s1/oNKY47xQ0Bg5nkjsfAEIej1VeH62bDDQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"foreground-child": "^3.1.0",
"jackspeak": "^2.0.3",
"minimatch": "^9.0.0",
"minipass": "^5.0.0",
"path-scurry": "^1.7.0"
},
"bin": {
"glob": "dist/cjs/src/bin.js"
},
"engines": {
"node": "*"
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -7346,6 +7498,36 @@
"node": ">= 6"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz",
"integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob/node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
@@ -8707,6 +8889,23 @@
"node": ">=0.10.0"
}
},
"node_modules/jackspeak": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.1.1.tgz",
"integrity": "sha512-juf9stUEwUaILepraGOWIJTLwg48bUnBmRqd2ln2Os1sW987zeoj/hzhbvRB95oMuS2ZTpjULmdwHNX4rzZIZw==",
"dependencies": {
"cliui": "^8.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jest-worker": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
@@ -9917,6 +10116,25 @@
"tiny-emitter": "^2.1.0"
}
},
"node_modules/nuxt-socket-io/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -10380,6 +10598,37 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"node_modules/path-scurry": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.7.0.tgz",
"integrity": "sha512-UkZUeDjczjYRE495+9thsgcVgsaCPkaw80slmfVFgllxY+IO8ubTsOpFVjDPROBqJdHfVPUFRHPBV/WciOVfWg==",
"dependencies": {
"lru-cache": "^9.0.0",
"minipass": "^5.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.1.tgz",
"integrity": "sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A==",
"engines": {
"node": "14 || >=16.14"
}
},
"node_modules/path-scurry/node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
@@ -13291,6 +13540,26 @@
"node": ">= 12"
}
},
"node_modules/purgecss/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
@@ -13674,6 +13943,26 @@
"node": ">=10"
}
},
"node_modules/replace-in-file/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -13826,6 +14115,25 @@
"rimraf": "bin.js"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ripemd160": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
@@ -14709,6 +15017,25 @@
"webpack": "^3.0.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/style-resources-loader/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/style-resources-loader/node_modules/schema-utils": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
@@ -16680,6 +17007,25 @@
"node": ">=6"
}
},
"node_modules/webpack/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/webpack/node_modules/is-accessor-descriptor": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
@@ -18641,6 +18987,19 @@
"rimraf": "^3.0.2"
},
"dependencies": {
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
@@ -18706,6 +19065,21 @@
"pify": "^5.0.0",
"serialize-javascript": "^5.0.1",
"upath": "^2.0.1"
},
"dependencies": {
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"@nuxt/cli": {
@@ -18759,6 +19133,21 @@
"semver": "^7.3.5",
"upath": "^2.0.1",
"vue-template-compiler": "^2.6.14"
},
"dependencies": {
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"@nuxt/config": {
@@ -19173,6 +19562,19 @@
"semver": "^7.3.2"
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"icss-utils": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz",
@@ -19434,6 +19836,20 @@
"universalify": "^2.0.0"
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -19581,6 +19997,12 @@
"stack-trace": "0.0.10"
}
},
"@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"optional": true
},
"@polka/url": {
"version": "1.0.0-next.21",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
@@ -20920,6 +21342,19 @@
"unique-filename": "^1.1.1"
},
"dependencies": {
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -21192,7 +21627,6 @@
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"requires": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
@@ -22863,6 +23297,22 @@
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
"integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="
},
"foreground-child": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
"integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
"requires": {
"cross-spawn": "^7.0.0",
"signal-exit": "^4.0.1"
},
"dependencies": {
"signal-exit": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.1.tgz",
"integrity": "sha512-uUWsN4aOxJAS8KOuf3QMyFtgm1pkb6I+KRZbRF/ghdf5T7sM+B1lLLzPDxswUjkmHyxQAVzEgG35E3NzDM9GVw=="
}
}
},
"fraction.js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
@@ -23034,16 +23484,38 @@
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.2.2.tgz",
"integrity": "sha512-Xsa0BcxIC6th9UwNjZkhrMtNo/MnyRL8jGCP+uEwhA5oFOCY1f2s1/oNKY47xQ0Bg5nkjsfAEIej1VeH62bDDQ==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"foreground-child": "^3.1.0",
"jackspeak": "^2.0.3",
"minimatch": "^9.0.0",
"minipass": "^5.0.0",
"path-scurry": "^1.7.0"
},
"dependencies": {
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"requires": {
"balanced-match": "^1.0.0"
}
},
"minimatch": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.0.tgz",
"integrity": "sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==",
"requires": {
"brace-expansion": "^2.0.1"
}
},
"minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="
}
}
},
"glob-parent": {
@@ -24044,6 +24516,15 @@
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="
},
"jackspeak": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.1.1.tgz",
"integrity": "sha512-juf9stUEwUaILepraGOWIJTLwg48bUnBmRqd2ln2Os1sW987zeoj/hzhbvRB95oMuS2ZTpjULmdwHNX4rzZIZw==",
"requires": {
"@pkgjs/parseargs": "^0.11.0",
"cliui": "^8.0.1"
}
},
"jest-worker": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
@@ -25033,6 +25514,21 @@
"socket.io": "^4.1.1",
"socket.io-client": "^4.1.1",
"tiny-emitter": "^2.1.0"
},
"dependencies": {
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"object-assign": {
@@ -25387,6 +25883,27 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"path-scurry": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.7.0.tgz",
"integrity": "sha512-UkZUeDjczjYRE495+9thsgcVgsaCPkaw80slmfVFgllxY+IO8ubTsOpFVjDPROBqJdHfVPUFRHPBV/WciOVfWg==",
"requires": {
"lru-cache": "^9.0.0",
"minipass": "^5.0.0"
},
"dependencies": {
"lru-cache": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.1.tgz",
"integrity": "sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A=="
},
"minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="
}
}
},
"path-to-regexp": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
@@ -27596,6 +28113,20 @@
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"dev": true
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
@@ -27891,6 +28422,22 @@
"chalk": "^4.1.2",
"glob": "^7.2.0",
"yargs": "^17.2.1"
},
"dependencies": {
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"require-directory": {
@@ -28007,6 +28554,21 @@
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"requires": {
"glob": "^7.1.3"
},
"dependencies": {
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"ripemd160": {
@@ -28708,6 +29270,19 @@
"tslib": "^2.3.1"
},
"dependencies": {
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"schema-utils": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
@@ -30148,6 +30723,19 @@
"locate-path": "^3.0.0"
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"is-accessor-descriptor": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
@@ -30753,4 +31341,4 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.2.17",
"version": "2.2.19",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {
@@ -19,6 +19,7 @@
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0",
"epubjs": "^0.3.88",
"glob": "^10.2.2",
"hls.js": "^1.0.7",
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.8",
@@ -37,4 +38,4 @@
"postcss": "^8.3.6",
"tailwindcss": "^3.1.4"
}
}
}

View File

@@ -21,13 +21,14 @@
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
<div class="w-32 hidden lg:block" />
</div>
<div class="flex items-center mb-3 py-1">
<div class="flex items-center mb-3 py-1 -mx-1">
<div class="w-12 hidden lg:block" />
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<ui-btn v-if="chapters.length" color="primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" class="mx-1" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<div class="flex-grow" />
<ui-btn v-if="hasChanges" small class="mx-2" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="success" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-32 hidden lg:block" />
</div>
@@ -41,7 +42,7 @@
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
<div class="flex-grow" />
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">close</span>
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">expand_less</span>
</div>
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
</div>
@@ -329,6 +330,7 @@ export default {
chap.start = Math.max(0, chap.start + amount)
}
}
this.checkChapters()
},
editItem() {
this.$store.commit('showEditModal', this.libraryItem)
@@ -587,6 +589,45 @@ export default {
]
}
this.checkChapters()
},
removeAllChaptersClick() {
const payload = {
message: this.$strings.MessageConfirmRemoveAllChapters,
callback: (confirmed) => {
if (confirmed) {
this.removeAllChapters()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
removeAllChapters() {
this.saving = true
const payload = {
chapters: []
}
this.$axios
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
.then((data) => {
if (data.updated) {
this.$toast.success('Chapters removed')
if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
this.$router.push(`/item/${this.libraryItem.id}`)
}
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
})
.catch((error) => {
console.error('Failed to remove chapters', error)
this.$toast.error('Failed to remove chapters')
})
.finally(() => {
this.saving = false
})
}
},
mounted() {

View File

@@ -62,14 +62,20 @@
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- metadata embed action buttons -->
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div>
<!-- m4b embed action buttons -->
<div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
@@ -83,6 +89,7 @@
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div>
<!-- advanced encoding options -->
<div v-if="isM4BTool" class="overflow-hidden">
<transition name="slide">
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
@@ -191,6 +198,7 @@ export default {
cnosole.error('No audio files')
return redirect('/?error=no audio files')
}
return {
libraryItem
}
@@ -200,7 +208,6 @@ export default {
processing: false,
audiofilesEncoding: {},
audiofilesFinished: {},
isFinished: false,
toneObject: null,
selectedTool: 'embed',
isCancelingEncode: false,
@@ -272,11 +279,28 @@ export default {
isTaskFinished() {
return this.task && this.task.isFinished
},
tasks() {
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
},
embedTask() {
return this.tasks.find((t) => t.action === 'embed-metadata')
},
encodeTask() {
return this.tasks.find((t) => t.action === 'encode-m4b')
},
task() {
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId)
if (this.isEmbedTool) return this.embedTask
else if (this.isM4BTool) return this.encodeTask
return null
},
taskRunning() {
return this.task && !this.task.isFinished
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []
},
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
}
},
methods: {
@@ -322,7 +346,7 @@ export default {
.catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
this.processing = true
this.processing = false
})
},
embedClick() {
@@ -349,24 +373,6 @@ export default {
this.processing = false
})
},
audioMetadataStarted(data) {
console.log('audio metadata started', data)
if (data.libraryItemId !== this.libraryItemId) return
this.audiofilesFinished = {}
},
audioMetadataFinished(data) {
console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return
this.processing = false
this.audiofilesEncoding = {}
if (data.failed) {
this.$toast.error(data.error)
} else {
this.isFinished = true
this.$toast.success('Audio file metadata updated')
}
},
audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, true)
@@ -412,14 +418,10 @@ export default {
},
mounted() {
this.init()
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
},
beforeDestroy() {
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
}

View File

@@ -52,9 +52,53 @@
</div>
</div>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
<!-- open listening sessions table -->
<p v-if="openListeningSessions.length" class="text-lg mb-4 mt-8">Open Listening Sessions</p>
<div v-if="openListeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr>
<tr v-for="session in openListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
</div>
</app-settings-content>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
</div>
</template>
@@ -81,6 +125,7 @@ export default {
showSessionModal: false,
selectedSession: null,
listeningSessions: [],
openListeningSessions: [],
numPages: 0,
total: 0,
currentPage: 0,
@@ -114,6 +159,9 @@ export default {
}
},
methods: {
closedSession() {
this.loadOpenSessions()
},
removedSession() {
// If on last page and this was the last session then load prev page
if (this.currentPage == this.numPages - 1) {
@@ -222,7 +270,7 @@ export default {
async loadSessions(page) {
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
console.error('Failed to load listening sesions', err)
console.error('Failed to load listening sessions', err)
return null
})
if (!data) {
@@ -236,8 +284,24 @@ export default {
this.listeningSessions = data.sessions
this.userFilter = data.userFilter
},
async loadOpenSessions() {
const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
console.error('Failed to load open sessions', err)
return null
})
if (!data) {
this.$toast.error('Failed to load open sessions')
return
}
this.openListeningSessions = (data.sessions || []).map((s) => {
s.open = true
return s
})
},
init() {
this.loadSessions(0)
this.loadOpenSessions()
}
},
mounted() {

View File

@@ -1,8 +1,8 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8">
<div class="flex flex-col md:flex-row max-w-6xl mx-auto">
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px">
<div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
<div class="flex flex-col lg:flex-row max-w-6xl mx-auto">
<div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px">
<div class="relative" style="height: fit-content">
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
@@ -21,13 +21,14 @@
</div>
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="flex-grow px-2 py-6 lg:py-0 md:px-10">
<div class="flex justify-center">
<div class="mb-4">
<h1 class="text-2xl md:text-3xl font-semibold">
<div class="flex items-center">
{{ title }}
<widgets-explicit-indicator :explicit="isExplicit" />
<widgets-abridged-indicator v-if="isAbridged" />
</div>
</h1>
@@ -156,7 +157,7 @@
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
<p v-else class="text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
<p v-if="progressPercent < 1 && !useEBookProgress" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
<p class="text-gray-400 text-xs pt-1">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
@@ -193,27 +194,18 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip>
<ui-tooltip v-if="showCollectionsButton" :text="$strings.LabelCollections" direction="top">
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast && tracks.length" :text="$strings.LabelYourPlaylists" direction="top">
<ui-icon-btn icon="playlist_add" class="mx-0.5" outlined @click="playlistsClick" />
</ui-tooltip>
<!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip>
<ui-tooltip v-if="bookmarks.length" :text="$strings.LabelYourBookmarks" direction="top">
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
</ui-tooltip>
<!-- RSS feed -->
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
</ui-tooltip>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons">more_horiz</span>
</button>
</template>
</ui-context-menu-dropdown>
</div>
<div class="my-4 max-w-2xl">
@@ -232,7 +224,7 @@
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item="libraryItem" class="mt-6" />
</div>
</div>
</div>
@@ -276,6 +268,12 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
@@ -288,9 +286,6 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isFile() {
return this.libraryItem.isFile
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
@@ -300,6 +295,9 @@ export default {
isDeveloperMode() {
return this.$store.state.developerMode
},
isFile() {
return this.libraryItem.isFile
},
isBook() {
return this.libraryItem.mediaType === 'book'
},
@@ -319,7 +317,10 @@ export default {
return this.libraryItem.isInvalid
},
isExplicit() {
return this.mediaMetadata.explicit || false
return !!this.mediaMetadata.explicit
},
isAbridged() {
return !!this.mediaMetadata.abridged
},
invalidAudioFiles() {
if (!this.isBook) return []
@@ -471,7 +472,12 @@ export default {
const duration = this.userMediaProgress.duration || this.duration
return duration - this.userMediaProgress.currentTime
},
useEBookProgress() {
if (!this.userMediaProgress || this.userMediaProgress.progress) return false
return this.userMediaProgress.ebookProgress > 0
},
progressPercent() {
if (this.useEBookProgress) return Math.max(Math.min(1, this.userMediaProgress.ebookProgress), 0)
return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0
},
userProgressStartedAt() {
@@ -510,12 +516,56 @@ export default {
},
showCollectionsButton() {
return this.isBook && this.userCanUpdate
},
contextMenuItems() {
const items = []
if (this.showCollectionsButton) {
items.push({
text: this.$strings.LabelCollections,
action: 'collections'
})
}
if (!this.isPodcast && this.tracks.length) {
items.push({
text: this.$strings.LabelYourPlaylists,
action: 'playlists'
})
}
if (this.bookmarks.length) {
items.push({
text: this.$strings.LabelYourBookmarks,
action: 'bookmarks'
})
}
if (this.showRssFeedBtn) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
action: 'rss-feeds'
})
}
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
}
},
methods: {
clickBookmarksBtn() {
this.showBookmarksModal = true
},
selectBookmark(bookmark) {
if (!bookmark) return
if (this.isStreaming) {
@@ -691,14 +741,6 @@ export default {
})
}
},
collectionsClick() {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowCollectionsModal', true)
},
playlistsClick() {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
this.$store.commit('globals/setShowPlaylistsModal', true)
},
clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.libraryItemId,
@@ -756,6 +798,58 @@ export default {
}
this.$store.commit('addItemToQueue', queueItem)
}
},
downloadLibraryItem() {
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
},
deleteLibraryItem() {
const payload = {
message: 'This will delete the library item from the database and your file system. Are you sure?',
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error',
checkboxDefaultValue: true,
callback: (confirmed, hardDelete) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
this.$toast.success('Item deleted')
this.$router.replace(`/library/${this.libraryId}`)
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction(action) {
if (action === 'collections') {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowCollectionsModal', true)
} else if (action === 'playlists') {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
this.$store.commit('globals/setShowPlaylistsModal', true)
} else if (action === 'bookmarks') {
this.showBookmarksModal = true
} else if (action === 'rss-feeds') {
this.clickRSSFeed()
} else if (action === 'download') {
this.downloadLibraryItem()
} else if (action === 'delete') {
this.deleteLibraryItem()
}
}
},
mounted() {

View File

@@ -11,27 +11,27 @@
<script>
export default {
async asyncData({ store, params, redirect, query, app }) {
var libraryId = params.library
var library = await store.dispatch('libraries/fetch', libraryId)
const libraryId = params.library
const library = await store.dispatch('libraries/fetch', libraryId)
if (!library) {
return redirect('/oops?message=Library not found')
}
var query = query.q
var results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query}`).catch((error) => {
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query.q}`).catch((error) => {
console.error('Failed to search library', error)
return null
})
results = {
podcasts: results && results.podcast ? results.podcast : null,
books: results && results.book ? results.book : null,
authors: results && results.authors.length ? results.authors : null,
series: results && results.series.length ? results.series : null,
tags: results && results.tags.length ? results.tags : null
podcasts: results?.podcast || [],
books: results?.book || [],
authors: results?.authors || [],
series: results?.series || [],
tags: results?.tags || [],
narrators: results?.narrators || []
}
return {
libraryId,
results,
query
query: query.q
}
},
data() {
@@ -55,16 +55,17 @@ export default {
},
methods: {
async search() {
var results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
const results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
console.error('Failed to search library', error)
return null
})
this.results = {
podcasts: results && results.podcast ? results.podcast : null,
books: results && results.book ? results.book : null,
authors: results && results.authors.length ? results.authors : null,
series: results && results.series.length ? results.series : null,
tags: results && results.tags.length ? results.tags : null
podcasts: results?.podcast || [],
books: results?.book || [],
authors: results?.authors || [],
series: results?.series || [],
tags: results?.tags || [],
narrators: results?.narrators || []
}
this.$nextTick(() => {
if (this.$refs.bookshelf) {

View File

@@ -123,7 +123,7 @@ export default class PlayerHandler {
playerError() {
// Switch to HLS stream on error
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
if (!this.isCasting && (this.player instanceof LocalAudioPlayer)) {
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
this.prepare(true)
}
@@ -173,16 +173,30 @@ export default class PlayerHandler {
this.ctx.setBufferTime(buffertime)
}
getDeviceId() {
let deviceId = localStorage.getItem('absDeviceId')
if (!deviceId) {
deviceId = this.ctx.$randomId()
localStorage.setItem('absDeviceId', deviceId)
}
return deviceId
}
async prepare(forceTranscode = false) {
var payload = {
this.currentSessionId = null // Reset session
const payload = {
deviceInfo: {
deviceId: this.getDeviceId()
},
supportedMimeTypes: this.player.playableMimeTypes,
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode,
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
}
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
var session = await this.ctx.$axios.$post(path, payload).catch((error) => {
const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
const session = await this.ctx.$axios.$post(path, payload).catch((error) => {
console.error('Failed to start stream', error)
})
this.prepareSession(session)
@@ -238,12 +252,17 @@ export default class PlayerHandler {
closePlayer() {
console.log('[PlayerHandler] Close Player')
this.sendCloseSession()
this.resetPlayer()
}
resetPlayer() {
if (this.player) {
this.player.destroy()
}
this.player = null
this.playerState = 'IDLE'
this.libraryItem = null
this.currentSessionId = null
this.startTime = 0
this.stopPlayInterval()
}

View File

@@ -16,6 +16,11 @@ export default function ({ $axios, store, $config }) {
config.url = `/dev${config.url}`
console.log('Making request to ' + config.url)
}
console.log($config)
if ($config.routerBasePath) {
config.url = `${$config.routerBasePath}${config.url}`
}
})
$axios.onError(error => {

10
client/plugins/favicon.js Normal file
View File

@@ -0,0 +1,10 @@
export default function ({ $config }) {
const faviconPath = $config.favicon || '/favicon.ico'
const link = document.createElement('link')
link.rel = 'icon'
link.type = 'image/x-icon'
link.href = `${$config.routerBasePath || ''}${faviconPath}`
document.head.appendChild(link)
}

View File

@@ -0,0 +1,7 @@
export default function ({ $config }) {
const script = document.createElement('script')
script.type = 'text/javascript'
script.src = `${$config.routerBasePath || ''}/libs/sortable.js`
document.head.appendChild(script)
}

View File

@@ -1,5 +1,8 @@
import Vue from 'vue'
import cronParser from 'cron-parser'
import { nanoid } from 'nanoid'
Vue.prototype.$randomId = () => nanoid()
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) {

View File

@@ -99,14 +99,14 @@ export const getters = {
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
},
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null) => {
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null, raw = false) => {
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
if (!libraryItemId) return placeholder
var userToken = rootGetters['user/getToken']
if (process.env.NODE_ENV !== 'production') { // Testing
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
}
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
},
getIsBatchSelectingMediaItems: (state) => {
return state.selectedMediaItems.length

View File

@@ -63,6 +63,12 @@ export const state = () => ({
text: 'iTunes',
value: 'itunes'
}
],
coverOnlyProviders: [
{
text: 'AudiobookCovers.com',
value: 'audiobookcovers'
}
]
})

View File

@@ -1,11 +1,12 @@
export const state = () => ({
tasks: []
tasks: [],
queuedEmbedLIds: []
})
export const getters = {
getTaskByLibraryItemId: (state) => (libraryItemId) => {
return state.tasks.find(t => t.data && t.data.libraryItemId === libraryItemId)
getTasksByLibraryItemId: (state) => (libraryItemId) => {
return state.tasks.filter(t => t.data && t.data.libraryItemId === libraryItemId)
}
}
@@ -18,14 +19,31 @@ export const mutations = {
state.tasks = tasks
},
addUpdateTask(state, task) {
var index = state.tasks.findIndex(d => d.id === task.id)
const index = state.tasks.findIndex(d => d.id === task.id)
if (index >= 0) {
state.tasks.splice(index, 1, task)
} else {
// Remove duplicate (only have one library item per action)
state.tasks = state.tasks.filter(_task => {
if (!_task.data?.libraryItemId || _task.action !== task.action) return true
return _task.data.libraryItemId !== task.data.libraryItemId
})
state.tasks.push(task)
}
},
removeTask(state, task) {
state.tasks = state.tasks.filter(d => d.id !== task.id)
},
setQueuedEmbedLIds(state, libraryItemIds) {
state.queuedEmbedLIds = libraryItemIds
},
addQueuedEmbedLId(state, libraryItemId) {
if (!state.queuedEmbedLIds.some(lid => lid === libraryItemId)) {
state.queuedEmbedLIds.push(libraryItemId)
}
},
removeQueuedEmbedLId(state, libraryItemId) {
state.queuedEmbedLIds = state.queuedEmbedLIds.filter(lid => lid !== libraryItemId)
}
}

View File

@@ -20,7 +20,7 @@
"ButtonCreate": "Erstellen",
"ButtonCreateBackup": "Sicherung erstellen",
"ButtonDelete": "Löschen",
"ButtonDownloadQueue": "Queue",
"ButtonDownloadQueue": "Warteschlange",
"ButtonEdit": "Bearbeiten",
"ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten",
@@ -93,9 +93,9 @@
"HeaderCollection": "Sammlungen",
"HeaderCollectionItems": "Sammlungseinträge",
"HeaderCover": "Titelbild",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderCurrentDownloads": "Aktuelle Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderDownloadQueue": "Download Warteschlange",
"HeaderEpisodes": "Episoden",
"HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen",
@@ -142,8 +142,8 @@
"HeaderSettingsGeneral": "Allgemein",
"HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Einschlaf-Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Längste Einträge (h)",
"HeaderStatsLargestItems": "Größte Medien",
"HeaderStatsLongestItems": "Längste Medien (h)",
"HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)",
"HeaderStatsRecentSessions": "Neueste Ereignisse",
"HeaderStatsTop10Authors": "Top 10 Autoren",
@@ -155,11 +155,13 @@
"HeaderUpdateLibrary": "Bibliothek aktualisieren",
"HeaderUsers": "Benutzer",
"HeaderYourStats": "Eigene Statistiken",
"LabelAbridged": "Gekürzt",
"LabelAccountType": "Kontoart",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gast",
"LabelAccountTypeUser": "Benutzer",
"LabelActivity": "Aktivitäten",
"LabelAdded": "Hinzugefügt",
"LabelAddedAt": "Hinzugefügt am",
"LabelAddToCollection": "Zur Sammlung hinzufügen",
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
@@ -167,7 +169,7 @@
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
"LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAlreadyInYourLibrary": "In der Bibliothek vorhanden",
"LabelAppend": "Anhängen",
"LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Vorname Nachname)",
@@ -181,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Zum Schutz vor Fehlkonfigurationen schlagen Sicherungen fehl, wenn sie die konfigurierte Größe überschreiten.",
"LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen",
"LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Bücher",
"LabelChangePassword": "Passwort ändern",
"LabelChannels": "Kanäle",
"LabelChapters": "Chapters",
"LabelChaptersFound": "gefundene Kapitel",
"LabelChapterTitle": "Kapitelüberschrift",
"LabelClosePlayer": "Player schließen",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Serien zusammenfassen",
"LabelCollections": "Sammlungen",
"LabelComplete": "Vollständig",
@@ -195,10 +201,10 @@
"LabelCover": "Titelbild",
"LabelCoverImageURL": "URL des Titelbildes",
"LabelCreatedAt": "Erstellt am",
"LabelCronExpression": "Cron Ausdruck",
"LabelCronExpression": "Cron-Ausdruck",
"LabelCurrent": "Aktuell",
"LabelCurrently": "Aktuell:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck",
"LabelDatetime": "Datum & Uhrzeit",
"LabelDescription": "Beschreibung",
"LabelDeselectAll": "Alles abwählen",
@@ -211,12 +217,13 @@
"LabelDuration": "Laufzeit",
"LabelDurationFound": "Gefundene Laufzeit:",
"LabelEdit": "Bearbeiten",
"LabelEmbeddedCover": "Eingebettetes Cover",
"LabelEnable": "Aktivieren",
"LabelEnd": "Ende",
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episodentitel",
"LabelEpisodeType": "Episodentyp",
"LabelExample": "Example",
"LabelExample": "Beispiel",
"LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelFeedURL": "Feed URL",
"LabelFile": "Datei",
@@ -228,6 +235,7 @@
"LabelFinished": "beendet",
"LabelFolder": "Ordner",
"LabelFolders": "Verzeichnisse",
"LabelFormat": "Format",
"LabelGenre": "Kategorie",
"LabelGenres": "Kategorien",
"LabelHardDeleteFile": "Datei dauerhaft löschen",
@@ -246,9 +254,12 @@
"LabelIntervalEveryDay": "Jeden Tag",
"LabelIntervalEveryHour": "Jede Stunde",
"LabelInvalidParts": "Ungültige Teile",
"LabelInvert": "Invert",
"LabelItem": "Medium",
"LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
"LabelLastBookAdded": "Zuletzt hinzugefügtes Medium",
"LabelLastBookUpdated": "Zuletzt aktualisiertes Medium",
"LabelLastSeen": "Zuletzt angesehen",
"LabelLastTime": "Letztes Mal",
"LabelLastUpdate": "Letzte Aktualisierung",
@@ -267,10 +278,12 @@
"LabelMediaType": "Medientyp",
"LabelMetadataProvider": "Metadatenanbieter",
"LabelMetaTag": "Meta Schlagwort",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute",
"LabelMissing": "Fehlend",
"LabelMissingParts": "Fehlende Teile",
"LabelMore": "Mehr",
"LabelMoreInfo": "More Info",
"LabelName": "Name",
"LabelNarrator": "Erzähler",
"LabelNarrators": "Erzähler",
@@ -278,8 +291,8 @@
"LabelNewestAuthors": "Neuste Autoren",
"LabelNewestEpisodes": "Neueste Episoden",
"LabelNewPassword": "Neues Passwort",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNextBackupDate": "Nächstes Sicherungsdatum",
"LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
"LabelNotes": "Hinweise",
"LabelNotFinished": "nicht beendet",
"LabelNotificationAppriseURL": "Apprise URL(s)",
@@ -312,7 +325,7 @@
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
"LabelProgress": "Fortschritt",
"LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum",
@@ -320,14 +333,14 @@
"LabelPublishYear": "Jahr",
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
"LabelRecentSeries": "Aktuelle Serien",
"LabelRecommended": "Recommended",
"LabelRecommended": "Empfohlen",
"LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum",
"LabelRemoveCover": "Lösche Titelbild",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
"LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
"LabelRSSFeedOpen": "RSS Feed Offen",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedPreventIndexing": "Indizierung verhindern",
"LabelRSSFeedSlug": "RSS Feed Schlagwort",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Begriff suchen",
@@ -372,7 +385,7 @@
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern",
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
"LabelSettingsTimeFormat": "Time Format",
"LabelSettingsTimeFormat": "Zeitformat",
"LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer",
@@ -400,7 +413,9 @@
"LabelTag": "Schlagwort",
"LabelTags": "Schlagwörter",
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
"LabelTasks": "Tasks Running",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Laufende Aufgaben",
"LabelTimeBase": "Basiszeit",
"LabelTimeListened": "Gehörte Zeit",
"LabelTimeListenedToday": "Heute gehörte Zeit",
"LabelTimeRemaining": "{0} verbleibend",
@@ -420,6 +435,7 @@
"LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ",
"LabelUnabridged": "Ungekürzt",
"LabelUnknown": "Unbekannt",
"LabelUpdateCover": "Titelbild aktualisieren",
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
@@ -454,15 +470,16 @@
"MessageChapterEndIsAfter": "Ungültige Kapitelendzeit: Kapitelende > Mediumende (Kapitelende liegt nach dem Ende des Mediums)",
"MessageChapterErrorFirstNotZero": "Ungültige Kapitelstartzeit: Das erste Kapitel muss bei 0 beginnen",
"MessageChapterErrorStartGteDuration": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumlänge (Kapitelanfang liegt zeitlich nach dem Ende des Mediums -> Lösung: Kapitelanfang < Mediumlänge)",
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
"MessageCheckingCron": "Überprüfe cron...",
"MessageCheckingCron": "Überprüfe Cron...",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
@@ -502,8 +519,8 @@
"MessageNoCollections": "Keine Sammlungen",
"MessageNoCoversFound": "Keine Titelbilder gefunden",
"MessageNoDescription": "Keine Beschreibung",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "Derzeit keine Downloads in Arbeit",
"MessageNoDownloadsQueued": "Keine Downloads in der Warteschlange",
"MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden",
"MessageNoEpisodes": "Keine Episoden",
"MessageNoFoldersAvailable": "Keine Ordner verfügbar",
@@ -520,7 +537,7 @@
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
"MessageNoSeries": "Keine Serien",
"MessageNoTags": "Keine Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNoTasksRunning": "Keine laufenden Aufgaben",
"MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
@@ -534,7 +551,7 @@
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
"MessageRemoveChapter": "Kapitel löschen",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen Remove from player queue",
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen",
"MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?",
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf",
"MessageResetChaptersConfirm": "Sind Sie sicher, dass Sie die Kapitel zurücksetzen und die vorgenommenen Änderungen rückgängig machen wollen?",
@@ -548,7 +565,7 @@
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
"MessageUploaderItemSuccess": "Erfolgreich hochgeladen!",
"MessageUploading": "Hochladen...",
"MessageValidCronExpression": "Gültiger cron-ausdruck",
"MessageValidCronExpression": "Gültiger Cron-Ausdruck",
"MessageWatcherIsDisabledGlobally": "Überwachung ist in den Servereinstellungen global deaktiviert",
"MessageXLibraryIsEmpty": "{0} Bibliothek ist leer!",
"MessageYourAudiobookDurationIsLonger": "Die Dauer Ihres Mediums ist länger als die gefundene Dauer",
@@ -566,7 +583,7 @@
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
"PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
"PlaceholderSearch": "Suche...",
"PlaceholderSearchEpisode": "Search episode...",
"PlaceholderSearchEpisode": "Suche Episode...",
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
"ToastAccountUpdateSuccess": "Konto aktualisiert",
"ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden",
@@ -636,4 +653,4 @@
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
"ToastUserDeleteSuccess": "Benutzer gelöscht"
}
}

View File

@@ -155,11 +155,13 @@
"HeaderUpdateLibrary": "Update Library",
"HeaderUsers": "Users",
"HeaderYourStats": "Your Stats",
"LabelAbridged": "Abridged",
"LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
"LabelAccountTypeUser": "User",
"LabelActivity": "Activity",
"LabelAdded": "Added",
"LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
@@ -181,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Number of backups to keep",
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Books",
"LabelChangePassword": "Change Password",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
@@ -211,6 +217,7 @@
"LabelDuration": "Duration",
"LabelDurationFound": "Duration found:",
"LabelEdit": "Edit",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
"LabelEpisode": "Episode",
@@ -228,6 +235,7 @@
"LabelFinished": "Finished",
"LabelFolder": "Folder",
"LabelFolders": "Folders",
"LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
@@ -246,9 +254,12 @@
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts",
"LabelInvert": "Invert",
"LabelItem": "Item",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update",
@@ -267,10 +278,12 @@
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute",
"LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts",
"LabelMore": "More",
"LabelMoreInfo": "More Info",
"LabelName": "Name",
"LabelNarrator": "Narrator",
"LabelNarrators": "Narrators",
@@ -400,7 +413,9 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened",
"LabelTimeListenedToday": "Time Listened Today",
"LabelTimeRemaining": "{0} remaining",
@@ -420,6 +435,7 @@
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover",
"LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
@@ -463,6 +479,7 @@
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
@@ -636,4 +653,4 @@
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Failed to delete user",
"ToastUserDeleteSuccess": "User deleted"
}
}

View File

@@ -155,11 +155,13 @@
"HeaderUpdateLibrary": "Actualizar Biblioteca",
"HeaderUsers": "Usuarios",
"HeaderYourStats": "Tus Estáticas",
"LabelAbridged": "Abridged",
"LabelAccountType": "Tipo de Cuenta",
"LabelAccountTypeAdmin": "Administrador",
"LabelAccountTypeGuest": "Invitado",
"LabelAccountTypeUser": "Usuario",
"LabelActivity": "Actividad",
"LabelAdded": "Added",
"LabelAddedAt": "Añadido",
"LabelAddToCollection": "Añadido a la Colección",
"LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección",
@@ -181,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Como protección contra una configuración errónea, los respaldos fallaran si se excede el tamaño configurado.",
"LabelBackupsNumberToKeep": "Numero de respaldos para conservar",
"LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, necesita removerlos manualmente.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Libros",
"LabelChangePassword": "Cambiar Contraseña",
"LabelChannels": "Canales",
"LabelChapters": "Capitulos",
"LabelChaptersFound": "Capitulo Encontrado",
"LabelChapterTitle": "Titulo del Capitulo",
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Colapsar Series",
"LabelCollections": "Colecciones",
"LabelComplete": "Completo",
@@ -211,6 +217,7 @@
"LabelDuration": "Duración",
"LabelDurationFound": "Duración Comprobada:",
"LabelEdit": "Editar",
"LabelEmbeddedCover": "Portada Integrada",
"LabelEnable": "Habilitar",
"LabelEnd": "Fin",
"LabelEpisode": "Episodio",
@@ -228,6 +235,7 @@
"LabelFinished": "Terminado",
"LabelFolder": "Carpeta",
"LabelFolders": "Carpetas",
"LabelFormat": "Formato",
"LabelGenre": "Genero",
"LabelGenres": "Géneros",
"LabelHardDeleteFile": "Eliminar Definitivamente",
@@ -246,9 +254,12 @@
"LabelIntervalEveryDay": "Cada Dia",
"LabelIntervalEveryHour": "Cada Hora",
"LabelInvalidParts": "Partes Invalidas",
"LabelInvert": "Invert",
"LabelItem": "Elemento",
"LabelLanguage": "Lenguaje",
"LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Ultima Vez Visto",
"LabelLastTime": "Ultima Vez",
"LabelLastUpdate": "Ultima Actualización",
@@ -267,10 +278,12 @@
"LabelMediaType": "Tipo de Multimedia",
"LabelMetadataProvider": "Proveedor de Metadata",
"LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minuto",
"LabelMissing": "Ausente",
"LabelMissingParts": "Partes Ausentes",
"LabelMore": "Mas",
"LabelMoreInfo": "Mas Información",
"LabelName": "Nombre",
"LabelNarrator": "Narrador",
"LabelNarrators": "Narradores",
@@ -400,7 +413,9 @@
"LabelTag": "Etiqueta",
"LabelTags": "Etiquetas",
"LabelTagsAccessibleToUser": "Etiquetas Accessible para el Usuario",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tareas Corriendo",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Tiempo Escuchando",
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
"LabelTimeRemaining": "{0} restante",
@@ -420,6 +435,7 @@
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Tipo",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Desconocido",
"LabelUpdateCover": "Actualizar Portada",
"LabelUpdateCoverHelp": "Permitir sobrescribir portadas existentes de los libros seleccionados cuando sean encontrados.",
@@ -463,6 +479,7 @@
"MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?",
"MessageConfirmMarkSeriesFinished": "Esta seguro que desea marcar todos los libros en esta serie como terminados?",
"MessageConfirmMarkSeriesNotFinished": "Esta seguro que desea marcar todos los libros en esta serie como no terminados?",
"MessageConfirmRemoveAllChapters": "Esta seguro que desea remover todos los capitulos?",
"MessageConfirmRemoveCollection": "Esta seguro que desea remover la colección \"{0}\"?",
"MessageConfirmRemoveEpisode": "Esta seguro que desea remover el episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Esta seguro que desea remover {0} episodios?",
@@ -534,7 +551,7 @@
"MessageRemoveAllItemsWarning": "ADVERTENCIA! Esta acción eliminará todos los elementos de la biblioteca de la base de datos incluyendo cualquier actualización o match. Esto no hace nada a sus archivos reales. Esta seguro que desea continuar?",
"MessageRemoveChapter": "Remover capítulos",
"MessageRemoveEpisodes": "Remover {0} episodio(s)",
"MessageRemoveFromPlayerQueue": "Remover de player queue",
"MessageRemoveFromPlayerQueue": "Romover la cola de reporduccion",
"MessageRemoveUserWarning": "Esta seguro que desea eliminar el usuario \"{0}\"?",
"MessageReportBugsAndContribute": "Reporte erres, solicite funciones y contribuye en",
"MessageResetChaptersConfirm": "Esta seguro que desea reiniciar el capitulo y deshacer los cambios que hiciste?",

View File

@@ -20,7 +20,7 @@
"ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une sauvegarde",
"ButtonDelete": "Effacer",
"ButtonDownloadQueue": "Queue",
"ButtonDownloadQueue": "File dattente de téléchargement",
"ButtonEdit": "Modifier",
"ButtonEditChapters": "Modifier les chapitres",
"ButtonEditPodcast": "Modifier les podcasts",
@@ -93,9 +93,9 @@
"HeaderCollection": "Collection",
"HeaderCollectionItems": "Entrées de la Collection",
"HeaderCover": "Couverture",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderCurrentDownloads": "File dattente de téléchargement",
"HeaderDetails": "Détails",
"HeaderDownloadQueue": "Download Queue",
"HeaderDownloadQueue": "Queue de téléchargement",
"HeaderEpisodes": "Épisodes",
"HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les chapitres",
@@ -129,7 +129,7 @@
"HeaderPreviewCover": "Prévisualiser la couverture",
"HeaderRemoveEpisode": "Supprimer lépisode",
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedGeneral": "Détails de flux RSS",
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
"HeaderSchedule": "Programmation",
@@ -155,11 +155,13 @@
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
"HeaderUsers": "Utilisateurs",
"HeaderYourStats": "Vos statistiques",
"LabelAbridged": "Version courte",
"LabelAccountType": "Type de compte",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Invité",
"LabelAccountTypeUser": "Utilisateur",
"LabelActivity": "Activité",
"LabelAdded": "Ajouté",
"LabelAddedAt": "Date dajout",
"LabelAddToCollection": "Ajouter à la collection",
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
@@ -167,7 +169,7 @@
"LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture",
"LabelAll": "Tout",
"LabelAllUsers": "Tous les utilisateurs",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque",
"LabelAppend": "Ajouter",
"LabelAuthor": "Auteur",
"LabelAuthorFirstLast": "Auteur (Prénom Nom)",
@@ -181,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.",
"LabelBackupsNumberToKeep": "Nombre de sauvegardes à maintenir",
"LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Livres",
"LabelChangePassword": "Modifier le mot de passe",
"LabelChannels": "Canaux",
"LabelChapters": "Chapitres",
"LabelChaptersFound": "Chapitres trouvés",
"LabelChapterTitle": "Titres du chapitre",
"LabelClosePlayer": "Fermer le lecteur",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Réduire les séries",
"LabelCollections": "Collections",
"LabelComplete": "Complet",
@@ -198,7 +204,7 @@
"LabelCronExpression": "Expression Cron",
"LabelCurrent": "Courrant",
"LabelCurrently": "En ce moment :",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelCustomCronExpression": "Expression cron personnalisée:",
"LabelDatetime": "Datetime",
"LabelDescription": "Description",
"LabelDeselectAll": "Tout déselectionner",
@@ -211,12 +217,13 @@
"LabelDuration": "Durée",
"LabelDurationFound": "Durée trouvée :",
"LabelEdit": "Modifier",
"LabelEmbeddedCover": "Couverture du livre intégrée",
"LabelEnable": "Activer",
"LabelEnd": "Fin",
"LabelEpisode": "Épisode",
"LabelEpisodeTitle": "Titre de lépisode",
"LabelEpisodeType": "Type de lépisode",
"LabelExample": "Example",
"LabelExample": "Exemple",
"LabelExplicit": "Restriction",
"LabelFeedURL": "URL deu flux",
"LabelFile": "Fichier",
@@ -228,6 +235,7 @@
"LabelFinished": "Fini(e)",
"LabelFolder": "Dossier",
"LabelFolders": "Dossiers",
"LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Suppression du fichier",
@@ -246,9 +254,12 @@
"LabelIntervalEveryDay": "Tous les jours",
"LabelIntervalEveryHour": "Toutes les heures",
"LabelInvalidParts": "Parties invalides",
"LabelInvert": "Invert",
"LabelItem": "Article",
"LabelLanguage": "Langue",
"LabelLanguageDefaultServer": "Langue par défaut",
"LabelLastBookAdded": "Dernier livre ajouté",
"LabelLastBookUpdated": "Dernier livre mis à jour",
"LabelLastSeen": "Vu dernièrement",
"LabelLastTime": "Progression",
"LabelLastUpdate": "Dernière mise à jour",
@@ -267,10 +278,12 @@
"LabelMediaType": "Type de média",
"LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMetaTag": "Etiquette de métadonnée",
"LabelMetaTags": "Etiquettes de métadonnée",
"LabelMinute": "Minute",
"LabelMissing": "Manquant",
"LabelMissingParts": "Parties manquantes",
"LabelMore": "Plus",
"LabelMoreInfo": "Plus dinfo",
"LabelName": "Nom",
"LabelNarrator": "Narrateur",
"LabelNarrators": "Narrateurs",
@@ -278,8 +291,8 @@
"LabelNewestAuthors": "Nouveaux auteurs",
"LabelNewestEpisodes": "Derniers épisodes",
"LabelNewPassword": "Nouveau mot de passe",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNextBackupDate": "Date de la prochaine sauvegarde",
"LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)",
"LabelNotificationAppriseURL": "URL(s) dapprise",
@@ -310,9 +323,9 @@
"LabelPlayMethod": "Méthode découte",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPodcastType": "Type de Podcast",
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPreventIndexing": "Empêcher lindexation de votre flux par les bases de donénes iTunes et Google podcast",
"LabelProgress": "Progression",
"LabelProvider": "Fournisseur",
"LabelPubDate": "Date de publication",
@@ -324,10 +337,10 @@
"LabelRegion": "Région",
"LabelReleaseDate": "Date de parution",
"LabelRemoveCover": "Supprimer la couverture",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedCustomOwnerEmail": "Email propriétaire personnalisé",
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
"LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedPreventIndexing": "Empêcher lindexation",
"LabelRSSFeedSlug": "Identificateur dadresse du Flux RSS ",
"LabelRSSFeedURL": "Adresse du flux RSS",
"LabelSearchTerm": "Terme de recherche",
@@ -372,7 +385,7 @@
"LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de larticle. Seul un fichier nommé « cover » sera conservé.",
"LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles",
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de larticle avec une extension « .abs ».",
"LabelSettingsTimeFormat": "Time Format",
"LabelSettingsTimeFormat": "Format dheure",
"LabelShowAll": "Afficher Tout",
"LabelSize": "Taille",
"LabelSleepTimer": "Minuterie",
@@ -400,7 +413,9 @@
"LabelTag": "Étiquette",
"LabelTags": "Étiquettes",
"LabelTagsAccessibleToUser": "Étiquettes accessibles à lutilisateur",
"LabelTasks": "Tasks Running",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tâches en cours",
"LabelTimeBase": "Base de temps",
"LabelTimeListened": "Temps découte",
"LabelTimeListenedToday": "Nombres découtes Aujourdhui",
"LabelTimeRemaining": "{0} restantes",
@@ -420,6 +435,7 @@
"LabelTracksMultiTrack": "Piste multiple",
"LabelTracksSingleTrack": "Piste simple",
"LabelType": "Type",
"LabelUnabridged": "Version intégrale",
"LabelUnknown": "Inconnu",
"LabelUpdateCover": "Mettre à jour la couverture",
"LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsquune correspondance est trouvée",
@@ -463,6 +479,7 @@
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?",
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer comme terminé tous les livres de cette série ?",
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer comme non terminé tous les livres de cette série ?",
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer lépisode « {0} » ?",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
@@ -520,7 +537,7 @@
"MessageNoSearchResultsFor": "Aucun résultat pour la recherche « {0} »",
"MessageNoSeries": "Aucune série",
"MessageNoTags": "Aucune détiquettes",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNoTasksRunning": "Aucune tâche en cours",
"MessageNotYetImplemented": "Non implémenté",
"MessageNoUpdateNecessary": "Aucune mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour nétait nécessaire",
@@ -566,7 +583,7 @@
"PlaceholderNewFolderPath": "Nouveau chemin de dossier",
"PlaceholderNewPlaylist": "Nouveau nom de liste de lecture",
"PlaceholderSearch": "Recherche...",
"PlaceholderSearchEpisode": "Search episode...",
"PlaceholderSearchEpisode": "Recherche dépisode...",
"ToastAccountUpdateFailed": "Échec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour",
"ToastAuthorImageRemoveFailed": "Échec de la suppression de limage",

656
client/strings/gu.json Normal file
View File

@@ -0,0 +1,656 @@
{
"ButtonAdd": "ઉમેરો",
"ButtonAddChapters": "પ્રકરણો ઉમેરો",
"ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો",
"ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો",
"ButtonApply": "લાગુ કરો",
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
"ButtonAuthors": "લેખકો",
"ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ",
"ButtonCancel": "રદ કરો",
"ButtonCancelEncode": "એન્કોડ રદ કરો",
"ButtonChangeRootPassword": "રૂટ પાસવર્ડ બદલો",
"ButtonCheckAndDownloadNewEpisodes": "નવા એપિસોડ્સ ચેક કરો અને ડાઉનલોડ કરો",
"ButtonChooseAFolder": "ફોલ્ડર પસંદ કરો",
"ButtonChooseFiles": "ફાઇલો પસંદ કરો",
"ButtonClearFilter": "ફિલ્ટર જતુ કરો ",
"ButtonCloseFeed": "ફીડ બંધ કરો",
"ButtonCollections": "સંગ્રહ",
"ButtonConfigureScanner": "સ્કેનર સેટિંગ બદલો",
"ButtonCreate": "બનાવો",
"ButtonCreateBackup": "બેકઅપ બનાવો",
"ButtonDelete": "કાઢી નાખો",
"ButtonDownloadQueue": "કતાર ડાઉનલોડ કરો",
"ButtonEdit": "સંપાદિત કરો",
"ButtonEditChapters": "પ્રકરણો સંપાદિત કરો",
"ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો",
"ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો",
"ButtonFullPath": "સંપૂર્ણ પથ",
"ButtonHide": "છુપાવો",
"ButtonHome": "ઘર",
"ButtonIssues": "સમસ્યાઓ",
"ButtonLatest": "નવીનતમ",
"ButtonLibrary": "પુસ્તકાલય",
"ButtonLogout": "લૉગ આઉટ",
"ButtonLookup": "શોધો",
"ButtonManageTracks": "ટ્રેક્સ મેનેજ કરો",
"ButtonMapChapterTitles": "પ્રકરણ શીર્ષકો મેપ કરો",
"ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
"ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
"ButtonNevermind": "કંઈ વાંધો નહીં",
"ButtonOk": "ઓકે",
"ButtonOpenFeed": "ફીડ ખોલો",
"ButtonOpenManager": "મેનેજર ખોલો",
"ButtonPlay": "ચલાવો",
"ButtonPlaying": "ચલાવી રહ્યું છે",
"ButtonPlaylists": "પ્લેલિસ્ટ",
"ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
"ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
"ButtonPurgeMediaProgress": "બધું સાંભળ્યું કાઢી નાખો",
"ButtonQueueAddItem": "કતારમાં ઉમેરો",
"ButtonQueueRemoveItem": "કતારથી કાઢી નાખો",
"ButtonQuickMatch": "ઝડપી મેળ ખવડાવો",
"ButtonRead": "વાંચો",
"ButtonRemove": "કાઢી નાખો",
"ButtonRemoveAll": "બધું કાઢી નાખો",
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
"ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
"ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો",
"ButtonReScan": "ફરીથી સ્કેન કરો",
"ButtonReset": "રીસેટ કરો",
"ButtonRestore": "પુનઃસ્થાપિત કરો",
"ButtonSave": "સાચવો",
"ButtonSaveAndClose": "સાચવો અને બંધ કરો",
"ButtonSaveTracklist": "ટ્રેક યાદી સાચવો",
"ButtonScan": "સ્કેન કરો",
"ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો",
"ButtonSearch": "શોધો",
"ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો",
"ButtonSeries": "સિરીઝ",
"ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો",
"ButtonShiftTimes": "સમય શિફ્ટ કરો",
"ButtonShow": "બતાવો",
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
"ButtonSubmit": "સબમિટ કરો",
"ButtonUpload": "અપલોડ કરો",
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
"ButtonUploadCover": "કવર અપલોડ કરો",
"ButtonUploadOPMLFile": "OPML ફાઇલ અપલોડ કરો",
"ButtonUserDelete": "વપરાશકર્તા {0} કાઢી નાખો",
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
"ButtonViewAll": "બધું જુઓ",
"ButtonYes": "હા",
"HeaderAccount": "એકાઉન્ટ",
"HeaderAdvanced": "અડ્વાન્સડ",
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
"HeaderAudiobookTools": "Audiobook File Management Tools",
"HeaderAudioTracks": "Audio Tracks",
"HeaderBackups": "Backups",
"HeaderChangePassword": "Change Password",
"HeaderChapters": "Chapters",
"HeaderChooseAFolder": "Choose a Folder",
"HeaderCollection": "Collection",
"HeaderCollectionItems": "Collection Items",
"HeaderCover": "Cover",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderEpisodes": "Episodes",
"HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries",
"HeaderLibraryFiles": "Library Files",
"HeaderLibraryStats": "Library Stats",
"HeaderListeningSessions": "Listening Sessions",
"HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account",
"HeaderNewLibrary": "New Library",
"HeaderNotifications": "Notifications",
"HeaderOpenRSSFeed": "Open RSS Feed",
"HeaderOtherFiles": "Other Files",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add",
"HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode",
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Set Backup Schedule",
"HeaderSettings": "Settings",
"HeaderSettingsDisplay": "Display",
"HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)",
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
"HeaderStatsRecentSessions": "Recent Sessions",
"HeaderStatsTop10Authors": "Top 10 Authors",
"HeaderStatsTop5Genres": "Top 5 Genres",
"HeaderTools": "Tools",
"HeaderUpdateAccount": "Update Account",
"HeaderUpdateAuthor": "Update Author",
"HeaderUpdateDetails": "Update Details",
"HeaderUpdateLibrary": "Update Library",
"HeaderUsers": "Users",
"HeaderYourStats": "Your Stats",
"LabelAbridged": "Abridged",
"LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
"LabelAccountTypeUser": "User",
"LabelActivity": "Activity",
"LabelAdded": "Added",
"LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All",
"LabelAllUsers": "All Users",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAppend": "Append",
"LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelBackToUser": "Back to User",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
"LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Number of backups to keep",
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Books",
"LabelChangePassword": "Change Password",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening",
"LabelContinueSeries": "Continue Series",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
"LabelCreatedAt": "Created At",
"LabelCronExpression": "Cron Expression",
"LabelCurrent": "Current",
"LabelCurrently": "Currently:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelDatetime": "Datetime",
"LabelDescription": "Description",
"LabelDeselectAll": "Deselect All",
"LabelDevice": "Device",
"LabelDeviceInfo": "Device Info",
"LabelDirectory": "Directory",
"LabelDiscFromFilename": "Disc from Filename",
"LabelDiscFromMetadata": "Disc from Metadata",
"LabelDownload": "Download",
"LabelDuration": "Duration",
"LabelDurationFound": "Duration found:",
"LabelEdit": "Edit",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episode Title",
"LabelEpisodeType": "Episode Type",
"LabelExample": "Example",
"LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL",
"LabelFile": "File",
"LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified",
"LabelFilename": "Filename",
"LabelFilterByUser": "Filter by User",
"LabelFindEpisodes": "Find Episodes",
"LabelFinished": "Finished",
"LabelFolder": "Folder",
"LabelFolders": "Folders",
"LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
"LabelHour": "Hour",
"LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist",
"LabelIncomplete": "Incomplete",
"LabelInProgress": "In Progress",
"LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
"LabelIntervalEvery12Hours": "Every 12 hours",
"LabelIntervalEvery15Minutes": "Every 15 minutes",
"LabelIntervalEvery2Hours": "Every 2 hours",
"LabelIntervalEvery30Minutes": "Every 30 minutes",
"LabelIntervalEvery6Hours": "Every 6 hours",
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts",
"LabelInvert": "Invert",
"LabelItem": "Item",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update",
"LabelLess": "Less",
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
"LabelLibrary": "Library",
"LabelLibraryItem": "Library Item",
"LabelLibraryName": "Library Name",
"LabelLimit": "Limit",
"LabelListenAgain": "Listen Again",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute",
"LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts",
"LabelMore": "More",
"LabelMoreInfo": "More Info",
"LabelName": "Name",
"LabelNarrator": "Narrator",
"LabelNarrators": "Narrators",
"LabelNew": "New",
"LabelNewestAuthors": "Newest Authors",
"LabelNewestEpisodes": "Newest Episodes",
"LabelNewPassword": "New Password",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNotes": "Notes",
"LabelNotFinished": "Not Finished",
"LabelNotificationAppriseURL": "Apprise URL(s)",
"LabelNotificationAvailableVariables": "Available variables",
"LabelNotificationBodyTemplate": "Body Template",
"LabelNotificationEvent": "Notification Event",
"LabelNotificationsMaxFailedAttempts": "Max failed attempts",
"LabelNotificationsMaxFailedAttemptsHelp": "Notifications are disabled once they fail to send this many times",
"LabelNotificationsMaxQueueSize": "Max queue size for notification events",
"LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
"LabelNotificationTitleTemplate": "Title Template",
"LabelNotStarted": "Not Started",
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password",
"LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
"LabelPermissionsAccessAllTags": "Can Access All Tags",
"LabelPermissionsAccessExplicitContent": "Can Access Explicit Content",
"LabelPermissionsDelete": "Can Delete",
"LabelPermissionsDownload": "Can Download",
"LabelPermissionsUpdate": "Can Update",
"LabelPermissionsUpload": "Can Upload",
"LabelPhotoPathURL": "Photo Path/URL",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Play Method",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progress",
"LabelProvider": "Provider",
"LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year",
"LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended",
"LabelRegion": "Region",
"LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Search Term",
"LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season",
"LabelSequence": "Sequence",
"LabelSeries": "Series",
"LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format",
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
"LabelSettingsParseSubtitles": "Parse subtitles",
"LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by \" - \"<br>i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
"LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
"LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Use square book covers",
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
"LabelSettingsStoreCoversWithItem": "Store covers with item",
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
"LabelSettingsStoreMetadataWithItem": "Store metadata with item",
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
"LabelStartTime": "Start Time",
"LabelStatsAudioTracks": "Audio Tracks",
"LabelStatsAuthors": "Authors",
"LabelStatsBestDay": "Best Day",
"LabelStatsDailyAverage": "Daily Average",
"LabelStatsDays": "Days",
"LabelStatsDaysListened": "Days Listened",
"LabelStatsHours": "Hours",
"LabelStatsInARow": "in a row",
"LabelStatsItemsFinished": "Items Finished",
"LabelStatsItemsInLibrary": "Items in Library",
"LabelStatsMinutes": "minutes",
"LabelStatsMinutesListening": "Minutes Listening",
"LabelStatsOverallDays": "Overall Days",
"LabelStatsOverallHours": "Overall Hours",
"LabelStatsWeekListening": "Week Listening",
"LabelSubtitle": "Subtitle",
"LabelSupportedFileTypes": "Supported File Types",
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened",
"LabelTimeListenedToday": "Time Listened Today",
"LabelTimeRemaining": "{0} remaining",
"LabelTimeToShift": "Time to shift in seconds",
"LabelTitle": "Title",
"LabelToolsEmbedMetadata": "Embed Metadata",
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
"LabelToolsMakeM4b": "Make M4B Audiobook File",
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsSplitM4b": "Split M4B to MP3's",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
"LabelTotalDuration": "Total Duration",
"LabelTotalTimeListened": "Total Time Listened",
"LabelTrackFromFilename": "Track from Filename",
"LabelTrackFromMetadata": "Track from Metadata",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover",
"LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
"LabelUpdatedAt": "Updated At",
"LabelUpdateDetails": "Update Details",
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files",
"LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track",
"LabelUser": "User",
"LabelUsername": "Username",
"LabelValue": "Value",
"LabelVersion": "Version",
"LabelViewBookmarks": "View bookmarks",
"LabelViewChapters": "View chapters",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdays to run",
"LabelYourAudiobookDuration": "Your audiobook duration",
"LabelYourBookmarks": "Your Bookmarks",
"LabelYourPlaylists": "Your Playlists",
"LabelYourProgress": "Your Progress",
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
"MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
"MessageBookshelfNoCollections": "You haven't made any collections yet",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
"MessageFeedURLWillBe": "Feed URL will be {0}",
"MessageFetching": "Fetching...",
"MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",
"MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...",
"MessageLoadingFolders": "Loading folders...",
"MessageM4BFailed": "M4B Failed!",
"MessageM4BFinished": "M4B Finished!",
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
"MessageMarkAsFinished": "Mark as Finished",
"MessageMarkAsNotFinished": "Mark as Not Finished",
"MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
"MessageNoAudioTracks": "No audio tracks",
"MessageNoAuthors": "No Authors",
"MessageNoBackups": "No Backups",
"MessageNoBookmarks": "No Bookmarks",
"MessageNoChapters": "No Chapters",
"MessageNoCollections": "No Collections",
"MessageNoCoversFound": "No Covers Found",
"MessageNoDescription": "No description",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "No episode matches found",
"MessageNoEpisodes": "No Episodes",
"MessageNoFoldersAvailable": "No Folders Available",
"MessageNoGenres": "No Genres",
"MessageNoIssues": "No Issues",
"MessageNoItems": "No Items",
"MessageNoItemsFound": "No items found",
"MessageNoListeningSessions": "No Listening Sessions",
"MessageNoLogs": "No Logs",
"MessageNoMediaProgress": "No Media Progress",
"MessageNoNotifications": "No Notifications",
"MessageNoPodcastsFound": "No podcasts found",
"MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary",
"MessageNoUserPlaylists": "You have no playlists",
"MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
"MessageRemoveChapter": "Remove chapter",
"MessageRemoveEpisodes": "Remove {0} episode(s)",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for",
"MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
"MessageThinking": "Thinking...",
"MessageUploaderItemFailed": "Failed to upload",
"MessageUploaderItemSuccess": "Successfully Uploaded!",
"MessageUploading": "Uploading...",
"MessageValidCronExpression": "Valid cron expression",
"MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
"MessageXLibraryIsEmpty": "{0} Library is empty!",
"MessageYourAudiobookDurationIsLonger": "Your audiobook duration is longer than the duration found",
"MessageYourAudiobookDurationIsShorter": "Your audiobook duration is shorter than duration found",
"NoteChangeRootPassword": "Root user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
"NoteUploaderOnlyAudioFiles": "If uploading only audio files then each audio file will be handled as a separate audiobook.",
"NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.",
"PlaceholderNewCollection": "New collection name",
"PlaceholderNewFolderPath": "New folder path",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
"ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated",
"ToastAuthorImageRemoveFailed": "Failed to remove image",
"ToastAuthorImageRemoveSuccess": "Author image removed",
"ToastAuthorUpdateFailed": "Failed to update author",
"ToastAuthorUpdateMerged": "Author merged",
"ToastAuthorUpdateSuccess": "Author updated",
"ToastAuthorUpdateSuccessNoImageFound": "Author updated (no image found)",
"ToastBackupCreateFailed": "Failed to create backup",
"ToastBackupCreateSuccess": "Backup created",
"ToastBackupDeleteFailed": "Failed to delete backup",
"ToastBackupDeleteSuccess": "Backup deleted",
"ToastBackupRestoreFailed": "Failed to restore backup",
"ToastBackupUploadFailed": "Failed to upload backup",
"ToastBackupUploadSuccess": "Backup uploaded",
"ToastBatchUpdateFailed": "Batch update failed",
"ToastBatchUpdateSuccess": "Batch update success",
"ToastBookmarkCreateFailed": "Failed to create bookmark",
"ToastBookmarkCreateSuccess": "Bookmark added",
"ToastBookmarkRemoveFailed": "Failed to remove bookmark",
"ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateFailed": "Failed to update bookmark",
"ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveFailed": "Failed to remove collection",
"ToastCollectionRemoveSuccess": "Collection removed",
"ToastCollectionUpdateFailed": "Failed to update collection",
"ToastCollectionUpdateSuccess": "Collection updated",
"ToastItemCoverUpdateFailed": "Failed to update item cover",
"ToastItemCoverUpdateSuccess": "Item cover updated",
"ToastItemDetailsUpdateFailed": "Failed to update item details",
"ToastItemDetailsUpdateSuccess": "Item details updated",
"ToastItemDetailsUpdateUnneeded": "No updates needed for item details",
"ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
"ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
"ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished",
"ToastItemMarkedAsNotFinishedSuccess": "Item marked as Not Finished",
"ToastLibraryCreateFailed": "Failed to create library",
"ToastLibraryCreateSuccess": "Library \"{0}\" created",
"ToastLibraryDeleteFailed": "Failed to delete library",
"ToastLibraryDeleteSuccess": "Library deleted",
"ToastLibraryScanFailedToStart": "Failed to start scan",
"ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPodcastCreateFailed": "Failed to create podcast",
"ToastPodcastCreateSuccess": "Podcast created successfully",
"ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session",
"ToastSessionDeleteSuccess": "Session deleted",
"ToastSocketConnected": "Socket connected",
"ToastSocketDisconnected": "Socket disconnected",
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Failed to delete user",
"ToastUserDeleteSuccess": "User deleted"
}

656
client/strings/hi.json Normal file
View File

@@ -0,0 +1,656 @@
{
"ButtonAdd": "जोड़ें",
"ButtonAddChapters": "अध्याय जोड़ें",
"ButtonAddPodcasts": "पॉडकास्ट जोड़ें",
"ButtonAddYourFirstLibrary": "अपनी पहली पुस्तकालय जोड़ें",
"ButtonApply": "लागू करें",
"ButtonApplyChapters": "अध्यायों में परिवर्तन लागू करें",
"ButtonAuthors": "लेखक",
"ButtonBrowseForFolder": "फ़ोल्डर खोजें",
"ButtonCancel": "रद्द करें",
"ButtonCancelEncode": "एनकोड रद्द करें",
"ButtonChangeRootPassword": "रूट का पासवर्ड बदलें",
"ButtonCheckAndDownloadNewEpisodes": "नए एपिसोड खोजें और डाउनलोड करें",
"ButtonChooseAFolder": "एक फ़ोल्डर चुनें",
"ButtonChooseFiles": "फ़ाइलें चुनें",
"ButtonClearFilter": "लागू फ़िल्टर साफ़ करें",
"ButtonCloseFeed": "फ़ीड बंद करें",
"ButtonCollections": "संग्रह",
"ButtonConfigureScanner": "स्कैनर सेटिंग्स बदलें",
"ButtonCreate": "बनाएं",
"ButtonCreateBackup": "बैकअप लें",
"ButtonDelete": "हटाएं",
"ButtonDownloadQueue": "कतार डाउनलोड करें",
"ButtonEdit": "संपादित करें",
"ButtonEditChapters": "अध्याय संपादित करें",
"ButtonEditPodcast": "पॉडकास्ट संपादित करें",
"ButtonForceReScan": "बलपूर्वक पुन: स्कैन करें",
"ButtonFullPath": "पूर्ण पथ",
"ButtonHide": "छुपाएं",
"ButtonHome": "घर",
"ButtonIssues": "समस्याएं",
"ButtonLatest": "नवीनतम",
"ButtonLibrary": "पुस्तकालय",
"ButtonLogout": "लॉग आउट",
"ButtonLookup": "तलाश करें",
"ButtonManageTracks": "ट्रैक्स मैनेज करें",
"ButtonMapChapterTitles": "अध्यायों का मिलान करें",
"ButtonMatchAllAuthors": "सभी लेखकों को तलाश करें",
"ButtonMatchBooks": "संबंधित पुस्तकों का मिलान करें",
"ButtonNevermind": "कोई बात नहीं",
"ButtonOk": "ठीक है",
"ButtonOpenFeed": "फ़ीड खोलें",
"ButtonOpenManager": "मैनेजर खोलें",
"ButtonPlay": "चलाएँ",
"ButtonPlaying": "चल रही है",
"ButtonPlaylists": "प्लेलिस्ट्स",
"ButtonPurgeAllCache": "सभी Cache मिटाएं",
"ButtonPurgeItemsCache": "आइटम Cache मिटाएं",
"ButtonPurgeMediaProgress": "अभी तक सुना हुआ सब हटा दे",
"ButtonQueueAddItem": "क़तार में जोड़ें",
"ButtonQueueRemoveItem": "कतार से हटाएं",
"ButtonQuickMatch": "जल्दी से समानता की तलाश करें",
"ButtonRead": "पढ़ लिया",
"ButtonRemove": "हटाएं",
"ButtonRemoveAll": "सभी हटाएं",
"ButtonRemoveAllLibraryItems": "पुस्तकालय की सभी आइटम हटाएं",
"ButtonRemoveFromContinueListening": "सुनना जारी रखें से हटाएं",
"ButtonRemoveSeriesFromContinueSeries": "इस सीरीज को कंटिन्यू सीरीज से हटा दें",
"ButtonReScan": "पुन: स्कैन करें",
"ButtonReset": "रीसेट करें",
"ButtonRestore": "पुनर्स्थापित करें",
"ButtonSave": "सहेजें",
"ButtonSaveAndClose": "सहेजें और बंद करें",
"ButtonSaveTracklist": "ट्रैक सूची सहेजें",
"ButtonScan": "स्कैन करें",
"ButtonScanLibrary": "पुस्तकालय स्कैन करें",
"ButtonSearch": "खोजें",
"ButtonSelectFolderPath": "फ़ोल्डर का पथ चुनें",
"ButtonSeries": "सीरीज",
"ButtonSetChaptersFromTracks": "ट्रैक्स से अध्याय बनाएं",
"ButtonShiftTimes": "समय खिसकाए",
"ButtonShow": "दिखाएं",
"ButtonStartM4BEncode": "M4B एन्कोडिंग शुरू करें",
"ButtonStartMetadataEmbed": "मेटाडेटा एम्बेडिंग शुरू करें",
"ButtonSubmit": "जमा करें",
"ButtonUpload": "अपलोड करें",
"ButtonUploadBackup": "बैकअप अपलोड करें",
"ButtonUploadCover": "कवर अपलोड करें",
"ButtonUploadOPMLFile": "OPML फ़ाइल अपलोड करें",
"ButtonUserDelete": "उपयोगकर्ता {0} को हटाएं",
"ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें",
"ButtonViewAll": "सभी को देखें",
"ButtonYes": "हाँ",
"HeaderAccount": "खाता",
"HeaderAdvanced": "विकसित",
"HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
"HeaderAudiobookTools": "Audiobook File Management Tools",
"HeaderAudioTracks": "Audio Tracks",
"HeaderBackups": "Backups",
"HeaderChangePassword": "Change Password",
"HeaderChapters": "Chapters",
"HeaderChooseAFolder": "Choose a Folder",
"HeaderCollection": "Collection",
"HeaderCollectionItems": "Collection Items",
"HeaderCover": "Cover",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Queue",
"HeaderEpisodes": "Episodes",
"HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries",
"HeaderLibraryFiles": "Library Files",
"HeaderLibraryStats": "Library Stats",
"HeaderListeningSessions": "Listening Sessions",
"HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account",
"HeaderNewLibrary": "New Library",
"HeaderNotifications": "Notifications",
"HeaderOpenRSSFeed": "Open RSS Feed",
"HeaderOtherFiles": "Other Files",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add",
"HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode",
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Set Backup Schedule",
"HeaderSettings": "Settings",
"HeaderSettingsDisplay": "Display",
"HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)",
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
"HeaderStatsRecentSessions": "Recent Sessions",
"HeaderStatsTop10Authors": "Top 10 Authors",
"HeaderStatsTop5Genres": "Top 5 Genres",
"HeaderTools": "Tools",
"HeaderUpdateAccount": "Update Account",
"HeaderUpdateAuthor": "Update Author",
"HeaderUpdateDetails": "Update Details",
"HeaderUpdateLibrary": "Update Library",
"HeaderUsers": "Users",
"HeaderYourStats": "Your Stats",
"LabelAbridged": "Abridged",
"LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
"LabelAccountTypeUser": "User",
"LabelActivity": "Activity",
"LabelAdded": "Added",
"LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All",
"LabelAllUsers": "All Users",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAppend": "Append",
"LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelBackToUser": "Back to User",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
"LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Number of backups to keep",
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Books",
"LabelChangePassword": "Change Password",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
"LabelContinueListening": "Continue Listening",
"LabelContinueSeries": "Continue Series",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
"LabelCreatedAt": "Created At",
"LabelCronExpression": "Cron Expression",
"LabelCurrent": "Current",
"LabelCurrently": "Currently:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelDatetime": "Datetime",
"LabelDescription": "Description",
"LabelDeselectAll": "Deselect All",
"LabelDevice": "Device",
"LabelDeviceInfo": "Device Info",
"LabelDirectory": "Directory",
"LabelDiscFromFilename": "Disc from Filename",
"LabelDiscFromMetadata": "Disc from Metadata",
"LabelDownload": "Download",
"LabelDuration": "Duration",
"LabelDurationFound": "Duration found:",
"LabelEdit": "Edit",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episode Title",
"LabelEpisodeType": "Episode Type",
"LabelExample": "Example",
"LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL",
"LabelFile": "File",
"LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified",
"LabelFilename": "Filename",
"LabelFilterByUser": "Filter by User",
"LabelFindEpisodes": "Find Episodes",
"LabelFinished": "Finished",
"LabelFolder": "Folder",
"LabelFolders": "Folders",
"LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
"LabelHour": "Hour",
"LabelIcon": "Icon",
"LabelIncludeInTracklist": "Include in Tracklist",
"LabelIncomplete": "Incomplete",
"LabelInProgress": "In Progress",
"LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
"LabelIntervalEvery12Hours": "Every 12 hours",
"LabelIntervalEvery15Minutes": "Every 15 minutes",
"LabelIntervalEvery2Hours": "Every 2 hours",
"LabelIntervalEvery30Minutes": "Every 30 minutes",
"LabelIntervalEvery6Hours": "Every 6 hours",
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts",
"LabelInvert": "Invert",
"LabelItem": "Item",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update",
"LabelLess": "Less",
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
"LabelLibrary": "Library",
"LabelLibraryItem": "Library Item",
"LabelLibraryName": "Library Name",
"LabelLimit": "Limit",
"LabelListenAgain": "Listen Again",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute",
"LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts",
"LabelMore": "More",
"LabelMoreInfo": "More Info",
"LabelName": "Name",
"LabelNarrator": "Narrator",
"LabelNarrators": "Narrators",
"LabelNew": "New",
"LabelNewestAuthors": "Newest Authors",
"LabelNewestEpisodes": "Newest Episodes",
"LabelNewPassword": "New Password",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNotes": "Notes",
"LabelNotFinished": "Not Finished",
"LabelNotificationAppriseURL": "Apprise URL(s)",
"LabelNotificationAvailableVariables": "Available variables",
"LabelNotificationBodyTemplate": "Body Template",
"LabelNotificationEvent": "Notification Event",
"LabelNotificationsMaxFailedAttempts": "Max failed attempts",
"LabelNotificationsMaxFailedAttemptsHelp": "Notifications are disabled once they fail to send this many times",
"LabelNotificationsMaxQueueSize": "Max queue size for notification events",
"LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
"LabelNotificationTitleTemplate": "Title Template",
"LabelNotStarted": "Not Started",
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password",
"LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
"LabelPermissionsAccessAllTags": "Can Access All Tags",
"LabelPermissionsAccessExplicitContent": "Can Access Explicit Content",
"LabelPermissionsDelete": "Can Delete",
"LabelPermissionsDownload": "Can Download",
"LabelPermissionsUpdate": "Can Update",
"LabelPermissionsUpload": "Can Upload",
"LabelPhotoPathURL": "Photo Path/URL",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Play Method",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progress",
"LabelProvider": "Provider",
"LabelPubDate": "Pub Date",
"LabelPublisher": "Publisher",
"LabelPublishYear": "Publish Year",
"LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended",
"LabelRegion": "Region",
"LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Search Term",
"LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN",
"LabelSeason": "Season",
"LabelSequence": "Sequence",
"LabelSeries": "Series",
"LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
"LabelSettingsChromecastSupport": "Chromecast support",
"LabelSettingsDateFormat": "Date Format",
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableEReader": "Enable e-reader for all users",
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
"LabelSettingsParseSubtitles": "Parse subtitles",
"LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by \" - \"<br>i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
"LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
"LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Use square book covers",
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
"LabelSettingsStoreCoversWithItem": "Store covers with item",
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
"LabelSettingsStoreMetadataWithItem": "Store metadata with item",
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
"LabelStartTime": "Start Time",
"LabelStatsAudioTracks": "Audio Tracks",
"LabelStatsAuthors": "Authors",
"LabelStatsBestDay": "Best Day",
"LabelStatsDailyAverage": "Daily Average",
"LabelStatsDays": "Days",
"LabelStatsDaysListened": "Days Listened",
"LabelStatsHours": "Hours",
"LabelStatsInARow": "in a row",
"LabelStatsItemsFinished": "Items Finished",
"LabelStatsItemsInLibrary": "Items in Library",
"LabelStatsMinutes": "minutes",
"LabelStatsMinutesListening": "Minutes Listening",
"LabelStatsOverallDays": "Overall Days",
"LabelStatsOverallHours": "Overall Hours",
"LabelStatsWeekListening": "Week Listening",
"LabelSubtitle": "Subtitle",
"LabelSupportedFileTypes": "Supported File Types",
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened",
"LabelTimeListenedToday": "Time Listened Today",
"LabelTimeRemaining": "{0} remaining",
"LabelTimeToShift": "Time to shift in seconds",
"LabelTitle": "Title",
"LabelToolsEmbedMetadata": "Embed Metadata",
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
"LabelToolsMakeM4b": "Make M4B Audiobook File",
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsSplitM4b": "Split M4B to MP3's",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
"LabelTotalDuration": "Total Duration",
"LabelTotalTimeListened": "Total Time Listened",
"LabelTrackFromFilename": "Track from Filename",
"LabelTrackFromMetadata": "Track from Metadata",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover",
"LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
"LabelUpdatedAt": "Updated At",
"LabelUpdateDetails": "Update Details",
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files",
"LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track",
"LabelUser": "User",
"LabelUsername": "Username",
"LabelValue": "Value",
"LabelVersion": "Version",
"LabelViewBookmarks": "View bookmarks",
"LabelViewChapters": "View chapters",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdays to run",
"LabelYourAudiobookDuration": "Your audiobook duration",
"LabelYourBookmarks": "Your Bookmarks",
"LabelYourPlaylists": "Your Playlists",
"LabelYourProgress": "Your Progress",
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
"MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
"MessageBookshelfNoCollections": "You haven't made any collections yet",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
"MessageFeedURLWillBe": "Feed URL will be {0}",
"MessageFetching": "Fetching...",
"MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",
"MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...",
"MessageLoadingFolders": "Loading folders...",
"MessageM4BFailed": "M4B Failed!",
"MessageM4BFinished": "M4B Finished!",
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
"MessageMarkAsFinished": "Mark as Finished",
"MessageMarkAsNotFinished": "Mark as Not Finished",
"MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
"MessageNoAudioTracks": "No audio tracks",
"MessageNoAuthors": "No Authors",
"MessageNoBackups": "No Backups",
"MessageNoBookmarks": "No Bookmarks",
"MessageNoChapters": "No Chapters",
"MessageNoCollections": "No Collections",
"MessageNoCoversFound": "No Covers Found",
"MessageNoDescription": "No description",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "No episode matches found",
"MessageNoEpisodes": "No Episodes",
"MessageNoFoldersAvailable": "No Folders Available",
"MessageNoGenres": "No Genres",
"MessageNoIssues": "No Issues",
"MessageNoItems": "No Items",
"MessageNoItemsFound": "No items found",
"MessageNoListeningSessions": "No Listening Sessions",
"MessageNoLogs": "No Logs",
"MessageNoMediaProgress": "No Media Progress",
"MessageNoNotifications": "No Notifications",
"MessageNoPodcastsFound": "No podcasts found",
"MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary",
"MessageNoUserPlaylists": "You have no playlists",
"MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
"MessageRemoveChapter": "Remove chapter",
"MessageRemoveEpisodes": "Remove {0} episode(s)",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for",
"MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
"MessageThinking": "Thinking...",
"MessageUploaderItemFailed": "Failed to upload",
"MessageUploaderItemSuccess": "Successfully Uploaded!",
"MessageUploading": "Uploading...",
"MessageValidCronExpression": "Valid cron expression",
"MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
"MessageXLibraryIsEmpty": "{0} Library is empty!",
"MessageYourAudiobookDurationIsLonger": "Your audiobook duration is longer than the duration found",
"MessageYourAudiobookDurationIsShorter": "Your audiobook duration is shorter than duration found",
"NoteChangeRootPassword": "रूट user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
"NoteUploaderOnlyAudioFiles": "If uploading only audio files then each audio file will be handled as a separate audiobook.",
"NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.",
"PlaceholderNewCollection": "New collection name",
"PlaceholderNewFolderPath": "New folder path",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
"ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated",
"ToastAuthorImageRemoveFailed": "Failed to remove image",
"ToastAuthorImageRemoveSuccess": "Author image removed",
"ToastAuthorUpdateFailed": "Failed to update author",
"ToastAuthorUpdateMerged": "Author merged",
"ToastAuthorUpdateSuccess": "Author updated",
"ToastAuthorUpdateSuccessNoImageFound": "Author updated (no image found)",
"ToastBackupCreateFailed": "Failed to create backup",
"ToastBackupCreateSuccess": "Backup created",
"ToastBackupDeleteFailed": "Failed to delete backup",
"ToastBackupDeleteSuccess": "Backup deleted",
"ToastBackupRestoreFailed": "Failed to restore backup",
"ToastBackupUploadFailed": "Failed to upload backup",
"ToastBackupUploadSuccess": "Backup uploaded",
"ToastBatchUpdateFailed": "Batch update failed",
"ToastBatchUpdateSuccess": "Batch update success",
"ToastBookmarkCreateFailed": "Failed to create bookmark",
"ToastBookmarkCreateSuccess": "Bookmark added",
"ToastBookmarkRemoveFailed": "Failed to remove bookmark",
"ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateFailed": "Failed to update bookmark",
"ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveFailed": "Failed to remove collection",
"ToastCollectionRemoveSuccess": "Collection removed",
"ToastCollectionUpdateFailed": "Failed to update collection",
"ToastCollectionUpdateSuccess": "Collection updated",
"ToastItemCoverUpdateFailed": "Failed to update item cover",
"ToastItemCoverUpdateSuccess": "Item cover updated",
"ToastItemDetailsUpdateFailed": "Failed to update item details",
"ToastItemDetailsUpdateSuccess": "Item details updated",
"ToastItemDetailsUpdateUnneeded": "No updates needed for item details",
"ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
"ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
"ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished",
"ToastItemMarkedAsNotFinishedSuccess": "Item marked as Not Finished",
"ToastLibraryCreateFailed": "Failed to create library",
"ToastLibraryCreateSuccess": "Library \"{0}\" created",
"ToastLibraryDeleteFailed": "Failed to delete library",
"ToastLibraryDeleteSuccess": "Library deleted",
"ToastLibraryScanFailedToStart": "Failed to start scan",
"ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPodcastCreateFailed": "Failed to create podcast",
"ToastPodcastCreateSuccess": "Podcast created successfully",
"ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session",
"ToastSessionDeleteSuccess": "Session deleted",
"ToastSocketConnected": "Socket connected",
"ToastSocketDisconnected": "Socket disconnected",
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Failed to delete user",
"ToastUserDeleteSuccess": "User deleted"
}

View File

@@ -155,11 +155,13 @@
"HeaderUpdateLibrary": "Aktualiziraj biblioteku",
"HeaderUsers": "Korinici",
"HeaderYourStats": "Tvoja statistika",
"LabelAbridged": "Abridged",
"LabelAccountType": "Vrsta korisničkog računa",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gost",
"LabelAccountTypeUser": "Korisnik",
"LabelActivity": "Aktivnost",
"LabelAdded": "Added",
"LabelAddedAt": "Added At",
"LabelAddToCollection": "Dodaj u kolekciju",
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
@@ -181,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Broj backupa zadržati",
"LabelBackupsNumberToKeepHelp": "Samo 1 backup će biti odjednom obrisan. Ako koristite više njih, morati ćete ih ručno ukloniti.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Knjige",
"LabelChangePassword": "Promijeni lozinku",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "poglavlja pronađena",
"LabelChapterTitle": "Ime poglavlja",
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Kolekcije",
"LabelComplete": "Complete",
@@ -211,6 +217,7 @@
"LabelDuration": "Trajanje",
"LabelDurationFound": "Pronađeno trajanje:",
"LabelEdit": "Uredi",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Uključi",
"LabelEnd": "Kraj",
"LabelEpisode": "Epizoda",
@@ -228,6 +235,7 @@
"LabelFinished": "Finished",
"LabelFolder": "Folder",
"LabelFolders": "Folderi",
"LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Žanrovi",
"LabelHardDeleteFile": "Obriši datoteku zauvijek",
@@ -246,9 +254,12 @@
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Nevaljajuči dijelovi",
"LabelInvert": "Invert",
"LabelItem": "Stavka",
"LabelLanguage": "Jezik",
"LabelLanguageDefaultServer": "Default jezik servera",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Zadnje pogledano",
"LabelLastTime": "Prošli put",
"LabelLastUpdate": "Zadnja aktualizacija",
@@ -267,10 +278,12 @@
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Poslužitelj metapodataka ",
"LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minuta",
"LabelMissing": "Nedostaje",
"LabelMissingParts": "Nedostajali dijelovi",
"LabelMore": "Više",
"LabelMoreInfo": "More Info",
"LabelName": "Ime",
"LabelNarrator": "Narrator",
"LabelNarrators": "Naratori",
@@ -400,7 +413,9 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags dostupni korisniku",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Vremena odslušano",
"LabelTimeListenedToday": "Vremena odslušano danas",
"LabelTimeRemaining": "{0} preostalo",
@@ -420,6 +435,7 @@
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Tip",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Nepoznato",
"LabelUpdateCover": "Aktualiziraj Cover",
"LabelUpdateCoverHelp": "Dozvoli postavljanje novog covera za odabrane knjige nakon što je match pronađen.",
@@ -463,6 +479,7 @@
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",

View File

@@ -155,11 +155,13 @@
"HeaderUpdateLibrary": "Aggiorna Libreria",
"HeaderUsers": "Utenti",
"HeaderYourStats": "Statistiche Personali",
"LabelAbridged": "Abridged",
"LabelAccountType": "Tipo di Account",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Ospite",
"LabelAccountTypeUser": "Utente",
"LabelActivity": "Attività",
"LabelAdded": "Added",
"LabelAddedAt": "Aggiunto il",
"LabelAddToCollection": "Aggiungi alla Raccolta",
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
@@ -181,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Come protezione contro gli errori di config, i backup falliranno se superano la dimensione configurata.",
"LabelBackupsNumberToKeep": "Numero di backup da mantenere",
"LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Libri",
"LabelChangePassword": "Cambia Password",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "Capitoli Trovati",
"LabelChapterTitle": "Titoli dei Capitoli",
"LabelClosePlayer": "Chiudi player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Comprimi Serie",
"LabelCollections": "Raccolte",
"LabelComplete": "Completo",
@@ -211,6 +217,7 @@
"LabelDuration": "Durata",
"LabelDurationFound": "Durata Trovata:",
"LabelEdit": "Modifica",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Abilita",
"LabelEnd": "Fine",
"LabelEpisode": "Episodio",
@@ -228,6 +235,7 @@
"LabelFinished": "Finita",
"LabelFolder": "Cartella",
"LabelFolders": "Cartelle",
"LabelFormat": "Format",
"LabelGenre": "Genere",
"LabelGenres": "Generi",
"LabelHardDeleteFile": "Elimina Definitivamente",
@@ -246,9 +254,12 @@
"LabelIntervalEveryDay": "Ogni Giorno",
"LabelIntervalEveryHour": "Ogni ora",
"LabelInvalidParts": "Parti Invalide",
"LabelInvert": "Invert",
"LabelItem": "Oggetti",
"LabelLanguage": "Lingua",
"LabelLanguageDefaultServer": "Lingua di Default",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Ultimi Visti",
"LabelLastTime": "Ultima Volta",
"LabelLastUpdate": "Ultimo Aggiornamento",
@@ -267,10 +278,12 @@
"LabelMediaType": "Tipo Media",
"LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minuto",
"LabelMissing": "Altro",
"LabelMissingParts": "Parti rimantenti",
"LabelMore": "Molto",
"LabelMoreInfo": "More Info",
"LabelName": "Nome",
"LabelNarrator": "Narratore",
"LabelNarrators": "Narratori",
@@ -400,7 +413,9 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Processi in esecuzione",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Tempo di Ascolto",
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
"LabelTimeRemaining": "{0} rimanente",
@@ -420,6 +435,7 @@
"LabelTracksMultiTrack": "Multi-traccia",
"LabelTracksSingleTrack": "Traccia-singola",
"LabelType": "Tipo",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Sconosciuto",
"LabelUpdateCover": "Aggiornamento Cover",
"LabelUpdateCoverHelp": "Consenti la sovrascrittura delle copertine esistenti per i libri selezionati quando viene trovata una corrispondenza",
@@ -463,6 +479,7 @@
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?",
"MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
@@ -636,4 +653,4 @@
"ToastSocketFailedToConnect": "Socket non riesce a connettersi",
"ToastUserDeleteFailed": "Errore eliminazione utente",
"ToastUserDeleteSuccess": "Utente eliminato"
}
}

View File

@@ -155,11 +155,13 @@
"HeaderUpdateLibrary": "Zaktualizuj bibliotekę",
"HeaderUsers": "Użytkownicy",
"HeaderYourStats": "Twoje statystyki",
"LabelAbridged": "Abridged",
"LabelAccountType": "Typ konta",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gość",
"LabelAccountTypeUser": "Użytkownik",
"LabelActivity": "Aktywność",
"LabelAdded": "Added",
"LabelAddedAt": "Dodano",
"LabelAddToCollection": "Dodaj do kolekcji",
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
@@ -181,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.",
"LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania",
"LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Książki",
"LabelChangePassword": "Zmień hasło",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "Znalezione rozdziały",
"LabelChapterTitle": "Tytuł rozdziału",
"LabelClosePlayer": "Zamknij odtwarzacz",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Podsumuj serię",
"LabelCollections": "Kolekcje",
"LabelComplete": "Ukończone",
@@ -211,6 +217,7 @@
"LabelDuration": "Czas trwania",
"LabelDurationFound": "Znaleziona długość:",
"LabelEdit": "Edytuj",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Włącz",
"LabelEnd": "Zakończ",
"LabelEpisode": "Odcinek",
@@ -228,6 +235,7 @@
"LabelFinished": "Zakończone",
"LabelFolder": "Folder",
"LabelFolders": "Foldery",
"LabelFormat": "Format",
"LabelGenre": "Gatunek",
"LabelGenres": "Gatunki",
"LabelHardDeleteFile": "Usuń trwale plik",
@@ -246,9 +254,12 @@
"LabelIntervalEveryDay": "Każdego dnia",
"LabelIntervalEveryHour": "Każdej godziny",
"LabelInvalidParts": "Nieprawidłowe części",
"LabelInvert": "Invert",
"LabelItem": "Pozycja",
"LabelLanguage": "Język",
"LabelLanguageDefaultServer": "Domyślny język serwera",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Ostatnio widziany",
"LabelLastTime": "Ostatni czas",
"LabelLastUpdate": "Ostatnia aktualizacja",
@@ -267,10 +278,12 @@
"LabelMediaType": "Typ mediów",
"LabelMetadataProvider": "Dostawca metadanych",
"LabelMetaTag": "Tag",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Minuta",
"LabelMissing": "Brakujący",
"LabelMissingParts": "Brakujące cześci",
"LabelMore": "Więcej",
"LabelMoreInfo": "More Info",
"LabelName": "Nazwa",
"LabelNarrator": "Narrator",
"LabelNarrators": "Lektorzy",
@@ -400,7 +413,9 @@
"LabelTag": "Tag",
"LabelTags": "Tagi",
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Tasks Running",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Czas odtwarzania",
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
"LabelTimeRemaining": "Pozostało {0}",
@@ -420,6 +435,7 @@
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Typ",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Nieznany",
"LabelUpdateCover": "Zaktalizuj odkładkę",
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
@@ -463,6 +479,7 @@
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",

View File

@@ -155,11 +155,13 @@
"HeaderUpdateLibrary": "Обновить библиотеку",
"HeaderUsers": "Пользователи",
"HeaderYourStats": "Ваша статистика",
"LabelAbridged": "Сокращенное издание",
"LabelAccountType": "Тип учетной записи",
"LabelAccountTypeAdmin": "Администратор",
"LabelAccountTypeGuest": "Гость",
"LabelAccountTypeUser": "Пользователь",
"LabelActivity": "Активность",
"LabelAdded": "Added",
"LabelAddedAt": "Дата добавления",
"LabelAddToCollection": "Добавить в коллекцию",
"LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию",
@@ -181,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "В качестве защиты процесс бэкапирования будет завершаться ошибкой, если будет превышен настроенный размер.",
"LabelBackupsNumberToKeep": "Сохранять бэкапов",
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
"LabelBitrate": "Bitrate",
"LabelBooks": "Книги",
"LabelChangePassword": "Изменить пароль",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "глав найдено",
"LabelChapterTitle": "Название главы",
"LabelClosePlayer": "Закрыть проигрыватель",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Свернуть серии",
"LabelCollections": "Коллекции",
"LabelComplete": "Завершить",
@@ -211,6 +217,7 @@
"LabelDuration": "Длина",
"LabelDurationFound": "Найденная длина:",
"LabelEdit": "Редактировать",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Включить",
"LabelEnd": "Конец",
"LabelEpisode": "Эпизод",
@@ -228,6 +235,7 @@
"LabelFinished": "Закончен",
"LabelFolder": "Папка",
"LabelFolders": "Папки",
"LabelFormat": "Format",
"LabelGenre": "Жанр",
"LabelGenres": "Жанры",
"LabelHardDeleteFile": "Жесткое удаление файла",
@@ -246,9 +254,12 @@
"LabelIntervalEveryDay": "Каждый день",
"LabelIntervalEveryHour": "Каждый час",
"LabelInvalidParts": "Неверные части",
"LabelInvert": "Invert",
"LabelItem": "Элемент",
"LabelLanguage": "Язык",
"LabelLanguageDefaultServer": "Язык сервера по умолчанию",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Последнее сканирование",
"LabelLastTime": "Последний по времени",
"LabelLastUpdate": "Последний обновленный",
@@ -267,10 +278,12 @@
"LabelMediaType": "Тип медиа",
"LabelMetadataProvider": "Провайдер",
"LabelMetaTag": "Мета тег",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "Минуты",
"LabelMissing": "Потеряно",
"LabelMissingParts": "Потерянные части",
"LabelMore": "Еще",
"LabelMoreInfo": "More Info",
"LabelName": "Имя",
"LabelNarrator": "Читает",
"LabelNarrators": "Чтецы",
@@ -393,14 +406,16 @@
"LabelStatsMinutes": "минут",
"LabelStatsMinutesListening": "Минут прослушано",
"LabelStatsOverallDays": "Всего дней",
"LabelStatsOverallHours": "Всего сасов",
"LabelStatsOverallHours": "Всего часов",
"LabelStatsWeekListening": "Недель прослушано",
"LabelSubtitle": "Подзаголовок",
"LabelSupportedFileTypes": "Поддерживаемые типы файлов",
"LabelTag": "Тег",
"LabelTags": "Теги",
"LabelTagsAccessibleToUser": "Теги доступные для пользователя",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "Запущенные задачи",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Время прослушивания",
"LabelTimeListenedToday": "Время прослушивания сегодня",
"LabelTimeRemaining": "{0} осталось",
@@ -420,6 +435,7 @@
"LabelTracksMultiTrack": "Мультитрек",
"LabelTracksSingleTrack": "Один трек",
"LabelType": "Тип",
"LabelUnabridged": "Полное издание",
"LabelUnknown": "Неизвестно",
"LabelUpdateCover": "Обновить обложку",
"LabelUpdateCoverHelp": "Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены",
@@ -463,6 +479,7 @@
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?",
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
@@ -566,7 +583,7 @@
"PlaceholderNewFolderPath": "Путь к новой папке",
"PlaceholderNewPlaylist": "Новое название плейлиста",
"PlaceholderSearch": "Поиск...",
"PlaceholderSearchEpisode": "Search episode...",
"PlaceholderSearchEpisode": "Поиск эпизода...",
"ToastAccountUpdateFailed": "Не удалось обновить учетную запись",
"ToastAccountUpdateSuccess": "Учетная запись обновлена",
"ToastAuthorImageRemoveFailed": "Не удалось удалить изображение",

View File

@@ -155,11 +155,13 @@
"HeaderUpdateLibrary": "更新媒体库",
"HeaderUsers": "用户",
"HeaderYourStats": "你的统计数据",
"LabelAbridged": "Abridged",
"LabelAccountType": "帐户类型",
"LabelAccountTypeAdmin": "管理员",
"LabelAccountTypeGuest": "来宾",
"LabelAccountTypeUser": "用户",
"LabelActivity": "活动",
"LabelAdded": "Added",
"LabelAddedAt": "添加于",
"LabelAddToCollection": "添加到收藏",
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
@@ -181,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "为了防止错误配置, 如果备份超过配置的大小, 备份将失败.",
"LabelBackupsNumberToKeep": "要保留的备份个数",
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
"LabelBitrate": "Bitrate",
"LabelBooks": "图书",
"LabelChangePassword": "修改密码",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChaptersFound": "找到的章节",
"LabelChapterTitle": "章节标题",
"LabelClosePlayer": "关闭播放器",
"LabelCodec": "Codec",
"LabelCollapseSeries": "折叠系列",
"LabelCollections": "收藏",
"LabelComplete": "已完成",
@@ -211,6 +217,7 @@
"LabelDuration": "持续时间",
"LabelDurationFound": "找到持续时间:",
"LabelEdit": "编辑",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "启用",
"LabelEnd": "结束",
"LabelEpisode": "剧集",
@@ -228,6 +235,7 @@
"LabelFinished": "已听完",
"LabelFolder": "文件夹",
"LabelFolders": "文件夹",
"LabelFormat": "Format",
"LabelGenre": "流派",
"LabelGenres": "流派",
"LabelHardDeleteFile": "完全删除文件",
@@ -246,9 +254,12 @@
"LabelIntervalEveryDay": "每天",
"LabelIntervalEveryHour": "每小时",
"LabelInvalidParts": "无效部件",
"LabelInvert": "Invert",
"LabelItem": "项目",
"LabelLanguage": "语言",
"LabelLanguageDefaultServer": "默认服务器语言",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "上次查看时间",
"LabelLastTime": "最近一次",
"LabelLastUpdate": "最近更新",
@@ -267,10 +278,12 @@
"LabelMediaType": "媒体类型",
"LabelMetadataProvider": "元数据提供者",
"LabelMetaTag": "元数据标签",
"LabelMetaTags": "Meta Tags",
"LabelMinute": "分钟",
"LabelMissing": "丢失",
"LabelMissingParts": "丢失的部分",
"LabelMore": "更多",
"LabelMoreInfo": "More Info",
"LabelName": "名称",
"LabelNarrator": "演播者",
"LabelNarrators": "演播者",
@@ -400,7 +413,9 @@
"LabelTag": "标签",
"LabelTags": "标签",
"LabelTagsAccessibleToUser": "用户可访问的标签",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTasks": "正在运行的任务",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "收听时间",
"LabelTimeListenedToday": "今日收听的时间",
"LabelTimeRemaining": "剩余 {0}",
@@ -420,6 +435,7 @@
"LabelTracksMultiTrack": "多轨",
"LabelTracksSingleTrack": "单轨",
"LabelType": "类型",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "未知",
"LabelUpdateCover": "更新封面",
"LabelUpdateCoverHelp": "找到匹配项时允许覆盖所选书籍存在的封面",
@@ -463,6 +479,7 @@
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",

View File

@@ -48,21 +48,9 @@
<Mode>rw</Mode>
</Volume>
</Data>
<Environment>
<Variable>
<Value>99</Value>
<Name>AUDIOBOOKSHELF_UID</Name>
<Mode/>
</Variable>
<Variable>
<Value>100</Value>
<Name>AUDIOBOOKSHELF_GID</Name>
<Mode/>
</Variable>
</Environment>
<Labels/>
<Config Name="Audiobooks" Target="/audiobooks" Default="" Mode="rw" Description="Container Path: /audiobooks" Type="Path" Display="always" Required="true" Mask="false" />
<Config Name="Config" Target="/config" Default="/mnt/user/appdata/audiobookshelf/config/" Mode="rw" Description="Container Path: /config" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/config/</Config>
<Config Name="Metadata" Target="/metadata" Default="/mnt/user/appdata/audiobookshelf/metadata/" Mode="rw" Description="Container Path: /metadata" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/metadata/</Config>
<Config Name="Web UI Port" Target="80" Default="13378" Mode="tcp" Description="Container Port: 80" Type="Port" Display="always" Required="false" Mask="false">13378</Config>
</Container>
</Container>

View File

@@ -12,6 +12,7 @@ if (isDev) {
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
process.env.CLIENT_PORT = devEnv.ClientPort || 8080
}
const PORT = process.env.PORT || 80
@@ -22,8 +23,9 @@ const UID = process.env.AUDIOBOOKSHELF_UID
const GID = process.env.AUDIOBOOKSHELF_GID
const SOURCE = process.env.SOURCE || 'docker'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
const CLIENT_PORT = process.env.CLIENT_PORT || 3000
console.log('Config', CONFIG_PATH, METADATA_PATH)
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH, CLIENT_PORT)
Server.start()

202
package-lock.json generated
View File

@@ -1,21 +1,22 @@
{
"name": "audiobookshelf",
"version": "2.2.17",
"version": "2.2.19",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.2.17",
"version": "2.2.19",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
"express": "^4.17.1",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"http-proxy-middleware": "^3.0.0-beta.1",
"node-tone": "^1.0.1",
"socket.io": "^4.5.4",
"xml2js": "^0.4.23"
"xml2js": "^0.5.0"
},
"bin": {
"audiobookshelf": "prod.js"
@@ -42,6 +43,14 @@
"@types/node": "*"
}
},
"node_modules/@types/http-proxy": {
"version": "1.17.11",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz",
"integrity": "sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
@@ -157,7 +166,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"dependencies": {
"fill-range": "^7.0.1"
},
@@ -451,6 +459,11 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/express": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
@@ -496,7 +509,6 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -682,6 +694,56 @@
"node": ">= 0.8"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-middleware": {
"version": "3.0.0-beta.1",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.0-beta.1.tgz",
"integrity": "sha512-hdiTlVVoaxncf239csnEpG5ew2lRWnoNR1PMWOO6kYulSphlrfLs5JFZtFVH3R5EUWSZNMkeUqvkvfctuWaK8A==",
"dependencies": {
"@types/http-proxy": "^1.17.10",
"debug": "^4.3.4",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.5"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/http-proxy-middleware/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/http-proxy-middleware/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -728,7 +790,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -737,7 +798,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@@ -749,11 +809,21 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -775,6 +845,18 @@
"node": ">= 0.6"
}
},
"node_modules/micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"dependencies": {
"braces": "^3.0.2",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -946,7 +1028,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@@ -1020,6 +1101,11 @@
"node": ">=8.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1238,7 +1324,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
@@ -1329,9 +1414,9 @@
}
},
"node_modules/xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
@@ -1368,6 +1453,14 @@
"@types/node": "*"
}
},
"@types/http-proxy": {
"version": "1.17.11",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz",
"integrity": "sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA==",
"requires": {
"@types/node": "*"
}
},
"@types/node": {
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
@@ -1467,7 +1560,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"requires": {
"fill-range": "^7.0.1"
}
@@ -1671,6 +1763,11 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"express": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
@@ -1713,7 +1810,6 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"requires": {
"to-regex-range": "^5.0.1"
}
@@ -1835,6 +1931,44 @@
"toidentifier": "1.0.1"
}
},
"http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"requires": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
}
},
"http-proxy-middleware": {
"version": "3.0.0-beta.1",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.0-beta.1.tgz",
"integrity": "sha512-hdiTlVVoaxncf239csnEpG5ew2lRWnoNR1PMWOO6kYulSphlrfLs5JFZtFVH3R5EUWSZNMkeUqvkvfctuWaK8A==",
"requires": {
"@types/http-proxy": "^1.17.10",
"debug": "^4.3.4",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.5"
},
"dependencies": {
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"requires": {
"ms": "2.1.2"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}
}
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -1871,14 +2005,12 @@
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
},
"is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"requires": {
"is-extglob": "^2.1.1"
}
@@ -1886,8 +2018,12 @@
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA=="
},
"media-typer": {
"version": "0.3.0",
@@ -1904,6 +2040,15 @@
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="
},
"micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"requires": {
"braces": "^3.0.2",
"picomatch": "^2.3.1"
}
},
"mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -2027,8 +2172,7 @@
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
"proxy-addr": {
"version": "2.0.7",
@@ -2078,6 +2222,11 @@
"picomatch": "^2.2.1"
}
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2244,7 +2393,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"requires": {
"is-number": "^7.0.0"
}
@@ -2300,9 +2448,9 @@
"requires": {}
},
"xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
@@ -2314,4 +2462,4 @@
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.2.17",
"version": "2.2.19",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
@@ -34,11 +34,12 @@
"express": "^4.17.1",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"http-proxy-middleware": "^3.0.0-beta.1",
"node-tone": "^1.0.1",
"socket.io": "^4.5.4",
"xml2js": "^0.4.23"
"xml2js": "^0.5.0"
},
"devDependencies": {
"nodemon": "^2.0.20"
}
}
}

View File

@@ -147,7 +147,7 @@ For this to work you must enable at least the following mods using `a2enmod`:
### SWAG Reverse Proxy
[See this solution](https://forums.unraid.net/topic/112698-support-audiobookshelf/?do=findComment&comment=1049637)
[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
### Synology Reverse Proxy
@@ -185,8 +185,99 @@ subdomain.domain.com {
# Run from source
[See discussion](https://github.com/advplyr/audiobookshelf/discussions/259#discussioncomment-1869729)
# Contributing
# Contributing / How to Support
This application is built using [NodeJs](https://nodejs.org/).
### Dev Container Setup
The easiest way to begin developing this project is to use a dev container. An introduction to dev containers in VSCode can be found [here](https://code.visualstudio.com/docs/devcontainers/containers).
Required Software:
* [Docker Desktop](https://www.docker.com/products/docker-desktop/)
* [VSCode](https://code.visualstudio.com/download)
*Note, it is possible to use other container software than Docker and IDEs other than VSCode. However, this setup is more complicated and not covered here.*
<div>
<details>
<summary>Install the required software on Windows with <a href=(https://docs.microsoft.com/en-us/windows/package-manager/winget/#production-recommended)>winget</a></summary>
<p>
Note: This requires a PowerShell prompt with winget installed. You should be able to copy and paste the code block to install. If you use an elevated PowerShell prompt, UAC will not pop up during the installs.
```PowerShell
winget install -e --id Docker.DockerDesktop; `
winget install -e --id Microsoft.VisualStudioCode
```
</p>
</details>
</div>
<div>
<details>
<summary>Install the required software on MacOS with <a href=(https://snapcraft.io/)>homebrew</a></summary>
<p>
```sh
brew install --cask docker visual-studio-code
```
</p>
</details>
</div>
<div style="padding-bottom: 1em">
<details>
<summary>Install the required software on Linux with <a href=(https://brew.sh/)>snap</a></summary>
<p>
```sh
sudo snap install docker; \
sudo snap install code --classic
```
</p>
</details>
</div>
After installing these packages, you can now install the [Remote Development](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack) extension for VSCode. After installing this extension open the command pallet (`ctrl+shift+p` or `cmd+shift+p`) and select the command `>Dev Containers: Rebuild and Reopen in Container`. This will cause the development environment container to be built and launched.
You are now ready to start development!
### Manual Environment Setup
If you don't want to use the dev container, you can still develop this project. First, you will need to install [NodeJs](https://nodejs.org/) (version 16) and [FFmpeg](https://ffmpeg.org/).
Next you will need to create a `dev.js` file in the project's root directory. This contains configuration information and paths unique to your development environment. You can find an example of this file in `.devcontainer/dev.js`.
You are now ready to build the client:
```sh
npm ci
cd client
npm ci
npm run generate
cd ..
```
### Development Commands
After setting up your development environment, either using the dev container or using your own custom environment, the following commands will help you run the server and client.
To run the server, you can use the command `npm run dev`. This will use the client that was built when you ran `npm run generate` in the client directory or when you started the dev container. If you make changes to the server, you will need to restart the server. If you make changes to the client, you will need to run the command `(cd client; npm run generate)` and then restart the server. By default the client runs at `localhost:3333`, though the port can be configured in `dev.js`.
You can also build a version of the client that supports live reloading. To do this, start the server, then run the command `(cd client; npm run dev)`. This will run a separate instance of the client at `localhost:3000` that will be automatically updated as you make changes to the client.
If you are using VSCode, this project includes a couple of pre-defined targets to speed up this process. First, if you build the project (`ctrl+shift+b` or `cmd+shift+b`) it will automatically generate the client. Next, there are debug commands for running the server and client. You can view these targets using the debug panel (bring it up with (`ctrl+shift+d` or `cmd+shift+d`):
* `Debug server`—Run the server.
* `Debug client (nuxt)`—Run the client with live reload.
* `Debug server and client (nuxt)`—Runs both the preceding two debug targets.
# How to Support
[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)

View File

@@ -126,12 +126,12 @@ class Auth {
async login(req, res) {
const ipAddress = requestIp.getClientIp(req)
var username = (req.body.username || '').toLowerCase()
var password = req.body.password || ''
const username = (req.body.username || '').toLowerCase()
const password = req.body.password || ''
var user = this.users.find(u => u.username.toLowerCase() === username)
const user = this.users.find(u => u.username.toLowerCase() === username)
if (!user || !user.isActive) {
if (!user?.isActive) {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
@@ -145,13 +145,15 @@ class Auth {
if (password) {
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
return res.json(this.getUserLoginResponsePayload(user))
}
}
// Check password match
var compare = await bcrypt.compare(password, user.pash)
const compare = await bcrypt.compare(password, user.pash)
if (compare) {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
res.json(this.getUserLoginResponsePayload(user))
} else {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)

View File

@@ -27,17 +27,16 @@ class Db {
this.SeriesPath = Path.join(global.ConfigPath, 'series')
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
const staleTime = 1000 * 60 * 2
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, { lockoptions: { stale: staleTime } })
this.usersDb = new njodb.Database(this.UsersPath, { lockoptions: { stale: staleTime } })
this.sessionsDb = new njodb.Database(this.SessionsPath, { lockoptions: { stale: staleTime } })
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
this.libraryItems = []
this.users = []
@@ -59,6 +58,21 @@ class Db {
return this.users.some(u => u.id === 'root')
}
getNjodbOptions() {
return {
lockoptions: {
stale: 1000 * 20, // 20 seconds
update: 2500,
retries: {
retries: 20,
minTimeout: 250,
maxTimeout: 5000,
factor: 1
}
}
}
}
getEntityDb(entityName) {
if (entityName === 'user') return this.usersDb
else if (entityName === 'session') return this.sessionsDb
@@ -88,17 +102,16 @@ class Db {
}
reinit() {
const staleTime = 1000 * 60 * 2
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, { lockoptions: { stale: staleTime } })
this.usersDb = new njodb.Database(this.UsersPath, { lockoptions: { stale: staleTime } })
this.sessionsDb = new njodb.Database(this.SessionsPath, { lockoptions: { stale: staleTime } })
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
return this.init()
}

View File

@@ -22,6 +22,7 @@ const SocketAuthority = require('./SocketAuthority')
const ApiRouter = require('./routers/ApiRouter')
const HlsRouter = require('./routers/HlsRouter')
const StaticRouter = require('./routers/StaticRouter')
const ClientRouter = require('./routers/ClientRouter')
const NotificationManager = require('./managers/NotificationManager')
const CoverManager = require('./managers/CoverManager')
@@ -35,10 +36,9 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager')
const TaskManager = require('./managers/TaskManager')
const EBookManager = require('./managers/EBookManager')
class Server {
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH, CLIENT_PORT) {
this.Port = PORT
this.Host = HOST
global.Source = SOURCE
@@ -48,6 +48,7 @@ class Server {
global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))
global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
global.RouterBasePath = ROUTER_BASE_PATH
global.ClientPort = CLIENT_PORT
global.XAccel = process.env.USE_X_ACCEL
if (!fs.pathExistsSync(global.ConfigPath)) {
@@ -75,7 +76,6 @@ class Server {
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db)
this.eBookManager = new EBookManager(this.db)
this.scanner = new Scanner(this.db, this.coverManager)
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
@@ -84,6 +84,7 @@ class Server {
this.apiRouter = new ApiRouter(this)
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager)
this.staticRouter = new StaticRouter(this.db)
this.clientRouter = new ClientRouter(global.appRoot, global.ClientPort, global.RouterBasePath)
Logger.logManager = this.logManager
@@ -119,7 +120,6 @@ class Server {
await this.purgeMetadata() // Remove metadata folders without library item
await this.playbackSessionManager.removeInvalidSessions()
await this.cacheManager.ensureCachePaths()
await this.abMergeManager.ensureDownloadDirPath()
await this.backupManager.init()
await this.logManager.init()
@@ -139,6 +139,7 @@ class Server {
async start() {
Logger.info('=== Starting Server ===')
await this.init()
this.clientRouter.start()
const app = express()
const router = express.Router()
@@ -152,10 +153,6 @@ class Server {
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
router.use(express.json({ limit: "5mb" }))
// Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')
router.use(express.static(distPath))
// Metadata folder static path
router.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
@@ -191,28 +188,6 @@ class Server {
this.rssFeedManager.getFeedItem(req, res)
})
// Client dynamic routes
const dyanimicRoutes = [
'/item/:id',
'/author/:id',
'/audiobook/:id/chapters',
'/audiobook/:id/edit',
'/audiobook/:id/manage',
'/library/:library',
'/library/:library/search',
'/library/:library/bookshelf/:id?',
'/library/:library/authors',
'/library/:library/series/:id?',
'/library/:library/podcast/search',
'/library/:library/podcast/latest',
'/config/users/:id',
'/config/users/:id/sessions',
'/config/item-metadata-utils/:id',
'/collection/:id',
'/playlist/:id'
]
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
router.post('/init', (req, res) => {
@@ -241,6 +216,9 @@ class Server {
})
app.get('/healthcheck', (req, res) => res.sendStatus(200))
// Serve client on all other routes, 404 will be handled by client
router.use(this.clientRouter.router)
this.server.listen(this.Port, this.Host, () => {
if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`)
else Logger.info(`Listening on port :${this.Port}`)
@@ -351,7 +329,8 @@ class Server {
}
async stop() {
await this.watcher.close()
this.watcher.close()
this.clientRouter.stop()
Logger.info('Watcher Closed')
return new Promise((resolve) => {

View File

@@ -167,18 +167,19 @@ class AuthorController {
}
async match(req, res) {
var authorData = null
let authorData = null
const region = req.body.region || 'us'
if (req.body.asin) {
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin)
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region)
} else {
authorData = await this.authorFinder.findAuthorByName(req.body.q)
authorData = await this.authorFinder.findAuthorByName(req.body.q, region)
}
if (!authorData) {
return res.status(404).send('Author not found')
}
Logger.debug(`[AuthorController] match author with "${req.body.q || req.body.asin}"`, authorData)
var hasUpdates = false
let hasUpdates = false
if (authorData.asin && req.author.asin !== authorData.asin) {
req.author.asin = authorData.asin
hasUpdates = true
@@ -188,7 +189,7 @@ class AuthorController {
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
this.cacheManager.purgeImageCache(req.author.id)
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) {
req.author.imagePath = imageData.path
hasUpdates = true
@@ -204,7 +205,7 @@ class AuthorController {
req.author.updatedAt = Date.now()
await this.db.updateEntity('author', req.author)
var numBooks = this.db.libraryItems.filter(li => {
const numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))

View File

@@ -1,52 +0,0 @@
const Logger = require('../Logger')
const { isNullOrNaN } = require('../utils/index')
class EBookController {
constructor() { }
async getEbookInfo(req, res) {
const isDev = req.query.dev == 1
const json = await this.eBookManager.getBookInfo(req.libraryItem, req.user, isDev)
res.json(json)
}
async getEbookPage(req, res) {
if (isNullOrNaN(req.params.page)) {
return res.status(400).send('Invalid page params')
}
const isDev = req.query.dev == 1
const pageIndex = Number(req.params.page)
const page = await this.eBookManager.getBookPage(req.libraryItem, req.user, pageIndex, isDev)
if (!page) {
return res.status(500).send('Failed to get page')
}
res.send(page)
}
async getEbookResource(req, res) {
if (!req.query.path) {
return res.status(400).send('Invalid query path')
}
const isDev = req.query.dev == 1
this.eBookManager.getBookResource(req.libraryItem, req.user, req.query.path, isDev, res)
}
middleware(req, res, next) {
const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
return res.sendStatus(403)
}
if (!item.isBook || !item.media.ebookFile) {
return res.status(400).send('Invalid ebook library item')
}
req.libraryItem = item
next()
}
}
module.exports = new EBookController()

View File

@@ -417,6 +417,10 @@ class LibraryController {
return se.totalDuration
} else if (payload.sortBy === 'addedAt') {
return se.addedAt
} else if (payload.sortBy === 'lastBookUpdated') {
return Math.max(...(se.books).map(x => x.updatedAt), 0)
} else if (payload.sortBy === 'lastBookAdded') {
return Math.max(...(se.books).map(x => x.addedAt), 0)
} else { // sort by name
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
}
@@ -592,6 +596,7 @@ class LibraryController {
const itemMatches = []
const authorMatches = {}
const narratorMatches = {}
const seriesMatches = {}
const tagMatches = {}
@@ -604,7 +609,7 @@ class LibraryController {
matchText: queryResult.matchText
})
}
if (queryResult.series && queryResult.series.length) {
if (queryResult.series?.length) {
queryResult.series.forEach((se) => {
if (!seriesMatches[se.id]) {
const _series = this.db.series.find(_se => _se.id === se.id)
@@ -614,7 +619,7 @@ class LibraryController {
}
})
}
if (queryResult.authors && queryResult.authors.length) {
if (queryResult.authors?.length) {
queryResult.authors.forEach((au) => {
if (!authorMatches[au.id]) {
const _author = this.db.authors.find(_au => _au.id === au.id)
@@ -627,7 +632,7 @@ class LibraryController {
}
})
}
if (queryResult.tags && queryResult.tags.length) {
if (queryResult.tags?.length) {
queryResult.tags.forEach((tag) => {
if (!tagMatches[tag]) {
tagMatches[tag] = { name: tag, books: [li.toJSON()] }
@@ -636,13 +641,23 @@ class LibraryController {
}
})
}
if (queryResult.narrators?.length) {
queryResult.narrators.forEach((narrator) => {
if (!narratorMatches[narrator]) {
narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] }
} else {
narratorMatches[narrator].books.push(li.toJSON())
}
})
}
})
const itemKey = req.library.mediaType
const results = {
[itemKey]: itemMatches.slice(0, maxResults),
tags: Object.values(tagMatches).slice(0, maxResults),
authors: Object.values(authorMatches).slice(0, maxResults),
series: Object.values(seriesMatches).slice(0, maxResults)
series: Object.values(seriesMatches).slice(0, maxResults),
narrators: Object.values(narratorMatches).slice(0, maxResults)
}
res.json(results)
}

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