mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
405 Commits
count_cach
...
jwt_auth_r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f7831611f | ||
|
|
d09db19cd5 | ||
|
|
030e43f382 | ||
|
|
f081a7fdc1 | ||
|
|
f0d5f46199 | ||
|
|
0b8f6db45e | ||
|
|
806c0a2991 | ||
|
|
7d6d3e6687 | ||
|
|
ad07ed7e25 | ||
|
|
d3402e30c2 | ||
|
|
25fe4dee3a | ||
|
|
3c21c82ce1 | ||
|
|
3c8876a37d | ||
|
|
fba70c9831 | ||
|
|
27e40d16fd | ||
|
|
448cbf8530 | ||
|
|
f1153f9da5 | ||
|
|
d09a21d922 | ||
|
|
62afa3c3ee | ||
|
|
85446be0e5 | ||
|
|
018ca8e7ee | ||
|
|
f02453ac92 | ||
|
|
84b77f4c7f | ||
|
|
d41276ba8c | ||
|
|
576d7dc024 | ||
|
|
6d2b1df560 | ||
|
|
8255e4308c | ||
|
|
794adf0292 | ||
|
|
f2e0b9762c | ||
|
|
7d0def0edb | ||
|
|
0653572396 | ||
|
|
d9a3750667 | ||
|
|
9c0c7b6b08 | ||
|
|
df1391d93f | ||
|
|
8775e55762 | ||
|
|
d0d152c20d | ||
|
|
4ff7355262 | ||
|
|
6cc7a44a22 | ||
|
|
ad092ef8f8 | ||
|
|
4102ed8be4 | ||
|
|
691f291843 | ||
|
|
ac381854e5 | ||
|
|
9c8900560c | ||
|
|
d9cfcc86e7 | ||
|
|
ce803dd6de | ||
|
|
97afd22f81 | ||
|
|
e24eaab3f1 | ||
|
|
e201247d69 | ||
|
|
a24dae5262 | ||
|
|
e59babdf24 | ||
|
|
8dbe1e4e5d | ||
|
|
cdc37ddb0f | ||
|
|
f127a7beb5 | ||
|
|
df60aeb456 | ||
|
|
30c327d92a | ||
|
|
596bddf791 | ||
|
|
44ff90a6f2 | ||
|
|
293851d931 | ||
|
|
8b995a179d | ||
|
|
4d32a22de9 | ||
|
|
af1ff12dbb | ||
|
|
d96ed01ce4 | ||
|
|
7610e97f0f | ||
|
|
4f5123e842 | ||
|
|
d102065d02 | ||
|
|
34315d4c10 | ||
|
|
276a179446 | ||
|
|
4462d32e98 | ||
|
|
9722674072 | ||
|
|
35bb77c9c2 | ||
|
|
cf6f49ce75 | ||
|
|
d614373c64 | ||
|
|
b9969c78a6 | ||
|
|
fbf482d6b6 | ||
|
|
dd74d0a726 | ||
|
|
b13b80e011 | ||
|
|
e384863148 | ||
|
|
d21fe49ce2 | ||
|
|
a992400d6a | ||
|
|
108b2a60f5 | ||
|
|
af684e6a69 | ||
|
|
5336d0525e | ||
|
|
bb4eec9355 | ||
|
|
28404f37b8 | ||
|
|
7b92c15a46 | ||
|
|
c150ed4e98 | ||
|
|
cb7632b216 | ||
|
|
b8849677de | ||
|
|
9bf8d7de11 | ||
|
|
6634ce8fd4 | ||
|
|
9d4303ef7b | ||
|
|
1f7be58124 | ||
|
|
6b8b27b04f | ||
|
|
ba4061e5a4 | ||
|
|
693dc00fa3 | ||
|
|
f3f5f3b9bd | ||
|
|
b515c6c746 | ||
|
|
35e196238a | ||
|
|
2dc93258f1 | ||
|
|
5123f7d240 | ||
|
|
06d3bd76a8 | ||
|
|
52196afd99 | ||
|
|
3e44ee6f50 | ||
|
|
9841826e10 | ||
|
|
def93d18ec | ||
|
|
387a3d05b4 | ||
|
|
398d04fc08 | ||
|
|
c5e5e516af | ||
|
|
1c6f99b876 | ||
|
|
d0af82e71a | ||
|
|
76e7616439 | ||
|
|
fe99a269bc | ||
|
|
5315f65023 | ||
|
|
c2809808c3 | ||
|
|
204ac4f204 | ||
|
|
accd5d1096 | ||
|
|
5025c6a3ea | ||
|
|
6d0d1415e4 | ||
|
|
514f5c2409 | ||
|
|
2cc58b2c8a | ||
|
|
777a055fcd | ||
|
|
b45085d2d6 | ||
|
|
22f6e86a12 | ||
|
|
dc6783ea76 | ||
|
|
a6f10ca48e | ||
|
|
aac01d6d9a | ||
|
|
a617994207 | ||
|
|
7a33a412fc | ||
|
|
0135b3560c | ||
|
|
6968a5c02a | ||
|
|
5e2bb0b12c | ||
|
|
7122756e58 | ||
|
|
8ecc912c2d | ||
|
|
c8cea4e6af | ||
|
|
0c5d05d319 | ||
|
|
4a3eb7727b | ||
|
|
81640464ba | ||
|
|
eda7036f70 | ||
|
|
e669a8d378 | ||
|
|
8e01859075 | ||
|
|
f0525d4f0d | ||
|
|
84c9c6cb50 | ||
|
|
346df3680c | ||
|
|
6aa7c8a3d8 | ||
|
|
704c6f7bde | ||
|
|
f01055f6e6 | ||
|
|
759c58d3f7 | ||
|
|
357176b301 | ||
|
|
9bb4dc3ab0 | ||
|
|
709c33f27a | ||
|
|
4d846e225a | ||
|
|
5dc6d613bd | ||
|
|
63ccdb68f0 | ||
|
|
424ef1aec3 | ||
|
|
b6995ba5d1 | ||
|
|
9968743a93 | ||
|
|
c377b57601 | ||
|
|
262d0b46e3 | ||
|
|
32fc4f6555 | ||
|
|
81572adab6 | ||
|
|
1ad2e71fd5 | ||
|
|
db66b9eaeb | ||
|
|
28c2e62e61 | ||
|
|
96401c377c | ||
|
|
9d45880b37 | ||
|
|
9052ceedd3 | ||
|
|
4968864498 | ||
|
|
f44c2d9e11 | ||
|
|
0c8e334b1a | ||
|
|
abaa7b5ad0 | ||
|
|
df01e493ec | ||
|
|
949c8ce230 | ||
|
|
9eaa0c26cd | ||
|
|
d71f091e3e | ||
|
|
2589121908 | ||
|
|
ff425212e7 | ||
|
|
243baaf775 | ||
|
|
7275b1063b | ||
|
|
4fd97510b8 | ||
|
|
6e67b1d9dd | ||
|
|
0fc6afec26 | ||
|
|
c950ac7d69 | ||
|
|
8979e19e92 | ||
|
|
6a51cb07e8 | ||
|
|
846a8c3881 | ||
|
|
0cd698cc8d | ||
|
|
13d9462868 | ||
|
|
d8e2ff8b0e | ||
|
|
35c2a5c1a3 | ||
|
|
19dc096d22 | ||
|
|
535ebc10f0 | ||
|
|
7486a0659b | ||
|
|
273866fe92 | ||
|
|
6425d95deb | ||
|
|
68a39449a2 | ||
|
|
8e08458ea2 | ||
|
|
1119ddef8a | ||
|
|
3d0219a866 | ||
|
|
6ce1806359 | ||
|
|
f05a513767 | ||
|
|
d03c338b48 | ||
|
|
5e5a988f7a | ||
|
|
6d1f0b27df | ||
|
|
d01a7cb756 | ||
|
|
cae874ef05 | ||
|
|
733afc3e29 | ||
|
|
0772730336 | ||
|
|
8b02fe07c8 | ||
|
|
98f93a665c | ||
|
|
754566b221 | ||
|
|
f4f9adad35 | ||
|
|
16f7f1166e | ||
|
|
f527b0f4d5 | ||
|
|
4f41df53c9 | ||
|
|
8a15f775a2 | ||
|
|
5e83bcd283 | ||
|
|
2fd5dfcb66 | ||
|
|
872ce4fa38 | ||
|
|
ba792d91e5 | ||
|
|
4997c716db | ||
|
|
fd72d05280 | ||
|
|
241b56ad45 | ||
|
|
635c384952 | ||
|
|
ef930fd1b4 | ||
|
|
49997a1336 | ||
|
|
8d0434143c | ||
|
|
8e0319994e | ||
|
|
0ed6045d1e | ||
|
|
25c7e95a64 | ||
|
|
1781c4bbcb | ||
|
|
c4ce72d44e | ||
|
|
78813c4b28 | ||
|
|
990baa2dc6 | ||
|
|
c85f4467d2 | ||
|
|
59f7609054 | ||
|
|
2ef827e3fa | ||
|
|
5cadc8d90f | ||
|
|
40e7e36ef6 | ||
|
|
d60ad96f8a | ||
|
|
46ba342d49 | ||
|
|
ace6b2b81f | ||
|
|
fa7e2dfafe | ||
|
|
015310c15d | ||
|
|
f624f04dec | ||
|
|
7c13cfcda2 | ||
|
|
fc265dadae | ||
|
|
f9905f887e | ||
|
|
eb72bfbbc0 | ||
|
|
c268cace09 | ||
|
|
9666caf7a3 | ||
|
|
9e01e5c24e | ||
|
|
25e613a867 | ||
|
|
fe23a86eaa | ||
|
|
cb5a7d6aef | ||
|
|
7deb89ce7a | ||
|
|
1e300c77c9 | ||
|
|
ed7cc42959 | ||
|
|
f681ff68a1 | ||
|
|
ba112bf9c2 | ||
|
|
718434545a | ||
|
|
0e9a4c95a9 | ||
|
|
3c997c8468 | ||
|
|
eb49646256 | ||
|
|
c54b5eadfd | ||
|
|
659c671c25 | ||
|
|
0df5a7816d | ||
|
|
26c976b6b9 | ||
|
|
bdeb22615e | ||
|
|
257bf2ebe0 | ||
|
|
fc33da447a | ||
|
|
df45347690 | ||
|
|
b876256736 | ||
|
|
3ce6e45761 | ||
|
|
5ac6b85da1 | ||
|
|
69e0a0732a | ||
|
|
087835a9f3 | ||
|
|
1f7b181b7b | ||
|
|
1afb8840db | ||
|
|
d9531166b6 | ||
|
|
336de49d8d | ||
|
|
3cc527484d | ||
|
|
45987ffd63 | ||
|
|
1a1ef9c378 | ||
|
|
342d100f3e | ||
|
|
e0b90c6813 | ||
|
|
2706a9c4aa | ||
|
|
2cc9d1b7f8 | ||
|
|
2b7268c952 | ||
|
|
e097fe1e88 | ||
|
|
6819c0b108 | ||
|
|
58cd751b43 | ||
|
|
9f834a5345 | ||
|
|
5eaf9c69ad | ||
|
|
a1074e69ac | ||
|
|
65aec6a099 | ||
|
|
38957d4f32 | ||
|
|
a2dc76e190 | ||
|
|
fd84cd0d7f | ||
|
|
db7744eb84 | ||
|
|
af513a2fb6 | ||
|
|
4cb5c934d5 | ||
|
|
37f84a0f62 | ||
|
|
70595181f1 | ||
|
|
b357bbed60 | ||
|
|
f7a720c6ac | ||
|
|
6549605efd | ||
|
|
33952fb1fd | ||
|
|
7b207dc5d8 | ||
|
|
cb24a9c1ec | ||
|
|
3b42af5213 | ||
|
|
b56691f1a2 | ||
|
|
ac3154093c | ||
|
|
01ef24f5e6 | ||
|
|
3fb73c7426 | ||
|
|
bf3bc06322 | ||
|
|
2733c28784 | ||
|
|
b3dac831e6 | ||
|
|
35702aa770 | ||
|
|
b2ffb3b7b9 | ||
|
|
c52fe4b583 | ||
|
|
af8ace7d1f | ||
|
|
de37e40a1e | ||
|
|
56f5df91dc | ||
|
|
fc590abb09 | ||
|
|
bc7bbc1b7d | ||
|
|
4345973213 | ||
|
|
b4ff9f5944 | ||
|
|
a9a253f769 | ||
|
|
a9783efa34 | ||
|
|
a380ee080f | ||
|
|
eabefd099c | ||
|
|
97799919e6 | ||
|
|
35870a0158 | ||
|
|
ec05bd36e4 | ||
|
|
be041f93c2 | ||
|
|
a156d3595b | ||
|
|
a1d549a2b1 | ||
|
|
812cb5a160 | ||
|
|
e6264540af | ||
|
|
79fe064c4a | ||
|
|
7e69713683 | ||
|
|
3bbeb8f27a | ||
|
|
04fb8fa61d | ||
|
|
2caa861b8a | ||
|
|
d7f0815fb3 | ||
|
|
e6ab05e177 | ||
|
|
c2ecfd428b | ||
|
|
9f26274ca8 | ||
|
|
7764f1cf75 | ||
|
|
bc1b99efd6 | ||
|
|
26309019e7 | ||
|
|
b47d7b734d | ||
|
|
62194b8781 | ||
|
|
7c114a051a | ||
|
|
26c0c89b94 | ||
|
|
c81071a7b3 | ||
|
|
31f48edcc3 | ||
|
|
f7109a055c | ||
|
|
9d0e7759e0 | ||
|
|
d15ccbd2fc | ||
|
|
cfeb1743df | ||
|
|
a7e0330b06 | ||
|
|
5f69e83d46 | ||
|
|
fdde62896f | ||
|
|
f1909d0fc7 | ||
|
|
28225618fd | ||
|
|
1a562a5f23 | ||
|
|
bfbbcba160 | ||
|
|
21e4d17ef3 | ||
|
|
87faebc7d9 | ||
|
|
5d868d1355 | ||
|
|
ac0fd41740 | ||
|
|
1202e95b66 | ||
|
|
c05cf9718b | ||
|
|
d8f1e43e85 | ||
|
|
ed7e87b168 | ||
|
|
4ca2c9e97f | ||
|
|
01dd1c0615 | ||
|
|
cd387b8fed | ||
|
|
c85cd69152 | ||
|
|
9e9b52a252 | ||
|
|
292d5783a9 | ||
|
|
c7faafd0f3 | ||
|
|
ca7388b14e | ||
|
|
ddf2ca3670 | ||
|
|
96825c3c2b | ||
|
|
6ed66fea16 | ||
|
|
ddcda197b4 | ||
|
|
8bea5d83f5 | ||
|
|
73c1ea92f3 | ||
|
|
4fb5330308 | ||
|
|
f853cff920 | ||
|
|
dc3c978f8d | ||
|
|
e87825f8df | ||
|
|
718433183b | ||
|
|
7f3421f78a | ||
|
|
13fac2d5bc | ||
|
|
59e099f950 | ||
|
|
b18da959db | ||
|
|
fd0af6b2dd | ||
|
|
121805ba39 | ||
|
|
f9bbd71174 | ||
|
|
2fbb31e0ea | ||
|
|
89167543fa | ||
|
|
33e0987d73 |
48
.github/workflows/component-tests.yml
vendored
Normal file
48
.github/workflows/component-tests.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Run Component Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch/Tag/SHA to test'
|
||||
required: true
|
||||
pull_request:
|
||||
paths:
|
||||
- 'client/**'
|
||||
- '.github/workflows/component-tests.yml'
|
||||
push:
|
||||
paths:
|
||||
- 'client/**'
|
||||
- '.github/workflows/component-tests.yml'
|
||||
|
||||
jobs:
|
||||
run-component-tests:
|
||||
name: Run Component Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout (push/pull request)
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
|
||||
- name: Checkout (workflow_dispatch)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd client
|
||||
npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd client
|
||||
npm test
|
||||
2
.github/workflows/docker-build.yml
vendored
2
.github/workflows/docker-build.yml
vendored
@@ -23,7 +23,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !contains(github.event.head_commit.message, 'skip ci') && github.repository == 'advplyr/audiobookshelf' }}
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ sw.*
|
||||
.DS_STORE
|
||||
.idea/*
|
||||
tailwind.compiled.css
|
||||
tailwind.config.js
|
||||
|
||||
50
Dockerfile
50
Dockerfile
@@ -1,34 +1,32 @@
|
||||
ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
|
||||
ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
|
||||
|
||||
### STAGE 0: Build client ###
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:20-alpine AS build-client
|
||||
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM node:20-alpine
|
||||
FROM node:20-alpine AS build-server
|
||||
|
||||
ARG NUSQLITE3_DIR
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apk update && \
|
||||
apk add --no-cache --update \
|
||||
RUN apk add --no-cache --update \
|
||||
curl \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
make \
|
||||
python3 \
|
||||
g++ \
|
||||
tini \
|
||||
unzip
|
||||
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY index.js package* /
|
||||
COPY server server
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
ENV NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
|
||||
ENV NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
|
||||
WORKDIR /server
|
||||
COPY index.js package* /server
|
||||
COPY /server /server/server
|
||||
|
||||
RUN case "$TARGETPLATFORM" in \
|
||||
"linux/amd64") \
|
||||
@@ -42,14 +40,34 @@ RUN case "$TARGETPLATFORM" in \
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
RUN apk del make python3 g++
|
||||
### STAGE 2: Create minimal runtime image ###
|
||||
FROM node:20-alpine
|
||||
|
||||
ARG NUSQLITE3_DIR
|
||||
ARG NUSQLITE3_PATH
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apk add --no-cache --update \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
tini
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy compiled frontend and server from build stages
|
||||
COPY --from=build-client /client/dist /app/client/dist
|
||||
COPY --from=build-server /server /app
|
||||
COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH}
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENV PORT=80
|
||||
ENV NODE_ENV=production
|
||||
ENV CONFIG_PATH="/config"
|
||||
ENV METADATA_PATH="/metadata"
|
||||
ENV SOURCE="docker"
|
||||
ENV NUSQLITE3_DIR=${NUSQLITE3_DIR}
|
||||
ENV NUSQLITE3_PATH=${NUSQLITE3_PATH}
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -217,6 +217,16 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.episodes?.length) {
|
||||
shelves.push({
|
||||
id: 'episodes',
|
||||
label: 'Episodes',
|
||||
labelStringKey: 'LabelEpisodes',
|
||||
type: 'episode',
|
||||
entities: this.results.episodes.map((res) => res.libraryItem)
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.series?.length) {
|
||||
shelves.push({
|
||||
id: 'series',
|
||||
|
||||
@@ -274,15 +274,10 @@ export default {
|
||||
isAuthorsPage() {
|
||||
return this.page === 'authors'
|
||||
},
|
||||
isAlbumsPage() {
|
||||
return this.page === 'albums'
|
||||
},
|
||||
numShowing() {
|
||||
return this.totalEntities
|
||||
},
|
||||
entityName() {
|
||||
if (this.isAlbumsPage) return 'Albums'
|
||||
|
||||
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
|
||||
if (!this.page) return this.$strings.LabelBooks
|
||||
if (this.isSeriesPage) return this.$strings.LabelSeries
|
||||
|
||||
@@ -70,6 +70,11 @@ export default {
|
||||
title: this.$strings.HeaderUsers,
|
||||
path: '/config/users'
|
||||
},
|
||||
{
|
||||
id: 'config-api-keys',
|
||||
title: this.$strings.HeaderApiKeys,
|
||||
path: '/config/api-keys'
|
||||
},
|
||||
{
|
||||
id: 'config-sessions',
|
||||
title: this.$strings.HeaderListeningSessions,
|
||||
|
||||
@@ -778,10 +778,6 @@ export default {
|
||||
windowResize() {
|
||||
this.executeRebuild()
|
||||
},
|
||||
socketInit() {
|
||||
// Server settings are set on socket init
|
||||
this.executeRebuild()
|
||||
},
|
||||
initListeners() {
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
|
||||
@@ -794,7 +790,6 @@ export default {
|
||||
})
|
||||
|
||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('socket_init', this.socketInit)
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
@@ -826,7 +821,6 @@ export default {
|
||||
}
|
||||
|
||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('socket_init', this.socketInit)
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
|
||||
@@ -156,7 +156,7 @@ export default {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
libraryId() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||
return this.streamLibraryItem?.libraryId || null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
// Adjusted by playback rate
|
||||
|
||||
@@ -71,9 +71,6 @@ export default {
|
||||
coverHeight() {
|
||||
return this.cardHeight
|
||||
},
|
||||
userToken() {
|
||||
return this.store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="grow px-2 episodeSearchCardContent">
|
||||
<p class="truncate text-sm">{{ episodeTitle }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">{{ podcastTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
return 50
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
episodeTitle() {
|
||||
return this.episode.title || 'No Title'
|
||||
},
|
||||
podcastTitle() {
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.episodeSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 75px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
<div class="w-1/2 px-2">
|
||||
<div v-if="!isPodcast" class="flex items-end">
|
||||
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
||||
<div class="ml-2 mb-1 w-8 h-8 bg-bg border border-white/10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" @click="fetchMetadata">
|
||||
<ui-tooltip direction="top" :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
||||
<button type="button" class="ml-2 mb-1 w-8 h-8 bg-bg border border-white/10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" @click="fetchMetadata">
|
||||
<span class="text-base text-white/80 font-mono material-symbols">sync</span>
|
||||
</div>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<div ref="card" :id="`album-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-xs z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded-sm overflow-hidden">
|
||||
<covers-preview-cover ref="cover" :src="coverSrc" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full">
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-xs border" :style="{ padding: `0em ${0.5}em` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8e h-8e py-1e rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ artist || ' ' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
albumMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
album: null,
|
||||
isSelectionMode: false,
|
||||
selected: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardWidth() {
|
||||
return this.width || this.coverHeight
|
||||
},
|
||||
coverHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
/*
|
||||
cardHeight() {
|
||||
return this.coverHeight + this.bottomTextHeight
|
||||
},
|
||||
bottomTextHeight() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
const lineHeight = 1.5
|
||||
const remSize = 16
|
||||
const baseHeight = this.sizeMultiplier * lineHeight * remSize
|
||||
const titleHeight = this.labelFontSize * baseHeight
|
||||
const paddingHeight = 4 * 2 * this.sizeMultiplier // py-1
|
||||
return titleHeight + paddingHeight
|
||||
},
|
||||
*/
|
||||
coverSrc() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.9
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
title() {
|
||||
return this.album ? this.album.title : ''
|
||||
},
|
||||
artist() {
|
||||
return this.album ? this.album.artist : ''
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setEntity(album) {
|
||||
this.album = album
|
||||
},
|
||||
setSelectionMode(val) {
|
||||
this.isSelectionMode = val
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickCard() {
|
||||
if (!this.album) return
|
||||
// const router = this.$router || this.$nuxt.$router
|
||||
// router.push(`/album/${this.$encode(this.title)}`)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.album)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
// remove the element from the DOM
|
||||
if (this.$el && this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
} else if (this.$el && this.$el.remove) {
|
||||
this.$el.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.albumMount) {
|
||||
this.setEntity(this.albumMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -198,7 +198,7 @@ export default {
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
@@ -223,8 +223,7 @@ export default {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
return this.store.getters['globals/getPlaceholderCoverSrc']
|
||||
},
|
||||
bookCoverSrc() {
|
||||
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden text-ellipsis">
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publishedYear" role="paragraph" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publisher" role="paragraph" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="podcastType" role="paragraph" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
||||
</div>
|
||||
<div class="capitalize">
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5" v-if="genres.length">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden text-ellipsis">
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5" v-if="tags.length">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelTags }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden text-ellipsis">
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="language" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelLanguage }}</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tracks.length || (isPodcast && totalPodcastDuration)" role="paragraph" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div role="paragraph" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="episodeResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelEpisodes }}</p>
|
||||
<template v-for="item in episodeResults">
|
||||
<li :key="item.libraryItem.recentEpisode.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-episode-search-card :episode="item.libraryItem.recentEpisode" :library-item="item.libraryItem" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
|
||||
<template v-for="item in authorResults">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
@@ -100,6 +109,7 @@ export default {
|
||||
isFetching: false,
|
||||
search: null,
|
||||
podcastResults: [],
|
||||
episodeResults: [],
|
||||
bookResults: [],
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
@@ -115,7 +125,7 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
totalResults() {
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length + this.episodeResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -132,6 +142,7 @@ export default {
|
||||
this.search = null
|
||||
this.lastSearch = null
|
||||
this.podcastResults = []
|
||||
this.episodeResults = []
|
||||
this.bookResults = []
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
@@ -175,6 +186,7 @@ export default {
|
||||
if (!this.isFetching) return
|
||||
|
||||
this.podcastResults = searchResults.podcast || []
|
||||
this.episodeResults = searchResults.episodes || []
|
||||
this.bookResults = searchResults.book || []
|
||||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
|
||||
@@ -94,6 +94,9 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
userCanAccessExplicitContent() {
|
||||
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
@@ -239,6 +242,15 @@ export default {
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
text: this.$strings.LabelShareOpen,
|
||||
@@ -249,7 +261,7 @@ export default {
|
||||
return items
|
||||
},
|
||||
podcastItems() {
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
@@ -276,8 +288,23 @@ export default {
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRSSFeedOpen,
|
||||
value: 'feed-open',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
selectItems() {
|
||||
if (this.isSeries) return this.seriesItems
|
||||
|
||||
@@ -39,9 +39,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
|
||||
@@ -96,8 +96,8 @@ export default {
|
||||
return this.author
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
const store = this.$store || this.$nuxt.$store
|
||||
return store.getters['globals/getPlaceholderCoverSrc']
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (!this.libraryItem) return null
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||
<p v-if="!imageFailed && showResolution && resolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -65,11 +65,12 @@ export default {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
resolution() {
|
||||
if (!this.naturalWidth || !this.naturalHeight) return null
|
||||
return `${this.naturalWidth}×${this.naturalHeight}px`
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
const store = this.$store || this.$nuxt.$store
|
||||
return store.getters['globals/getPlaceholderCoverSrc']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -309,9 +309,9 @@ export default {
|
||||
} else {
|
||||
console.log('Account updated', data.user)
|
||||
|
||||
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
||||
console.log('Current user token was updated')
|
||||
this.$store.commit('user/setUserToken', data.user.token)
|
||||
if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) {
|
||||
console.log('Current user access token was updated')
|
||||
this.$store.commit('user/setAccessToken', data.user.accessToken)
|
||||
}
|
||||
|
||||
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
||||
@@ -351,9 +351,6 @@ export default {
|
||||
this.$toast.error(errMsg || 'Failed to create account')
|
||||
})
|
||||
},
|
||||
toggleActive() {
|
||||
this.newUser.isActive = !this.newUser.isActive
|
||||
},
|
||||
userTypeUpdated(type) {
|
||||
this.newUser.permissions = {
|
||||
download: type !== 'guest',
|
||||
|
||||
60
client/components/modals/ApiKeyCreatedModal.vue
Normal file
60
client/components/modals/ApiKeyCreatedModal.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="api-key-created" :width="800" :height="'unset'" persistent>
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<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: 200px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<p class="text-lg text-white mb-4">{{ $getString('LabelApiKeyCreated', [apiKeyName]) }}</p>
|
||||
|
||||
<p class="text-lg text-white mb-4">{{ $strings.LabelApiKeyCreatedDescription }}</p>
|
||||
|
||||
<ui-text-input label="API Key" :value="apiKeyKey" readonly show-copy />
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<ui-btn color="bg-primary" @click="show = false">{{ $strings.ButtonClose }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
apiKey: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.$strings.HeaderNewApiKey
|
||||
},
|
||||
apiKeyName() {
|
||||
return this.apiKey?.name || ''
|
||||
},
|
||||
apiKeyKey() {
|
||||
return this.apiKey?.apiKey || ''
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
198
client/components/modals/ApiKeyModal.vue
Normal file
198
client/components/modals/ApiKeyModal.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="api-key" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<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">
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div v-if="isNew" class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" :min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center pt-4 pb-2 gap-2">
|
||||
<div class="flex items-center px-2">
|
||||
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p>
|
||||
<ui-toggle-switch :disabled="isExpired && !apiKey.isActive" labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
||||
</div>
|
||||
<div v-if="isExpired" class="px-2">
|
||||
<p class="text-sm text-error">{{ $strings.LabelExpired }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full border-t border-b border-black-200 py-4 px-3 mt-4">
|
||||
<p class="text-lg mb-2 font-semibold">{{ $strings.LabelApiKeyUser }}</p>
|
||||
<p class="text-sm mb-2 text-gray-400">{{ $strings.LabelApiKeyUserDescription }}</p>
|
||||
<ui-select-input v-model="newApiKey.userId" :disabled="isExpired && !apiKey.isActive" :items="userItems" :placeholder="$strings.LabelSelectUser" :label="$strings.LabelApiKeyUser" label-hidden />
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 px-2">
|
||||
<div class="grow" />
|
||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
apiKey: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newApiKey: {},
|
||||
isNew: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
|
||||
},
|
||||
userItems() {
|
||||
return this.users
|
||||
.filter((u) => {
|
||||
// Only show root user if the current user is root
|
||||
return u.type !== 'root' || this.$store.getters['user/getIsRoot']
|
||||
})
|
||||
.map((u) => ({ text: u.username, value: u.id, subtext: u.type }))
|
||||
},
|
||||
isExpired() {
|
||||
if (!this.apiKey || !this.apiKey.expiresAt) return false
|
||||
|
||||
return new Date(this.apiKey.expiresAt).getTime() < Date.now()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
if (!this.newApiKey.name) {
|
||||
this.$toast.error(this.$strings.ToastNameRequired)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.newApiKey.userId) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyUserError)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateApiKey()
|
||||
} else {
|
||||
this.submitUpdateApiKey()
|
||||
}
|
||||
},
|
||||
submitUpdateApiKey() {
|
||||
if (this.newApiKey.isActive === this.apiKey.isActive && this.newApiKey.userId === this.apiKey.userId) {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = {
|
||||
isActive: this.newApiKey.isActive,
|
||||
userId: this.newApiKey.userId
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/api-keys/${this.apiKey.id}`, apiKey)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)
|
||||
} else {
|
||||
this.show = false
|
||||
this.$emit('updated', data.apiKey)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to update apiKey', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
},
|
||||
submitCreateApiKey() {
|
||||
const apiKey = { ...this.newApiKey }
|
||||
|
||||
if (this.newApiKey.expiresIn) {
|
||||
apiKey.expiresIn = parseInt(this.newApiKey.expiresIn)
|
||||
} else {
|
||||
delete apiKey.expiresIn
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/api-keys', apiKey)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(this.$strings.ToastFailedToCreate + ': ' + data.error)
|
||||
} else {
|
||||
this.show = false
|
||||
this.$emit('created', data.apiKey)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to create apiKey', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToCreate)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.isNew = !this.apiKey
|
||||
|
||||
if (this.apiKey) {
|
||||
this.newApiKey = {
|
||||
name: this.apiKey.name,
|
||||
isActive: this.apiKey.isActive,
|
||||
userId: this.apiKey.userId
|
||||
}
|
||||
} else {
|
||||
this.newApiKey = {
|
||||
name: null,
|
||||
expiresIn: null,
|
||||
isActive: true,
|
||||
userId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -79,10 +79,10 @@ export default {
|
||||
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="text-error text-sm mt-2 p-1">{{ error }}</div>
|
||||
<div class="flex justify-end mt-2 p-1">
|
||||
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
@@ -34,12 +35,17 @@ export default {
|
||||
existingSeriesNames: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
originalSeriesSequence: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
content: null,
|
||||
error: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -85,10 +91,17 @@ export default {
|
||||
}
|
||||
},
|
||||
submitSeriesForm() {
|
||||
this.error = null
|
||||
|
||||
if (this.$refs.newSeriesSelect) {
|
||||
this.$refs.newSeriesSelect.blur()
|
||||
}
|
||||
|
||||
if (this.selectedSeries.sequence !== this.originalSeriesSequence && this.selectedSeries.sequence.includes(' ')) {
|
||||
this.error = this.$strings.MessageSeriesSequenceCannotContainSpaces
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('submit')
|
||||
},
|
||||
clickClose() {
|
||||
@@ -100,6 +113,7 @@ export default {
|
||||
}
|
||||
},
|
||||
setShow() {
|
||||
this.error = null
|
||||
if (!this.el || !this.content) {
|
||||
this.init()
|
||||
}
|
||||
|
||||
@@ -159,10 +159,10 @@ export default {
|
||||
return 'Unknown'
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
},
|
||||
isOpenSession() {
|
||||
return !!this._session.open
|
||||
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
processing: Boolean,
|
||||
persistent: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: false
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
@@ -99,7 +99,7 @@ export default {
|
||||
this.preventClickoutside = false
|
||||
return
|
||||
}
|
||||
if (this.processing && this.persistent) return
|
||||
if (this.processing || this.persistent) return
|
||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||
this.show = false
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export default {
|
||||
expirationDateString() {
|
||||
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
||||
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
||||
return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat)
|
||||
return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat'))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
}
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
releasesToShow() {
|
||||
return this.versionData?.releasesToShow || []
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-col sm:flex-row mb-4">
|
||||
<div class="relative self-center md:self-start">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-preview-cover :src="coverUrl" :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">
|
||||
@@ -157,6 +157,12 @@ export default {
|
||||
coverPath() {
|
||||
return this.media.coverPath
|
||||
},
|
||||
coverUrl() {
|
||||
if (!this.coverPath) {
|
||||
return this.$store.getters['globals/getPlaceholderCoverSrc']
|
||||
}
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId, this.libraryItemUpdatedAt, true)
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
|
||||
@@ -29,9 +29,6 @@ export default {
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
|
||||
@@ -74,19 +74,12 @@ export default {
|
||||
mediaTracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
isSingleM4b() {
|
||||
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
showM4bDownload() {
|
||||
if (!this.mediaTracks.length) return false
|
||||
return !this.isSingleM4b
|
||||
},
|
||||
showMp3Split() {
|
||||
if (!this.mediaTracks.length) return false
|
||||
return this.isSingleM4b && this.chapters.length
|
||||
return true
|
||||
},
|
||||
queuedEmbedLIds() {
|
||||
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||
|
||||
@@ -55,7 +55,7 @@ export default {
|
||||
return this.item.coverPath
|
||||
},
|
||||
coverUrl() {
|
||||
if (!this.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||
if (!this.coverPath) return this.$store.getters['globals/getPlaceholderCoverSrc']
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<form @submit.prevent="submit" class="flex grow">
|
||||
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="grow mr-2 text-sm md:text-base" />
|
||||
</form>
|
||||
<ui-btn :padding-x="4" @click="toggleSort">
|
||||
<span class="pr-4">{{ $strings.LabelSortPubDate }}</span>
|
||||
<span class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<span class="material-symbols text-xl" :aria-label="sortDescending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ sortDescending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
|
||||
<div v-for="(episode, index) in episodesList" :key="index" class="relative" :class="episode.isDownloaded || episode.isDownloading ? 'bg-primary/40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success/10' : index % 2 == 0 ? 'cursor-pointer bg-primary/25 hover:bg-primary/40' : 'cursor-pointer bg-primary/5 hover:bg-primary/25'" @click="toggleSelectEpisode(episode)">
|
||||
@@ -29,7 +35,14 @@
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- published -->
|
||||
<p class="text-xs text-gray-300 w-40">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
<!-- duration -->
|
||||
<p v-if="episode.durationSeconds && !isNaN(episode.durationSeconds)" class="text-xs text-gray-300 min-w-28">{{ $strings.LabelDuration }}: {{ $elapsedPretty(episode.durationSeconds) }}</p>
|
||||
<!-- size -->
|
||||
<p v-if="episode.enclosure?.length && !isNaN(episode.enclosure.length) && Number(episode.enclosure.length) > 0" class="text-xs text-gray-300">{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +86,8 @@ export default {
|
||||
searchTimeout: null,
|
||||
searchText: null,
|
||||
downloadedEpisodeGuidMap: {},
|
||||
downloadedEpisodeUrlMap: {}
|
||||
downloadedEpisodeUrlMap: {},
|
||||
sortDescending: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -141,6 +155,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleSort() {
|
||||
this.sortDescending = !this.sortDescending
|
||||
this.episodesCleaned = this.episodesCleaned.toSorted((a, b) => {
|
||||
if (this.sortDescending) {
|
||||
return a.publishedAt < b.publishedAt ? 1 : -1
|
||||
}
|
||||
return a.publishedAt > b.publishedAt ? 1 : -1
|
||||
})
|
||||
this.selectedEpisodes = {}
|
||||
this.selectAll = false
|
||||
},
|
||||
getIsEpisodeDownloaded(episode) {
|
||||
if (episode.guid && !!this.downloadedEpisodeGuidMap[episode.guid]) {
|
||||
return true
|
||||
@@ -226,8 +251,8 @@ export default {
|
||||
const sizeInMb = payloadSize / 1024 / 1024
|
||||
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||
console.log('Request size', sizeInMb)
|
||||
if (sizeInMb > 4.99) {
|
||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||
if (sizeInMb > 9.99) {
|
||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 10Mb`)
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }}
|
||||
</p>
|
||||
<p v-else class="text-lg text-gray-200 mb-4">{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}</p>
|
||||
<p class="text-xs font-semibold text-warning/90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||
<p class="text-xs font-semibold text-warning/90">{{ $strings.MessageConfirmRemoveEpisodeNote }}</p>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<ui-checkbox v-model="hardDeleteFile" :label="$strings.LabelHardDeleteFile" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" dir="auto" class="default-style less-spacing" v-html="description" />
|
||||
<div v-if="description" dir="auto" class="default-style less-spacing" @click="handleDescriptionClick" v-html="description" />
|
||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white/5 my-4" />
|
||||
@@ -34,6 +34,12 @@
|
||||
{{ audioFileSize }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<p class="font-semibold text-xs mb-1">{{ $strings.LabelDuration }}</p>
|
||||
<p class="mb-2 text-xs">
|
||||
{{ audioFileDuration }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -68,7 +74,7 @@ export default {
|
||||
return this.episode.title || 'No Episode Title'
|
||||
},
|
||||
description() {
|
||||
return this.episode.description || ''
|
||||
return this.parseDescription(this.episode.description || '')
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
@@ -90,11 +96,49 @@ export default {
|
||||
|
||||
return this.$bytesPretty(size)
|
||||
},
|
||||
audioFileDuration() {
|
||||
const duration = this.episode.duration || 0
|
||||
return this.$elapsedPretty(duration)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
handleDescriptionClick(e) {
|
||||
if (e.target.matches('span.time-marker')) {
|
||||
const time = parseInt(e.target.dataset.time)
|
||||
if (!isNaN(time)) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
episodeId: this.episodeId,
|
||||
libraryItemId: this.libraryItem.id,
|
||||
startTime: time
|
||||
})
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
parseDescription(description) {
|
||||
const timeMarkerLinkRegex = /<a href="#([^"]*?\b\d{1,2}:\d{1,2}(?::\d{1,2})?)">(.*?)<\/a>/g
|
||||
const timeMarkerRegex = /\b\d{1,2}:\d{1,2}(?::\d{1,2})?\b/g
|
||||
|
||||
function convertToSeconds(time) {
|
||||
const timeParts = time.split(':').map(Number)
|
||||
return timeParts.reduce((acc, part, index) => acc * 60 + part, 0)
|
||||
}
|
||||
|
||||
return description
|
||||
.replace(timeMarkerLinkRegex, (match, href, displayTime) => {
|
||||
const time = displayTime.match(timeMarkerRegex)[0]
|
||||
const seekTimeInSeconds = convertToSeconds(time)
|
||||
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${displayTime}</span>`
|
||||
})
|
||||
.replace(timeMarkerRegex, (match) => {
|
||||
const seekTimeInSeconds = convertToSeconds(match)
|
||||
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${match}</span>`
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
currentChapterStart() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.start
|
||||
},
|
||||
isMobile() {
|
||||
return this.$store.state.globals.isMobile
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -145,6 +148,9 @@ export default {
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
if (this.isMobile) {
|
||||
return
|
||||
}
|
||||
const offsetX = e.offsetX
|
||||
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||
@@ -198,6 +204,7 @@ export default {
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.trackOffsetLeft = this.$refs.track.getBoundingClientRect().left
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
|
||||
@@ -129,9 +129,6 @@ export default {
|
||||
return `${hoursRounded}h`
|
||||
}
|
||||
},
|
||||
token() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
|
||||
@@ -104,9 +104,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
@@ -234,10 +231,7 @@ export default {
|
||||
async extract() {
|
||||
this.loading = true
|
||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
responseType: 'blob'
|
||||
})
|
||||
const archive = await Archive.open(buff)
|
||||
const originalFilesObject = await archive.getFilesObject()
|
||||
|
||||
@@ -57,9 +57,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
/** @returns {string} */
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
@@ -97,9 +94,9 @@ export default {
|
||||
},
|
||||
ebookUrl() {
|
||||
if (this.fileId) {
|
||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||
}
|
||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
|
||||
return `/api/items/${this.libraryItemId}/ebook`
|
||||
},
|
||||
themeRules() {
|
||||
const isDark = this.ereaderSettings.theme === 'dark'
|
||||
@@ -309,14 +306,24 @@ export default {
|
||||
/** @type {EpubReader} */
|
||||
const reader = this
|
||||
|
||||
// Use axios to make request because we have token refresh logic in interceptor
|
||||
const customRequest = async (url) => {
|
||||
try {
|
||||
return this.$axios.$get(url, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('EpubReader.initEpub customRequest failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {ePub.Book} */
|
||||
reader.book = new ePub(reader.ebookUrl, {
|
||||
width: this.readerWidth,
|
||||
height: this.readerHeight - 50,
|
||||
openAs: 'epub',
|
||||
requestHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
requestMethod: customRequest
|
||||
})
|
||||
|
||||
/** @type {ePub.Rendition} */
|
||||
@@ -337,29 +344,33 @@ export default {
|
||||
this.applyTheme()
|
||||
})
|
||||
|
||||
reader.book.ready.then(() => {
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
reader.rendition.on('keydown', reader.keyUp)
|
||||
reader.book.ready
|
||||
.then(() => {
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
reader.rendition.on('keydown', reader.keyUp)
|
||||
|
||||
reader.rendition.on('touchstart', (event) => {
|
||||
this.$emit('touchstart', event)
|
||||
})
|
||||
reader.rendition.on('touchend', (event) => {
|
||||
this.$emit('touchend', event)
|
||||
})
|
||||
|
||||
// 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())
|
||||
reader.rendition.on('touchstart', (event) => {
|
||||
this.$emit('touchstart', event)
|
||||
})
|
||||
}
|
||||
this.getChapters()
|
||||
})
|
||||
reader.rendition.on('touchend', (event) => {
|
||||
this.$emit('touchend', event)
|
||||
})
|
||||
|
||||
// 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())
|
||||
})
|
||||
}
|
||||
this.getChapters()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('EpubReader.initEpub failed:', error)
|
||||
})
|
||||
},
|
||||
getChapters() {
|
||||
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
|
||||
|
||||
@@ -26,9 +26,6 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
@@ -96,11 +93,8 @@ export default {
|
||||
},
|
||||
async initMobi() {
|
||||
// Fetch mobi file as blob
|
||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
const buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
var reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
|
||||
@@ -55,7 +55,8 @@ export default {
|
||||
loadedRatio: 0,
|
||||
page: 1,
|
||||
numPages: 0,
|
||||
pdfDocInitParams: null
|
||||
pdfDocInitParams: null,
|
||||
isRefreshing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -152,7 +153,34 @@ export default {
|
||||
this.page++
|
||||
this.updateProgress()
|
||||
},
|
||||
error(err) {
|
||||
async refreshToken() {
|
||||
if (this.isRefreshing) return
|
||||
this.isRefreshing = true
|
||||
const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
|
||||
console.error('Failed to refresh token', error)
|
||||
return null
|
||||
})
|
||||
if (!newAccessToken) {
|
||||
// Redirect to login on failed refresh
|
||||
this.$router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Force Vue to re-render the PDF component by creating a new object
|
||||
this.pdfDocInitParams = {
|
||||
url: this.ebookUrl,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${newAccessToken}`
|
||||
}
|
||||
}
|
||||
this.isRefreshing = false
|
||||
},
|
||||
async error(err) {
|
||||
if (err && err.status === 401) {
|
||||
console.log('Received 401 error, refreshing token')
|
||||
await this.refreshToken()
|
||||
return
|
||||
}
|
||||
console.error(err)
|
||||
},
|
||||
resize() {
|
||||
|
||||
@@ -266,9 +266,6 @@ export default {
|
||||
isComic() {
|
||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
keepProgress() {
|
||||
return this.$store.state.ereaderKeepProgress
|
||||
},
|
||||
|
||||
@@ -164,14 +164,15 @@ export default {
|
||||
beforeMount() {
|
||||
this.yearInReviewYear = new Date().getFullYear()
|
||||
|
||||
// When not December show previous year
|
||||
if (new Date().getMonth() < 11) {
|
||||
this.availableYears = this.getAvailableYears()
|
||||
const availableYearValues = this.availableYears.map((y) => y.value)
|
||||
|
||||
// When not December show previous year if data is available
|
||||
if (new Date().getMonth() < 11 && availableYearValues.includes(this.yearInReviewYear - 1)) {
|
||||
this.yearInReviewYear--
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.availableYears = this.getAvailableYears()
|
||||
|
||||
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
||||
this.showShareButton = true
|
||||
} else {
|
||||
|
||||
177
client/components/tables/ApiKeysTable.vue
Normal file
177
client/components/tables/ApiKeysTable.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<table v-if="apiKeys.length > 0" id="api-keys">
|
||||
<tr>
|
||||
<th>{{ $strings.LabelName }}</th>
|
||||
<th class="w-44">{{ $strings.LabelApiKeyUser }}</th>
|
||||
<th class="w-32">{{ $strings.LabelExpiresAt }}</th>
|
||||
<th class="w-32">{{ $strings.LabelCreatedAt }}</th>
|
||||
<th class="w-32"></th>
|
||||
</tr>
|
||||
<tr v-for="apiKey in apiKeys" :key="apiKey.id" :class="apiKey.isActive ? '' : 'bg-error/10!'">
|
||||
<td>
|
||||
<div class="flex items-center">
|
||||
<p class="pl-2 truncate">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<nuxt-link v-if="apiKey.user" :to="`/config/users/${apiKey.user.id}`" class="text-xs hover:underline">
|
||||
{{ apiKey.user.username }}
|
||||
</nuxt-link>
|
||||
<p v-else class="text-xs">Error</p>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<p v-if="apiKey.expiresAt" class="text-xs" :title="apiKey.expiresAt">{{ getExpiresAtText(apiKey) }}</p>
|
||||
<p v-else class="text-xs">{{ $strings.LabelExpiresNever }}</p>
|
||||
</td>
|
||||
<td class="text-xs font-mono">
|
||||
<ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)">
|
||||
{{ $formatJsDate(new Date(apiKey.createdAt), dateFormat) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="py-0">
|
||||
<div class="w-full flex justify-left">
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-white/100 cursor-pointer" @click.stop="editApiKey(apiKey)">
|
||||
<button type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-base">edit</button>
|
||||
</div>
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-error cursor-pointer" @click.stop="deleteApiKeyClick(apiKey)">
|
||||
<button type="button" :aria-label="$strings.ButtonDelete" class="material-symbols text-base">delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p v-else class="text-base text-gray-300 py-4">{{ $strings.LabelNoApiKeys }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
apiKeys: [],
|
||||
isDeletingApiKey: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getExpiresAtText(apiKey) {
|
||||
if (new Date(apiKey.expiresAt).getTime() < Date.now()) {
|
||||
return this.$strings.LabelExpired
|
||||
}
|
||||
return this.$formatJsDatetime(new Date(apiKey.expiresAt), this.dateFormat, this.timeFormat)
|
||||
},
|
||||
deleteApiKeyClick(apiKey) {
|
||||
if (this.isDeletingApiKey) return
|
||||
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmDeleteApiKey', [apiKey.name]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteApiKey(apiKey) {
|
||||
this.isDeletingApiKey = true
|
||||
this.$axios
|
||||
.$delete(`/api/api-keys/${apiKey.id}`)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.removeApiKey(apiKey.id)
|
||||
this.$emit('numApiKeys', this.apiKeys.length)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete apiKey', error)
|
||||
this.$toast.error(this.$strings.ToastFailedToDelete)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isDeletingApiKey = false
|
||||
})
|
||||
},
|
||||
editApiKey(apiKey) {
|
||||
this.$emit('edit', apiKey)
|
||||
},
|
||||
addApiKey(apiKey) {
|
||||
this.apiKeys.push(apiKey)
|
||||
},
|
||||
removeApiKey(apiKeyId) {
|
||||
this.apiKeys = this.apiKeys.filter((a) => a.id !== apiKeyId)
|
||||
},
|
||||
updateApiKey(apiKey) {
|
||||
this.apiKeys = this.apiKeys.map((a) => (a.id === apiKey.id ? apiKey : a))
|
||||
},
|
||||
loadApiKeys() {
|
||||
this.$axios
|
||||
.$get('/api/api-keys')
|
||||
.then((res) => {
|
||||
this.apiKeys = res.apiKeys.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
this.$emit('numApiKeys', this.apiKeys.length)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load apiKeys', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadApiKeys()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#api-keys {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #474747;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#api-keys td,
|
||||
#api-keys th {
|
||||
/* border: 1px solid #2e2e2e; */
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#api-keys td.py-0 {
|
||||
padding: 0px 8px;
|
||||
}
|
||||
|
||||
#api-keys tr:nth-child(even) {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
#api-keys tr:nth-child(odd) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
#api-keys tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#api-keys th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #272727;
|
||||
}
|
||||
</style>
|
||||
@@ -26,9 +26,9 @@
|
||||
<span class="material-symbols text-2xl text-error">error_outline</span>
|
||||
</ui-tooltip>
|
||||
|
||||
<button aria-label="Download Backup" class="inline-flex material-symbols text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
|
||||
<button aria-label="Download backup" class="inline-flex material-symbols text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
|
||||
|
||||
<button aria-label="Delete Backup" class="inline-flex material-symbols text-xl mx-1 text-white/70 hover:text-error" @click.stop="deleteBackupClick(backup)">delete</button>
|
||||
<button aria-label="Delete backup" class="inline-flex material-symbols text-xl mx-1 text-white/70 hover:text-error" @click.stop="deleteBackupClick(backup)">delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -78,10 +78,10 @@ export default {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -49,9 +49,6 @@ export default {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
|
||||
@@ -53,9 +53,6 @@ export default {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
|
||||
@@ -76,10 +76,10 @@ export default {
|
||||
return usermap
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -112,7 +112,7 @@ export default {
|
||||
return this.episode?.publishedAt
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
itemProgress() {
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
<template>
|
||||
<div id="lazy-episodes-table" class="w-full py-6">
|
||||
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
|
||||
@@ -176,6 +175,13 @@ export default {
|
||||
return episodeProgress && !episodeProgress.isFinished
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Swap values if sort descending
|
||||
if (this.sortDesc) {
|
||||
const temp = a
|
||||
a = b
|
||||
b = temp
|
||||
}
|
||||
|
||||
let aValue
|
||||
let bValue
|
||||
|
||||
@@ -194,10 +200,23 @@ export default {
|
||||
if (!bValue) bValue = Number.MAX_VALUE
|
||||
}
|
||||
|
||||
if (this.sortDesc) {
|
||||
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
const primaryCompare = String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
if (primaryCompare !== 0 || this.sortKey === 'publishedAt') return primaryCompare
|
||||
|
||||
// When sorting by season, secondary sort is by episode number
|
||||
if (this.sortKey === 'season') {
|
||||
const aEpisode = a.episode || ''
|
||||
const bEpisode = b.episode || ''
|
||||
|
||||
const secondaryCompare = String(aEpisode).localeCompare(String(bEpisode), undefined, { numeric: true, sensitivity: 'base' })
|
||||
if (secondaryCompare !== 0) return secondaryCompare
|
||||
}
|
||||
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
|
||||
// Final sort by publishedAt
|
||||
let aPubDate = a.publishedAt || Number.MAX_VALUE
|
||||
let bPubDate = b.publishedAt || Number.MAX_VALUE
|
||||
|
||||
return String(aPubDate).localeCompare(String(bPubDate), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
},
|
||||
episodesList() {
|
||||
@@ -220,10 +239,10 @@ export default {
|
||||
})
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -85,9 +85,6 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.disabled) classes.push('bg-black-300')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<p v-if="label && !labelHidden" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }">{{ selectedText || placeholder }}</span>
|
||||
<span v-if="selectedSubtext">: </span>
|
||||
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
||||
</span>
|
||||
@@ -36,10 +36,15 @@ export default {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
labelHidden: Boolean,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: Boolean,
|
||||
small: Boolean,
|
||||
menuMaxHeight: {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</label>
|
||||
</slot>
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
min: [String, Number],
|
||||
readonly: Boolean,
|
||||
disabled: Boolean,
|
||||
inputClass: String,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="inline-flex toggle-btn-wrapper shadow-md">
|
||||
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-hidden relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
|
||||
<button v-for="item in items" :key="item.value" type="button" :disabled="disabled" class="toggle-btn outline-hidden relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
|
||||
{{ item.text }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -9,13 +9,17 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
value: [String, Number],
|
||||
/**
|
||||
* [{ "text", "", "value": "" }]
|
||||
*/
|
||||
items: {
|
||||
type: Array,
|
||||
default: Object
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -76,10 +80,19 @@ export default {
|
||||
.toggle-btn.selected {
|
||||
color: white;
|
||||
}
|
||||
.toggle-btn.selected:disabled {
|
||||
color: white;
|
||||
}
|
||||
.toggle-btn.selected::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button.toggle-btn.selected:disabled::before {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
button.toggle-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
button.toggle-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</trix-toolbar>
|
||||
<trix-editor :toolbar="toolbarId" :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" />
|
||||
<trix-editor :toolbar="toolbarId" :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" @trix-attachment-add="handleAttachmentAdd" />
|
||||
<input type="hidden" :name="inputName" :id="computedId" :value="editorContent" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -316,6 +316,10 @@ export default {
|
||||
if (this.$refs.trix && this.$refs.trix.blur) {
|
||||
this.$refs.trix.blur()
|
||||
}
|
||||
},
|
||||
handleAttachmentAdd(event) {
|
||||
// Prevent pasting in images/any files from the browser
|
||||
event.attachment.remove()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
nextRun() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
||||
return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || ''
|
||||
return this.$formatJsDatetime(parsed, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) || ''
|
||||
},
|
||||
description() {
|
||||
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
||||
|
||||
219
client/components/widgets/EncoderOptionsCard.vue
Normal file
219
client/components/widgets/EncoderOptionsCard.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="w-full py-2">
|
||||
<div class="flex -mb-px">
|
||||
<button type="button" :disabled="disabled" class="w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center disabled:cursor-not-allowed" :class="!showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'" @click="showAdvancedView = false">
|
||||
<p class="text-sm">{{ $strings.HeaderPresets }}</p>
|
||||
</button>
|
||||
<button type="button" :disabled="disabled" class="w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px disabled:cursor-not-allowed" :class="showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'" @click="showAdvancedView = true">
|
||||
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 md:p-8 border border-black-200 rounded-b-md mr-px bg-bg">
|
||||
<template v-if="!showAdvancedView">
|
||||
<div class="flex flex-wrap gap-4 sm:gap-8 justify-start sm:justify-center">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<p class="text-sm w-40">{{ $strings.LabelCodec }}</p>
|
||||
<ui-toggle-btns v-model="selectedCodec" :items="codecItems" :disabled="disabled" />
|
||||
<p class="text-xs text-gray-300">
|
||||
{{ $strings.LabelCurrently }} <span class="text-white">{{ currentCodec }}</span> <span v-if="isCodecsDifferent" class="text-warning">(mixed)</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<p class="text-sm w-40">{{ $strings.LabelBitrate }}</p>
|
||||
<ui-toggle-btns v-model="selectedBitrate" :items="bitrateItems" :disabled="disabled" />
|
||||
<p class="text-xs text-gray-300">
|
||||
{{ $strings.LabelCurrently }} <span class="text-white">{{ currentBitrate }} KB/s</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<p class="text-sm w-40">{{ $strings.LabelChannels }}</p>
|
||||
<ui-toggle-btns v-model="selectedChannels" :items="channelsItems" :disabled="disabled" />
|
||||
<p class="text-xs text-gray-300">
|
||||
{{ $strings.LabelCurrently }} <span class="text-white">{{ currentChannels }} ({{ currentChanelLayout }})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-4 sm:gap-8 justify-start sm:justify-center mb-4">
|
||||
<div class="w-40">
|
||||
<ui-text-input-with-label v-model="customCodec" :label="$strings.LabelAudioCodec" :disabled="disabled" @input="customCodecChanged" />
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<ui-text-input-with-label v-model="customBitrate" :label="$strings.LabelAudioBitrate" :disabled="disabled" @input="customBitrateChanged" />
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<ui-text-input-with-label v-model="customChannels" :label="$strings.LabelAudioChannels" type="number" :disabled="disabled" @input="customChannelsChanged" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-warning sm:text-center">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audioTracks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAdvancedView: false,
|
||||
selectedCodec: 'aac',
|
||||
selectedBitrate: '128k',
|
||||
selectedChannels: 2,
|
||||
customCodec: 'aac',
|
||||
customBitrate: '128k',
|
||||
customChannels: 2,
|
||||
currentCodec: '',
|
||||
currentBitrate: '',
|
||||
currentChannels: '',
|
||||
currentChanelLayout: '',
|
||||
isCodecsDifferent: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
codecItems() {
|
||||
return [
|
||||
{
|
||||
text: 'Copy',
|
||||
value: 'copy'
|
||||
},
|
||||
{
|
||||
text: 'AAC',
|
||||
value: 'aac'
|
||||
},
|
||||
{
|
||||
text: 'OPUS',
|
||||
value: 'opus'
|
||||
}
|
||||
]
|
||||
},
|
||||
bitrateItems() {
|
||||
return [
|
||||
{
|
||||
text: '32k',
|
||||
value: '32k'
|
||||
},
|
||||
{
|
||||
text: '64k',
|
||||
value: '64k'
|
||||
},
|
||||
{
|
||||
text: '128k',
|
||||
value: '128k'
|
||||
},
|
||||
{
|
||||
text: '192k',
|
||||
value: '192k'
|
||||
}
|
||||
]
|
||||
},
|
||||
channelsItems() {
|
||||
return [
|
||||
{
|
||||
text: '1 (mono)',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
text: '2 (stereo)',
|
||||
value: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
customBitrateChanged(val) {
|
||||
localStorage.setItem('embedMetadataBitrate', val)
|
||||
},
|
||||
customChannelsChanged(val) {
|
||||
localStorage.setItem('embedMetadataChannels', val)
|
||||
},
|
||||
customCodecChanged(val) {
|
||||
localStorage.setItem('embedMetadataCodec', val)
|
||||
},
|
||||
getEncodingOptions() {
|
||||
if (this.showAdvancedView) {
|
||||
return {
|
||||
codec: this.customCodec || this.selectedCodec || 'aac',
|
||||
bitrate: this.customBitrate || this.selectedBitrate || '128k',
|
||||
channels: this.customChannels || this.selectedChannels || 2
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
codec: this.selectedCodec || 'aac',
|
||||
bitrate: this.selectedBitrate || '128k',
|
||||
channels: this.selectedChannels || 2
|
||||
}
|
||||
}
|
||||
},
|
||||
setPreset() {
|
||||
// If already AAC and not mixed, set copy
|
||||
if (this.currentCodec === 'aac' && !this.isCodecsDifferent) {
|
||||
this.selectedCodec = 'copy'
|
||||
} else {
|
||||
this.selectedCodec = 'aac'
|
||||
}
|
||||
|
||||
if (!this.currentBitrate) {
|
||||
this.selectedBitrate = '128k'
|
||||
} else {
|
||||
// Find closest bitrate rounding up
|
||||
const bitratesToMatch = [32, 64, 128, 192]
|
||||
const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate) || 192
|
||||
this.selectedBitrate = closestBitrate + 'k'
|
||||
}
|
||||
|
||||
if (!this.currentChannels || isNaN(this.currentChannels)) {
|
||||
this.selectedChannels = 2
|
||||
} else {
|
||||
// Either 1 or 2
|
||||
this.selectedChannels = Math.max(Math.min(Number(this.currentChannels), 2), 1)
|
||||
}
|
||||
},
|
||||
setCurrentValues() {
|
||||
if (this.audioTracks.length === 0) return
|
||||
|
||||
this.currentChannels = this.audioTracks[0].channels
|
||||
this.currentChanelLayout = this.audioTracks[0].channelLayout
|
||||
this.currentCodec = this.audioTracks[0].codec
|
||||
|
||||
let totalBitrate = 0
|
||||
for (const track of this.audioTracks) {
|
||||
const trackBitrate = !isNaN(track.bitRate) ? track.bitRate : 0
|
||||
totalBitrate += trackBitrate
|
||||
|
||||
if (track.channels > this.currentChannels) this.currentChannels = track.channels
|
||||
if (track.codec !== this.currentCodec) {
|
||||
console.warn('Audio track codec is different from the first track', track.codec)
|
||||
this.isCodecsDifferent = true
|
||||
}
|
||||
}
|
||||
|
||||
this.currentBitrate = Math.round(totalBitrate / this.audioTracks.length / 1000)
|
||||
},
|
||||
init() {
|
||||
this.customBitrate = localStorage.getItem('embedMetadataBitrate') || '128k'
|
||||
this.customChannels = localStorage.getItem('embedMetadataChannels') || 2
|
||||
this.customCodec = localStorage.getItem('embedMetadataCodec') || 'aac'
|
||||
|
||||
this.setCurrentValues()
|
||||
|
||||
this.setPreset()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -248,4 +248,4 @@ export default {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<ui-multi-select-query-input v-model="seriesItems" text-key="displayName" :label="$strings.LabelSeries" :disabled="disabled" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||
|
||||
<modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" @submit="submitSeriesForm" />
|
||||
<modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" :original-series-sequence="originalSeriesSequence" @submit="submitSeriesForm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +18,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
selectedSeries: null,
|
||||
originalSeriesSequence: null,
|
||||
showSeriesForm: false
|
||||
}
|
||||
},
|
||||
@@ -59,6 +60,7 @@ export default {
|
||||
..._series
|
||||
}
|
||||
|
||||
this.originalSeriesSequence = _series.sequence
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
addNewSeries() {
|
||||
@@ -68,6 +70,7 @@ export default {
|
||||
sequence: ''
|
||||
}
|
||||
|
||||
this.originalSeriesSequence = null
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
submitSeriesForm() {
|
||||
@@ -106,4 +109,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,9 @@ describe('AuthorCard', () => {
|
||||
const mocks = {
|
||||
$strings: {
|
||||
LabelBooks: 'Books',
|
||||
ButtonQuickMatch: 'Quick Match'
|
||||
ButtonQuickMatch: 'Quick Match',
|
||||
ToastAuthorUpdateSuccess: 'Author updated',
|
||||
ToastAuthorUpdateSuccessNoImageFound: 'Author updated (no image found)'
|
||||
},
|
||||
$store: {
|
||||
getters: {
|
||||
@@ -167,7 +169,7 @@ describe('AuthorCard', () => {
|
||||
cy.get('&match').click()
|
||||
|
||||
cy.get('&spinner').should('be.hidden')
|
||||
cy.get('@success').should('have.been.calledOnceWithExactly', 'Author John Doe was updated (no image found)')
|
||||
cy.get('@success').should('have.been.calledOnceWithExactly', 'Author updated (no image found)')
|
||||
cy.get('@error').should('not.have.been.called')
|
||||
cy.get('@info').should('not.have.been.called')
|
||||
})
|
||||
@@ -189,7 +191,7 @@ describe('AuthorCard', () => {
|
||||
cy.get('&match').click()
|
||||
|
||||
cy.get('&spinner').should('be.hidden')
|
||||
cy.get('@success').should('have.been.calledOnceWithExactly', 'Author John Doe was updated')
|
||||
cy.get('@success').should('have.been.calledOnceWithExactly', 'Author updated')
|
||||
cy.get('@error').should('not.have.been.called')
|
||||
cy.get('@info').should('not.have.been.called')
|
||||
})
|
||||
|
||||
@@ -49,6 +49,7 @@ function createMountOptions() {
|
||||
'libraries/getLibraryProvider': () => 'audible.us',
|
||||
'libraries/getBookCoverAspectRatio': 1,
|
||||
'globals/getLibraryItemCoverSrc': () => 'https://my.server.com/book_placeholder.jpg',
|
||||
'globals/getPlaceholderCoverSrc': 'https://my.server.com/book_placeholder.jpg',
|
||||
getLibraryItemsStreaming: () => null,
|
||||
getIsMediaQueued: () => false,
|
||||
getIsStreamingFromDifferentLibrary: () => false
|
||||
@@ -172,6 +173,7 @@ describe('LazyBookCard', () => {
|
||||
})
|
||||
|
||||
it('shows titleImageNotReady and sets opacity 0 on coverImage when image not ready', () => {
|
||||
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/notfound.jpg'
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&titleImageNotReady').should('be.visible')
|
||||
@@ -257,7 +259,7 @@ describe('LazyBookCard', () => {
|
||||
cy.get('#book-card-0').trigger('mouseover')
|
||||
|
||||
cy.get('&titleImageNotReady').should('be.hidden')
|
||||
cy.get('&seriesNameOverlay').should('be.visible').and('have.text', 'Middle Earth Chronicles')
|
||||
cy.get('&seriesNameOverlay').should('be.visible').and('have.text', 'The Lord of the Rings')
|
||||
})
|
||||
|
||||
it('shows the seriesSequenceList when collapsed series has a sequence list', () => {
|
||||
|
||||
@@ -30,8 +30,17 @@ describe('LazySeriesCard', () => {
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$getString: (id, args) => {
|
||||
switch (id) {
|
||||
case 'LabelAddedDate':
|
||||
return `Added ${args[0]}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
$store: {
|
||||
getters: {
|
||||
getServerSetting: () => 'MM/dd/yyyy',
|
||||
'user/getUserCanUpdate': true,
|
||||
'user/getUserMediaProgress': (id) => null,
|
||||
'user/getSizeMultiplier': 1,
|
||||
|
||||
@@ -33,6 +33,7 @@ export default {
|
||||
return {
|
||||
socket: null,
|
||||
isSocketConnected: false,
|
||||
isSocketAuthenticated: false,
|
||||
isFirstSocketConnection: true,
|
||||
socketConnectionToastId: null,
|
||||
currentLang: null,
|
||||
@@ -81,9 +82,28 @@ export default {
|
||||
document.body.classList.add('app-bar')
|
||||
}
|
||||
},
|
||||
tokenRefreshed(newAccessToken) {
|
||||
if (this.isSocketConnected && !this.isSocketAuthenticated) {
|
||||
console.log('[SOCKET] Re-authenticating socket after token refresh')
|
||||
this.socket.emit('auth', newAccessToken)
|
||||
}
|
||||
},
|
||||
updateSocketConnectionToast(content, type, timeout) {
|
||||
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
|
||||
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
|
||||
const toastUpdateOptions = {
|
||||
content: content,
|
||||
options: {
|
||||
timeout: timeout,
|
||||
type: type,
|
||||
closeButton: false,
|
||||
position: 'bottom-center',
|
||||
onClose: () => {
|
||||
this.socketConnectionToastId = null
|
||||
},
|
||||
closeOnClick: timeout !== null
|
||||
}
|
||||
}
|
||||
this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false)
|
||||
} else {
|
||||
this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })
|
||||
}
|
||||
@@ -109,7 +129,7 @@ export default {
|
||||
this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)
|
||||
},
|
||||
reconnect() {
|
||||
console.error('[SOCKET] reconnected')
|
||||
console.log('[SOCKET] reconnected')
|
||||
},
|
||||
reconnectAttempt(val) {
|
||||
console.log(`[SOCKET] reconnect attempt ${val}`)
|
||||
@@ -120,6 +140,10 @@ export default {
|
||||
reconnectFailed() {
|
||||
console.error('[SOCKET] reconnect failed')
|
||||
},
|
||||
authFailed(payload) {
|
||||
console.error('[SOCKET] auth failed', payload.message)
|
||||
this.isSocketAuthenticated = false
|
||||
},
|
||||
init(payload) {
|
||||
console.log('Init Payload', payload)
|
||||
|
||||
@@ -127,7 +151,7 @@ export default {
|
||||
this.$store.commit('users/setUsersOnline', payload.usersOnline)
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('socket_init')
|
||||
this.isSocketAuthenticated = true
|
||||
},
|
||||
streamOpen(stream) {
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
|
||||
@@ -183,7 +207,7 @@ export default {
|
||||
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
|
||||
if (this.$store.state.selectedLibraryItem?.id === libraryItem.id) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
if (this.$store.state.globals.selectedEpisode && libraryItem.mediaType === 'podcast') {
|
||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === this.$store.state.globals.selectedEpisode.id)
|
||||
@@ -192,6 +216,9 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.$store.state.streamLibraryItem?.id === libraryItem.id) {
|
||||
this.$store.commit('updateStreamLibraryItem', libraryItem)
|
||||
}
|
||||
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
|
||||
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||
},
|
||||
@@ -351,6 +378,15 @@ export default {
|
||||
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
|
||||
},
|
||||
initializeSocket() {
|
||||
if (this.$root.socket) {
|
||||
// Can happen in dev due to hot reload
|
||||
console.warn('Socket already initialized')
|
||||
this.socket = this.$root.socket
|
||||
this.isSocketConnected = this.$root.socket?.connected
|
||||
this.isFirstSocketConnection = false
|
||||
this.socketConnectionToastId = null
|
||||
return
|
||||
}
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
persist: 'main',
|
||||
@@ -361,6 +397,7 @@ export default {
|
||||
path: `${this.$config.routerBasePath}/socket.io`
|
||||
})
|
||||
this.$root.socket = this.socket
|
||||
this.isSocketAuthenticated = false
|
||||
console.log('Socket initialized')
|
||||
|
||||
// Pre-defined socket events
|
||||
@@ -374,6 +411,7 @@ export default {
|
||||
|
||||
// Event received after authorizing socket
|
||||
this.socket.on('init', this.init)
|
||||
this.socket.on('auth_failed', this.authFailed)
|
||||
|
||||
// Stream Listeners
|
||||
this.socket.on('stream_open', this.streamOpen)
|
||||
@@ -568,6 +606,7 @@ export default {
|
||||
this.updateBodyClass()
|
||||
this.resize()
|
||||
this.$eventBus.$on('change-lang', this.changeLanguage)
|
||||
this.$eventBus.$on('token_refreshed', this.tokenRefreshed)
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
||||
@@ -591,6 +630,7 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('change-lang', this.changeLanguage)
|
||||
this.$eventBus.$off('token_refreshed', this.tokenRefreshed)
|
||||
window.removeEventListener('resize', this.resize)
|
||||
window.removeEventListener('keydown', this.keyDown)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import LazyBookCard from '@/components/cards/LazyBookCard'
|
||||
import LazySeriesCard from '@/components/cards/LazySeriesCard'
|
||||
import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
|
||||
import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'
|
||||
import LazyAlbumCard from '@/components/cards/LazyAlbumCard'
|
||||
import AuthorCard from '@/components/cards/AuthorCard'
|
||||
|
||||
export default {
|
||||
@@ -20,7 +19,6 @@ export default {
|
||||
if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
|
||||
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
|
||||
if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)
|
||||
if (this.entityName === 'albums') return Vue.extend(LazyAlbumCard)
|
||||
if (this.entityName === 'authors') return Vue.extend(AuthorCard)
|
||||
return Vue.extend(LazyBookCard)
|
||||
},
|
||||
@@ -28,7 +26,6 @@ export default {
|
||||
if (this.entityName === 'series') return 'cards-lazy-series-card'
|
||||
if (this.entityName === 'collections') return 'cards-lazy-collection-card'
|
||||
if (this.entityName === 'playlists') return 'cards-lazy-playlist-card'
|
||||
if (this.entityName === 'albums') return 'cards-lazy-album-card'
|
||||
if (this.entityName === 'authors') return 'cards-author-card'
|
||||
return 'cards-lazy-book-card'
|
||||
},
|
||||
|
||||
@@ -73,7 +73,8 @@ module.exports = {
|
||||
|
||||
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||
axios: {
|
||||
baseURL: routerBasePath
|
||||
baseURL: routerBasePath,
|
||||
progress: false
|
||||
},
|
||||
|
||||
// nuxt/pwa https://pwa.nuxtjs.org
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.20.0",
|
||||
"version": "2.25.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.20.0",
|
||||
"version": "2.25.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.20.0",
|
||||
"version": "2.25.1",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -182,18 +182,19 @@ export default {
|
||||
password: this.password,
|
||||
newPassword: this.newPassword
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
||||
this.resetForm()
|
||||
} else {
|
||||
this.$toast.error(res.error || this.$strings.ToastUnknownError)
|
||||
}
|
||||
this.changingPassword = false
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
||||
this.resetForm()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
this.$toast.error(this.$strings.ToastUnknownError)
|
||||
console.error('Failed to change password', error)
|
||||
let errorMessage = this.$strings.ToastUnknownError
|
||||
if (error.response?.data && typeof error.response.data === 'string') {
|
||||
errorMessage = error.response.data
|
||||
}
|
||||
this.$toast.error(errorMessage)
|
||||
})
|
||||
.finally(() => {
|
||||
this.changingPassword = false
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex items-center py-4 px-2 md:px-0 max-w-7xl mx-auto">
|
||||
<div class="flex items-center py-4 px-4 max-w-7xl mx-auto">
|
||||
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
|
||||
<h1 class="text-lg lg:text-xl">{{ title }}</h1>
|
||||
</nuxt-link>
|
||||
@@ -12,7 +12,7 @@
|
||||
<p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap-reverse justify-center py-4 px-2">
|
||||
<div class="flex flex-wrap-reverse lg:flex-nowrap justify-center py-4 px-4">
|
||||
<div class="w-full max-w-3xl py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 hidden lg:block" />
|
||||
@@ -23,8 +23,8 @@
|
||||
</div>
|
||||
<div class="flex items-center mb-3 py-1 -mx-1">
|
||||
<div class="w-12 hidden lg:block" />
|
||||
<ui-btn v-if="chapters.length" color="bg-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 v-if="chapters.length" color="bg-primary" small class="mx-1 whitespace-nowrap" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
|
||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg-bg' : 'bg-primary'" class="mx-1 whitespace-nowrap" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
||||
<ui-btn color="bg-primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
|
||||
<div class="grow" />
|
||||
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
|
||||
@@ -65,7 +65,7 @@
|
||||
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
|
||||
</div>
|
||||
<div class="grow px-1">
|
||||
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs" />
|
||||
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs min-w-52" />
|
||||
</div>
|
||||
<div class="w-32 min-w-32 px-2 py-1">
|
||||
<div class="flex items-center">
|
||||
@@ -141,10 +141,22 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<div v-if="!chapterData" class="flex p-20">
|
||||
<ui-text-input-with-label v-model.trim="asinInput" label="ASIN" />
|
||||
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-32 mx-1" />
|
||||
<ui-btn small color="bg-primary" class="mt-5" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
<div v-if="!chapterData" class="flex flex-col items-center justify-center p-20">
|
||||
<div class="relative">
|
||||
<div class="flex items-end space-x-2">
|
||||
<ui-text-input-with-label v-model.trim="asinInput" label="ASIN" class="flex-grow" />
|
||||
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-20 max-w-20" />
|
||||
<ui-btn color="bg-primary" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ui-checkbox v-model="removeBranding" :label="$strings.LabelRemoveAudibleBranding" small checkbox-bg="bg" label-class="pl-2 text-base text-sm" @click="toggleRemoveBranding" />
|
||||
</div>
|
||||
<div class="absolute left-0 mt-1.5 text-error text-s h-5">
|
||||
<p v-if="asinError">{{ asinError }}</p>
|
||||
<p v-if="asinError">{{ $strings.MessageAsinCheck }}</p>
|
||||
</div>
|
||||
<div class="invisible mt-1 text-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<div class="flex justify-between mb-4">
|
||||
@@ -221,6 +233,11 @@ export default {
|
||||
return redirect('/')
|
||||
}
|
||||
|
||||
// Fetch and set library if this items library does not match the current
|
||||
if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) {
|
||||
await store.dispatch('libraries/fetch', libraryItem.libraryId)
|
||||
}
|
||||
|
||||
var previousRoute = from ? from.fullPath : null
|
||||
if (from && from.path === '/login') previousRoute = null
|
||||
return {
|
||||
@@ -244,6 +261,8 @@ export default {
|
||||
findingChapters: false,
|
||||
showFindChaptersModal: false,
|
||||
chapterData: null,
|
||||
asinError: null,
|
||||
removeBranding: false,
|
||||
showSecondInputs: false,
|
||||
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],
|
||||
hasChanges: false
|
||||
@@ -305,6 +324,9 @@ export default {
|
||||
|
||||
this.checkChapters()
|
||||
},
|
||||
toggleRemoveBranding() {
|
||||
this.removeBranding = !this.removeBranding
|
||||
},
|
||||
shiftChapterTimes() {
|
||||
if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) {
|
||||
return
|
||||
@@ -314,12 +336,12 @@ export default {
|
||||
|
||||
const lastChapter = this.newChapters[this.newChapters.length - 1]
|
||||
if (lastChapter.start + amount > this.mediaDurationRounded) {
|
||||
this.$toast.error('Invalid shift amount. Last chapter start time would extend beyond the duration of this audiobook.')
|
||||
this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountLast)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.newChapters[0].end + amount <= 0) {
|
||||
this.$toast.error('Invalid shift amount. First chapter would have zero or negative length.')
|
||||
if (this.newChapters[1].start + amount <= 0) {
|
||||
this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountStart)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -541,17 +563,17 @@ export default {
|
||||
|
||||
this.findingChapters = true
|
||||
this.chapterData = null
|
||||
this.asinError = null // used to show warning about audible vs amazon ASIN
|
||||
this.$axios
|
||||
.$get(`/api/search/chapters?asin=${this.asinInput}®ion=${this.regionInput}`)
|
||||
.then((data) => {
|
||||
this.findingChapters = false
|
||||
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
this.showFindChaptersModal = false
|
||||
this.asinError = this.$getString(data.stringKey)
|
||||
} else {
|
||||
console.log('Chapter data', data)
|
||||
this.chapterData = data
|
||||
this.chapterData = this.removeBranding ? this.removeBrandingFromData(data) : data
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -561,6 +583,37 @@ export default {
|
||||
this.showFindChaptersModal = false
|
||||
})
|
||||
},
|
||||
removeBrandingFromData(data) {
|
||||
if (!data) return data
|
||||
try {
|
||||
const introDuration = data.brandIntroDurationMs
|
||||
const outroDuration = data.brandOutroDurationMs
|
||||
|
||||
for (let i = 0; i < data.chapters.length; i++) {
|
||||
const chapter = data.chapters[i]
|
||||
if (chapter.startOffsetMs < introDuration) {
|
||||
// This should never happen, as the intro is not longer than the first chapter
|
||||
// If this happens set to the next second
|
||||
// Will be 0 for the first chapter anayways
|
||||
chapter.startOffsetMs = i * 1000
|
||||
chapter.startOffsetSec = i
|
||||
} else {
|
||||
chapter.startOffsetMs -= introDuration
|
||||
chapter.startOffsetSec = Math.floor(chapter.startOffsetMs / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const lastChapter = data.chapters[data.chapters.length - 1]
|
||||
// If there is an outro that's in the outro duration, remove it
|
||||
if (lastChapter && lastChapter.lengthMs <= outroDuration) {
|
||||
data.chapters.pop()
|
||||
}
|
||||
|
||||
return data
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
},
|
||||
resetChapters() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageResetChaptersConfirm,
|
||||
|
||||
@@ -103,6 +103,12 @@ export default {
|
||||
console.error('No need to edit library item that is 1 file...')
|
||||
return redirect('/')
|
||||
}
|
||||
|
||||
// Fetch and set library if this items library does not match the current
|
||||
if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) {
|
||||
await store.dispatch('libraries/fetch', libraryItem.libraryId)
|
||||
}
|
||||
|
||||
return {
|
||||
libraryItem,
|
||||
files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex items-center justify-center mb-6">
|
||||
<div class="w-full max-w-2xl">
|
||||
<p class="text-2xl mb-2">{{ $strings.HeaderAudiobookTools }}</p>
|
||||
<div class="flex items-center mb-4">
|
||||
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
|
||||
<h1 class="text-lg lg:text-xl">{{ mediaMetadata.title }}</h1>
|
||||
</nuxt-link>
|
||||
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
|
||||
<span class="material-symbols text-base">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl">
|
||||
<div class="flex justify-end">
|
||||
@@ -13,43 +20,43 @@
|
||||
|
||||
<div class="flex justify-center mb-2">
|
||||
<div class="w-full max-w-2xl">
|
||||
<p class="text-xl">{{ $strings.HeaderMetadataToEmbed }}</p>
|
||||
<p class="text-lg">{{ $strings.HeaderMetadataToEmbed }}</p>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg mx-2">
|
||||
<div class="flex justify-center flex-wrap lg:flex-nowrap gap-4">
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
||||
<div class="flex py-2 px-4">
|
||||
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
|
||||
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
||||
<div class="w-28 min-w-28 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
|
||||
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
||||
</div>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<template v-for="(value, key, index) in metadataObject">
|
||||
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary/25' : ''">
|
||||
<div class="w-1/3 font-semibold">{{ key }}</div>
|
||||
<div class="w-2/3">
|
||||
<div class="w-28 min-w-28 font-semibold">{{ key }}</div>
|
||||
<div class="grow">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg mx-2">
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
||||
<div class="flex py-2 px-4 bg-primary/25">
|
||||
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
|
||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
|
||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
|
||||
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
|
||||
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
|
||||
</div>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
|
||||
<template v-for="(chapter, index) in metadataChapters">
|
||||
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary/25' : ''">
|
||||
<div class="grow font-semibold">{{ chapter.title }}</div>
|
||||
<div class="w-24">
|
||||
<div class="w-16 min-w-16">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<div class="w-16 min-w-16">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,10 +84,6 @@
|
||||
</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-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">{{ $strings.LabelUseAdvancedOptions }}</span>
|
||||
</button>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<ui-btn v-if="!isTaskFinished && processing" color="bg-error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
|
||||
@@ -89,18 +92,16 @@
|
||||
<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 || usingCustomEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
||||
<div class="flex flex-wrap -mx-2">
|
||||
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
|
||||
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
|
||||
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
|
||||
</div>
|
||||
<p class="text-sm text-warning">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
|
||||
</div>
|
||||
</transition>
|
||||
<!-- show encoding options for running task -->
|
||||
<div v-if="encodeTaskHasEncodingOptions" class="mb-4 pb-4 border-b border-white/10">
|
||||
<div class="flex flex-wrap -mx-2">
|
||||
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" readonly :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
|
||||
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" readonly :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
|
||||
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" readonly :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isM4BTool" class="mb-4">
|
||||
<widgets-encoder-options-card ref="encoderOptionsCard" :audio-tracks="audioFiles" :disabled="processing || isTaskFinished" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
@@ -146,19 +147,29 @@
|
||||
<div class="flex py-2 px-4 bg-primary/25">
|
||||
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
|
||||
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelFilename }}</div>
|
||||
<div class="w-20 text-xs font-semibold uppercase text-gray-200 hidden lg:block">{{ $strings.LabelChannels }}</div>
|
||||
<div class="w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block">{{ $strings.LabelCodec }}</div>
|
||||
<div class="w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block">{{ $strings.LabelBitrate }}</div>
|
||||
<div class="w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelSize }}</div>
|
||||
<div class="w-24"></div>
|
||||
</div>
|
||||
<template v-for="file in audioFiles">
|
||||
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary/25' : ''">
|
||||
<div class="w-10">{{ file.index }}</div>
|
||||
<div :key="file.index" class="flex py-2 px-4 text-xs sm:text-sm" :class="file.index % 2 === 0 ? 'bg-primary/25' : ''">
|
||||
<div class="w-10 min-w-10">{{ file.index }}</div>
|
||||
<div class="grow">
|
||||
{{ file.metadata.filename }}
|
||||
</div>
|
||||
<div class="w-16 font-mono text-gray-200">
|
||||
<div class="w-20 min-w-20 text-gray-200 hidden lg:block">{{ file.channels || 'unknown' }} ({{ file.channelLayout || 'unknown' }})</div>
|
||||
<div class="w-16 min-w-16 text-gray-200 hidden md:block">
|
||||
{{ file.codec || 'unknown' }}
|
||||
</div>
|
||||
<div class="w-16 min-w-16 text-gray-200 hidden md:block">
|
||||
{{ $bytesPretty(file.bitRate || 0, 0) }}
|
||||
</div>
|
||||
<div class="w-16 min-w-16 text-gray-200">
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<div class="w-24 min-w-24">
|
||||
<div class="flex justify-center">
|
||||
<span v-if="audioFilesFinished[file.ino]" class="material-symbols text-xl text-success leading-none">check_circle</span>
|
||||
<div v-else-if="audioFilesEncoding[file.ino]">
|
||||
@@ -195,10 +206,15 @@ export default {
|
||||
return redirect('/?error=invalid media type')
|
||||
}
|
||||
if (!libraryItem.media.audioFiles.length) {
|
||||
cnosole.error('No audio files')
|
||||
console.error('No audio files')
|
||||
return redirect('/?error=no audio files')
|
||||
}
|
||||
|
||||
// Fetch and set library if this items library does not match the current
|
||||
if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) {
|
||||
await store.dispatch('libraries/fetch', libraryItem.libraryId)
|
||||
}
|
||||
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
@@ -209,7 +225,6 @@ export default {
|
||||
metadataObject: null,
|
||||
selectedTool: 'embed',
|
||||
isCancelingEncode: false,
|
||||
showEncodeOptions: false,
|
||||
shouldBackupAudioFiles: true,
|
||||
encodingOptions: {
|
||||
bitrate: '128k',
|
||||
@@ -258,9 +273,6 @@ export default {
|
||||
audioFiles() {
|
||||
return (this.media.audioFiles || []).filter((af) => !af.exclude)
|
||||
},
|
||||
isSingleM4b() {
|
||||
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
@@ -268,14 +280,10 @@ export default {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
availableTools() {
|
||||
if (this.isSingleM4b) {
|
||||
return [{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
|
||||
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
|
||||
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
|
||||
]
|
||||
},
|
||||
taskFailed() {
|
||||
return this.isTaskFinished && this.task.isFailed
|
||||
@@ -309,8 +317,8 @@ export default {
|
||||
isMetadataEmbedQueued() {
|
||||
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||
},
|
||||
usingCustomEncodeOptions() {
|
||||
return this.isM4BTool && this.encodeTask && this.encodeTask.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0
|
||||
encodeTaskHasEncodingOptions() {
|
||||
return this.isM4BTool && !!this.encodeTask?.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -346,19 +354,15 @@ export default {
|
||||
if (this.$refs.channelsInput) this.$refs.channelsInput.blur()
|
||||
if (this.$refs.codecInput) this.$refs.codecInput.blur()
|
||||
|
||||
let queryStr = ''
|
||||
if (this.showEncodeOptions) {
|
||||
const options = []
|
||||
if (this.encodingOptions.bitrate) options.push(`bitrate=${this.encodingOptions.bitrate}`)
|
||||
if (this.encodingOptions.channels) options.push(`channels=${this.encodingOptions.channels}`)
|
||||
if (this.encodingOptions.codec) options.push(`codec=${this.encodingOptions.codec}`)
|
||||
if (options.length) {
|
||||
queryStr = `?${options.join('&')}`
|
||||
}
|
||||
}
|
||||
const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
|
||||
|
||||
this.encodingOptions = encodeOptions
|
||||
|
||||
const queryParams = new URLSearchParams(encodeOptions)
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b${queryStr}`)
|
||||
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b?${queryParams.toString()}`)
|
||||
.then(() => {
|
||||
console.log('Ab m4b merge started')
|
||||
})
|
||||
@@ -411,14 +415,10 @@ export default {
|
||||
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
|
||||
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
|
||||
|
||||
if (this.usingCustomEncodeOptions) {
|
||||
if (this.encodeTaskHasEncodingOptions) {
|
||||
if (this.encodeTask.data.encodeOptions.bitrate) this.encodingOptions.bitrate = this.encodeTask.data.encodeOptions.bitrate
|
||||
if (this.encodeTask.data.encodeOptions.channels) this.encodingOptions.channels = this.encodeTask.data.encodeOptions.channels
|
||||
if (this.encodeTask.data.encodeOptions.codec) this.encodingOptions.codec = this.encodeTask.data.encodeOptions.codec
|
||||
} else {
|
||||
this.encodingOptions.bitrate = localStorage.getItem('embedMetadataBitrate') || '128k'
|
||||
this.encodingOptions.channels = localStorage.getItem('embedMetadataChannels') || '2'
|
||||
this.encodingOptions.codec = localStorage.getItem('embedMetadataCodec') || 'aac'
|
||||
}
|
||||
},
|
||||
fetchMetadataEmbedObject() {
|
||||
@@ -433,10 +433,24 @@ export default {
|
||||
},
|
||||
taskUpdated(task) {
|
||||
this.processing = !task.isFinished
|
||||
},
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
if (libraryItem.id === this.libraryItem.id) {
|
||||
this.libraryItem = libraryItem
|
||||
this.fetchMetadataEmbedObject()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,7 @@ export default {
|
||||
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
|
||||
else if (pageName === 'stats') return this.$strings.HeaderYourStats
|
||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||
else if (pageName === 'api-keys') return this.$strings.HeaderApiKeys
|
||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||
|
||||
84
client/pages/config/api-keys/index.vue
Normal file
84
client/pages/config/api-keys/index.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderApiKeys">
|
||||
<template #header-items>
|
||||
<div v-if="numApiKeys" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||
<span>{{ numApiKeys }}</span>
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<ui-btn color="bg-primary" :disabled="loadingUsers || users.length === 0" small @click="setShowApiKeyModal()">{{ $strings.ButtonAddApiKey }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" />
|
||||
</app-settings-content>
|
||||
<modals-api-key-modal ref="apiKeyModal" v-model="showApiKeyModal" :api-key="selectedApiKey" :users="users" @created="apiKeyCreated" @updated="apiKeyUpdated" />
|
||||
<modals-api-key-created-modal ref="apiKeyCreatedModal" v-model="showApiKeyCreatedModal" :api-key="selectedApiKey" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loadingUsers: false,
|
||||
selectedApiKey: null,
|
||||
showApiKeyModal: false,
|
||||
showApiKeyCreatedModal: false,
|
||||
numApiKeys: 0,
|
||||
users: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
apiKeyCreated(apiKey) {
|
||||
this.numApiKeys++
|
||||
this.selectedApiKey = apiKey
|
||||
this.showApiKeyCreatedModal = true
|
||||
if (this.$refs.apiKeysTable) {
|
||||
this.$refs.apiKeysTable.addApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
apiKeyUpdated(apiKey) {
|
||||
if (this.$refs.apiKeysTable) {
|
||||
this.$refs.apiKeysTable.updateApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
setShowApiKeyModal(selectedApiKey) {
|
||||
this.selectedApiKey = selectedApiKey
|
||||
this.showApiKeyModal = true
|
||||
},
|
||||
loadUsers() {
|
||||
this.loadingUsers = true
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((res) => {
|
||||
this.users = res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingUsers = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadUsers()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -122,7 +122,8 @@
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="w-full flex items-center justify-end p-4">
|
||||
<div class="w-full flex items-center justify-between p-4">
|
||||
<p v-if="enableOpenIDAuth" class="text-sm text-warning">{{ $strings.MessageAuthenticationOIDCChangesRestart }}</p>
|
||||
<ui-btn color="bg-success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<p class="truncate">{{ feed.meta.title }}</p>
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="hidden xl:table-cell">
|
||||
<td class="hidden xl:table-cell max-w-48">
|
||||
<p class="truncate">{{ feed.slug }}</p>
|
||||
</td>
|
||||
<!-- -->
|
||||
@@ -57,7 +57,7 @@
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="text-center">
|
||||
<ui-icon-btn icon="delete" class="mx-0.5" :size="7" bg-color="bg-error" outlined @click.stop="deleteFeedClick(feed)" />
|
||||
<ui-icon-btn icon="delete" class="mx-0.5 text-white/70" borderless :size="7" iconFontSize="1.25rem" outlined @click.stop="deleteFeedClick(feed)" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -78,10 +78,10 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -250,10 +250,10 @@ export default {
|
||||
return user?.username || null
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
},
|
||||
numSelected() {
|
||||
return this.listeningSessions.filter((s) => s.selected).length
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<widgets-online-indicator :value="!!userOnline" />
|
||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||
</div>
|
||||
<div v-if="userToken" class="flex text-xs mt-4">
|
||||
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly show-copy />
|
||||
<div v-if="legacyToken" class="flex text-xs mt-4">
|
||||
<ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy />
|
||||
</div>
|
||||
<div class="w-full h-px bg-white/10 my-2" />
|
||||
<div class="py-2">
|
||||
@@ -100,9 +100,12 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
legacyToken() {
|
||||
return this.user.token
|
||||
},
|
||||
userToken() {
|
||||
return this.user.accessToken
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
@@ -129,10 +132,10 @@ export default {
|
||||
return this.listeningSessions.sessions[0]
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -98,10 +98,10 @@ export default {
|
||||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -91,15 +91,15 @@
|
||||
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-tooltip v-if="showQueueBtn" :text="isQueued ? $strings.ButtonQueueRemoveItem : $strings.ButtonQueueAddItem" direction="top">
|
||||
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" :bg-color="isQueued ? 'bg-primary' : 'bg-success/60'" class="mx-0.5" :class="isQueued ? 'text-success' : ''" @click="queueBtnClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn v-if="showReadButton" color="bg-info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||
<span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
|
||||
{{ $strings.ButtonRead }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-tooltip v-if="showQueueBtn" :text="isQueued ? $strings.ButtonQueueRemoveItem : $strings.ButtonQueueAddItem" direction="top">
|
||||
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" :bg-color="isQueued ? 'bg-primary' : 'bg-success/60'" class="mx-0.5" :class="isQueued ? 'text-success' : ''" @click="queueBtnClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
|
||||
<ui-icon-btn icon="" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
@@ -193,7 +193,7 @@ export default {
|
||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
@@ -819,6 +819,17 @@ export default {
|
||||
-webkit-line-clamp: 4;
|
||||
max-height: calc(6 * 1lh);
|
||||
}
|
||||
|
||||
/* Safari-specific fix for the description clamping */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
#item-description {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
max-height: calc(6 * 1lh);
|
||||
}
|
||||
}
|
||||
|
||||
#item-description.show-full {
|
||||
-webkit-line-clamp: unset;
|
||||
max-height: 999rem;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</tr>
|
||||
<tr v-for="narrator in narrators" :key="narrator.id">
|
||||
<td>
|
||||
<p v-if="selectedNarrator?.id !== narrator.id" class="text-sm md:text-base text-gray-100">{{ narrator.name }}</p>
|
||||
<nuxt-link v-if="selectedNarrator?.id !== narrator.id" :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`" class="text-sm md:text-base text-gray-100 hover:underline">{{ narrator.name }}</nuxt-link>
|
||||
<form v-else @submit.prevent="saveClick">
|
||||
<ui-text-input v-model="newNarratorName" />
|
||||
</form>
|
||||
|
||||
@@ -141,7 +141,7 @@ export default {
|
||||
return episodeIds
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -249,7 +249,7 @@ export default {
|
||||
},
|
||||
async loadRecentEpisodes(page = 0) {
|
||||
this.processing = true
|
||||
const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
|
||||
const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=50&page=${page}`).catch((error) => {
|
||||
console.error('Failed to get recent episodes', error)
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
return null
|
||||
|
||||
@@ -22,6 +22,7 @@ export default {
|
||||
})
|
||||
results = {
|
||||
podcasts: results?.podcast || [],
|
||||
episodes: results?.episodes || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
@@ -61,6 +62,7 @@ export default {
|
||||
})
|
||||
this.results = {
|
||||
podcasts: results?.podcast || [],
|
||||
episodes: results?.episodes || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
|
||||
@@ -89,14 +89,16 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect, store }) {
|
||||
async asyncData({ redirect, store, params }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.state.libraries.currentLibraryId) {
|
||||
return redirect('/config')
|
||||
const libraryId = params.library
|
||||
const library = await store.dispatch('libraries/fetch', libraryId)
|
||||
if (!library) {
|
||||
return redirect(`/oops?message=Library "${libraryId}" not found`)
|
||||
}
|
||||
return {}
|
||||
},
|
||||
|
||||
@@ -40,6 +40,15 @@
|
||||
|
||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||
|
||||
<div v-if="showNewAuthSystemMessage" class="mb-4">
|
||||
<widgets-alert type="warning">
|
||||
<div>
|
||||
<p>{{ $strings.MessageAuthenticationSecurityMessage }}</p>
|
||||
<a v-if="showNewAuthSystemAdminMessage" href="https://github.com/advplyr/audiobookshelf/discussions/4460" target="_blank" class="underline">{{ $strings.LabelMoreInfo }}</a>
|
||||
</div>
|
||||
</widgets-alert>
|
||||
</div>
|
||||
|
||||
<form v-show="login_local" @submit.prevent="submitForm">
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||
<ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
|
||||
@@ -85,7 +94,10 @@ export default {
|
||||
MetadataPath: '',
|
||||
login_local: true,
|
||||
login_openid: false,
|
||||
authFormData: null
|
||||
authFormData: null,
|
||||
// New JWT auth system re-login flags
|
||||
showNewAuthSystemMessage: false,
|
||||
showNewAuthSystemAdminMessage: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -179,11 +191,14 @@ export default {
|
||||
|
||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||
this.$store.commit('user/setUser', user)
|
||||
this.$store.commit('user/setAccessToken', user.accessToken)
|
||||
|
||||
this.$store.dispatch('user/loadUserSettings')
|
||||
},
|
||||
async submitForm() {
|
||||
this.error = null
|
||||
this.showNewAuthSystemMessage = false
|
||||
this.showNewAuthSystemAdminMessage = false
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
@@ -217,15 +232,24 @@ export default {
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
// Force re-login if user is using an old token with no expiration
|
||||
if (res.user.isOldToken) {
|
||||
this.username = res.user.username
|
||||
this.showNewAuthSystemMessage = true
|
||||
// Admin user sees link to github discussion
|
||||
this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root'
|
||||
return false
|
||||
}
|
||||
this.setUser(res)
|
||||
this.processing = false
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Authorize error', error)
|
||||
this.processing = false
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
checkStatus() {
|
||||
this.processing = true
|
||||
@@ -280,8 +304,9 @@ export default {
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (this.$route.query?.setToken) {
|
||||
localStorage.setItem('token', this.$route.query.setToken)
|
||||
// Token passed as query parameter after successful oidc login
|
||||
if (this.$route.query?.accessToken) {
|
||||
localStorage.setItem('token', this.$route.query.accessToken)
|
||||
}
|
||||
if (localStorage.getItem('token')) {
|
||||
if (await this.checkAuth()) return // if valid user no need to check status
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }">
|
||||
<div class="w-full max-w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }">
|
||||
<div class="w-screen h-screen absolute inset-0 pointer-events-none" style="background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)"></div>
|
||||
<div class="absolute inset-0 w-screen h-dvh flex items-center justify-center z-10">
|
||||
<div class="w-full p-2 sm:p-4 md:p-8">
|
||||
@@ -64,7 +64,7 @@ export default {
|
||||
return this.mediaItemShare.playbackSession
|
||||
},
|
||||
coverUrl() {
|
||||
if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||
if (!this.playbackSession.coverPath) return this.$store.getters['globals/getPlaceholderCoverSrc']
|
||||
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
|
||||
},
|
||||
downloadUrl() {
|
||||
@@ -335,8 +335,11 @@ export default {
|
||||
}
|
||||
},
|
||||
resize() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
setTimeout(() => {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
||||
}, 100)
|
||||
},
|
||||
playerError(error) {
|
||||
console.error('Player error', error)
|
||||
|
||||
@@ -316,9 +316,8 @@ export default {
|
||||
.$post('/api/upload', form)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
|
||||
this.$toast.error(errorMessage)
|
||||
console.error('Failed to upload item', error)
|
||||
this.$toast.error(error.response?.data || 'Oops, something went wrong...')
|
||||
return false
|
||||
})
|
||||
},
|
||||
@@ -360,15 +359,14 @@ export default {
|
||||
// Check if path already exists before starting upload
|
||||
// uploading fails if path already exists
|
||||
for (const item of items) {
|
||||
const filepath = Path.join(this.selectedFolder.fullPath, item.directory)
|
||||
const exists = await this.$axios
|
||||
.$post(`/api/filesystem/pathexists`, { filepath, directory: item.directory, folderPath: this.selectedFolder.fullPath })
|
||||
.$post(`/api/filesystem/pathexists`, { directory: item.directory, folderPath: this.selectedFolder.fullPath })
|
||||
.then((data) => {
|
||||
if (data.exists) {
|
||||
if (data.libraryItemTitle) {
|
||||
this.$toast.error(this.$getString('ToastUploaderItemExistsInSubdirectoryError', [data.libraryItemTitle]))
|
||||
} else {
|
||||
this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [filepath]))
|
||||
this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [Path.join(this.selectedFolder.fullPath, item.directory)]))
|
||||
}
|
||||
}
|
||||
return data.exists
|
||||
@@ -382,13 +380,9 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
let itemsUploaded = 0
|
||||
let itemsFailed = 0
|
||||
for (const item of itemsToUpload) {
|
||||
this.updateItemCardStatus(item.index, 'uploading')
|
||||
const result = await this.uploadItem(item)
|
||||
if (result) itemsUploaded++
|
||||
else itemsFailed++
|
||||
this.updateItemCardStatus(item.index, result ? 'success' : 'failed')
|
||||
}
|
||||
this.processing = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default class AudioTrack {
|
||||
constructor(track, userToken, routerBasePath) {
|
||||
constructor(track, sessionId, routerBasePath) {
|
||||
this.index = track.index || 0
|
||||
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||
this.duration = track.duration || 0
|
||||
@@ -8,28 +8,29 @@ export default class AudioTrack {
|
||||
this.mimeType = track.mimeType
|
||||
this.metadata = track.metadata || {}
|
||||
|
||||
this.userToken = userToken
|
||||
this.sessionId = sessionId
|
||||
this.routerBasePath = routerBasePath || ''
|
||||
if (this.contentUrl?.startsWith('/hls')) {
|
||||
this.sessionTrackUrl = this.contentUrl
|
||||
} else {
|
||||
this.sessionTrackUrl = `/public/session/${sessionId}/track/${this.index}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for CastPlayer
|
||||
*/
|
||||
get fullContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||
return `${process.env.serverUrl}${this.sessionTrackUrl}`
|
||||
}
|
||||
return `${window.location.origin}${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
|
||||
return `${window.location.origin}${this.routerBasePath}${this.sessionTrackUrl}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for LocalPlayer
|
||||
*/
|
||||
get relativeContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
return `${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
|
||||
return `${this.routerBasePath}${this.sessionTrackUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@ export default class PlayerHandler {
|
||||
get isPlayingLocalItem() {
|
||||
return this.libraryItem && this.player instanceof LocalAudioPlayer
|
||||
}
|
||||
get userToken() {
|
||||
return this.ctx.$store.getters['user/getToken']
|
||||
}
|
||||
get playerPlaying() {
|
||||
return this.playerState === 'PLAYING'
|
||||
}
|
||||
@@ -226,7 +223,7 @@ export default class PlayerHandler {
|
||||
|
||||
console.log('[PlayerHandler] Preparing Session', session)
|
||||
|
||||
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken, this.ctx.$config.routerBasePath))
|
||||
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, session.id, this.ctx.$config.routerBasePath))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
export default function ({ $axios, store, $config }) {
|
||||
export default function ({ $axios, store, $root, app }) {
|
||||
// Track if we're currently refreshing to prevent multiple refresh attempts
|
||||
let isRefreshing = false
|
||||
let failedQueue = []
|
||||
|
||||
const processQueue = (error, token = null) => {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(token)
|
||||
}
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
$axios.onRequest((config) => {
|
||||
if (!config.url) {
|
||||
console.error('Axios request invalid config', config)
|
||||
@@ -7,7 +22,7 @@ export default function ({ $axios, store, $config }) {
|
||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||
return
|
||||
}
|
||||
const bearerToken = store.state.user.user?.token || null
|
||||
const bearerToken = store.getters['user/getToken']
|
||||
if (bearerToken) {
|
||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||
}
|
||||
@@ -17,9 +32,79 @@ export default function ({ $axios, store, $config }) {
|
||||
}
|
||||
})
|
||||
|
||||
$axios.onError((error) => {
|
||||
$axios.onError(async (error) => {
|
||||
const originalRequest = error.config
|
||||
const code = parseInt(error.response && error.response.status)
|
||||
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
|
||||
console.error('Axios error', code, message)
|
||||
|
||||
// Handle 401 Unauthorized (token expired)
|
||||
if (code === 401 && !originalRequest._retry) {
|
||||
// Skip refresh for auth endpoints to prevent infinite loops
|
||||
if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') {
|
||||
// Refresh failed or login failed, redirect to login
|
||||
store.commit('user/setUser', null)
|
||||
store.commit('user/setAccessToken', null)
|
||||
app.router.push('/login')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
// If already refreshing, queue this request
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
})
|
||||
.then((token) => {
|
||||
if (!originalRequest.headers) {
|
||||
originalRequest.headers = {}
|
||||
}
|
||||
originalRequest.headers['Authorization'] = `Bearer ${token}`
|
||||
return $axios(originalRequest)
|
||||
})
|
||||
.catch((err) => {
|
||||
return Promise.reject(err)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
// Attempt to refresh the token
|
||||
// Updates store if successful, otherwise clears store and throw error
|
||||
const newAccessToken = await store.dispatch('user/refreshToken')
|
||||
if (!newAccessToken) {
|
||||
console.error('No new access token received')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Update the original request with new token
|
||||
if (!originalRequest.headers) {
|
||||
originalRequest.headers = {}
|
||||
}
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`
|
||||
|
||||
// Process any queued requests
|
||||
processQueue(null, newAccessToken)
|
||||
|
||||
// Retry the original request
|
||||
return $axios(originalRequest)
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError)
|
||||
|
||||
// Process queued requests with error
|
||||
processQueue(refreshError, null)
|
||||
|
||||
// Redirect to login
|
||||
app.router.push('/login')
|
||||
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const SupportedFileTypes = {
|
||||
image: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpeg', 'mpg'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'aif','wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpeg', 'mpg'],
|
||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
info: ['nfo'],
|
||||
text: ['txt'],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { supplant } from './utils'
|
||||
const defaultCode = 'en-us'
|
||||
|
||||
const languageCodeMap = {
|
||||
ar: { label: 'عربي', dateFnsLocale: 'ar' },
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
|
||||
@@ -87,7 +87,7 @@ export const getters = {
|
||||
getLibraryItemCoverSrc:
|
||||
(state, getters, rootState, rootGetters) =>
|
||||
(libraryItem, placeholder = null, raw = false) => {
|
||||
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
if (!placeholder) placeholder = getters.getPlaceholderCoverSrc
|
||||
if (!libraryItem) return placeholder
|
||||
const media = libraryItem.media
|
||||
if (!media?.coverPath || media.coverPath === placeholder) return placeholder
|
||||
@@ -95,7 +95,6 @@ export const getters = {
|
||||
// Absolute URL covers (should no longer be used)
|
||||
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
|
||||
|
||||
const userToken = rootGetters['user/getToken']
|
||||
const lastUpdate = libraryItem.updatedAt || Date.now()
|
||||
const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?ts=${lastUpdate}${raw ? '&raw=1' : ''}`
|
||||
@@ -103,11 +102,13 @@ export const getters = {
|
||||
getLibraryItemCoverSrcById:
|
||||
(state, getters, rootState, rootGetters) =>
|
||||
(libraryItemId, timestamp = null, raw = false) => {
|
||||
const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
if (!libraryItemId) return placeholder
|
||||
const userToken = rootGetters['user/getToken']
|
||||
if (!libraryItemId) return getters.getPlaceholderCoverSrc
|
||||
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}`
|
||||
},
|
||||
getPlaceholderCoverSrc: (state, getters, rootState, rootGetters) => {
|
||||
return `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
},
|
||||
getIsBatchSelectingMediaItems: (state) => {
|
||||
return state.selectedMediaItems.length
|
||||
}
|
||||
|
||||
@@ -171,6 +171,10 @@ export const mutations = {
|
||||
state.playerQueueItems = payload.queueItems || []
|
||||
}
|
||||
},
|
||||
updateStreamLibraryItem(state, libraryItem) {
|
||||
if (!libraryItem) return
|
||||
state.streamLibraryItem = libraryItem
|
||||
},
|
||||
setIsPlaying(state, isPlaying) {
|
||||
state.streamIsPlaying = isPlaying
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
settings: {
|
||||
orderBy: 'media.metadata.title',
|
||||
orderDesc: false,
|
||||
@@ -25,19 +26,19 @@ export const getters = {
|
||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||
getToken: (state) => {
|
||||
return state.user?.token || null
|
||||
return state.accessToken || null
|
||||
},
|
||||
getUserMediaProgress:
|
||||
(state) =>
|
||||
(libraryItemId, episodeId = null) => {
|
||||
if (!state.user.mediaProgress) return null
|
||||
if (!state.user?.mediaProgress) return null
|
||||
return state.user.mediaProgress.find((li) => {
|
||||
if (episodeId && li.episodeId !== episodeId) return false
|
||||
return li.libraryItemId == libraryItemId
|
||||
})
|
||||
},
|
||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
||||
if (!state.user.bookmarks) return []
|
||||
if (!state.user?.bookmarks) return []
|
||||
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
||||
},
|
||||
getUserSetting: (state) => (key) => {
|
||||
@@ -58,6 +59,9 @@ export const getters = {
|
||||
getUserCanAccessAllLibraries: (state) => {
|
||||
return !!state.user?.permissions?.accessAllLibraries
|
||||
},
|
||||
getUserCanAccessExplicitContent: (state) => {
|
||||
return !!state.user?.permissions?.accessExplicitContent
|
||||
},
|
||||
getLibrariesAccessible: (state, getters) => {
|
||||
if (!state.user) return []
|
||||
if (getters.getUserCanAccessAllLibraries) return []
|
||||
@@ -142,21 +146,42 @@ export const actions = {
|
||||
} catch (error) {
|
||||
console.error('Failed to load userSettings from local storage', error)
|
||||
}
|
||||
},
|
||||
refreshToken({ state, commit }) {
|
||||
return this.$axios
|
||||
.$post('/auth/refresh')
|
||||
.then(async (response) => {
|
||||
const newAccessToken = response.user.accessToken
|
||||
commit('setUser', response.user)
|
||||
commit('setAccessToken', newAccessToken)
|
||||
// Emit event used to re-authenticate socket in default.vue since $root is not available here
|
||||
if (this.$eventBus) {
|
||||
this.$eventBus.$emit('token_refreshed', newAccessToken)
|
||||
}
|
||||
return newAccessToken
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to refresh token', error)
|
||||
commit('setUser', null)
|
||||
commit('setAccessToken', null)
|
||||
// Calling function handles redirect to login
|
||||
throw error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
if (user) {
|
||||
if (user.token) localStorage.setItem('token', user.token)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
},
|
||||
setUserToken(state, token) {
|
||||
state.user.token = token
|
||||
localStorage.setItem('token', token)
|
||||
setAccessToken(state, token) {
|
||||
if (!token) {
|
||||
localStorage.removeItem('token')
|
||||
state.accessToken = null
|
||||
} else {
|
||||
state.accessToken = token
|
||||
localStorage.setItem('token', token)
|
||||
}
|
||||
},
|
||||
updateMediaProgress(state, { id, data }) {
|
||||
if (!state.user) return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -177,6 +177,7 @@
|
||||
"HeaderPlaylist": "Плейлист",
|
||||
"HeaderPlaylistItems": "Елементи от плейлист",
|
||||
"HeaderPodcastsToAdd": "Подкасти за Добавяне",
|
||||
"HeaderPresets": "Настройки по подразбиране",
|
||||
"HeaderPreviewCover": "Преглед на Корица",
|
||||
"HeaderRSSFeedGeneral": "RSS подробности",
|
||||
"HeaderRSSFeedIsOpen": "RSS емисията е отворена",
|
||||
@@ -219,6 +220,7 @@
|
||||
"LabelAccountTypeAdmin": "Администратор",
|
||||
"LabelAccountTypeGuest": "Гост",
|
||||
"LabelAccountTypeUser": "Потребител",
|
||||
"LabelActivities": "Дейности",
|
||||
"LabelActivity": "Дейност",
|
||||
"LabelAddToCollection": "Добави в Колекция",
|
||||
"LabelAddToCollectionBatch": "Добави {0} Книги в Колекция",
|
||||
@@ -253,7 +255,7 @@
|
||||
"LabelBackupLocation": "Местоположение на Архив",
|
||||
"LabelBackupsEnableAutomaticBackups": "Включи автоматично архивиране",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Архиви запазени в /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Максимален размер на архива (в GB)",
|
||||
"LabelBackupsMaxBackupSize": "Максимален размер на архива (в GB) (0 за неограничен)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "За защита срещу грешки в конфигурацията, архивите ще се провалят ако надхвърлят конфигурирания размер.",
|
||||
"LabelBackupsNumberToKeep": "Брой архиви за запазване",
|
||||
"LabelBackupsNumberToKeepHelp": "Само 1 архив ще бъде премахнат на веднъж, така че ако вече имате повече архиви от това трябва да ги премахнете ръчно.",
|
||||
@@ -283,6 +285,7 @@
|
||||
"LabelContinueSeries": "Продължи серии",
|
||||
"LabelCover": "Корица",
|
||||
"LabelCoverImageURL": "URL на Корица",
|
||||
"LabelCoverProvider": "Източник за обложки",
|
||||
"LabelCreatedAt": "Създадено на",
|
||||
"LabelCronExpression": "Cron израз",
|
||||
"LabelCurrent": "Текущо",
|
||||
@@ -325,11 +328,20 @@
|
||||
"LabelEncodingClearItemCache": "Уверете се, че периодично изчиствате кеша на елементите.",
|
||||
"LabelEncodingFinishedM4B": "Завършеният M4B файл ще бъде поставен в папката на вашите аудиокниги на:",
|
||||
"LabelEncodingInfoEmbedded": "Метаданните ще бъдат вградени в аудио траковете в папката на вашите аудиокниги.",
|
||||
"LabelEncodingStartedNavigation": "Когато задачата е стартирана, можете да смените тази страница.",
|
||||
"LabelEncodingTimeWarning": "Кодирането може да отнеме до 30 минути.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Внимание: Не променяйте тези настройки, ако не сте запознати с ffmpeg настройките за кодиране.",
|
||||
"LabelEncodingWatcherDisabled": "Ако сте изключили наблюдението на папки, ще е нужно да сканирате повторно аудио книгата.",
|
||||
"LabelEnd": "Край",
|
||||
"LabelEndOfChapter": "Край на глава",
|
||||
"LabelEpisode": "Епизод",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Епизодът не е свързан с RSS канал",
|
||||
"LabelEpisodeNumber": "Епизод #{0}",
|
||||
"LabelEpisodeTitle": "Заглавие на Епизод",
|
||||
"LabelEpisodeType": "Тип на Епизод",
|
||||
"LabelEpisodeUrlFromRssFeed": "URL адрес на епизод от RSS канал",
|
||||
"LabelEpisodes": "Епизоди",
|
||||
"LabelEpisodic": "Епизодичен",
|
||||
"LabelExample": "Пример",
|
||||
"LabelExpandSeries": "Покажи сериите",
|
||||
"LabelExpandSubSeries": "Покажи съб сериите",
|
||||
@@ -341,7 +353,9 @@
|
||||
"LabelFetchingMetadata": "Взимане на Метаданни",
|
||||
"LabelFile": "Файл",
|
||||
"LabelFileBirthtime": "Дата на създаване на файла",
|
||||
"LabelFileBornDate": "Роден {0}",
|
||||
"LabelFileModified": "Дата на модификация на файла",
|
||||
"LabelFileModifiedDate": "Променен {0}",
|
||||
"LabelFilename": "Име на файла",
|
||||
"LabelFilterByUser": "Филтриране по Потребител",
|
||||
"LabelFindEpisodes": "Намери Епизоди",
|
||||
@@ -355,14 +369,17 @@
|
||||
"LabelFontScale": "Мащаб на шрифта",
|
||||
"LabelFontStrikethrough": "Зачертан",
|
||||
"LabelFormat": "Формат",
|
||||
"LabelFull": "Пълен",
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанрове",
|
||||
"LabelHardDeleteFile": "Пълно Изтриване на Файл",
|
||||
"LabelHasEbook": "Има е-книга",
|
||||
"LabelHasSupplementaryEbook": "Има допълнителна е-книга",
|
||||
"LabelHideSubtitles": "Скрий субтитри",
|
||||
"LabelHighestPriority": "Най-висок Приоритет",
|
||||
"LabelHost": "Хост",
|
||||
"LabelHour": "Час",
|
||||
"LabelHours": "Часа",
|
||||
"LabelIcon": "Икона",
|
||||
"LabelImageURLFromTheWeb": "URL на Изображение от Интернет",
|
||||
"LabelInProgress": "В процес на изпълнение",
|
||||
@@ -377,8 +394,11 @@
|
||||
"LabelIntervalEvery6Hours": "Всеки 6 часа",
|
||||
"LabelIntervalEveryDay": "Всеки ден",
|
||||
"LabelIntervalEveryHour": "Всеки час",
|
||||
"LabelIntervalEveryMinute": "Всяка минута",
|
||||
"LabelInvert": "Обърни",
|
||||
"LabelItem": "Елемент",
|
||||
"LabelJumpBackwardAmount": "Количество за прескачане назад",
|
||||
"LabelJumpForwardAmount": "Количество за прескачане напред",
|
||||
"LabelLanguage": "Език",
|
||||
"LabelLanguageDefaultServer": "Език по подразбиране на сървъра",
|
||||
"LabelLanguages": "Езици",
|
||||
@@ -393,6 +413,7 @@
|
||||
"LabelLess": "По-малко",
|
||||
"LabelLibrariesAccessibleToUser": "Библиотеки Достъпни за Потребителя",
|
||||
"LabelLibrary": "Библиотека",
|
||||
"LabelLibraryFilterSublistEmpty": "Не {0}",
|
||||
"LabelLibraryItem": "Елемент на Библиотека",
|
||||
"LabelLibraryName": "Име на Библиотека",
|
||||
"LabelLimit": "Лимит",
|
||||
@@ -405,6 +426,10 @@
|
||||
"LabelLowestPriority": "Най-нисък Приоритет",
|
||||
"LabelMatchExistingUsersBy": "Съпостави съществуващи потребители по",
|
||||
"LabelMatchExistingUsersByDescription": "Използва се за свързване на съществуващи потребители. След свързване потребителите ще бъдат съпоставени по уникален идентификатор от вашия доставчик на SSO",
|
||||
"LabelMaxEpisodesToDownload": "Максимален брой епизоди за сваляне. Използвай 0 за неограничен.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Максимален брой нови епизоди за сваляне за проверка",
|
||||
"LabelMaxEpisodesToKeep": "Максимален брой епизоди за запазване",
|
||||
"LabelMaxEpisodesToKeepHelp": "Стойност 0 указва без максимален лимит. След като нов епизод е автоматично свален, най-старият епизод ще бъде изтрит, ако имате повече от X епизода. Само по един епизод ще бъде изтриван за всеки нов свален такъв.",
|
||||
"LabelMediaPlayer": "Медия Плейър",
|
||||
"LabelMediaType": "Тип медия",
|
||||
"LabelMetaTag": "Мета Таг",
|
||||
@@ -412,6 +437,7 @@
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "По-високите източници на метаданни ще заменят по-ниските",
|
||||
"LabelMetadataProvider": "Доставчик на Метаданни",
|
||||
"LabelMinute": "Минута",
|
||||
"LabelMinutes": "Минути",
|
||||
"LabelMissing": "Липсващо",
|
||||
"LabelMissingEbook": "Няма електронна книга",
|
||||
"LabelMissingSupplementaryEbook": "Няма допълнителна електронна книга",
|
||||
@@ -449,11 +475,14 @@
|
||||
"LabelOpenIDGroupClaimDescription": "Име на OpenID твърдението, което съдържа списък с групите на потребителя. Обикновено се нарича <code>groups</code>. <b>Ако е конфигурирано</b>, приложението автоматично ще присвоява роли въз основа на членството на потребителя в групи, при условие че тези групи са наименувани без чувствителност към регистъра като 'admin', 'user' или 'guest' в твърдението. Твърдението трябва да съдържа списък и ако потребителят принадлежи към множество групи, приложението ще присвои ролята, съответстваща на най-високото ниво на достъп. Ако няма съвпадение с група, достъпът ще бъде отказан.",
|
||||
"LabelOpenRSSFeed": "Отвори RSS Feed",
|
||||
"LabelOverwrite": "Презапиши",
|
||||
"LabelPaginationPageXOfY": "Страница {0} от {1}",
|
||||
"LabelPassword": "Парола",
|
||||
"LabelPath": "Път",
|
||||
"LabelPermanent": "Постоянен",
|
||||
"LabelPermissionsAccessAllLibraries": "Може да достъпи до всички библиотеки",
|
||||
"LabelPermissionsAccessAllTags": "Може да достъпи всички тагове",
|
||||
"LabelPermissionsAccessExplicitContent": "Може да достъпи експлицитно съдържание",
|
||||
"LabelPermissionsCreateEreader": "Може да създава електронен четец",
|
||||
"LabelPermissionsDelete": "Може да трие",
|
||||
"LabelPermissionsDownload": "Може да сваля",
|
||||
"LabelPermissionsUpdate": "Може да обновява",
|
||||
@@ -461,6 +490,8 @@
|
||||
"LabelPersonalYearReview": "Преглед на годината Ви ({0})",
|
||||
"LabelPhotoPathURL": "Път/URL на Снимка",
|
||||
"LabelPlayMethod": "Метод на Пускане",
|
||||
"LabelPlaybackRateIncrementDecrement": "Размер на увеличаване/намаляне при скоростта на възпроизвеждане",
|
||||
"LabelPlayerChapterNumberMarker": "{0} от {1}",
|
||||
"LabelPlaylists": "Плейлисти",
|
||||
"LabelPodcast": "Подкаст",
|
||||
"LabelPodcastSearchRegion": "Регион за Търсене на Подкасти",
|
||||
@@ -472,9 +503,12 @@
|
||||
"LabelPrimaryEbook": "Основна Електронна Книга",
|
||||
"LabelProgress": "Прогрес",
|
||||
"LabelProvider": "Доставчик",
|
||||
"LabelProviderAuthorizationValue": "Стойност на Authorization Header",
|
||||
"LabelPubDate": "Дата на публикуване",
|
||||
"LabelPublishYear": "Година на публикуване",
|
||||
"LabelPublishedDate": "Публикувани {0}",
|
||||
"LabelPublishedDecade": "Десетилетие на публикуване",
|
||||
"LabelPublishedDecades": "Десетилетия на публикуване",
|
||||
"LabelPublisher": "Издател",
|
||||
"LabelPublishers": "Издателство",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика",
|
||||
@@ -484,6 +518,7 @@
|
||||
"LabelRSSFeedSlug": "идентификатор на RSS емисия",
|
||||
"LabelRSSFeedURL": "URL на RSS емисия",
|
||||
"LabelRandomly": "Случайно",
|
||||
"LabelReAddSeriesToContinueListening": "Добави отново в \"Продължете да слушате\"",
|
||||
"LabelRead": "Прочети",
|
||||
"LabelReadAgain": "Прочети отново",
|
||||
"LabelReadEbookWithoutProgress": "Прочети електронната книга без записване прогрес",
|
||||
@@ -493,29 +528,40 @@
|
||||
"LabelRedo": "Повтори",
|
||||
"LabelRegion": "Регион",
|
||||
"LabelReleaseDate": "Дата на Издаване",
|
||||
"LabelRemoveAllMetadataAbs": "Премахни всички metadata.abs файлове",
|
||||
"LabelRemoveAllMetadataJson": "Премахни всички metadata.json файлове",
|
||||
"LabelRemoveAudibleBranding": "Премахни въведението и заключението на Audible от главите",
|
||||
"LabelRemoveCover": "Премахни Корица",
|
||||
"LabelRemoveMetadataFile": "Премахни файловете с метаданни от папката на библиотеката",
|
||||
"LabelRemoveMetadataFileHelp": "Премахни всички metadata.json и metadata.abs файлове от вашата {0} папка.",
|
||||
"LabelRowsPerPage": "Редове на Страница",
|
||||
"LabelSearchTerm": "Търси Термин",
|
||||
"LabelSearchTitle": "Търси Заглавие",
|
||||
"LabelSearchTitleOrASIN": "Търси Заглавие или ASIN",
|
||||
"LabelSeason": "Сезон",
|
||||
"LabelSeasonNumber": "Сезон #{0}",
|
||||
"LabelSelectAll": "Избери всичко",
|
||||
"LabelSelectAllEpisodes": "Избери всички епизоди",
|
||||
"LabelSelectEpisodesShowing": "Избери {0} епизоди показани",
|
||||
"LabelSelectUsers": "Избери Потребители",
|
||||
"LabelSendEbookToDevice": "Изпрати електронна книга до ...",
|
||||
"LabelSequence": "Последователност",
|
||||
"LabelSerial": "Сериал",
|
||||
"LabelSeries": "От сериите",
|
||||
"LabelSeriesName": "Име на Серия",
|
||||
"LabelSeriesProgress": "Прогрес на Серия",
|
||||
"LabelServerLogLevel": "Ниво на сървърен журнал",
|
||||
"LabelServerYearReview": "Преглед на годината на сървъра ({0})",
|
||||
"LabelSetEbookAsPrimary": "Направи главен",
|
||||
"LabelSetEbookAsSupplementary": "Направи второстепенен",
|
||||
"LabelSettingsAllowIframe": "Разреши вграждане в iframe",
|
||||
"LabelSettingsAudiobooksOnly": "Само аудиокниги",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Активирането на тази настройка ще игнорира файловете на електронни книги, освен ако не са в папка с аудиокниги, в което случай ще бъдат зададени като допълнителни електронни книги",
|
||||
"LabelSettingsBookshelfViewHelp": "Скеуморфен дизайн с дървени рафтове",
|
||||
"LabelSettingsChromecastSupport": "Chromecast поддръжка",
|
||||
"LabelSettingsDateFormat": "Формат на Дата",
|
||||
"LabelSettingsEnableWatcher": "Автоматично сканиране на библиотеките за промени",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Автоматично сканиране на библиотеката за промени",
|
||||
"LabelSettingsEnableWatcherHelp": "Включва автоматичното добавяне/обновяване на елементи, когато се открият промени във файловете. *Изисква рестарт на сървъра",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Позволи скриптово съдържание в epub-и",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Позволи epub файловете да изпълняват скриптове. Препоръчително е да бъде изключено освен ако не се доверявате на източника на epub файловете.",
|
||||
@@ -527,10 +573,13 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Сериите с една книга ще бъдат скрити от страницата на серията и рафтовете на началната страница.",
|
||||
"LabelSettingsHomePageBookshelfView": "Начална страница изглед на рафт",
|
||||
"LabelSettingsLibraryBookshelfView": "Библиотека изглед на рафт",
|
||||
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Процент завършеност е по-голям от",
|
||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Оставащо време е по-малко от (секунди)",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Отбелязване на мултимедиен елемент като завършен когато",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропусни предишни книги в Продължи Поредица",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Рафтът на началната страница 'Продължи поредицата' показва първата книга, която не е започната в поредици, в които има поне една завършена книга и няма книги в процес на четене. Активирането на тази настройка ще продължи поредицата от най-далечната завършена книга вместо от първата незапочната книга.",
|
||||
"LabelSettingsParseSubtitles": "Извлечи подзаглавия",
|
||||
"LabelSettingsParseSubtitlesHelp": "Извлича подзаглавия от имената на папките на аудиокнигите.<br>Подзаглавията трябва да бъдат разделени с \" - \"<br>например \"Заглавие на Книга - Тук е Подзаглавито\" има подзаглавие \"Тук е Подзаглавито\"",
|
||||
"LabelSettingsParseSubtitlesHelp": "Извлича подзаглавия от имената на папките на аудио книгите.<br>Подзаглавията трябва да бъдат разделени с \" - \"<br>например \"Заглавие на Книга - Тук е подзаглавието\" има подзаглавие \"Тук е подзаглавието\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Предпочети съвпадащи метаданни",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Съвпадащите данни ще заменят детайлите на елемента при използване на Бързо Съпоставяне. По подразбиране Бързото Съпоставяне ще попълни само липсващите детайли.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Пропусни съвпадащи книги, които вече имат ASIN",
|
||||
@@ -544,11 +593,19 @@
|
||||
"LabelSettingsStoreMetadataWithItem": "Запази метаданните с елемента",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "По подразбиране метаданните се съхраняват в /metadata/items, като активирате тази настройка метаданните ще се съхраняват в папката на елемента на вашата библиотека",
|
||||
"LabelSettingsTimeFormat": "Формат на Време",
|
||||
"LabelShare": "Сподели",
|
||||
"LabelShareDownloadableHelp": "Разреши на потребителите през връзка за споделяне да свалят zip файл с мултимедийния елемент.",
|
||||
"LabelShareOpen": "Общодостъпно",
|
||||
"LabelShareURL": "URL за споделяне",
|
||||
"LabelShowAll": "Покажи всички",
|
||||
"LabelShowSeconds": "Покажи секунди",
|
||||
"LabelShowSubtitles": "Показвай подзаглавия",
|
||||
"LabelSize": "Размер",
|
||||
"LabelSleepTimer": "Таймер за изключване",
|
||||
"LabelSlug": "Слъг",
|
||||
"LabelSortAscending": "Възходящ",
|
||||
"LabelSortDescending": "Низходящ",
|
||||
"LabelSortPubDate": "Подреди по дата на публикуване",
|
||||
"LabelStart": "Старт",
|
||||
"LabelStartTime": "Начално Време",
|
||||
"LabelStarted": "Стартирано",
|
||||
@@ -583,6 +640,11 @@
|
||||
"LabelThemeDark": "Тъмна",
|
||||
"LabelThemeLight": "Светла",
|
||||
"LabelTimeBase": "Времева Основа",
|
||||
"LabelTimeDurationXHours": "{0} часа",
|
||||
"LabelTimeDurationXMinutes": "{0} минути",
|
||||
"LabelTimeDurationXSeconds": "{0} секунди",
|
||||
"LabelTimeInMinutes": "Време в минути",
|
||||
"LabelTimeLeft": "остава {0}",
|
||||
"LabelTimeListened": "Време Слушано",
|
||||
"LabelTimeListenedToday": "Време Слушано Днес",
|
||||
"LabelTimeRemaining": "{0} оставащи",
|
||||
@@ -590,6 +652,7 @@
|
||||
"LabelTitle": "Заглавие",
|
||||
"LabelToolsEmbedMetadata": "Вграждане на Метаданни",
|
||||
"LabelToolsEmbedMetadataDescription": "Вграждане на метаданни в аудио файлове, включително корица и глави.",
|
||||
"LabelToolsM4bEncoder": "M4B кодировчик",
|
||||
"LabelToolsMakeM4b": "Направи M4B Аудиокнига Файл",
|
||||
"LabelToolsMakeM4bDescription": "Генериране на .M4B аудиокнига файл с вградени метаданни, корица и глави.",
|
||||
"LabelToolsSplitM4b": "Раздели M4B на MP3-ки",
|
||||
@@ -602,26 +665,32 @@
|
||||
"LabelTracksMultiTrack": "Многоканален",
|
||||
"LabelTracksNone": "Няма канали",
|
||||
"LabelTracksSingleTrack": "Единичен канал",
|
||||
"LabelTrailer": "Трейлър",
|
||||
"LabelType": "Тип",
|
||||
"LabelUnabridged": "Несъкратен",
|
||||
"LabelUndo": "Отмени",
|
||||
"LabelUnknown": "Неизвестен",
|
||||
"LabelUnknownPublishDate": "Неизвестна дата на публикуване",
|
||||
"LabelUpdateCover": "Обнови Корица",
|
||||
"LabelUpdateCoverHelp": "Позволи презаписване на съществуващите корици за избраните книги, когато се намери съвпадение",
|
||||
"LabelUpdateDetails": "Обнови Детайли",
|
||||
"LabelUpdateDetailsHelp": "Позволи презаписване на съществуващите детайли за избраните книги, когато се намери съвпадение",
|
||||
"LabelUpdatedAt": "Обновено на",
|
||||
"LabelUploaderDragAndDrop": "Плъзни и Пусни Файлове или Папки",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Извлачване на файлове",
|
||||
"LabelUploaderDropFiles": "Пусни Файлове",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Автоматично вземи заглавие, автор и серия",
|
||||
"LabelUseAdvancedOptions": "Използвай разширени опции",
|
||||
"LabelUseChapterTrack": "Използвай канал за глава",
|
||||
"LabelUseFullTrack": "Използвай пълен канал",
|
||||
"LabelUseZeroForUnlimited": "Използвай 0 за неограничен",
|
||||
"LabelUser": "Потребител",
|
||||
"LabelUsername": "Потребителско име",
|
||||
"LabelValue": "Стойност",
|
||||
"LabelVersion": "Версия",
|
||||
"LabelViewBookmarks": "Виж Отметки",
|
||||
"LabelViewChapters": "Виж Глави",
|
||||
"LabelViewPlayerSettings": "Виж настройки на плеъра",
|
||||
"LabelViewQueue": "Виж Опашка",
|
||||
"LabelVolume": "Сила на Звука",
|
||||
"LabelWeekdaysToRun": "Делници за изпълнение",
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
{
|
||||
"ButtonAdd": "Afegeix",
|
||||
"ButtonAddChapters": "Afegeix",
|
||||
"ButtonAddDevice": "Afegeix Dispositiu",
|
||||
"ButtonAddLibrary": "Crea Biblioteca",
|
||||
"ButtonAddChapters": "Afegeix capítols",
|
||||
"ButtonAddDevice": "Afegeix un aparell",
|
||||
"ButtonAddLibrary": "Afegeix una biblioteca",
|
||||
"ButtonAddPodcasts": "Afegeix pòdcasts",
|
||||
"ButtonAddUser": "Crea Usuari",
|
||||
"ButtonAddYourFirstLibrary": "Crea la teva Primera Biblioteca",
|
||||
"ButtonAddUser": "Afegeix un usuari",
|
||||
"ButtonAddYourFirstLibrary": "Afegiu la vostra primera biblioteca",
|
||||
"ButtonApply": "Aplica",
|
||||
"ButtonApplyChapters": "Aplica Capítols",
|
||||
"ButtonApplyChapters": "Aplica capítols",
|
||||
"ButtonAuthors": "Autors",
|
||||
"ButtonBack": "Enrere",
|
||||
"ButtonBrowseForFolder": "Cerca Carpeta",
|
||||
"ButtonBatchEditPopulateFromExisting": "Omplir des d'existent",
|
||||
"ButtonBatchEditPopulateMapDetails": "Omple els detalls del mapa",
|
||||
"ButtonBrowseForFolder": "Cerca una carpeta",
|
||||
"ButtonCancel": "Cancel·la",
|
||||
"ButtonCancelEncode": "Cancel·la Codificador",
|
||||
"ButtonCancelEncode": "Cancel·la la codificació",
|
||||
"ButtonChangeRootPassword": "Canvia Contrasenya Root",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Verifica i Descarrega Nous Episodis",
|
||||
"ButtonChooseAFolder": "Tria una Carpeta",
|
||||
"ButtonChooseFiles": "Tria un Fitxer",
|
||||
"ButtonClearFilter": "Elimina Filtres",
|
||||
"ButtonCloseFeed": "Tanca Font",
|
||||
"ButtonChooseAFolder": "Trieu una carpeta",
|
||||
"ButtonChooseFiles": "Trieu fitxers",
|
||||
"ButtonClearFilter": "Neteja el filtre",
|
||||
"ButtonCloseFeed": "Tanca el canal",
|
||||
"ButtonCloseSession": "Tanca la sessió oberta",
|
||||
"ButtonCollections": "Col·leccions",
|
||||
"ButtonConfigureScanner": "Configura Escàner",
|
||||
"ButtonCreate": "Crea",
|
||||
"ButtonCreateBackup": "Crea Còpia de Seguretat",
|
||||
"ButtonDelete": "Elimina",
|
||||
"ButtonDelete": "Suprimeix",
|
||||
"ButtonDownloadQueue": "Cua",
|
||||
"ButtonEdit": "Edita",
|
||||
"ButtonEditChapters": "Edita Capítol",
|
||||
"ButtonEditChapters": "Edita capítols",
|
||||
"ButtonEditPodcast": "Edita el pòdcast",
|
||||
"ButtonEnable": "Habilita",
|
||||
"ButtonFireAndFail": "Executat i fallat",
|
||||
@@ -117,7 +119,7 @@
|
||||
"HeaderAccount": "Compte",
|
||||
"HeaderAddCustomMetadataProvider": "Afegeix un proveïdor de metadades personalitzat",
|
||||
"HeaderAdvanced": "Avançat",
|
||||
"HeaderAppriseNotificationSettings": "Configuració de Notificacions Apprise",
|
||||
"HeaderAppriseNotificationSettings": "Paràmetres de notificacions Apprise",
|
||||
"HeaderAudioTracks": "Pistes d'àudio",
|
||||
"HeaderAudiobookTools": "Eines de gestió de fitxers de l'audiollibre",
|
||||
"HeaderAuthentication": "Autenticació",
|
||||
@@ -133,9 +135,9 @@
|
||||
"HeaderCustomMetadataProviders": "Proveïdors de metadades personalitzats",
|
||||
"HeaderDetails": "Detalls",
|
||||
"HeaderDownloadQueue": "Cua de baixades",
|
||||
"HeaderEbookFiles": "Fitxers de Llibres Digitals",
|
||||
"HeaderEbookFiles": "Fitxers de llibres digitals",
|
||||
"HeaderEmail": "Correu electrònic",
|
||||
"HeaderEmailSettings": "Configuració de Correu Electrònic",
|
||||
"HeaderEmailSettings": "Paràmetres de correu electrònic",
|
||||
"HeaderEpisodes": "Episodis",
|
||||
"HeaderEreaderDevices": "Dispositius Ereader",
|
||||
"HeaderEreaderSettings": "Paràmetres del lector",
|
||||
@@ -171,10 +173,11 @@
|
||||
"HeaderPasswordAuthentication": "Autenticació per Contrasenya",
|
||||
"HeaderPermissions": "Permisos",
|
||||
"HeaderPlayerQueue": "Cua del Reproductor",
|
||||
"HeaderPlayerSettings": "Configuració del Reproductor",
|
||||
"HeaderPlayerSettings": "Paràmetres del reproductor",
|
||||
"HeaderPlaylist": "Llista de Reproducció",
|
||||
"HeaderPlaylistItems": "Elements de la Llista de Reproducció",
|
||||
"HeaderPodcastsToAdd": "Pòdcasts a afegir",
|
||||
"HeaderPresets": "Valors predefinits",
|
||||
"HeaderPreviewCover": "Previsualització de la Portada",
|
||||
"HeaderRSSFeedGeneral": "Detalls RSS",
|
||||
"HeaderRSSFeedIsOpen": "La Font RSS està oberta",
|
||||
@@ -190,7 +193,7 @@
|
||||
"HeaderSettings": "Paràmetres",
|
||||
"HeaderSettingsDisplay": "Interfície",
|
||||
"HeaderSettingsExperimental": "Funcionalitats experimentals",
|
||||
"HeaderSettingsGeneral": "General",
|
||||
"HeaderSettingsGeneral": "Generals",
|
||||
"HeaderSettingsScanner": "Escàner",
|
||||
"HeaderSettingsWebClient": "Client web",
|
||||
"HeaderSleepTimer": "Temporitzador de son",
|
||||
@@ -219,10 +222,10 @@
|
||||
"LabelAccountTypeUser": "Usuari",
|
||||
"LabelActivities": "Activitats",
|
||||
"LabelActivity": "Activitat",
|
||||
"LabelAddToCollection": "Afegit a la Col·lecció",
|
||||
"LabelAddToCollectionBatch": "S'han Afegit {0} Llibres a la Col·lecció",
|
||||
"LabelAddToPlaylist": "Afegit a la llista de reproducció",
|
||||
"LabelAddToPlaylistBatch": "S'han Afegit {0} Elements a la Llista de Reproducció",
|
||||
"LabelAddToCollection": "Afegeix a la col·lecció",
|
||||
"LabelAddToCollectionBatch": "Afegeix {0} llibres a la col·lecció",
|
||||
"LabelAddToPlaylist": "Afegeix a la llista de reproducció",
|
||||
"LabelAddToPlaylistBatch": "Afegeix {0} elements a la llista de reproducció",
|
||||
"LabelAddedAt": "Afegit",
|
||||
"LabelAddedDate": "{0} Afegit",
|
||||
"LabelAdminUsersOnly": "Només usuaris administradors",
|
||||
@@ -231,7 +234,7 @@
|
||||
"LabelAllUsers": "Tots els usuaris",
|
||||
"LabelAllUsersExcludingGuests": "Tots els usuaris excepte convidats",
|
||||
"LabelAllUsersIncludingGuests": "Tots els usuaris i convidats",
|
||||
"LabelAlreadyInYourLibrary": "Ja existeix a la Biblioteca",
|
||||
"LabelAlreadyInYourLibrary": "Ja existeix a la biblioteca",
|
||||
"LabelApiToken": "Testimoni de l'API",
|
||||
"LabelAppend": "Adjuntar",
|
||||
"LabelAudioBitrate": "Taxa de bits d'àudio (per exemple, 128k)",
|
||||
@@ -288,14 +291,14 @@
|
||||
"LabelCronExpression": "Expressió de Cron",
|
||||
"LabelCurrent": "Actual",
|
||||
"LabelCurrently": "En aquest moment:",
|
||||
"LabelCustomCronExpression": "Expressió de Cron Personalitzada:",
|
||||
"LabelDatetime": "Hora i Data",
|
||||
"LabelCustomCronExpression": "Expressió del Cron personalitzada:",
|
||||
"LabelDatetime": "Data i hora",
|
||||
"LabelDays": "Dies",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Suprimeix del sistema de fitxers (desmarqueu per a eliminar de la base de dades només)",
|
||||
"LabelDescription": "Descripció",
|
||||
"LabelDeselectAll": "Desseleccionar Tots",
|
||||
"LabelDevice": "Dispositiu",
|
||||
"LabelDeviceInfo": "Informació del Dispositiu",
|
||||
"LabelDeviceInfo": "Informació de l'aparell",
|
||||
"LabelDeviceIsAvailableTo": "El dispositiu està disponible per a...",
|
||||
"LabelDirectory": "Directori",
|
||||
"LabelDiscFromFilename": "Disc a partir del nom de fitxer",
|
||||
@@ -333,11 +336,11 @@
|
||||
"LabelEnd": "Fi",
|
||||
"LabelEndOfChapter": "Fi del capítol",
|
||||
"LabelEpisode": "Episodi",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Episodi no enllaçat al feed RSS",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Episodi no enllaçat al canal RSS",
|
||||
"LabelEpisodeNumber": "Episodi #{0}",
|
||||
"LabelEpisodeTitle": "Títol de l'Episodi",
|
||||
"LabelEpisodeType": "Tipus d'Episodi",
|
||||
"LabelEpisodeUrlFromRssFeed": "URL de l'episodi del feed RSS",
|
||||
"LabelEpisodeUrlFromRssFeed": "URL de l'episodi del canal RSS",
|
||||
"LabelEpisodes": "Episodis",
|
||||
"LabelEpisodic": "Episodis",
|
||||
"LabelExample": "Exemple",
|
||||
@@ -350,7 +353,7 @@
|
||||
"LabelFeedURL": "Font de URL",
|
||||
"LabelFetchingMetadata": "Obtenció de metadades",
|
||||
"LabelFile": "Fitxer",
|
||||
"LabelFileBirthtime": "Arxiu creat a",
|
||||
"LabelFileBirthtime": "Fitxer creat a",
|
||||
"LabelFileBornDate": "Creat {0}",
|
||||
"LabelFileModified": "Fitxer modificat",
|
||||
"LabelFileModifiedDate": "Modificat {0}",
|
||||
@@ -437,7 +440,7 @@
|
||||
"LabelMinute": "Minut",
|
||||
"LabelMinutes": "Minuts",
|
||||
"LabelMissing": "Absent",
|
||||
"LabelMissingEbook": "No té ebook",
|
||||
"LabelMissingEbook": "No té llibre electrònic",
|
||||
"LabelMissingSupplementaryEbook": "No té ebook complementari",
|
||||
"LabelMobileRedirectURIs": "URI de redirecció mòbil permeses",
|
||||
"LabelMobileRedirectURIsDescription": "Aquesta és una llista blanca d'URI de redirecció vàlides per a aplicacions mòbils. El predeterminat és <code> audiobookshelf</code>, que pots eliminar o complementar amb URI addicionals per a la integració d'aplicacions de tercers. Usant un asterisc (<code> *</code>) com a única entrada que permet qualsevol URI.",
|
||||
@@ -471,6 +474,7 @@
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "Nom de la notificació de OpenID que conté permisos avançats per accions d'usuari dins l'aplicació que s'aplicaran a rols que no siguin d'administrador (<b>si estan configurats</b>). Si el reclam no apareix en la resposta, es denegarà l'accés a ABS. Si manca una sola opció, es tractarà com a <code>falsa</code>. Assegura't que la notificació del proveïdor d'identitats coincideixi amb l'estructura esperada:",
|
||||
"LabelOpenIDClaims": "Deixa les següents opcions buides per desactivar l'assignació avançada de grups i permisos, el que assignaria automàticament al grup 'Usuari'.",
|
||||
"LabelOpenIDGroupClaimDescription": "Nom de la declaració OpenID que conté una llista de grups de l'usuari. Comunament coneguts com <code>grups</code>. <b>Si es configura</b>, l'aplicació assignarà automàticament rols basats en la pertinença a grups de l'usuari, sempre que aquests grups es denominen 'admin', 'user' o 'guest' en la notificació. La sol·licitud ha de contenir una llista, i si un usuari pertany a diversos grups, l'aplicació assignarà el rol corresponent al major nivell d'accés. Si cap grup coincideix, es denegarà l'accés.",
|
||||
"LabelOpenRSSFeed": "Obre el canal RSS",
|
||||
"LabelOverwrite": "Sobreescriure",
|
||||
"LabelPaginationPageXOfY": "Pàgina {0} de {1}",
|
||||
"LabelPassword": "Contrasenya",
|
||||
@@ -494,25 +498,25 @@
|
||||
"LabelPodcastType": "Tipus de pòdcast",
|
||||
"LabelPodcasts": "Pòdcasts",
|
||||
"LabelPort": "Port",
|
||||
"LabelPrefixesToIgnore": "Prefixos per Ignorar (no distingeix entre majúscules i minúscules.)",
|
||||
"LabelPreventIndexing": "Evita que la teva font sigui indexada pels directoris de podcasts d'iTunes i Google",
|
||||
"LabelPrimaryEbook": "Ebook Principal",
|
||||
"LabelPrefixesToIgnore": "Prefixos a ignorar (no distingeix entre majúscules i minúscules)",
|
||||
"LabelPreventIndexing": "Evita que el vostre canal l'indexin els directoris de pòdcasts de l'iTunes i Google",
|
||||
"LabelPrimaryEbook": "Llibre electrònic principal",
|
||||
"LabelProgress": "Progrés",
|
||||
"LabelProvider": "Proveïdor",
|
||||
"LabelProviderAuthorizationValue": "Valor de l'encapçalament d'autorització",
|
||||
"LabelPubDate": "Data de Publicació",
|
||||
"LabelPublishYear": "Any de Publicació",
|
||||
"LabelPubDate": "Data de publicació",
|
||||
"LabelPublishYear": "Any de publicació",
|
||||
"LabelPublishedDate": "Publicat {0}",
|
||||
"LabelPublishedDecade": "Dècada de Publicació",
|
||||
"LabelPublishedDecade": "Dècada de publicació",
|
||||
"LabelPublishedDecades": "Dècades Publicades",
|
||||
"LabelPublisher": "Editor",
|
||||
"LabelPublishers": "Editors",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Correu Electrònic Personalitzat del Propietari",
|
||||
"LabelRSSFeedCustomOwnerName": "Nom Personalitzat del Propietari",
|
||||
"LabelRSSFeedOpen": "Font RSS Oberta",
|
||||
"LabelRSSFeedPreventIndexing": "Evitar l'indexació",
|
||||
"LabelRSSFeedSlug": "Font RSS Slug",
|
||||
"LabelRSSFeedURL": "URL de la Font RSS",
|
||||
"LabelRSSFeedPreventIndexing": "Evita la indexació",
|
||||
"LabelRSSFeedSlug": "URL semàntic del canal RSS",
|
||||
"LabelRSSFeedURL": "URL del canal RSS",
|
||||
"LabelRandomly": "A l'atzar",
|
||||
"LabelReAddSeriesToContinueListening": "Reafegir la sèrie per continuar escoltant-la",
|
||||
"LabelRead": "Llegit",
|
||||
@@ -521,52 +525,61 @@
|
||||
"LabelRecentSeries": "Sèries recents",
|
||||
"LabelRecentlyAdded": "Addicions recents",
|
||||
"LabelRecommended": "Recomanats",
|
||||
"LabelRedo": "Refer",
|
||||
"LabelRedo": "Refés",
|
||||
"LabelRegion": "Regió",
|
||||
"LabelReleaseDate": "Data d'Estrena",
|
||||
"LabelRemoveAllMetadataAbs": "Eliminar tots els fitxers metadata.abs",
|
||||
"LabelRemoveAllMetadataJson": "Eliminar tots els fitxers metadata.json",
|
||||
"LabelRemoveCover": "Eliminar Coberta",
|
||||
"LabelReleaseDate": "Data d'estrena",
|
||||
"LabelRemoveAllMetadataAbs": "Elimina tots els fitxers metadata.abs",
|
||||
"LabelRemoveAllMetadataJson": "Elimina tots els fitxers metadata.json",
|
||||
"LabelRemoveAudibleBranding": "Elimina la introducció i el tancament de l'Audible dels capítols",
|
||||
"LabelRemoveCover": "Elimina la coberta",
|
||||
"LabelRemoveMetadataFile": "Eliminar fitxers de metadades en carpetes d'elements de biblioteca",
|
||||
"LabelRemoveMetadataFileHelp": "Elimina tots els fitxers metadata.json i metadata.abs de les teves carpetes {0}.",
|
||||
"LabelRowsPerPage": "Files per Pàgina",
|
||||
"LabelSearchTerm": "Cercar Terme",
|
||||
"LabelSearchTitle": "Cercar Títol",
|
||||
"LabelSearchTitleOrASIN": "Cercar Títol o ASIN",
|
||||
"LabelRemoveMetadataFileHelp": "Elimina tots els fitxers metadata.json i metadata.abs de les vostres carpetes {0}.",
|
||||
"LabelRowsPerPage": "Files per pàgina",
|
||||
"LabelSearchTerm": "Cerca terme",
|
||||
"LabelSearchTitle": "Cerca títol",
|
||||
"LabelSearchTitleOrASIN": "Cerca títol o ASIN",
|
||||
"LabelSeason": "Temporada",
|
||||
"LabelSeasonNumber": "Temporada #{0}",
|
||||
"LabelSelectAll": "Seleccionar tot",
|
||||
"LabelSelectAllEpisodes": "Seleccionar tots els episodis",
|
||||
"LabelSeasonNumber": "{0}a temporada",
|
||||
"LabelSelectAll": "Selecciona-ho tot",
|
||||
"LabelSelectAllEpisodes": "Selecciona tots els episodis",
|
||||
"LabelSelectEpisodesShowing": "Seleccionar els {0} episodis visibles",
|
||||
"LabelSelectUsers": "Seleccionar usuaris",
|
||||
"LabelSendEbookToDevice": "Enviar Ebook a...",
|
||||
"LabelSequence": "Seqüència",
|
||||
"LabelSerial": "En sèrie",
|
||||
"LabelSeries": "Sèries",
|
||||
"LabelSeriesName": "Nom de la Sèrie",
|
||||
"LabelSeriesProgress": "Progrés de la Sèrie",
|
||||
"LabelSeries": "Sèrie",
|
||||
"LabelSeriesName": "Nom de la sèrie",
|
||||
"LabelSeriesProgress": "Progrés de la sèrie",
|
||||
"LabelServerLogLevel": "Nivell de registre del servidor",
|
||||
"LabelServerYearReview": "Resum de l'any del servidor ({0})",
|
||||
"LabelSetEbookAsPrimary": "Establir com a principal",
|
||||
"LabelSetEbookAsSupplementary": "Establir com a suplementari",
|
||||
"LabelSettingsAudiobooksOnly": "Només Audiollibres",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Activant aquesta opció s'ignoraran els fitxers d'ebook, excepte si estan dins d'una carpeta d'audiollibre, en aquest cas es marcaran com ebooks suplementaris",
|
||||
"LabelSettingsAudiobooksOnly": "Només audiollibres",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "En activar aquesta opció s'ignoraran els fitxers de llibre electrònic, excepte si estan dins d'una carpeta d'audiollibre; en aquest cas es marcaran com a llibres suplementaris",
|
||||
"LabelSettingsBookshelfViewHelp": "Disseny esqueomorf amb prestatgeries de fusta",
|
||||
"LabelSettingsChromecastSupport": "Compatibilitat amb Chromecast",
|
||||
"LabelSettingsDateFormat": "Format de Data",
|
||||
"LabelSettingsDateFormat": "Format de data",
|
||||
"LabelSettingsEnableWatcherHelp": "Permet afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Permetre scripts en epubs",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Permetre que els fitxers epub executin scripts. Es recomana mantenir aquesta opció desactivada tret que confiïs en l'origen dels fitxers epub.",
|
||||
"LabelSettingsExperimentalFeatures": "Funcions Experimentals",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Funcions en desenvolupament que es beneficiarien dels teus comentaris i experiències de prova. Feu clic aquí per obrir una conversa a Github.",
|
||||
"LabelSettingsFindCovers": "Troba cobertes",
|
||||
"LabelSettingsHideSingleBookSeries": "Amaga les sèries amb un sol llibre",
|
||||
"LabelSettingsParseSubtitles": "Analitza els subtítols",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignora els prefixos en ordenar",
|
||||
"LabelSettingsTimeFormat": "Format d'hora",
|
||||
"LabelShare": "Comparteix",
|
||||
"LabelShareDownloadableHelp": "Permet els usuaris amb l'enllaç de compartició de baixar un fitxer ZIP amb l'element de la biblioteca.",
|
||||
"LabelShareURL": "URL de compartició",
|
||||
"LabelShowAll": "Mostra-ho tot",
|
||||
"LabelShowSeconds": "Mostra segons",
|
||||
"LabelShowSubtitles": "Mostra subtítols",
|
||||
"LabelSize": "Mida",
|
||||
"LabelSleepTimer": "Temporitzador de repòs",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Ascendent",
|
||||
"LabelSortDescending": "Descendent",
|
||||
"LabelStart": "Inicia",
|
||||
"LabelStartTime": "Hora d'inici",
|
||||
"LabelStarted": "Iniciat",
|
||||
@@ -654,88 +667,98 @@
|
||||
"LabelViewPlayerSettings": "Mostra els ajustaments del reproductor",
|
||||
"LabelViewQueue": "Mostra cua del reproductor",
|
||||
"LabelVolume": "Volum",
|
||||
"LabelWebRedirectURLsDescription": "Autoritza aquestes URL al teu proveïdor OAuth per permetre redirecció a l'aplicació web després d'iniciar sessió:",
|
||||
"LabelWebRedirectURLsDescription": "Autoritzeu aquests URL al vostre proveïdor OAuth per a permetre redirigir a l’aplicació web després d'iniciar sessió:",
|
||||
"LabelWebRedirectURLsSubfolder": "Subcarpeta per a URL de redirecció",
|
||||
"LabelWeekdaysToRun": "Executar en dies de la setmana",
|
||||
"LabelXBooks": "{0} llibres",
|
||||
"LabelXItems": "{0} elements",
|
||||
"LabelYearReviewHide": "Oculta resum de l'any",
|
||||
"LabelYearReviewShow": "Mostra resum de l'any",
|
||||
"LabelYourAudiobookDuration": "Duració del teu audiollibre",
|
||||
"LabelYourAudiobookDuration": "Duració del vostre audiollibre",
|
||||
"LabelYourBookmarks": "Els vostres marcadors",
|
||||
"LabelYourPlaylists": "Les teves llistes",
|
||||
"LabelYourPlaylists": "Les vostres llistes",
|
||||
"LabelYourProgress": "El vostre progrés",
|
||||
"MessageAddToPlayerQueue": "Afegeix a la cua del reproductor",
|
||||
"MessageAppriseDescription": "Per utilitzar aquesta funció, hauràs de tenir l'<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API d'Apprise</a> en funcionament o una API que gestioni resultats similars. <br/>La URL de l'API d'Apprise ha de tenir la mateixa ruta d'arxius que on s'envien les notificacions. Per exemple: si la teva API és a <code>http://192.168.1.1:8337</code>, llavors posaries <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Reengegueu el servidor després de desar perquè s'hi apliquin els canvis d'OIDC.",
|
||||
"MessageBackupsDescription": "Les còpies de seguretat inclouen: usuaris, progrés dels usuaris, detalls dels elements de la biblioteca, configuració del servidor i imatges a <code>/metadata/items</code> i <code>/metadata/authors</code>. Les còpies de seguretat <strong>NO</strong> inclouen cap fitxer guardat a la carpeta de la teva biblioteca.",
|
||||
"MessageBackupsLocationEditNote": "Nota: Actualitzar la ubicació de la còpia de seguretat no mourà ni modificarà les còpies existents",
|
||||
"MessageBackupsLocationNoEditNote": "Nota: La ubicació de la còpia de seguretat es defineix mitjançant una variable d'entorn i no es pot modificar aquí.",
|
||||
"MessageBackupsLocationPathEmpty": "La ruta de la còpia de seguretat no pot estar buida",
|
||||
"MessageBatchQuickMatchDescription": "La funció \"Troba Ràpid\" intentarà afegir portades i metadades que falten als elements seleccionats. Activa l'opció següent perquè \"Troba Ràpid\" pugui sobreescriure portades i/o metadades existents.",
|
||||
"MessageBookshelfNoCollections": "No tens cap col·lecció",
|
||||
"MessageBookshelfNoCollections": "Encara no heu fet cap col·lecció",
|
||||
"MessageBookshelfNoCollectionsHelp": "Les col·leccions són públiques. Tots els usuaris amb accés a la biblioteca les podran veure.",
|
||||
"MessageBookshelfNoRSSFeeds": "Cap font RSS està oberta",
|
||||
"MessageBookshelfNoResultsForFilter": "Cap resultat per al filtre \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForFilter": "Cap resultat per al filtre «{0}: {1}»",
|
||||
"MessageBookshelfNoResultsForQuery": "Cap resultat per a la consulta",
|
||||
"MessageBookshelfNoSeries": "No tens cap sèrie",
|
||||
"MessageBookshelfNoSeries": "No teniu cap sèrie",
|
||||
"MessageChapterEndIsAfter": "El final del capítol és després del final del teu audiollibre",
|
||||
"MessageChapterErrorFirstNotZero": "El primer capítol ha de començar a 0",
|
||||
"MessageChapterErrorStartGteDuration": "El temps d'inici no és vàlid: ha de ser inferior a la durada de l'audiollibre",
|
||||
"MessageChapterErrorStartLtPrev": "El temps d'inici no és vàlid: ha de ser igual o més gran que el temps d'inici del capítol anterior",
|
||||
"MessageChapterStartIsAfter": "L'inici del capítol és després del final del teu audiollibre",
|
||||
"MessageChaptersNotFound": "No s'han trobat els capítols",
|
||||
"MessageCheckingCron": "Comprovant cron...",
|
||||
"MessageConfirmCloseFeed": "Estàs segur que vols tancar aquesta font?",
|
||||
"MessageConfirmDeleteBackup": "Estàs segur que vols eliminar la còpia de seguretat {0}?",
|
||||
"MessageConfirmDeleteDevice": "Estàs segur que vols eliminar el lector electrònic \"{0}\"?",
|
||||
"MessageConfirmDeleteFile": "Això eliminarà el fitxer del teu sistema. Estàs segur?",
|
||||
"MessageConfirmDeleteLibrary": "Estàs segur que vols eliminar permanentment la biblioteca \"{0}\"?",
|
||||
"MessageConfirmDeleteLibraryItem": "Això eliminarà l'element de la base de dades i del sistema. Estàs segur?",
|
||||
"MessageConfirmDeleteLibraryItems": "Això eliminarà {0} element(s) de la base de dades i del sistema. Estàs segur?",
|
||||
"MessageConfirmDeleteMetadataProvider": "Estàs segur que vols eliminar el proveïdor de metadades personalitzat \"{0}\"?",
|
||||
"MessageConfirmDeleteNotification": "Estàs segur que vols eliminar aquesta notificació?",
|
||||
"MessageConfirmDeleteSession": "Estàs segur que vols eliminar aquesta sessió?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Estàs segur que vols incrustar metadades a {0} fitxer(s) d'àudio?",
|
||||
"MessageConfirmForceReScan": "Estàs segur que vols forçar un reescaneig?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Estàs segur que vols marcar tots els episodis com a acabats?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Estàs segur que vols marcar tots els episodis com a no acabats?",
|
||||
"MessageConfirmMarkItemFinished": "Estàs segur que vols marcar \"{0}\" com a acabat?",
|
||||
"MessageConfirmMarkItemNotFinished": "Estàs segur que vols marcar \"{0}\" com a no acabat?",
|
||||
"MessageConfirmMarkSeriesFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a acabats?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a no acabats?",
|
||||
"MessageConfirmNotificationTestTrigger": "Vols activar aquesta notificació amb dades de prova?",
|
||||
"MessageConfirmPurgeCache": "Esborrar la memòria cau eliminarà tot el directori localitzat a <code>/metadata/cache</code>. <br /><br />Estàs segur que vols eliminar-lo?",
|
||||
"MessageConfirmCloseFeed": "Segur que voleu tancar aquest canal?",
|
||||
"MessageConfirmDeleteBackup": "Segur que voleu suprimir la còpia de seguretat de {0}?",
|
||||
"MessageConfirmDeleteDevice": "Segur que voleu suprimir el lector electrònic «{0}»?",
|
||||
"MessageConfirmDeleteFile": "Això suprimirà el fitxer del vostre sistema de fitxers. N'esteu segur?",
|
||||
"MessageConfirmDeleteLibrary": "Segur que voleu suprimir permanentment la biblioteca «{0}»?",
|
||||
"MessageConfirmDeleteLibraryItem": "Això suprimirà l’element de la base de dades i del sistema de fitxers. N’esteu segur?",
|
||||
"MessageConfirmDeleteLibraryItems": "Això suprimirà {0} element(s) de la base de dades i del sistema de fitxers. N'esteu segur?",
|
||||
"MessageConfirmDeleteMetadataProvider": "Segur que voleu suprimir el proveïdor de metadades personalitzat «{0}»?",
|
||||
"MessageConfirmDeleteNotification": "Segur que voleu suprimir aquesta notificació?",
|
||||
"MessageConfirmDeleteSession": "Segur que voleu suprimir aquesta sessió?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Segur que voleu incrustar metadades a {0} fitxer(s) d'àudio?",
|
||||
"MessageConfirmForceReScan": "Segur que voleu forçar un reescaneig?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Segur que voleu marcar tots els episodis com a acabats?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Segur que voleu marcar tots els episodis com a no acabats?",
|
||||
"MessageConfirmMarkItemFinished": "Segur que voleu marcar «{0}» com a acabat?",
|
||||
"MessageConfirmMarkItemNotFinished": "Segur que voleu marcar «{0}» com a no acabat?",
|
||||
"MessageConfirmMarkSeriesFinished": "Segur que voleu marcar tots els llibres d'aquesta sèrie com a acabats?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Segur que voleu marcar tots els llibres d'aquesta sèrie com a no acabats?",
|
||||
"MessageConfirmNotificationTestTrigger": "Voleu activar aquesta notificació amb dades de prova?",
|
||||
"MessageConfirmPurgeCache": "Purgar la memòria cau suprimirà tot el directori localitzat a <code>/metadata/cache</code>. <br /><br />Segur que voleu eliminar-lo?",
|
||||
"MessageConfirmPurgeItemsCache": "Esborrar la memòria cau dels elements eliminarà el directori <code>/metadata/cache/items</code>.<br />Estàs segur?",
|
||||
"MessageConfirmQuickEmbed": "Advertència! La integració ràpida no fa còpies de seguretat dels teus fitxers d'àudio. Assegura't d'haver-ne fet una còpia abans. <br><br>Vols continuar?",
|
||||
"MessageConfirmQuickEmbed": "Avís: la incrustació ràpida no fa còpies de seguretat dels vostres fitxers d'àudio. Assegureu-vos d'haver-ne fet una còpia abans. <br><br>Voleu continuar?",
|
||||
"MessageConfirmQuickMatchEpisodes": "El reconeixement ràpid sobreescriurà els detalls si es troba una coincidència. Estàs segur?",
|
||||
"MessageConfirmReScanLibraryItems": "Estàs segur que vols reescanejar {0} element(s)?",
|
||||
"MessageConfirmRemoveAllChapters": "Estàs segur que vols eliminar tots els capítols?",
|
||||
"MessageConfirmRemoveAuthor": "Estàs segur que vols eliminar l'autor \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Estàs segur que vols eliminar la col·lecció \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Estàs segur que vols eliminar l'episodi \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Estàs segur que vols eliminar {0} episodis?",
|
||||
"MessageConfirmRemoveListeningSessions": "Estàs segur que vols eliminar {0} sessions d'escolta?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Estàs segur que vols eliminar tots els fitxers de metadades.{0} de les carpetes dels elements de la teva biblioteca?",
|
||||
"MessageConfirmRemoveNarrator": "Estàs segur que vols eliminar el narrador \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Estàs segur que vols eliminar la llista de reproducció \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Estàs segur que vols canviar el gènere \"{0}\" a \"{1}\" per a tots els elements?",
|
||||
"MessageConfirmReScanLibraryItems": "Segur que voleu reescanejar {0} element(s)?",
|
||||
"MessageConfirmRemoveAllChapters": "Segur que voleu eliminar tots els capítols?",
|
||||
"MessageConfirmRemoveAuthor": "Segur que voleu eliminar l'autor «{0}»?",
|
||||
"MessageConfirmRemoveCollection": "Segur que voleu eliminar la col·lecció «{0}»?",
|
||||
"MessageConfirmRemoveEpisode": "Segur que voleu eliminar l'episodi «{0}»?",
|
||||
"MessageConfirmRemoveEpisodes": "Segur que voleu eliminar {0} episodis?",
|
||||
"MessageConfirmRemoveListeningSessions": "Segur que voleu eliminar {0} sessions d'escolta?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Segur que voleu eliminar tots els fitxers metadata.{0} de les carpetes dels elements de la vostra biblioteca?",
|
||||
"MessageConfirmRemoveNarrator": "Segur que voleu eliminar el narrador «{0}»?",
|
||||
"MessageConfirmRemovePlaylist": "Segur que voleu eliminar la llista de reproducció «{0}»?",
|
||||
"MessageConfirmRenameGenre": "Segur que voleu canviar el nom del gènere «{0}» a «{1}» per a tots els elements?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Nota: Aquest gènere ja existeix, i es fusionarà.",
|
||||
"MessageConfirmRenameGenreWarning": "Advertència! Ja existeix un gènere similar \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Estàs segur que vols canviar l'etiqueta \"{0}\" a \"{1}\" per a tots els elements?",
|
||||
"MessageConfirmRenameTag": "Segur que voleu canviar el nom de l'etiqueta «{0}» a «{1}» per a tots els elements?",
|
||||
"MessageConfirmRenameTagMergeNote": "Nota: Aquesta etiqueta ja existeix, i es fusionarà.",
|
||||
"MessageConfirmRenameTagWarning": "Advertència! Ja existeix una etiqueta similar \"{0}\".",
|
||||
"MessageConfirmResetProgress": "Estàs segur que vols reiniciar el teu progrés?",
|
||||
"MessageConfirmSendEbookToDevice": "Estàs segur que vols enviar {0} ebook(s) \"{1}\" al dispositiu \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Estàs segur que vols desvincular aquest usuari d'OpenID?",
|
||||
"MessageDownloadingEpisode": "Descarregant capítol",
|
||||
"MessageConfirmResetProgress": "Segur que voleu reinicialitzar el vostre progrés?",
|
||||
"MessageConfirmSendEbookToDevice": "Segur que voleu enviar {0} llibre(s) «{1}» al dispositiu «{2}»?",
|
||||
"MessageConfirmUnlinkOpenId": "Segur que voleu desenllaçar aquest usuari d'OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} dies escoltats l'any passat",
|
||||
"MessageDownloadingEpisode": "S'està baixant l'episodi",
|
||||
"MessageDragFilesIntoTrackOrder": "Arrossega els fitxers en l'ordre correcte de les pistes",
|
||||
"MessageEmbedFailed": "Error en incrustar!",
|
||||
"MessageEmbedFinished": "Incrustació acabada!",
|
||||
"MessageEmbedQueue": "En cua per incrustar metadades ({0} en cua)",
|
||||
"MessageFeedURLWillBe": "L'URL del canal serà {0}",
|
||||
"MessageFetching": "S'està recuperant...",
|
||||
"MessageImportantNotice": "Avís important",
|
||||
"MessageInsertChapterBelow": "Insereix un capítol a sota",
|
||||
"MessageInvalidAsin": "L'ASIN no és vàlid",
|
||||
"MessageItemsSelected": "{0} elements seleccionats",
|
||||
"MessageItemsUpdated": "{0} elements actualitzats",
|
||||
"MessageJoinUsOn": "Uniu-vos a nosaltres a",
|
||||
"MessageLoading": "S'està carregant...",
|
||||
"MessageLoadingFolders": "S'estan carregant les carpetes...",
|
||||
"MessageMarkAllEpisodesFinished": "Marca tots els episodis com a acabats",
|
||||
"MessageMarkAllEpisodesNotFinished": "Marca tots els episodis com a inacabats",
|
||||
"MessageMarkAsFinished": "Marcar com acabat",
|
||||
"MessageMarkAsNotFinished": "Marcar com no acabat",
|
||||
"MessageMatchBooksDescription": "S'intentarà fer coincidir els llibres de la biblioteca amb un llibre del proveïdor de cerca seleccionat, i s'ompliran els detalls buits i la portada. No sobreescriu els detalls.",
|
||||
@@ -776,38 +799,40 @@
|
||||
"MessagePauseChapter": "Pausar la reproducció del capítol",
|
||||
"MessagePlayChapter": "Escoltar l'inici del capítol",
|
||||
"MessagePlaylistCreateFromCollection": "Crear una llista de reproducció a partir d'una col·lecció",
|
||||
"MessagePleaseWait": "Espera si us plau...",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "El podcast no té una URL de font RSS que es pugui utilitzar",
|
||||
"MessagePodcastSearchField": "Introdueix el terme de cerca o la URL de la font RSS",
|
||||
"MessagePleaseWait": "Espereu...",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "El pòdcast no té un URL de canal RSS que es pugui utilitzar",
|
||||
"MessagePodcastSearchField": "Introduïu el terme de cerca o l'URL del canal RSS",
|
||||
"MessageQuickEmbedInProgress": "Integració ràpida en procés",
|
||||
"MessageQuickEmbedQueue": "En cua per a inserció ràpida ({0} en cua)",
|
||||
"MessageQuickMatchAllEpisodes": "Combina ràpidament tots els episodis",
|
||||
"MessageQuickMatchDescription": "Omple detalls d'elements buits i portades amb els primers resultats de '{0}'. No sobreescriu els detalls tret que l'opció \"Preferir metadades trobades\" del servidor estigui habilitada.",
|
||||
"MessageRemoveChapter": "Eliminar capítols",
|
||||
"MessageRemoveEpisodes": "Eliminar {0} episodi(s)",
|
||||
"MessageRemoveFromPlayerQueue": "Eliminar de la cua del reproductor",
|
||||
"MessageRemoveUserWarning": "Estàs segur que vols eliminar l'usuari \"{0}\"?",
|
||||
"MessageQuickMatchDescription": "Emplena els detalls i la coberta dels elements buits amb el resultat de la primera coincidència de «{0}». No sobreescriu els detalls tret que s'activi el paràmetre del servidor «Prefereix metadades coincidents».",
|
||||
"MessageRemoveChapter": "Elimina el capítol",
|
||||
"MessageRemoveEpisodes": "Elimina {0} episodi(s)",
|
||||
"MessageRemoveFromPlayerQueue": "Elimina de la cua del reproductor",
|
||||
"MessageRemoveUserWarning": "Segur que voleu suprimir permanentment l'usuari «{0}»?",
|
||||
"MessageReportBugsAndContribute": "Informa d'errors, sol·licita funcions i contribueix a",
|
||||
"MessageResetChaptersConfirm": "Estàs segur que vols desfer els canvis i revertir els capítols al seu estat original?",
|
||||
"MessageRestoreBackupConfirm": "Estàs segur que vols restaurar la còpia de seguretat creada a",
|
||||
"MessageResetChaptersConfirm": "Segur que voleu desfer els canvis i revertir els capítols al seu estat original?",
|
||||
"MessageRestoreBackupConfirm": "Segur que voleu restaurar la còpia de seguretat creada a",
|
||||
"MessageRestoreBackupWarning": "Restaurar sobreescriurà tota la base de dades situada a /config i les imatges de portades a /metadata/items i /metadata/authors.<br /><br />La còpia de seguretat no modifica cap fitxer a les carpetes de la teva biblioteca. Si has activat l'opció del servidor per guardar portades i metadades a les carpetes de la biblioteca, aquests fitxers no es guarden ni sobreescriuen.<br /><br />Tots els clients que utilitzin el teu servidor s'actualitzaran automàticament.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Executa cada {0} a les {1}",
|
||||
"MessageSearchResultsFor": "Resultats de la cerca de",
|
||||
"MessageSelected": "{0} seleccionat(s)",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "La seqüència de la sèrie no pot contenir espais",
|
||||
"MessageServerCouldNotBeReached": "No es va poder establir la connexió amb el servidor",
|
||||
"MessageSetChaptersFromTracksDescription": "Establir capítols utilitzant cada fitxer d'àudio com un capítol i el títol del capítol com el nom del fitxer d'àudio",
|
||||
"MessageShareExpirationWillBe": "La caducitat serà <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Caduca en {0}",
|
||||
"MessageShareURLWillBe": "La URL per compartir serà <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "Començar la reproducció per a \"{0}\" a {1}?",
|
||||
"MessageTaskAudioFileNotWritable": "El fitxer d'àudio \"{0}\" no es pot escriure",
|
||||
"MessageStartPlaybackAtTime": "Voleu començar la reproducció per a «{0}» a {1}?",
|
||||
"MessageTaskAudioFileNotWritable": "El fitxer d'àudio «{0}» no es pot escriure",
|
||||
"MessageTaskCanceledByUser": "Tasca cancel·lada per l'usuari",
|
||||
"MessageTaskDownloadingEpisodeDescription": "Descarregant l'episodi \"{0}\"",
|
||||
"MessageTaskDownloadingEpisodeDescription": "S'està baixant l'episodi «{0}»",
|
||||
"MessageTaskEmbeddingMetadata": "Inserint metadades",
|
||||
"MessageTaskEmbeddingMetadataDescription": "Inserint metadades en l'audiollibre \"{0}\"",
|
||||
"MessageTaskEncodingM4b": "Codificant M4B",
|
||||
"MessageTaskEncodingM4bDescription": "Codificant l'audiollibre \"{0}\" en un únic fitxer M4B",
|
||||
"MessageTaskEncodingM4bDescription": "S'està codificant l'audiollibre «{0}» en un únic fitxer M4B",
|
||||
"MessageTaskFailed": "Fallada",
|
||||
"MessageTaskFailedToBackupAudioFile": "Error en fer una còpia de seguretat del fitxer d'àudio \"{0}\"",
|
||||
"MessageTaskFailedToBackupAudioFile": "No s'ha pogut fer una còpia de seguretat del fitxer d'àudio «{0}»",
|
||||
"MessageTaskFailedToCreateCacheDirectory": "Error en crear el directori de la memòria cau",
|
||||
"MessageTaskFailedToEmbedMetadataInFile": "Error en incrustar metadades en el fitxer \"{0}\"",
|
||||
"MessageTaskFailedToMergeAudioFiles": "Error en fusionar fitxers d'àudio",
|
||||
@@ -816,14 +841,14 @@
|
||||
"MessageTaskMatchingBooksInLibrary": "Coincidint llibres a la biblioteca \"{0}\"",
|
||||
"MessageTaskNoFilesToScan": "Sense fitxers per escanejar",
|
||||
"MessageTaskOpmlImport": "Importar OPML",
|
||||
"MessageTaskOpmlImportDescription": "Creant podcasts a partir de {0} fonts RSS",
|
||||
"MessageTaskOpmlImportFeed": "Importació de feed OPML",
|
||||
"MessageTaskOpmlImportFeedDescription": "Importació del feed RSS \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedFailed": "No es pot obtenir el podcast",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "Creant el podcast \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "El podcast ja existeix a la ruta",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "Error en crear el podcast",
|
||||
"MessageTaskOpmlImportFinished": "Afegit {0} podcasts",
|
||||
"MessageTaskOpmlImportDescription": "S'estan creant pòdcasts a partir de {0} canals RSS",
|
||||
"MessageTaskOpmlImportFeed": "Importació d'un canal OPML",
|
||||
"MessageTaskOpmlImportFeedDescription": "S'està important el canal RSS «{0}»",
|
||||
"MessageTaskOpmlImportFeedFailed": "No s'ha pogut obtenir el canal del pòdcast",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "S'està creant el pòdcast «{0}»",
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "El pòdcast ja existeix al camí",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "No s'ha pogut crear el pòdcast",
|
||||
"MessageTaskOpmlImportFinished": "S'han afegit {0} pòdcasts",
|
||||
"MessageTaskOpmlParseFailed": "No s'ha pogut analitzar el fitxer OPML",
|
||||
"MessageTaskOpmlParseFastFail": "No s'ha trobat l'etiqueta <opml> o <outline> al fitxer OPML",
|
||||
"MessageTaskOpmlParseNoneFound": "No s'han trobat fonts al fitxer OPML",
|
||||
@@ -841,13 +866,13 @@
|
||||
"MessageValidCronExpression": "Expressió de cron vàlida",
|
||||
"MessageWatcherIsDisabledGlobally": "El watcher està desactivat globalment a la configuració del servidor",
|
||||
"MessageXLibraryIsEmpty": "La biblioteca {0} està buida!",
|
||||
"MessageYourAudiobookDurationIsLonger": "La durada del teu audiollibre és més llarga que la durada trobada",
|
||||
"MessageYourAudiobookDurationIsShorter": "La durada del teu audiollibre és més curta que la durada trobada",
|
||||
"MessageYourAudiobookDurationIsLonger": "La durada del vostre audiollibre és major que la durada trobada",
|
||||
"MessageYourAudiobookDurationIsShorter": "La durada del vostre audiollibre és menor que la durada trobada",
|
||||
"NoteChangeRootPassword": "L'usuari Root és l'únic usuari que pot no tenir una contrasenya",
|
||||
"NoteChapterEditorTimes": "Nota: El temps d'inici del primer capítol ha de romandre a 0:00, i el temps d'inici de l'últim capítol no pot superar la durada de l'audiollibre.",
|
||||
"NoteFolderPicker": "Nota: Les carpetes ja assignades no es mostraran",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Advertència: La majoria d'aplicacions de podcast requereixen que la URL de la font RSS utilitzi HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Advertència: Un o més dels teus episodis no tenen data de publicació. Algunes aplicacions de podcast ho requereixen.",
|
||||
"NoteChapterEditorTimes": "Nota: el temps d'inici del primer capítol ha de romandre a 0:00, i el temps d'inici de l'últim capítol no pot superar la durada de l'audiollibre.",
|
||||
"NoteFolderPicker": "Nota: les carpetes ja assignades no es mostraran",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Avís: la majoria d'aplicacions de pòdcast requereixen que l'URL del canal RSS utilitzi HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Avís: un o més dels vostres episodis no tenen data de publicació. Algunes aplicacions de pòdcast ho requereixen.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Les carpetes amb fitxers multimèdia es gestionaran com a elements separats a la biblioteca.",
|
||||
"NoteUploaderOnlyAudioFiles": "Si només puges fitxers d'àudio, cada fitxer es gestionarà com un audiollibre separat.",
|
||||
"NoteUploaderUnsupportedFiles": "Els fitxers no compatibles seran ignorats. Si selecciona o arrossega una carpeta, els fitxers que no estiguin dins d'una subcarpeta seran ignorats.",
|
||||
@@ -856,7 +881,7 @@
|
||||
"NotificationOnEpisodeDownloadedDescription": "S'activa quan es descarrega automàticament un episodi d'un podcast",
|
||||
"NotificationOnTestDescription": "Esdeveniment per provar el sistema de notificacions",
|
||||
"PlaceholderNewCollection": "Nou nom de la col·lecció",
|
||||
"PlaceholderNewFolderPath": "Nova ruta de carpeta",
|
||||
"PlaceholderNewFolderPath": "Camí de carpeta nou",
|
||||
"PlaceholderNewPlaylist": "Nou nom de la llista de reproducció",
|
||||
"PlaceholderSearch": "Cerca...",
|
||||
"PlaceholderSearchEpisode": "Cerca d'episodis...",
|
||||
@@ -882,7 +907,7 @@
|
||||
"ToastAppriseUrlRequired": "Cal introduir una URL de Apprise",
|
||||
"ToastAsinRequired": "ASIN requerit",
|
||||
"ToastAuthorImageRemoveSuccess": "S'ha eliminat la imatge de l'autor",
|
||||
"ToastAuthorNotFound": "No s'ha trobat l'autor \"{0}\"",
|
||||
"ToastAuthorNotFound": "No s'ha trobat l'autor «{0}»",
|
||||
"ToastAuthorRemoveSuccess": "Autor eliminat",
|
||||
"ToastAuthorSearchNotFound": "No s'ha trobat l'autor",
|
||||
"ToastAuthorUpdateMerged": "Autor combinat",
|
||||
@@ -898,6 +923,7 @@
|
||||
"ToastBackupRestoreFailed": "Error en restaurar la còpia de seguretat",
|
||||
"ToastBackupUploadFailed": "Error en carregar la còpia de seguretat",
|
||||
"ToastBackupUploadSuccess": "Còpia de seguretat carregada",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "S'han aplicat els detalls als elements",
|
||||
"ToastBatchDeleteFailed": "Error en l'eliminació per lots",
|
||||
"ToastBatchDeleteSuccess": "Eliminació per lots correcte",
|
||||
"ToastBatchQuickMatchFailed": "Error en la sincronització ràpida per lots!",
|
||||
@@ -910,6 +936,8 @@
|
||||
"ToastCachePurgeFailed": "Error en purgar la memòria cau",
|
||||
"ToastCachePurgeSuccess": "Memòria cau purgada amb èxit",
|
||||
"ToastChaptersHaveErrors": "Els capítols tenen errors",
|
||||
"ToastChaptersInvalidShiftAmountLast": "La quantitat de desplaçament no és vàlida. L'hora d'inici de l'últim capítol s'estendria més enllà de la durada d'aquest audiollibre.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "La quantitat de desplaçament no és vàlida. El primer capítol tindria una durada zero o negativa i el sobreescriuria el segon capítol. Augmenteu la durada inicial del segon capítol.",
|
||||
"ToastChaptersMustHaveTitles": "Els capítols han de tenir un títol",
|
||||
"ToastChaptersRemoved": "Capítols eliminats",
|
||||
"ToastChaptersUpdated": "Capítols actualitzats",
|
||||
@@ -917,6 +945,7 @@
|
||||
"ToastCollectionRemoveSuccess": "Col·lecció eliminada",
|
||||
"ToastCollectionUpdateSuccess": "Col·lecció actualitzada",
|
||||
"ToastCoverUpdateFailed": "Error en actualitzar la portada",
|
||||
"ToastDateTimeInvalidOrIncomplete": "La data i hora no és vàlida o està incompleta",
|
||||
"ToastDeleteFileFailed": "No s'ha pogut suprimir el fitxer",
|
||||
"ToastDeleteFileSuccess": "Fitxer suprimit",
|
||||
"ToastDeviceAddFailed": "Error en afegir el dispositiu",
|
||||
@@ -947,34 +976,35 @@
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Element marcat com a no acabat",
|
||||
"ToastItemUpdateSuccess": "Element actualitzat",
|
||||
"ToastLibraryCreateFailed": "Error en crear la biblioteca",
|
||||
"ToastLibraryCreateSuccess": "Biblioteca \"{0}\" creada",
|
||||
"ToastLibraryCreateSuccess": "S'ha creat la biblioteca «{0}»",
|
||||
"ToastLibraryDeleteFailed": "Error en eliminar la biblioteca",
|
||||
"ToastLibraryDeleteSuccess": "Biblioteca eliminada",
|
||||
"ToastLibraryScanFailedToStart": "Error en iniciar l'escaneig",
|
||||
"ToastLibraryScanStarted": "S'ha iniciat l'escaneig de la biblioteca",
|
||||
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualitzada",
|
||||
"ToastLibraryUpdateSuccess": "S'ha actualitzat la biblioteca «{0}»",
|
||||
"ToastMatchAllAuthorsFailed": "No coincideix amb tots els autors",
|
||||
"ToastMetadataFilesRemovedError": "Error en eliminar metadades de {0} arxius",
|
||||
"ToastMetadataFilesRemovedNoneFound": "No s'han trobat metadades en {0} arxius",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Cap metadada eliminada en {0} arxius",
|
||||
"ToastMetadataFilesRemovedError": "S’ha produït un error en eliminar els fitxers metadata.{0}",
|
||||
"ToastMetadataFilesRemovedNoneFound": "No hi ha cap fitxer metadata.{0} a la biblioteca",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "No s'ha eliminat cap fitxer metadata.{0}",
|
||||
"ToastMetadataFilesRemovedSuccess": "{0} metadades eliminades en {1} arxius",
|
||||
"ToastMustHaveAtLeastOnePath": "Ha de tenir almenys una ruta",
|
||||
"ToastNameEmailRequired": "El nom i el correu electrònic són obligatoris",
|
||||
"ToastNameRequired": "Nom obligatori",
|
||||
"ToastNewEpisodesFound": "{0} episodi(s) nou(s) trobat(s)",
|
||||
"ToastNewUserCreatedFailed": "Error en crear el compte: \"{0}\"",
|
||||
"ToastNewUserCreatedFailed": "No s'ha pogut crear el compte: «{0}»",
|
||||
"ToastNewUserCreatedSuccess": "Nou compte creat",
|
||||
"ToastNewUserLibraryError": "Ha de seleccionar almenys una biblioteca",
|
||||
"ToastNewUserPasswordError": "Necessites una contrasenya, només el root pot estar sense contrasenya",
|
||||
"ToastNewUserTagError": "Selecciona almenys una etiqueta",
|
||||
"ToastNewUserUsernameError": "Introdueix un nom d'usuari",
|
||||
"ToastNewUserLibraryError": "S'ha de seleccionar almenys una biblioteca",
|
||||
"ToastNewUserPasswordError": "Cal una contrasenya; només l'usuari primari pot estar sense contrasenya",
|
||||
"ToastNewUserTagError": "S'ha de seleccionar almenys una etiqueta",
|
||||
"ToastNewUserUsernameError": "Introduïu un nom d'usuari",
|
||||
"ToastNoNewEpisodesFound": "No s'han trobat nous episodis",
|
||||
"ToastNoRSSFeed": "El pòdcast no té canal RSS",
|
||||
"ToastNoUpdatesNecessary": "No cal actualitzar",
|
||||
"ToastNotificationCreateFailed": "Error en crear la notificació",
|
||||
"ToastNotificationDeleteFailed": "Error en eliminar la notificació",
|
||||
"ToastNotificationCreateFailed": "No s'ha pogut crear la notificació",
|
||||
"ToastNotificationDeleteFailed": "No s'ha pogut suprimir la notificació",
|
||||
"ToastNotificationFailedMaximum": "El nombre màxim d'intents fallits ha de ser ≥ 0",
|
||||
"ToastNotificationQueueMaximum": "La cua de notificació màxima ha de ser ≥ 0",
|
||||
"ToastNotificationSettingsUpdateSuccess": "Configuració de notificació actualitzada",
|
||||
"ToastNotificationSettingsUpdateSuccess": "S'han actualitzat els paràmetres de notificacions",
|
||||
"ToastNotificationTestTriggerFailed": "No s'ha pogut activar la notificació de prova",
|
||||
"ToastNotificationTestTriggerSuccess": "Notificació de prova activada",
|
||||
"ToastNotificationUpdateSuccess": "Notificació actualitzada",
|
||||
@@ -984,16 +1014,16 @@
|
||||
"ToastPlaylistUpdateSuccess": "Llista de reproducció actualitzada",
|
||||
"ToastPodcastCreateFailed": "No s'ha pogut crear el pòdcast",
|
||||
"ToastPodcastCreateSuccess": "S'ha creat el pòdcast correctament",
|
||||
"ToastPodcastGetFeedFailed": "No s'ha pogut obtenir el podcast",
|
||||
"ToastPodcastNoEpisodesInFeed": "No s'han trobat episodis en el feed RSS",
|
||||
"ToastPodcastNoRssFeed": "El podcast no té un feed RSS",
|
||||
"ToastPodcastGetFeedFailed": "No s'ha pogut obtenir el canal del pòdcast",
|
||||
"ToastPodcastNoEpisodesInFeed": "No s'ha trobat cap episodi al canal RSS",
|
||||
"ToastPodcastNoRssFeed": "El pòdcast no té un canal RSS",
|
||||
"ToastProgressIsNotBeingSynced": "El progrés no s'està sincronitzant, reinicia la reproducció",
|
||||
"ToastProviderCreatedFailed": "Error en afegir el proveïdor",
|
||||
"ToastProviderCreatedSuccess": "Nou proveïdor afegit",
|
||||
"ToastProviderNameAndUrlRequired": "Nom i URL obligatoris",
|
||||
"ToastProviderRemoveSuccess": "Proveïdor eliminat",
|
||||
"ToastRSSFeedCloseFailed": "Error en tancar el feed RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Feed RSS tancat",
|
||||
"ToastRSSFeedCloseFailed": "No s'ha pogut tancar el canal RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Canal RSS tancat",
|
||||
"ToastRemoveFailed": "Error en eliminar",
|
||||
"ToastRemoveItemFromCollectionFailed": "Error en eliminar l'element de la col·lecció",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Element eliminat de la col·lecció",
|
||||
@@ -1007,7 +1037,8 @@
|
||||
"ToastScanFailed": "No s'ha pogut escanejar l'element de la biblioteca",
|
||||
"ToastSelectAtLeastOneUser": "Selecciona almenys un usuari",
|
||||
"ToastSendEbookToDeviceFailed": "Error en enviar l'ebook al dispositiu",
|
||||
"ToastSendEbookToDeviceSuccess": "Ebook enviat al dispositiu \"{0}\"",
|
||||
"ToastSendEbookToDeviceSuccess": "El llibre electrònic s'ha enviat al dispositiu «{0}»",
|
||||
"ToastSeriesSubmitFailedSameName": "No és possible afegir dues sèries amb el mateix nom",
|
||||
"ToastSeriesUpdateFailed": "Error en actualitzar la sèrie",
|
||||
"ToastSeriesUpdateSuccess": "Sèrie actualitzada",
|
||||
"ToastServerSettingsUpdateSuccess": "Configuració del servidor actualitzada",
|
||||
@@ -1026,6 +1057,8 @@
|
||||
"ToastUnknownError": "Error desconegut",
|
||||
"ToastUnlinkOpenIdFailed": "Error en desvincular l'usuari d'OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Usuari desvinculat d'OpenID",
|
||||
"ToastUploaderFilepathExistsError": "El camí del fitxer «{0}» ja existeix al servidor",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "L'element «{0}» usa un subdirectori del camí de pujada.",
|
||||
"ToastUserDeleteFailed": "Error en eliminar l'usuari",
|
||||
"ToastUserDeleteSuccess": "Usuari eliminat",
|
||||
"ToastUserPasswordChangeSuccess": "Contrasenya canviada correctament",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"ButtonApplyChapters": "Aplikovat kapitoly",
|
||||
"ButtonAuthors": "Autoři",
|
||||
"ButtonBack": "Zpět",
|
||||
"ButtonBatchEditPopulateFromExisting": "Vytvořit z existujících",
|
||||
"ButtonBatchEditPopulateMapDetails": "Předvyplnit podrobnosti mapování",
|
||||
"ButtonBrowseForFolder": "Vyhledat složku",
|
||||
"ButtonCancel": "Zrušit",
|
||||
"ButtonCancelEncode": "Zrušit kódování",
|
||||
@@ -145,14 +147,14 @@
|
||||
"HeaderItemFiles": "Soubory položek",
|
||||
"HeaderItemMetadataUtils": "Nástroje metadat položek",
|
||||
"HeaderLastListeningSession": "Poslední poslechová relace",
|
||||
"HeaderLatestEpisodes": "Nejnovější epizody",
|
||||
"HeaderLatestEpisodes": "Nové epizody",
|
||||
"HeaderLibraries": "Knihovny",
|
||||
"HeaderLibraryFiles": "Soubory knihovny",
|
||||
"HeaderLibraryStats": "Statistiky knihovny",
|
||||
"HeaderListeningSessions": "Poslechové relace",
|
||||
"HeaderListeningStats": "Statistiky poslechu",
|
||||
"HeaderLogin": "Přihlásit",
|
||||
"HeaderLogs": "Záznamy",
|
||||
"HeaderLogs": "Logy",
|
||||
"HeaderManageGenres": "Spravovat žánry",
|
||||
"HeaderManageTags": "Spravovat štítky",
|
||||
"HeaderMapDetails": "Podrobnosti mapování",
|
||||
@@ -175,6 +177,7 @@
|
||||
"HeaderPlaylist": "Seznam skladeb",
|
||||
"HeaderPlaylistItems": "Položky seznamu přehrávání",
|
||||
"HeaderPodcastsToAdd": "Podcasty k přidání",
|
||||
"HeaderPresets": "Předvolba",
|
||||
"HeaderPreviewCover": "Náhled obálky",
|
||||
"HeaderRSSFeedGeneral": "Podrobnosti o RSS",
|
||||
"HeaderRSSFeedIsOpen": "Informační kanál RSS je otevřený",
|
||||
@@ -227,6 +230,7 @@
|
||||
"LabelAddedDate": "Přidáno {0}",
|
||||
"LabelAdminUsersOnly": "Pouze administrátoři",
|
||||
"LabelAll": "Vše",
|
||||
"LabelAllEpisodesDownloaded": "Všechny epizody staženy",
|
||||
"LabelAllUsers": "Všichni uživatelé",
|
||||
"LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů",
|
||||
"LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů",
|
||||
@@ -250,7 +254,7 @@
|
||||
"LabelBackToUser": "Zpět k uživateli",
|
||||
"LabelBackupAudioFiles": "Zálohovat zvukové soubory",
|
||||
"LabelBackupLocation": "Umístění zálohy",
|
||||
"LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování",
|
||||
"LabelBackupsEnableAutomaticBackups": "Automatické zálohování",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Zálohy uložené v /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maximální velikost zálohy (v GB) (0 bez omezení)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Ochrana proti chybné konfiguraci: Zálohování se nezdaří, pokud překročí nastavenou velikost.",
|
||||
@@ -282,6 +286,7 @@
|
||||
"LabelContinueSeries": "Pokračovat v sérii",
|
||||
"LabelCover": "Obálka",
|
||||
"LabelCoverImageURL": "URL obrázku obálky",
|
||||
"LabelCoverProvider": "Poskytovatel obálky",
|
||||
"LabelCreatedAt": "Vytvořeno v",
|
||||
"LabelCronExpression": "Výraz Cronu",
|
||||
"LabelCurrent": "Aktuální",
|
||||
@@ -341,11 +346,11 @@
|
||||
"LabelExample": "Příklad",
|
||||
"LabelExpandSeries": "Rozbalit série",
|
||||
"LabelExpandSubSeries": "Rozbalit podsérie",
|
||||
"LabelExplicit": "Explicitní",
|
||||
"LabelExplicit": "Explicitně",
|
||||
"LabelExplicitChecked": "Explicitní (zaškrtnuto)",
|
||||
"LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)",
|
||||
"LabelExportOPML": "Export OPML",
|
||||
"LabelFeedURL": "URL zdroje",
|
||||
"LabelFeedURL": "URL kanálu",
|
||||
"LabelFetchingMetadata": "Získávání metadat",
|
||||
"LabelFile": "Soubor",
|
||||
"LabelFileBirthtime": "Čas vzniku souboru",
|
||||
@@ -421,7 +426,7 @@
|
||||
"LabelLookForNewEpisodesAfterDate": "Hledat nové epizody po tomto datu",
|
||||
"LabelLowestPriority": "Nejnižší priorita",
|
||||
"LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle",
|
||||
"LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.",
|
||||
"LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO",
|
||||
"LabelMaxEpisodesToDownload": "Maximální # epizod pro stažení. Použijte 0 pro bez omezení.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Maximální počet nových epizod ke stažení při jedné kontrole",
|
||||
"LabelMaxEpisodesToKeep": "Maximální počet epizod k zachování",
|
||||
@@ -430,7 +435,7 @@
|
||||
"LabelMediaType": "Typ média",
|
||||
"LabelMetaTag": "Metaznačka",
|
||||
"LabelMetaTags": "Metaznačky",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Zdroje metadat s vyšší prioritou budou mít přednost před zdroji metadat s nižší prioritou.",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Zdroje metadat s vyšší prioritou budou mít přednost před zdroji metadat s nižší prioritou",
|
||||
"LabelMetadataProvider": "Poskytovatel metadat",
|
||||
"LabelMinute": "Minuta",
|
||||
"LabelMinutes": "Minuty",
|
||||
@@ -509,9 +514,9 @@
|
||||
"LabelPublishers": "Vydavatelé",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
||||
"LabelRSSFeedOpen": "Otevření RSS kanálu",
|
||||
"LabelRSSFeedOpen": "RSS kanál otevřen",
|
||||
"LabelRSSFeedPreventIndexing": "Zabránit indexování",
|
||||
"LabelRSSFeedSlug": "RSS kanál Slug",
|
||||
"LabelRSSFeedSlug": "Klíčové slovo kanálu RSS",
|
||||
"LabelRSSFeedURL": "URL RSS kanálu",
|
||||
"LabelRandomly": "Náhodně",
|
||||
"LabelReAddSeriesToContinueListening": "Znovu přidat sérii k pokračování poslechu",
|
||||
@@ -526,6 +531,7 @@
|
||||
"LabelReleaseDate": "Datum vydání",
|
||||
"LabelRemoveAllMetadataAbs": "Odebrat všechny soubory metadata.abs",
|
||||
"LabelRemoveAllMetadataJson": "Smazat všechny soubory metadata.json",
|
||||
"LabelRemoveAudibleBranding": "Odebrat úvod a závěr Audible z kapitol",
|
||||
"LabelRemoveCover": "Odstranit obálku",
|
||||
"LabelRemoveMetadataFile": "Odstranit soubory metadat ve složkách položek knihovny",
|
||||
"LabelRemoveMetadataFileHelp": "Odstraníte všechny soubory metadata.json a metadata.abs ve svých složkách {0}.",
|
||||
@@ -545,7 +551,7 @@
|
||||
"LabelSeries": "Série",
|
||||
"LabelSeriesName": "Název série",
|
||||
"LabelSeriesProgress": "Průběh série",
|
||||
"LabelServerLogLevel": "Úroveň protokolu serveru",
|
||||
"LabelServerLogLevel": "Úroveň Logování serveru",
|
||||
"LabelServerYearReview": "Přehled roku na serveru ({0})",
|
||||
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
||||
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
||||
@@ -555,6 +561,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi",
|
||||
"LabelSettingsChromecastSupport": "Podpora Chromecastu",
|
||||
"LabelSettingsDateFormat": "Formát data",
|
||||
"LabelSettingsEnableWatcher": "Automaticky skenovat změny v knihovnách",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Automaticky skenovat změny v knihovně",
|
||||
"LabelSettingsEnableWatcherHelp": "Povoluje automatické přidávání/aktualizaci položek, když jsou zjištěny změny souborů. *Vyžaduje restart serveru",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Povolení skriptovaného obsahu v epubu",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Povolení spouštění skriptů v souborech epub. Doporučujeme toto nastavení vypnout, pokud nedůvěřujete zdroji souborů epub.",
|
||||
@@ -598,6 +606,7 @@
|
||||
"LabelSlug": "URL název",
|
||||
"LabelSortAscending": "Vzestupně",
|
||||
"LabelSortDescending": "Sestupně",
|
||||
"LabelSortPubDate": "Seřadit podle datumu publikování",
|
||||
"LabelStart": "Spustit",
|
||||
"LabelStartTime": "Čas Spuštění",
|
||||
"LabelStarted": "Spuštěno",
|
||||
@@ -698,10 +707,14 @@
|
||||
"LabelYourProgress": "Váš pokrok",
|
||||
"MessageAddToPlayerQueue": "Přidat do fronty přehrávače",
|
||||
"MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Po uložení restartujte server, aby se změny OIDC použily.",
|
||||
"MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.",
|
||||
"MessageBackupsLocationEditNote": "Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy",
|
||||
"MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.",
|
||||
"MessageBackupsLocationPathEmpty": "Umístění záloh nemůže být prázdné",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Předvyplnit vybraná pole datami ze všech položek. Pole s více hodnotami budou sloučena",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Předvyplnit povolená pole mapování daty z této položky",
|
||||
"MessageBatchQuickMatchDescription": "Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.",
|
||||
"MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku",
|
||||
"MessageBookshelfNoCollectionsHelp": "Kolekce jsou veřejné. Mohou je zobrazit všichni uživatelé s přístupem do knihovny.",
|
||||
@@ -714,6 +727,7 @@
|
||||
"MessageChapterErrorStartGteDuration": "Neplatný čas začátku, musí být kratší než doba trvání audioknihy",
|
||||
"MessageChapterErrorStartLtPrev": "Neplatný čas začátku, musí být větší nebo roven času začátku předchozí kapitoly",
|
||||
"MessageChapterStartIsAfter": "Začátek kapitoly přesahuje konec audioknihy",
|
||||
"MessageChaptersNotFound": "Kapitoly nenalezeny",
|
||||
"MessageCheckingCron": "Kontrola cronu...",
|
||||
"MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?",
|
||||
"MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?",
|
||||
@@ -743,6 +757,7 @@
|
||||
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru“",
|
||||
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
|
||||
"MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?",
|
||||
@@ -770,12 +785,13 @@
|
||||
"MessageForceReScanDescription": "znovu prohledá všechny soubory jako při novém skenování. ID3 tagy zvukových souborů OPF soubory a textové soubory budou skenovány jako nové.",
|
||||
"MessageImportantNotice": "Důležité upozornění!",
|
||||
"MessageInsertChapterBelow": "Vložit kapitolu níže",
|
||||
"MessageInvalidAsin": "Neplatný ASIN",
|
||||
"MessageItemsSelected": "{0} vybraných položek",
|
||||
"MessageItemsUpdated": "{0} položky byly aktualizovány",
|
||||
"MessageJoinUsOn": "Přidejte se k nám",
|
||||
"MessageLoading": "Načítá se...",
|
||||
"MessageLoadingFolders": "Načítám složky...",
|
||||
"MessageLogsDescription": "Protokoly se ukládají do souborů JSON v <code>/metadata/logs</code>. Protokoly o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
"MessageLogsDescription": "Logy se ukládají do souborů JSON v <code>/metadata/logs</code>. Logy o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
"MessageM4BFailed": "M4B se nezdařil!",
|
||||
"MessageM4BFinished": "M4B dokončen!",
|
||||
"MessageMapChapterTitles": "Mapování názvů kapitol ke stávajícím kapitolám audioknihy bez úpravy časových razítek",
|
||||
@@ -799,11 +815,11 @@
|
||||
"MessageNoEpisodes": "Žádné epizody",
|
||||
"MessageNoFoldersAvailable": "Nejsou k dispozici žádné složky",
|
||||
"MessageNoGenres": "Žádné žánry",
|
||||
"MessageNoIssues": "Žádné výtisk",
|
||||
"MessageNoIssues": "Žádné problémy",
|
||||
"MessageNoItems": "Žádné položky",
|
||||
"MessageNoItemsFound": "Nebyly nalezeny žádné položky",
|
||||
"MessageNoListeningSessions": "Žádné poslechové relace",
|
||||
"MessageNoLogs": "Žádné protokoly",
|
||||
"MessageNoLogs": "Žádné logy",
|
||||
"MessageNoMediaProgress": "Žádný průběh médií",
|
||||
"MessageNoNotifications": "Žádná oznámení",
|
||||
"MessageNoPodcastFeed": "Neplatný podcast: Žádný kanál",
|
||||
@@ -838,8 +854,10 @@
|
||||
"MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne",
|
||||
"MessageRestoreBackupWarning": "Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.<br /><br />Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.<br /><br />Všichni klienti používající váš server budou automaticky obnoveni.",
|
||||
"MessageScheduleLibraryScanNote": "Většině uživatelů se doporučuje ponechat tuto funkci vypnutou a ponechat zapnuté nastavení sledování složek. Sledování složek automaticky zjistí změny ve složkách vaší knihovny. Sledování složek nefunguje pro každý souborový systém (jako je NFS), takže místo toho lze použít plánované skenování knihoven.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Spusť každý {0} v {1}",
|
||||
"MessageSearchResultsFor": "Výsledky hledání pro",
|
||||
"MessageSelected": "{0} vybráno",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "Sekvence série nesmí obsahovat mezery",
|
||||
"MessageServerCouldNotBeReached": "Server je nedostupný",
|
||||
"MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru",
|
||||
"MessageShareExpirationWillBe": "Expiruje <strong>{0}</strong>",
|
||||
@@ -901,6 +919,8 @@
|
||||
"NotificationOnBackupCompletedDescription": "Spuštěno po dokončení zálohování",
|
||||
"NotificationOnBackupFailedDescription": "Spuštěno pokud zálohování selže",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Spuštěno při automatickém stažení epizody podcastu",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Aktivováno když je automatické stahování pozastaveno z důvodu příliš mnoho neůspěšných pokusů",
|
||||
"NotificationOnRSSFeedFailedDescription": "Aktivováno když selže RSS kanál pro stahování epizod",
|
||||
"NotificationOnTestDescription": "Akce pro otestování upozorňovacího systému",
|
||||
"PlaceholderNewCollection": "Nový název kolekce",
|
||||
"PlaceholderNewFolderPath": "Nová cesta ke složce",
|
||||
@@ -945,6 +965,7 @@
|
||||
"ToastBackupRestoreFailed": "Nepodařilo se obnovit zálohu",
|
||||
"ToastBackupUploadFailed": "Nepodařilo se nahrát zálohu",
|
||||
"ToastBackupUploadSuccess": "Záloha nahrána",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Detaily byly aplikované na položky",
|
||||
"ToastBatchDeleteFailed": "Hromadné smazání selhalo",
|
||||
"ToastBatchDeleteSuccess": "Hromadné smazání proběhlo úspěšně",
|
||||
"ToastBatchQuickMatchFailed": "Rychlá schoda dávky se nezdařila!",
|
||||
@@ -957,6 +978,8 @@
|
||||
"ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť",
|
||||
"ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna",
|
||||
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Nesprávná délka posunu. Čas začátku poslední kapitoly by přesáhl dobu trvání této audioknihy.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "Nesprávná délka posunu. První kapitola by měla nulovou nebo zápornou délku a byla by přepsána druhou kapitolou. Zvětšete čas začátku druhé kapitoly.",
|
||||
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
|
||||
"ToastChaptersRemoved": "Kapitoly odstraněny",
|
||||
"ToastChaptersUpdated": "Kapitola aktualizována",
|
||||
@@ -1057,6 +1080,7 @@
|
||||
"ToastSelectAtLeastOneUser": "Vyberte alespoň jednoho uživatele",
|
||||
"ToastSendEbookToDeviceFailed": "Odeslání e-knihy do zařízení se nezdařilo",
|
||||
"ToastSendEbookToDeviceSuccess": "E-kniha odeslána do zařízení \"{0}\"",
|
||||
"ToastSeriesSubmitFailedSameName": "Nelze přidat dvě série se stejným názvem",
|
||||
"ToastSeriesUpdateFailed": "Aktualizace série se nezdařila",
|
||||
"ToastSeriesUpdateSuccess": "Aktualizace série byla úspěšná",
|
||||
"ToastServerSettingsUpdateSuccess": "Nastavení serveru aktualizováno",
|
||||
@@ -1075,6 +1099,8 @@
|
||||
"ToastUnknownError": "Neznámý error",
|
||||
"ToastUnlinkOpenIdFailed": "Chyba při odpárování uživatele z OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Uživatel odpárován z uživatele z OpenID",
|
||||
"ToastUploaderFilepathExistsError": "Soubor \"{0}\" na serveru již existuje",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Položka \"{0}\" používá podadresář cesty pro nahrání.",
|
||||
"ToastUserDeleteFailed": "Nepodařilo se smazat uživatele",
|
||||
"ToastUserDeleteSuccess": "Uživatel smazán",
|
||||
"ToastUserPasswordChangeSuccess": "Heslo bylo změněno úspěšně",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user