Compare commits

..

No commits in common. "master" and "v2.22.0" have entirely different histories.

206 changed files with 2821 additions and 13549 deletions

View file

@ -47,7 +47,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@ -60,7 +60,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v3 uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@ -73,6 +73,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v2
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View file

@ -1,9 +1,5 @@
ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
### STAGE 0: Build client ### ### STAGE 0: Build client ###
FROM node:20-alpine AS build-client FROM node:20-alpine AS build-client
WORKDIR /client WORKDIR /client
COPY /client /client COPY /client /client
RUN npm ci && npm cache clean --force RUN npm ci && npm cache clean --force
@ -12,9 +8,6 @@ RUN npm run generate
### STAGE 1: Build server ### ### STAGE 1: Build server ###
FROM node:20-alpine AS build-server FROM node:20-alpine AS build-server
ARG NUSQLITE3_DIR
ARG TARGETPLATFORM
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apk add --no-cache --update \ RUN apk add --no-cache --update \
@ -28,6 +21,11 @@ WORKDIR /server
COPY index.js package* /server COPY index.js package* /server
COPY /server /server/server COPY /server /server/server
ARG TARGETPLATFORM
ENV NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
ENV NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
RUN case "$TARGETPLATFORM" in \ RUN case "$TARGETPLATFORM" in \
"linux/amd64") \ "linux/amd64") \
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-x64.zip" ;; \ curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-x64.zip" ;; \
@ -43,9 +41,6 @@ RUN npm ci --only=production
### STAGE 2: Create minimal runtime image ### ### STAGE 2: Create minimal runtime image ###
FROM node:20-alpine FROM node:20-alpine
ARG NUSQLITE3_DIR
ARG NUSQLITE3_PATH
# Install only runtime dependencies # Install only runtime dependencies
RUN apk add --no-cache --update \ RUN apk add --no-cache --update \
tzdata \ tzdata \
@ -57,17 +52,13 @@ WORKDIR /app
# Copy compiled frontend and server from build stages # Copy compiled frontend and server from build stages
COPY --from=build-client /client/dist /app/client/dist COPY --from=build-client /client/dist /app/client/dist
COPY --from=build-server /server /app COPY --from=build-server /server /app
COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH}
EXPOSE 80 EXPOSE 80
ENV PORT=80 ENV PORT=80
ENV NODE_ENV=production
ENV CONFIG_PATH="/config" ENV CONFIG_PATH="/config"
ENV METADATA_PATH="/metadata" ENV METADATA_PATH="/metadata"
ENV SOURCE="docker" ENV SOURCE="docker"
ENV NUSQLITE3_DIR=${NUSQLITE3_DIR}
ENV NUSQLITE3_PATH=${NUSQLITE3_PATH}
ENTRYPOINT ["tini", "--"] ENTRYPOINT ["tini", "--"]
CMD ["node", "index.js"] CMD ["node", "index.js"]

View file

@ -22,7 +22,7 @@ add_user() {
declare -r descr="${4:-No description}" declare -r descr="${4:-No description}"
declare -r shell="${5:-/bin/false}" declare -r shell="${5:-/bin/false}"
if ! getent passwd "$user" 2>&1 >/dev/null; then if ! getent passwd | grep -q "^$user:"; then
echo "Creating system user: $user in $group with $descr and shell $shell" echo "Creating system user: $user in $group with $descr and shell $shell"
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
fi fi
@ -39,7 +39,7 @@ add_group() {
declare -r gid_flags="--gid $gid" declare -r gid_flags="--gid $gid"
fi fi
if ! getent group "$group" 2>&1 >/dev/null; then if ! getent group | grep -q "^$group:" ; then
echo "Creating system group: $group" echo "Creating system group: $group"
groupadd $gid_flags --system $group groupadd $gid_flags --system $group
fi fi

View file

@ -217,16 +217,6 @@ 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) { if (this.results.series?.length) {
shelves.push({ shelves.push({
id: 'series', id: 'series',

View file

@ -93,10 +93,10 @@ export default {
editAuthor(author) { editAuthor(author) {
this.$store.commit('globals/showEditAuthorModal', author) this.$store.commit('globals/showEditAuthorModal', author)
}, },
editItem(libraryItem, tab = 'details') { editItem(libraryItem) {
var itemIds = this.shelf.entities.map((e) => e.id) var itemIds = this.shelf.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', itemIds) this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModalOnTab', { libraryItem, tab: tab || 'details' }) this.$store.commit('showEditModal', libraryItem)
}, },
editEpisode({ libraryItem, episode }) { editEpisode({ libraryItem, episode }) {
this.$store.commit('setEpisodeTableEpisodeIds', [episode.id]) this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])

View file

@ -3,18 +3,24 @@
<div class="flex md:hidden h-10 items-center"> <div class="flex md:hidden h-10 items-center">
<nuxt-link :to="`/library/${currentLibraryId}`" class="grow h-full flex justify-center items-center" :class="isHomePage ? 'bg-primary/80' : 'bg-primary/40'"> <nuxt-link :to="`/library/${currentLibraryId}`" class="grow h-full flex justify-center items-center" :class="isHomePage ? 'bg-primary/80' : 'bg-primary/40'">
<p v-if="isHomePage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonHome }}</p> <p v-if="isHomePage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonHome }}</p>
<span v-else class="material-symbols text-lg">home</span> <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</nuxt-link> </nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="grow h-full flex justify-center items-center" :class="isLibraryPage ? 'bg-primary/80' : 'bg-primary/40'"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="grow h-full flex justify-center items-center" :class="isLibraryPage ? 'bg-primary/80' : 'bg-primary/40'">
<p v-if="isLibraryPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonLibrary }}</p> <p v-if="isLibraryPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonLibrary }}</p>
<span v-else class="material-symbols text-lg">import_contacts</span> <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary/80' : 'bg-primary/40'"> <nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary/80' : 'bg-primary/40'">
<p class="text-sm">{{ $strings.ButtonLatest }}</p> <p class="text-sm">{{ $strings.ButtonLatest }}</p>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary/80' : 'bg-primary/40'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary/80' : 'bg-primary/40'">
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p> <p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
<span v-else class="material-symbols text-lg">view_column</span> <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary/80' : 'bg-primary/40'"> <nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary/80' : 'bg-primary/40'">
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p> <p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
@ -26,7 +32,12 @@
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-primary/40'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-primary/40'">
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p> <p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
<span v-else class="material-symbols text-lg">groups</span> <svg v-else class="w-5 h-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
/>
</svg>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary/80' : 'bg-primary/40'"> <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary/80' : 'bg-primary/40'">
<p class="text-sm">{{ $strings.ButtonAdd }}</p> <p class="text-sm">{{ $strings.ButtonAdd }}</p>

View file

@ -70,11 +70,6 @@ export default {
title: this.$strings.HeaderUsers, title: this.$strings.HeaderUsers,
path: '/config/users' path: '/config/users'
}, },
{
id: 'config-api-keys',
title: this.$strings.HeaderApiKeys,
path: '/config/api-keys'
},
{ {
id: 'config-sessions', id: 'config-sessions',
title: this.$strings.HeaderListeningSessions, title: this.$strings.HeaderListeningSessions,

View file

@ -232,11 +232,11 @@ export default {
clearFilter() { clearFilter() {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' }) this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
}, },
editEntity(entity, tab = 'details') { editEntity(entity) {
if (this.entityName === 'items' || this.entityName === 'series-books') { if (this.entityName === 'items' || this.entityName === 'series-books') {
const bookIds = this.entities.map((e) => e.id) const bookIds = this.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds) this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModalOnTab', { libraryItem: entity, tab: tab || 'details' }) this.$store.commit('showEditModal', entity)
} else if (this.entityName === 'collections') { } else if (this.entityName === 'collections') {
this.$store.commit('globals/setEditCollection', entity) this.$store.commit('globals/setEditCollection', entity)
} else if (this.entityName === 'playlists') { } else if (this.entityName === 'playlists') {
@ -778,6 +778,10 @@ export default {
windowResize() { windowResize() {
this.executeRebuild() this.executeRebuild()
}, },
socketInit() {
// Server settings are set on socket init
this.executeRebuild()
},
initListeners() { initListeners() {
window.addEventListener('resize', this.windowResize) window.addEventListener('resize', this.windowResize)
@ -790,6 +794,7 @@ export default {
}) })
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities) this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('socket_init', this.socketInit)
this.$eventBus.$on('user-settings', this.settingsUpdated) this.$eventBus.$on('user-settings', this.settingsUpdated)
if (this.$root.socket) { if (this.$root.socket) {
@ -821,6 +826,7 @@ export default {
} }
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities) this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('socket_init', this.socketInit)
this.$eventBus.$off('user-settings', this.settingsUpdated) this.$eventBus.$off('user-settings', this.settingsUpdated)
if (this.$root.socket) { if (this.$root.socket) {

View file

@ -5,7 +5,9 @@
<div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden"> <div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary/80' : 'bg-bg/60'"> <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">home</span> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p> <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
@ -21,7 +23,9 @@
</nuxt-link> </nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary/80' : 'bg-bg/60'"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">import_contacts</span> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p> <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
@ -29,7 +33,9 @@
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary/80' : 'bg-bg/60'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">view_column</span> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p> <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
@ -53,7 +59,12 @@
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-bg/60'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">groups</span> <svg class="w-6 h-6" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
/>
</svg>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p> <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
@ -105,7 +116,7 @@
</div> </div>
<div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }"> <div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }">
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1 cursor-pointer" @click="clickChangelog">v{{ $config.version }}</p> <p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p> <p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
</div> </div>

View file

@ -71,6 +71,9 @@ export default {
coverHeight() { coverHeight() {
return this.cardHeight return this.cardHeight
}, },
userToken() {
return this.store.getters['user/getToken']
},
_author() { _author() {
return this.author || {} return this.author || {}
}, },

View file

@ -13,17 +13,9 @@
<div class="grow" /> <div class="grow" />
<p class="text-sm md:text-base">{{ book.publishedYear }}</p> <p class="text-sm md:text-base">{{ book.publishedYear }}</p>
</div> </div>
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
<div class="flex items-center"> <p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
<div> <p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
</div>
<div class="grow" />
<div v-if="book.matchConfidence" class="rounded-full px-2 py-1 text-xs whitespace-nowrap text-white" :class="book.matchConfidence > 0.95 ? 'bg-success/80' : 'bg-info/80'">{{ $strings.LabelMatchConfidence }}: {{ (book.matchConfidence * 100).toFixed(0) }}%</div>
</div>
<div v-if="book.series?.length" class="flex py-1 -mx-1"> <div v-if="book.series?.length" class="flex py-1 -mx-1">
<div v-for="(series, index) in book.series" :key="index" class="bg-white/10 rounded-full px-1 py-0.5 mx-1"> <div v-for="(series, index) in book.series" :key="index" class="bg-white/10 rounded-full px-1 py-0.5 mx-1">
<p class="leading-3 text-xs text-gray-400"> <p class="leading-3 text-xs text-gray-400">

View file

@ -1,60 +0,0 @@
<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>

View file

@ -62,24 +62,7 @@
</widgets-alert> </widgets-alert>
<div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black/50 flex items-center justify-center z-20"> <div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black/50 flex items-center justify-center z-20">
<ui-loading-indicator> <ui-loading-indicator :text="nonInteractionLabel" />
<div class="mb-4">
<span class="text-lg font-medium text-white">
{{ nonInteractionLabel }}
</span>
</div>
<div v-if="isUploading" class="w-64 mx-auto mb-2">
<div class="flex items-center justify-center mb-2">
<span class="text-sm font-medium text-white/60 text-center w-full">
{{ uploadProgressText }}
</span>
</div>
<div class="w-full bg-primary/20 rounded-full h-2.5">
<div class="bg-green-500 h-2.5 rounded-full transition-all duration-300 ease-out" :style="{ width: uploadProgressPercent + '%' }"></div>
</div>
</div>
</ui-loading-indicator>
</div> </div>
</div> </div>
</template> </template>
@ -108,11 +91,7 @@ export default {
isUploading: false, isUploading: false,
uploadFailed: false, uploadFailed: false,
uploadSuccess: false, uploadSuccess: false,
isFetchingMetadata: false, isFetchingMetadata: false
uploadProgress: {
loaded: 0,
total: 0
}
} }
}, },
computed: { computed: {
@ -137,15 +116,6 @@ export default {
} else if (this.isFetchingMetadata) { } else if (this.isFetchingMetadata) {
return this.$strings.LabelFetchingMetadata return this.$strings.LabelFetchingMetadata
} }
},
uploadProgressPercent() {
if (this.uploadProgress.total === 0) return 0
return Math.min(100, Math.round((this.uploadProgress.loaded / this.uploadProgress.total) * 100))
},
uploadProgressText() {
const loaded = this.$bytesPretty(this.uploadProgress.loaded)
const total = this.$bytesPretty(this.uploadProgress.total)
return `${this.uploadProgressPercent}% (${loaded} / ${total})`
} }
}, },
methods: { methods: {
@ -153,21 +123,6 @@ export default {
this.isUploading = status === 'uploading' this.isUploading = status === 'uploading'
this.uploadFailed = status === 'failed' this.uploadFailed = status === 'failed'
this.uploadSuccess = status === 'success' this.uploadSuccess = status === 'success'
if (status !== 'uploading') {
this.uploadProgress = {
loaded: 0,
total: 0
}
}
},
setUploadProgress(progress) {
if (this.isUploading && progress) {
this.uploadProgress = {
loaded: progress.loaded || 0,
total: progress.total || 0
}
}
}, },
titleUpdated() { titleUpdated() {
this.error = '' this.error = ''

View file

@ -78,7 +78,7 @@
</div> </div>
<!-- Error widget --> <!-- Error widget -->
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" plaintext class="absolute bottom-4e left-0 z-10"> <ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4e left-0 z-10">
<div :style="{ height: 1.5 + 'em', width: 2.5 + 'em' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300"> <div :style="{ height: 1.5 + 'em', width: 2.5 + 'em' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span> <span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
</div> </div>
@ -101,8 +101,7 @@
<!-- Podcast Episode # --> <!-- Podcast Episode # -->
<div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }"> <div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
<p :style="{ fontSize: 0.8 + 'em' }"> <p :style="{ fontSize: 0.8 + 'em' }">
Episode Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
<span v-if="recentEpisodeNumber">#{{ recentEpisodeNumber }}</span>
</p> </p>
</div> </div>
@ -121,12 +120,12 @@
<!-- Alternative bookshelf title/author/sort --> <!-- Alternative bookshelf title/author/sort -->
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full"> <div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
<div :style="{ fontSize: 0.9 + 'em' }"> <div :style="{ fontSize: 0.9 + 'em' }">
<ui-tooltip v-if="displayTitle" :text="displayTitle" plaintext :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center"> <ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p> <p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" /> <widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
</ui-tooltip> </ui-tooltip>
</div> </div>
<ui-tooltip v-if="showSubtitles" :text="displaySubtitle" plaintext :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center"> <ui-tooltip v-if="showSubtitles" :text="displaySubtitle" :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<p cy-id="subtitle" class="truncate" ref="displaySubtitle" :style="{ fontSize: 0.6 + 'em' }">{{ displaySubtitle }}</p> <p cy-id="subtitle" class="truncate" ref="displaySubtitle" :style="{ fontSize: 0.6 + 'em' }">{{ displaySubtitle }}</p>
</ui-tooltip> </ui-tooltip>
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p> <p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p>
@ -199,10 +198,7 @@ export default {
return this.store.getters['user/getSizeMultiplier'] return this.store.getters['user/getSizeMultiplier']
}, },
dateFormat() { dateFormat() {
return this.store.getters['getServerSetting']('dateFormat') return this.store.state.serverSettings.dateFormat
},
timeFormat() {
return this.store.getters['getServerSetting']('timeFormat')
}, },
_libraryItem() { _libraryItem() {
return this.libraryItem || {} return this.libraryItem || {}
@ -349,18 +345,6 @@ export default {
if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear]) if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])
return '\u00A0' return '\u00A0'
} }
if (this.orderBy === 'progress') {
if (!this.userProgressLastUpdated) return '\u00A0'
return this.$getString('LabelLastProgressDate', [this.$formatDatetime(this.userProgressLastUpdated, this.dateFormat, this.timeFormat)])
}
if (this.orderBy === 'progress.createdAt') {
if (!this.userProgressStartedDate) return '\u00A0'
return this.$getString('LabelStartedDate', [this.$formatDatetime(this.userProgressStartedDate, this.dateFormat, this.timeFormat)])
}
if (this.orderBy === 'progress.finishedAt') {
if (!this.userProgressFinishedDate) return '\u00A0'
return this.$getString('LabelFinishedDate', [this.$formatDatetime(this.userProgressFinishedDate, this.dateFormat, this.timeFormat)])
}
return null return null
}, },
episodeProgress() { episodeProgress() {
@ -393,18 +377,6 @@ export default {
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0 let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
return Math.max(Math.min(1, progressPercent), 0) return Math.max(Math.min(1, progressPercent), 0)
}, },
userProgressLastUpdated() {
if (!this.userProgress) return null
return this.userProgress.lastUpdate
},
userProgressStartedDate() {
if (!this.userProgress) return null
return this.userProgress.startedAt
},
userProgressFinishedDate() {
if (!this.userProgress) return null
return this.userProgress.finishedAt
},
itemIsFinished() { itemIsFinished() {
if (this.booksInSeries) return this.seriesIsFinished if (this.booksInSeries) return this.seriesIsFinished
return this.userProgress ? !!this.userProgress.isFinished : false return this.userProgress ? !!this.userProgress.isFinished : false
@ -788,11 +760,11 @@ export default {
}, },
showEditModalFiles() { showEditModalFiles() {
// More menu func // More menu func
this.$emit('edit', this.libraryItem, 'files') this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
}, },
showEditModalMatch() { showEditModalMatch() {
// More menu func // More menu func
this.$emit('edit', this.libraryItem, 'match') this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
}, },
sendToDevice(deviceName) { sendToDevice(deviceName) {
// More menu func // More menu func

View file

@ -71,7 +71,7 @@ export default {
return this.height * this.sizeMultiplier return this.height * this.sizeMultiplier
}, },
dateFormat() { dateFormat() {
return this.store.getters['getServerSetting']('dateFormat') return this.store.state.serverSettings.dateFormat
}, },
labelFontSize() { labelFontSize() {
if (this.width < 160) return 0.75 if (this.width < 160) return 0.75

View file

@ -39,15 +39,6 @@
</li> </li>
</template> </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> <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"> <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"> <li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
@ -109,7 +100,6 @@ export default {
isFetching: false, isFetching: false,
search: null, search: null,
podcastResults: [], podcastResults: [],
episodeResults: [],
bookResults: [], bookResults: [],
authorResults: [], authorResults: [],
seriesResults: [], seriesResults: [],
@ -125,7 +115,7 @@ export default {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
totalResults() { totalResults() {
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 return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
} }
}, },
methods: { methods: {
@ -142,7 +132,6 @@ export default {
this.search = null this.search = null
this.lastSearch = null this.lastSearch = null
this.podcastResults = [] this.podcastResults = []
this.episodeResults = []
this.bookResults = [] this.bookResults = []
this.authorResults = [] this.authorResults = []
this.seriesResults = [] this.seriesResults = []
@ -186,7 +175,6 @@ export default {
if (!this.isFetching) return if (!this.isFetching) return
this.podcastResults = searchResults.podcast || [] this.podcastResults = searchResults.podcast || []
this.episodeResults = searchResults.episodes || []
this.bookResults = searchResults.book || [] this.bookResults = searchResults.book || []
this.authorResults = searchResults.authors || [] this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || [] this.seriesResults = searchResults.series || []

View file

@ -94,9 +94,6 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
userCanAccessExplicitContent() {
return this.$store.getters['user/getUserCanAccessExplicitContent']
},
libraryMediaType() { libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] return this.$store.getters['libraries/getCurrentLibraryMediaType']
}, },
@ -242,15 +239,6 @@ export default {
sublist: false sublist: false
} }
] ]
if (this.userCanAccessExplicitContent) {
items.push({
text: this.$strings.LabelExplicit,
value: 'explicit',
sublist: false
})
}
if (this.userIsAdminOrUp) { if (this.userIsAdminOrUp) {
items.push({ items.push({
text: this.$strings.LabelShareOpen, text: this.$strings.LabelShareOpen,
@ -261,7 +249,7 @@ export default {
return items return items
}, },
podcastItems() { podcastItems() {
const items = [ return [
{ {
text: this.$strings.LabelAll, text: this.$strings.LabelAll,
value: 'all' value: 'all'
@ -288,23 +276,8 @@ export default {
text: this.$strings.ButtonIssues, text: this.$strings.ButtonIssues,
value: 'issues', value: 'issues',
sublist: false 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() { selectItems() {
if (this.isSeries) return this.seriesItems if (this.isSeries) return this.seriesItems
@ -338,18 +311,6 @@ export default {
const series = this.series.find((se) => se.id == decoded) const series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name if (series) filterValue = series.name
} }
} else if (parts[0] === 'progress') {
const item = this.progress.find((p) => p.id == decoded)
if (item) filterValue = item.name
} else if (parts[0] === 'tracks') {
const item = this.tracks.find((t) => t.id == decoded)
if (item) filterValue = item.name
} else if (parts[0] === 'ebooks') {
const item = this.ebooks.find((e) => e.id == decoded)
if (item) filterValue = item.name
} else if (parts[0] === 'missing') {
const item = this.missing.find((m) => m.id == decoded)
if (item) filterValue = item.name
} else { } else {
filterValue = decoded filterValue = decoded
} }

View file

@ -7,7 +7,7 @@
</span> </span>
</button> </button>
<ul v-show="showMenu" class="librarySortMenu absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm" role="menu"> <ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm" role="menu">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
@ -130,18 +130,6 @@ export default {
text: this.$strings.LabelFileModified, text: this.$strings.LabelFileModified,
value: 'mtimeMs' value: 'mtimeMs'
}, },
{
text: this.$strings.LabelLibrarySortByProgress,
value: 'progress'
},
{
text: this.$strings.LabelLibrarySortByProgressStarted,
value: 'progress.createdAt'
},
{
text: this.$strings.LabelLibrarySortByProgressFinished,
value: 'progress.finishedAt'
},
{ {
text: this.$strings.LabelRandomly, text: this.$strings.LabelRandomly,
value: 'random' value: 'random'
@ -203,9 +191,3 @@ export default {
} }
} }
</script> </script>
<style scoped>
.librarySortMenu {
max-height: calc(100vh - 125px);
}
</style>

View file

@ -39,6 +39,9 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
_author() { _author() {
return this.author || {} return this.author || {}
}, },

View file

@ -309,9 +309,9 @@ export default {
} else { } else {
console.log('Account updated', data.user) console.log('Account updated', data.user)
if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) { if (data.user.id === this.user.id && data.user.token !== this.user.token) {
console.log('Current user access token was updated') console.log('Current user token was updated')
this.$store.commit('user/setAccessToken', data.user.accessToken) this.$store.commit('user/setUserToken', data.user.token)
} }
this.$toast.success(this.$strings.ToastAccountUpdateSuccess) this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
@ -351,6 +351,9 @@ export default {
this.$toast.error(errMsg || 'Failed to create account') this.$toast.error(errMsg || 'Failed to create account')
}) })
}, },
toggleActive() {
this.newUser.isActive = !this.newUser.isActive
},
userTypeUpdated(type) { userTypeUpdated(type) {
this.newUser.permissions = { this.newUser.permissions = {
download: type !== 'guest', download: type !== 'guest',

View file

@ -1,60 +0,0 @@
<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>

View file

@ -1,198 +0,0 @@
<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>

View file

@ -88,7 +88,7 @@ export default {
}, },
providers() { providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.bookProviders return this.$store.state.scanners.providers
}, },
libraryProvider() { libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google' return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
@ -96,9 +96,6 @@ export default {
}, },
methods: { methods: {
init() { init() {
// Fetch providers when modal is shown
this.$store.dispatch('scanners/fetchProviders')
// If we don't have a set provider (first open of dialog) or we've switched library, set // If we don't have a set provider (first open of dialog) or we've switched library, set
// the selected provider to the current library default provider // the selected provider to the current library default provider
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) { if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
@ -130,7 +127,8 @@ export default {
this.show = false this.show = false
}) })
} }
} },
mounted() {}
} }
</script> </script>

View file

@ -79,10 +79,10 @@ export default {
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1) return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
timeFormat() { timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat') return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {

View file

@ -14,7 +14,6 @@
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" /> <ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
</div> </div>
</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"> <div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn> <ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div> </div>
@ -35,17 +34,12 @@ export default {
existingSeriesNames: { existingSeriesNames: {
type: Array, type: Array,
default: () => [] default: () => []
},
originalSeriesSequence: {
type: String,
default: null
} }
}, },
data() { data() {
return { return {
el: null, el: null,
content: null, content: null
error: null
} }
}, },
watch: { watch: {
@ -91,17 +85,10 @@ export default {
} }
}, },
submitSeriesForm() { submitSeriesForm() {
this.error = null
if (this.$refs.newSeriesSelect) { if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur() this.$refs.newSeriesSelect.blur()
} }
if (this.selectedSeries.sequence !== this.originalSeriesSequence && this.selectedSeries.sequence.includes(' ')) {
this.error = this.$strings.MessageSeriesSequenceCannotContainSpaces
return
}
this.$emit('submit') this.$emit('submit')
}, },
clickClose() { clickClose() {
@ -113,7 +100,6 @@ export default {
} }
}, },
setShow() { setShow() {
this.error = null
if (!this.el || !this.content) { if (!this.el || !this.content) {
this.init() this.init()
} }

View file

@ -81,7 +81,7 @@
</div> </div>
<div class="w-full md:w-1/3"> <div class="w-full md:w-1/3">
<p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p> <p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ username }}</p> <p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _session.userId }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p> <p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
<p class="mb-1">{{ playMethodName }}</p> <p class="mb-1">{{ playMethodName }}</p>
@ -132,9 +132,6 @@ export default {
_session() { _session() {
return this.session || {} return this.session || {}
}, },
username() {
return this._session.user?.username || this._session.userId || ''
},
deviceInfo() { deviceInfo() {
return this._session.deviceInfo || {} return this._session.deviceInfo || {}
}, },
@ -162,10 +159,10 @@ export default {
return 'Unknown' return 'Unknown'
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
timeFormat() { timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat') return this.$store.state.serverSettings.timeFormat
}, },
isOpenSession() { isOpenSession() {
return !!this._session.open return !!this._session.open

View file

@ -23,7 +23,7 @@ export default {
processing: Boolean, processing: Boolean,
persistent: { persistent: {
type: Boolean, type: Boolean,
default: false default: true
}, },
width: { width: {
type: [String, Number], type: [String, Number],
@ -99,7 +99,7 @@ export default {
this.preventClickoutside = false this.preventClickoutside = false
return return
} }
if (this.processing || this.persistent) return if (this.processing && this.persistent) return
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) { if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
this.show = false this.show = false
} }

View file

@ -144,7 +144,7 @@ export default {
expirationDateString() { expirationDateString() {
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
const dateMs = Date.now() + this.expireDurationSeconds * 1000 const dateMs = Date.now() + this.expireDurationSeconds * 1000
return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat)
} }
}, },
methods: { methods: {

View file

@ -40,7 +40,7 @@ export default {
} }
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
releasesToShow() { releasesToShow() {
return this.versionData?.releasesToShow || [] return this.versionData?.releasesToShow || []

View file

@ -51,21 +51,19 @@
<form @submit.prevent="submitSearchForm"> <form @submit.prevent="submitSearchForm">
<div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1"> <div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
<div class="w-48 grow p-1"> <div class="w-48 grow p-1">
<ui-dropdown v-model="provider" :items="providers" :disabled="searchInProgress" :label="$strings.LabelProvider" small /> <ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
</div> </div>
<div class="w-72 grow p-1"> <div class="w-72 grow p-1">
<ui-text-input-with-label v-model="searchTitle" :disabled="searchInProgress" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" /> <ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
</div> </div>
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 grow p-1"> <div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 grow p-1">
<ui-text-input-with-label v-model="searchAuthor" :disabled="searchInProgress" :label="$strings.LabelAuthor" /> <ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
</div> </div>
<ui-btn v-if="!searchInProgress" class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn> <ui-btn class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
<ui-btn v-else class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="button" color="bg-error" @click.prevent="cancelCurrentSearch">{{ $strings.ButtonCancel }}</ui-btn>
</div> </div>
</form> </form>
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full"> <div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
<p v-if="searchInProgress && !coversFound.length" class="text-gray-300 py-4">{{ $strings.MessageLoading }}</p> <p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
<p v-else-if="!searchInProgress && !coversFound.length" class="text-gray-300 py-4">{{ $strings.MessageNoCoversFound }}</p>
<template v-for="cover in coversFound"> <template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)"> <div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
@ -107,10 +105,7 @@ export default {
showLocalCovers: false, showLocalCovers: false,
previewUpload: null, previewUpload: null,
selectedFile: null, selectedFile: null,
provider: 'google', provider: 'google'
currentSearchRequestId: null,
searchInProgress: false,
socketListenersActive: false
} }
}, },
watch: { watch: {
@ -133,8 +128,8 @@ export default {
} }
}, },
providers() { providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastCoverProviders if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.bookCoverProviders return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
}, },
searchTitleLabel() { searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
@ -191,9 +186,6 @@ export default {
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}` _file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
return _file return _file
}) })
},
socket() {
return this.$root.socket
} }
}, },
methods: { methods: {
@ -243,19 +235,7 @@ export default {
this.searchTitle = this.mediaMetadata.title || '' this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || '' this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes' if (this.isPodcast) this.provider = 'itunes'
else { else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
// Migrate from 'all' to 'best' (only once)
const migrationKey = 'book-cover-provider-migrated'
const currentProvider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
if (!localStorage.getItem(migrationKey) && currentProvider === 'all') {
localStorage.setItem('book-cover-provider', 'best')
localStorage.setItem(migrationKey, 'true')
this.provider = 'best'
} else {
this.provider = currentProvider
}
}
}, },
removeCover() { removeCover() {
if (!this.coverPath) { if (!this.coverPath) {
@ -311,116 +291,22 @@ export default {
console.error('PersistProvider', error) console.error('PersistProvider', error)
} }
}, },
generateRequestId() {
return `cover-search-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
},
addSocketListeners() {
if (!this.socket || this.socketListenersActive) return
this.socket.on('cover_search_result', this.handleSearchResult)
this.socket.on('cover_search_complete', this.handleSearchComplete)
this.socket.on('cover_search_error', this.handleSearchError)
this.socket.on('cover_search_provider_error', this.handleProviderError)
this.socket.on('cover_search_cancelled', this.handleSearchCancelled)
this.socket.on('disconnect', this.handleSocketDisconnect)
this.socketListenersActive = true
},
removeSocketListeners() {
if (!this.socket || !this.socketListenersActive) return
this.socket.off('cover_search_result', this.handleSearchResult)
this.socket.off('cover_search_complete', this.handleSearchComplete)
this.socket.off('cover_search_error', this.handleSearchError)
this.socket.off('cover_search_provider_error', this.handleProviderError)
this.socket.off('cover_search_cancelled', this.handleSearchCancelled)
this.socket.off('disconnect', this.handleSocketDisconnect)
this.socketListenersActive = false
},
handleSearchResult(data) {
if (data.requestId !== this.currentSearchRequestId) return
// Add new covers to the list (avoiding duplicates)
const newCovers = data.covers.filter((cover) => !this.coversFound.includes(cover))
this.coversFound.push(...newCovers)
},
handleSearchComplete(data) {
if (data.requestId !== this.currentSearchRequestId) return
this.searchInProgress = false
this.currentSearchRequestId = null
},
handleSearchError(data) {
if (data.requestId !== this.currentSearchRequestId) return
console.error('[Cover Search] Search error:', data.error)
this.$toast.error(this.$strings.ToastCoverSearchFailed)
this.searchInProgress = false
this.currentSearchRequestId = null
},
handleProviderError(data) {
if (data.requestId !== this.currentSearchRequestId) return
console.warn(`[Cover Search] Provider ${data.provider} failed:`, data.error)
},
handleSearchCancelled(data) {
if (data.requestId !== this.currentSearchRequestId) return
this.searchInProgress = false
this.currentSearchRequestId = null
},
handleSocketDisconnect() {
// If we were in the middle of a search, cancel it (server can't send results anymore)
if (this.searchInProgress && this.currentSearchRequestId) {
this.searchInProgress = false
this.currentSearchRequestId = null
}
},
cancelCurrentSearch() {
if (!this.currentSearchRequestId || !this.socket?.connected) {
console.error('[Cover Search] Socket not connected')
this.$toast.error(this.$strings.ToastConnectionNotAvailable)
return
}
this.socket.emit('cancel_cover_search', this.currentSearchRequestId)
this.currentSearchRequestId = null
this.searchInProgress = false
},
async submitSearchForm() { async submitSearchForm() {
if (!this.socket?.connected) {
console.error('[Cover Search] Socket not connected')
this.$toast.error(this.$strings.ToastConnectionNotAvailable)
return
}
// Cancel any existing search
if (this.searchInProgress) {
this.cancelCurrentSearch()
}
// Store provider in local storage // Store provider in local storage
this.persistProvider() this.persistProvider()
// Setup socket listeners if not already done this.isProcessing = true
this.addSocketListeners() const searchQuery = this.getSearchQuery()
const results = await this.$axios
// Clear previous results .$get(`/api/search/covers?${searchQuery}`)
this.coversFound = [] .then((res) => res.results)
.catch((error) => {
console.error('Failed', error)
return []
})
this.coversFound = results
this.isProcessing = false
this.hasSearched = true this.hasSearched = true
this.searchInProgress = true
// Generate unique request ID
const requestId = this.generateRequestId()
this.currentSearchRequestId = requestId
// Emit search request via WebSocket
this.socket.emit('search_covers', {
requestId,
title: this.searchTitle,
author: this.searchAuthor || '',
provider: this.provider,
podcast: this.isPodcast
})
}, },
setCover(coverFile) { setCover(coverFile) {
this.isProcessing = true this.isProcessing = true
@ -434,20 +320,6 @@ export default {
this.isProcessing = false this.isProcessing = false
}) })
} }
},
mounted() {
// Setup socket listeners when component is mounted
this.addSocketListeners()
// Fetch providers if not already loaded
this.$store.dispatch('scanners/fetchProviders')
},
beforeDestroy() {
// Cancel any ongoing search when component is destroyed
if (this.searchInProgress) {
this.cancelCurrentSearch()
}
// Remove socket listeners
this.removeSocketListeners()
} }
} }
</script> </script>

View file

@ -29,6 +29,9 @@ export default {
media() { media() {
return this.libraryItem.media || {} return this.libraryItem.media || {}
}, },
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },

View file

@ -2,7 +2,7 @@
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative"> <div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
<form @submit.prevent="submitSearch"> <form @submit.prevent="submitSearch">
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1"> <div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
<div v-if="providersLoaded" class="w-36 px-1"> <div class="w-36 px-1">
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small /> <ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
</div> </div>
<div class="grow md:w-72 px-1"> <div class="grow md:w-72 px-1">
@ -77,8 +77,8 @@
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
<div class="grow ml-4"> <div class="grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" /> <ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" />
<p v-if="mediaMetadata.authorName || (isPodcast && mediaMetadata.author)" class="text-xs ml-1 text-white/60"> <p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white/60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', isPodcast ? mediaMetadata.author : mediaMetadata.authorName)">{{ isPodcast ? mediaMetadata.author : mediaMetadata.authorName }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -87,7 +87,7 @@
<div class="grow ml-4"> <div class="grow ml-4">
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" /> <ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white/60"> <p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white/60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -96,7 +96,7 @@
<div class="grow ml-4"> <div class="grow ml-4">
<ui-rich-text-editor v-model="selectedMatch.description" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" /> <ui-rich-text-editor v-model="selectedMatch.description" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white/60"> <p v-if="mediaMetadata.description" class="text-xs ml-1 text-white/60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.descriptionPlain.substr(0, 100) + (mediaMetadata.descriptionPlain.length > 100 ? '...' : '') }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.descriptionPlain.substr(0, 100) + (mediaMetadata.descriptionPlain.length > 100 ? '...' : '') }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -105,7 +105,7 @@
<div class="grow ml-4"> <div class="grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" /> <ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" />
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white/60"> <p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white/60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -114,7 +114,7 @@
<div class="grow ml-4"> <div class="grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" /> <ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" />
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white/60"> <p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white/60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -253,7 +253,6 @@ export default {
hasSearched: false, hasSearched: false,
selectedMatch: null, selectedMatch: null,
selectedMatchOrig: null, selectedMatchOrig: null,
waitingForProviders: false,
selectedMatchUsage: { selectedMatchUsage: {
title: true, title: true,
subtitle: true, subtitle: true,
@ -286,19 +285,9 @@ export default {
handler(newVal) { handler(newVal) {
if (newVal) this.init() if (newVal) this.init()
} }
},
providersLoaded(isLoaded) {
// Complete initialization once providers are loaded
if (isLoaded && this.waitingForProviders) {
this.waitingForProviders = false
this.initProviderAndSearch()
}
} }
}, },
computed: { computed: {
providersLoaded() {
return this.$store.getters['scanners/areProvidersLoaded']
},
isProcessing: { isProcessing: {
get() { get() {
return this.processing return this.processing
@ -330,7 +319,7 @@ export default {
}, },
providers() { providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.bookProviders return this.$store.state.scanners.providers
}, },
searchTitleLabel() { searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
@ -411,9 +400,7 @@ export default {
this.$toast.warning(this.$strings.ToastTitleRequired) this.$toast.warning(this.$strings.ToastTitleRequired)
return return
} }
if (!this.isPodcast) { this.persistProvider()
this.persistProvider()
}
this.runSearch() this.runSearch()
}, },
async runSearch() { async runSearch() {
@ -489,24 +476,6 @@ export default {
this.checkboxToggled() this.checkboxToggled()
}, },
initProviderAndSearch() {
// Set provider based on media type
if (this.isPodcast) {
this.provider = 'itunes'
} else {
this.provider = this.getDefaultBookProvider()
}
// Prefer using ASIN if set and using audible provider
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
this.searchTitle = this.libraryItem.media.metadata.asin
this.searchAuthor = ''
}
if (this.searchTitle) {
this.submitSearch()
}
},
init() { init() {
this.clearSelectedMatch() this.clearSelectedMatch()
this.initSelectedMatchUsage() this.initSelectedMatchUsage()
@ -524,13 +493,19 @@ export default {
} }
this.searchTitle = this.libraryItem.media.metadata.title this.searchTitle = this.libraryItem.media.metadata.title
this.searchAuthor = this.libraryItem.media.metadata.authorName || '' this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else {
this.provider = this.getDefaultBookProvider()
}
// Wait for providers to be loaded before setting provider and searching // Prefer using ASIN if set and using audible provider
if (this.providersLoaded || this.isPodcast) { if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
this.waitingForProviders = false this.searchTitle = this.libraryItem.media.metadata.asin
this.initProviderAndSearch() this.searchAuthor = ''
} else { }
this.waitingForProviders = true
if (this.searchTitle) {
this.submitSearch()
} }
}, },
selectMatch(match) { selectMatch(match) {
@ -660,10 +635,6 @@ export default {
this.selectedMatch = null this.selectedMatch = null
this.selectedMatchOrig = null this.selectedMatchOrig = null
} }
},
mounted() {
// Fetch providers if not already loaded
this.$store.dispatch('scanners/fetchProviders')
} }
} }
</script> </script>

View file

@ -74,7 +74,7 @@ export default {
}, },
providers() { providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.bookProviders return this.$store.state.scanners.providers
} }
}, },
methods: { methods: {
@ -156,8 +156,6 @@ export default {
}, },
mounted() { mounted() {
this.init() this.init()
// Fetch providers if not already loaded
this.$store.dispatch('scanners/fetchProviders')
} }
} }
</script> </script>

View file

@ -104,6 +104,7 @@ export default {
}, },
data() { data() {
return { return {
provider: null,
useSquareBookCovers: false, useSquareBookCovers: false,
enableWatcher: false, enableWatcher: false,
skipMatchingMediaWithAsin: false, skipMatchingMediaWithAsin: false,
@ -133,6 +134,10 @@ export default {
isPodcastLibrary() { isPodcastLibrary() {
return this.mediaType === 'podcast' return this.mediaType === 'podcast'
}, },
providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
},
maskAsFinishedWhenItems() { maskAsFinishedWhenItems() {
return [ return [
{ {

View file

@ -97,10 +97,7 @@ export default {
...playlist ...playlist
} }
}) })
.sort((a, b) => { .sort((a, b) => (a.isItemIncluded ? -1 : 1))
if (a.isItemIncluded !== b.isItemIncluded) return a.isItemIncluded ? -1 : 1
return a.name.localeCompare(b.name)
})
}, },
isBatch() { isBatch() {
return this.selectedPlaylistItems.length > 1 return this.selectedPlaylistItems.length > 1

View file

@ -35,14 +35,7 @@
<widgets-podcast-type-indicator :type="episode.episodeType" /> <widgets-podcast-type-indicator :type="episode.episodeType" />
</div> </div>
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p> <p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
<div class="flex items-center space-x-2"> <p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
<!-- 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> </div>
</div> </div>
@ -251,8 +244,8 @@ export default {
const sizeInMb = payloadSize / 1024 / 1024 const sizeInMb = payloadSize / 1024 / 1024
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB' const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
console.log('Request size', sizeInMb) console.log('Request size', sizeInMb)
if (sizeInMb > 9.99) { if (sizeInMb > 4.99) {
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 10Mb`) return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
} }
this.processing = true this.processing = true

View file

@ -11,7 +11,7 @@
{{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }} {{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }}
</p> </p>
<p v-else class="text-lg text-gray-200 mb-4">{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}</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">{{ $strings.MessageConfirmRemoveEpisodeNote }}</p> <p class="text-xs font-semibold text-warning/90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
</div> </div>
<div class="flex justify-between items-center pt-4"> <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" /> <ui-checkbox v-model="hardDeleteFile" :label="$strings.LabelHardDeleteFile" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
@ -94,6 +94,7 @@ export default {
} }
this.processing = false this.processing = false
this.$toast.success(`${this.episodes.length} episode${this.episodes.length > 1 ? 's' : ''} removed`)
this.show = false this.show = false
this.$emit('clearSelected') this.$emit('clearSelected')
} }

View file

@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p> <p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" dir="auto" class="default-style less-spacing" @click="handleDescriptionClick" v-html="description" /> <div v-if="description" dir="auto" class="default-style less-spacing" v-html="description" />
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p> <p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
<div class="w-full h-px bg-white/5 my-4" /> <div class="w-full h-px bg-white/5 my-4" />
@ -34,12 +34,6 @@
{{ audioFileSize }} {{ audioFileSize }}
</p> </p>
</div> </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>
</div> </div>
</modals-modal> </modals-modal>
@ -74,7 +68,7 @@ export default {
return this.episode.title || 'No Episode Title' return this.episode.title || 'No Episode Title'
}, },
description() { description() {
return this.parseDescription(this.episode.description || '') return this.episode.description || ''
}, },
media() { media() {
return this.libraryItem?.media || {} return this.libraryItem?.media || {}
@ -96,49 +90,11 @@ export default {
return this.$bytesPretty(size) return this.$bytesPretty(size)
}, },
audioFileDuration() {
const duration = this.episode.duration || 0
return this.$elapsedPretty(duration)
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] 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() {} mounted() {}
} }
</script> </script>

View file

@ -114,7 +114,7 @@ export default {
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatePayload) .$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatePayload)
.then(() => { .then(() => {
this.isProcessing = false this.isProcessing = false
this.$toast.success(this.$strings.ToastPodcastEpisodeUpdated) this.$toast.success('Podcast episode updated')
this.$emit('selectTab', 'details') this.$emit('selectTab', 'details')
}) })
.catch((error) => { .catch((error) => {

View file

@ -8,7 +8,7 @@
</button> </button>
</ui-tooltip> </ui-tooltip>
<ui-tooltip direction="top" :text="jumpBackwardText"> <ui-tooltip direction="top" :text="jumpBackwardText">
<button :aria-label="jumpBackwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward"> <button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-symbols text-2xl sm:text-3xl">replay</span> <span class="material-symbols text-2xl sm:text-3xl">replay</span>
</button> </button>
</ui-tooltip> </ui-tooltip>

View file

@ -129,6 +129,9 @@ export default {
return `${hoursRounded}h` return `${hoursRounded}h`
} }
}, },
token() {
return this.$store.getters['user/getToken']
},
timeRemaining() { timeRemaining() {
if (this.useChapterTrack && this.currentChapter) { if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start var currChapTime = this.currentTime - this.currentChapter.start

View file

@ -104,6 +104,9 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
}, },
@ -231,7 +234,10 @@ export default {
async extract() { async extract() {
this.loading = true this.loading = true
var buff = await this.$axios.$get(this.ebookUrl, { var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob' responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
const archive = await Archive.open(buff) const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject() const originalFilesObject = await archive.getFilesObject()

View file

@ -57,6 +57,9 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */ /** @returns {string} */
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
@ -94,37 +97,27 @@ export default {
}, },
ebookUrl() { ebookUrl() {
if (this.fileId) { if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}` return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
} }
return `/api/items/${this.libraryItemId}/ebook` return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
}, },
themeRules() { themeRules() {
const theme = this.ereaderSettings.theme const isDark = this.ereaderSettings.theme === 'dark'
const isDark = theme === 'dark' const fontColor = isDark ? '#fff' : '#000'
const isSepia = theme === 'sepia' const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
const fontColor = isDark
? '#fff'
: isSepia
? '#5b4636'
: '#000'
const backgroundColor = isDark
? 'rgb(35 35 35)'
: isSepia
? 'rgb(244, 236, 216)'
: 'rgb(255, 255, 255)'
const lineSpacing = this.ereaderSettings.lineSpacing / 100 const lineSpacing = this.ereaderSettings.lineSpacing / 100
const fontScale = this.ereaderSettings.fontScale / 100
const textStroke = this.ereaderSettings.textStroke / 100 const fontScale = this.ereaderSettings.fontScale / 100
const textStroke = this.ereaderSettings.textStroke / 100
return { return {
'*': { '*': {
color: `${fontColor}!important`, color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`, 'background-color': `${backgroundColor}!important`,
'line-height': `${lineSpacing * fontScale}rem!important`, 'line-height': lineSpacing * fontScale + 'rem!important',
'-webkit-text-stroke': `${textStroke}px ${fontColor}!important` '-webkit-text-stroke': textStroke + 'px ' + fontColor + '!important'
}, },
a: { a: {
color: `${fontColor}!important` color: `${fontColor}!important`
@ -316,24 +309,14 @@ export default {
/** @type {EpubReader} */ /** @type {EpubReader} */
const reader = this 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} */ /** @type {ePub.Book} */
reader.book = new ePub(reader.ebookUrl, { reader.book = new ePub(reader.ebookUrl, {
width: this.readerWidth, width: this.readerWidth,
height: this.readerHeight - 50, height: this.readerHeight - 50,
openAs: 'epub', openAs: 'epub',
requestMethod: customRequest requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}) })
/** @type {ePub.Rendition} */ /** @type {ePub.Rendition} */
@ -354,33 +337,29 @@ export default {
this.applyTheme() this.applyTheme()
}) })
reader.book.ready reader.book.ready.then(() => {
.then(() => { // set up event listeners
// set up event listeners reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('relocated', reader.relocated) reader.rendition.on('keydown', reader.keyUp)
reader.rendition.on('keydown', reader.keyUp)
reader.rendition.on('touchstart', (event) => { reader.rendition.on('touchstart', (event) => {
this.$emit('touchstart', event) this.$emit('touchstart', event)
}) })
reader.rendition.on('touchend', (event) => { reader.rendition.on('touchend', (event) => {
this.$emit('touchend', event) this.$emit('touchend', event)
}) })
// load ebook cfi locations // load ebook cfi locations
const savedLocations = this.loadLocations() const savedLocations = this.loadLocations()
if (savedLocations) { if (savedLocations) {
reader.book.locations.load(savedLocations) reader.book.locations.load(savedLocations)
} else { } else {
reader.book.locations.generate().then(() => { reader.book.locations.generate().then(() => {
this.checkSaveLocations(reader.book.locations.save()) this.checkSaveLocations(reader.book.locations.save())
}) })
} }
this.getChapters() this.getChapters()
}) })
.catch((error) => {
console.error('EpubReader.initEpub failed:', error)
})
}, },
getChapters() { getChapters() {
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759 // Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759

View file

@ -26,6 +26,9 @@ export default {
return {} return {}
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
}, },
@ -93,8 +96,11 @@ export default {
}, },
async initMobi() { async initMobi() {
// Fetch mobi file as blob // Fetch mobi file as blob
const buff = await this.$axios.$get(this.ebookUrl, { var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob' responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
var reader = new FileReader() var reader = new FileReader()
reader.onload = async (event) => { reader.onload = async (event) => {

View file

@ -55,8 +55,7 @@ export default {
loadedRatio: 0, loadedRatio: 0,
page: 1, page: 1,
numPages: 0, numPages: 0,
pdfDocInitParams: null, pdfDocInitParams: null
isRefreshing: false
} }
}, },
computed: { computed: {
@ -153,34 +152,7 @@ export default {
this.page++ this.page++
this.updateProgress() this.updateProgress()
}, },
async refreshToken() { error(err) {
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) console.error(err)
}, },
resize() { resize() {

View file

@ -1,5 +1,5 @@
<template> <template>
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black data-[theme=sepia]:bg-[rgb(244,236,216)] data-[theme=sepia]:text-[#5b4636]" :class="{ 'reader-player-open': !!streamLibraryItem }"> <div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
<div class="absolute top-4 left-4 z-20 flex items-center"> <div class="absolute top-4 left-4 z-20 flex items-center">
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100"> <button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-symbols text-2xl">menu</span> <span class="material-symbols text-2xl">menu</span>
@ -27,12 +27,7 @@
<!-- TOC side nav --> <!-- TOC side nav -->
<div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> <div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
<div <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent>
v-if="isEpub"
class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black group-data-[theme=sepia]:bg-[rgb(244,236,216)] group-data-[theme=sepia]:text-[#5b4636]"
:class="tocOpen ? 'translate-x-0' : '-translate-x-96'"
@click.stop.prevent
>
<div class="flex flex-col p-4 h-full"> <div class="flex flex-col p-4 h-full">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100"> <button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
@ -42,7 +37,7 @@
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p> <p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
</div> </div>
<form @submit.prevent="searchBook" @click.stop.prevent> <form @submit.prevent="searchBook" @click.stop.prevent>
<ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" custom-input-class="text-inherit !bg-inherit" class="h-8 w-full text-sm flex mb-2" /> <ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" />
</form> </form>
<div class="overflow-y-auto"> <div class="overflow-y-auto">
@ -186,10 +181,6 @@ export default {
text: this.$strings.LabelThemeDark, text: this.$strings.LabelThemeDark,
value: 'dark' value: 'dark'
}, },
{
text: this.$strings.LabelThemeSepia,
value: 'sepia'
},
{ {
text: this.$strings.LabelThemeLight, text: this.$strings.LabelThemeLight,
value: 'light' value: 'light'
@ -275,6 +266,9 @@ export default {
isComic() { isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr' return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
}, },
userToken() {
return this.$store.getters['user/getToken']
},
keepProgress() { keepProgress() {
return this.$store.state.ereaderKeepProgress return this.$store.state.ereaderKeepProgress
}, },

View file

@ -14,7 +14,7 @@
<div :key="n" class="absolute pointer-events-none left-0 h-px bg-white/10" :style="{ top: n * lineSpacing - lineSpacing / 2 + 'px', width: '360px', marginLeft: '24px' }" /> <div :key="n" class="absolute pointer-events-none left-0 h-px bg-white/10" :style="{ top: n * lineSpacing - lineSpacing / 2 + 'px', width: '360px', marginLeft: '24px' }" />
<div :key="`dot-${n}`" class="absolute z-10" :style="{ left: points[n - 1].x + 'px', bottom: points[n - 1].y + 'px' }"> <div :key="`dot-${n}`" class="absolute z-10" :style="{ left: points[n - 1].x + 'px', bottom: points[n - 1].y + 'px' }">
<ui-tooltip :text="last7DaysOfListening[n - 1].minutesListening" plaintext direction="top"> <ui-tooltip :text="last7DaysOfListening[n - 1].minutesListening" direction="top">
<div class="h-2 w-2 bg-yellow-400 hover:bg-yellow-300 rounded-full transform duration-150 transition-transform hover:scale-125" /> <div class="h-2 w-2 bg-yellow-400 hover:bg-yellow-300 rounded-full transform duration-150 transition-transform hover:scale-125" />
</ui-tooltip> </ui-tooltip>
</div> </div>
@ -186,16 +186,10 @@ export default {
daysInARow() { daysInARow() {
var count = 0 var count = 0
while (true) { while (true) {
const _date = this.$addDaysToToday(count * -1 - 1) var _date = this.$addDaysToToday(count * -1)
const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd') var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) { if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
// don't require listening today to count towards days in a row, but do count it if already listened today
const today = this.$formatJsDate(new Date(), 'yyyy-MM-dd')
if (this.listeningStatsDays[today]) {
count++
}
return count return count
} }
count++ count++

View file

@ -152,7 +152,7 @@ export default {
this.showingTooltipIndex = index this.showingTooltipIndex = index
this.tooltipEl.style.display = 'block' this.tooltipEl.style.display = 'block'
this.tooltipTextEl.innerHTML = block.value ? this.$getString('MessageHeatmapListeningTimeTooltip', [this.$elapsedPrettyLocalized(block.value, true), block.datePretty]) : this.$getString('MessageHeatmapNoListeningSessions', [block.datePretty]) this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
const calculateRect = this.tooltipEl.getBoundingClientRect() const calculateRect = this.tooltipEl.getBoundingClientRect()

View file

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

View file

@ -1,177 +0,0 @@
<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>

View file

@ -78,10 +78,10 @@ export default {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
timeFormat() { timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat') return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {

View file

@ -49,6 +49,9 @@ export default {
libraryItemId() { libraryItemId() {
return this.libraryItem.id return this.libraryItem.id
}, },
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },

View file

@ -53,6 +53,9 @@ export default {
libraryItemId() { libraryItemId() {
return this.libraryItem.id return this.libraryItem.id
}, },
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },

View file

@ -76,10 +76,10 @@ export default {
return usermap return usermap
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
timeFormat() { timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat') return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {

View file

@ -112,7 +112,7 @@ export default {
return this.episode?.publishedAt return this.episode?.publishedAt
}, },
dateFormat() { dateFormat() {
return this.store.getters['getServerSetting']('dateFormat') return this.store.state.serverSettings.dateFormat
}, },
itemProgress() { itemProgress() {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)

View file

@ -239,10 +239,10 @@ export default {
}) })
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
timeFormat() { timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat') return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {

View file

@ -1,5 +1,5 @@
<template> <template>
<div :class="hasSlotContent ? 'w-auto' : 'w-40'"> <div class="w-40">
<div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md"> <div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
<div class="loader-dots block relative w-20 h-5 mt-2"> <div class="loader-dots block relative w-20 h-5 mt-2">
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div> <div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
@ -7,9 +7,7 @@
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div> <div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div> <div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
</div> </div>
<slot> <div class="text-gray-200 text-xs font-light mt-2 text-center">{{ message }}</div>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ message }}</div>
</slot>
</div> </div>
</div> </div>
</template> </template>
@ -25,9 +23,6 @@ export default {
computed: { computed: {
message() { message() {
return this.text || this.$strings.MessagePleaseWait return this.text || this.$strings.MessagePleaseWait
},
hasSlotContent() {
return this.$slots.default && this.$slots.default.length > 0
} }
} }
} }

View file

@ -4,11 +4,10 @@
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" role="list" style="min-height: 36px" class="flex-wrap relative w-full shadow-xs flex items-center border border-gray-600 rounded-sm px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" role="list" style="min-height: 36px" class="flex-wrap relative w-full shadow-xs flex items-center border border-gray-600 rounded-sm px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<!-- Use index in v-for and key in case the same key exists multiple times --> <div v-for="item in selected" :key="item" role="listitem" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
<div v-for="(item, idx) in selected" :key="item + '-' + idx" role="listitem" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg/75 flex items-center justify-end opacity-0 hover:opacity-100" :class="{ 'opacity-100': inputFocused }"> <div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg/75 flex items-center justify-end opacity-0 hover:opacity-100" :class="{ 'opacity-100': inputFocused }">
<button v-if="showEdit" type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</button> <button v-if="showEdit" type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</button>
<button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item, idx)" @keydown.enter.stop.prevent="removeItem(item, idx)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button> <button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)" @keydown.enter.stop.prevent="removeItem(item)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button>
</div> </div>
{{ item }} {{ item }}
</div> </div>
@ -260,9 +259,8 @@ export default {
} }
this.focus() this.focus()
}, },
removeItem(item, idx) { removeItem(item) {
var remaining = this.selected.slice() var remaining = this.selected.filter((i) => i !== item)
remaining.splice(idx, 1)
this.$emit('input', remaining) this.$emit('input', remaining)
this.$emit('removedItem', item) this.$emit('removedItem', item)
this.$nextTick(() => { this.$nextTick(() => {
@ -278,7 +276,7 @@ export default {
}) })
}, },
insertNewItem(item) { insertNewItem(item) {
if (!this.selected.includes(item)) this.selected.push(item) this.selected.push(item)
this.$emit('input', this.selected) this.$emit('input', this.selected)
this.$emit('newItem', item) this.$emit('newItem', item)
this.textInput = null this.textInput = null

View file

@ -85,6 +85,9 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
userToken() {
return this.$store.getters['user/getToken']
},
wrapperClass() { wrapperClass() {
var classes = [] var classes = []
if (this.disabled) classes.push('bg-black-300') if (this.disabled) classes.push('bg-black-300')
@ -287,7 +290,7 @@ export default {
}) })
}, },
insertNewItem(item) { insertNewItem(item) {
if (!this.selected.find((i) => i.name === item.name)) this.selected.push(item) this.selected.push(item)
this.$emit('input', this.selected) this.$emit('input', this.selected)
this.$emit('newItem', item) this.$emit('newItem', item)
this.textInput = null this.textInput = null

View file

@ -1,8 +1,12 @@
<template> <template>
<button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn"> <button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 relative"> <div class="w-5 h-5 text-white relative">
<span v-if="isRead" class="material-symbols fill text-xl text-success">beenhere</span> <svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<span v-else class="material-symbols text-xl text-white">beenhere</span> <path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
</svg>
</div> </div>
</button> </button>
</template> </template>

View file

@ -1,9 +1,9 @@
<template> <template>
<div class="relative w-full"> <div class="relative w-full">
<p v-if="label && !labelHidden" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button 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"> <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="flex items-center">
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }">{{ selectedText || placeholder }}</span> <span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
<span v-if="selectedSubtext">:&nbsp;</span> <span v-if="selectedSubtext">:&nbsp;</span>
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span> <span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
</span> </span>
@ -36,15 +36,10 @@ export default {
type: String, type: String,
default: '' default: ''
}, },
labelHidden: Boolean,
items: { items: {
type: Array, type: Array,
default: () => [] default: () => []
}, },
placeholder: {
type: String,
default: ''
},
disabled: Boolean, disabled: Boolean,
small: Boolean, small: Boolean,
menuMaxHeight: { menuMaxHeight: {

View file

@ -6,7 +6,7 @@
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> <em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</label> </label>
</slot> </slot>
<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" /> <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" />
</div> </div>
</template> </template>
@ -21,7 +21,6 @@ export default {
type: String, type: String,
default: 'text' default: 'text'
}, },
min: [String, Number],
readonly: Boolean, readonly: Boolean,
disabled: Boolean, disabled: Boolean,
inputClass: String, inputClass: String,

View file

@ -22,8 +22,7 @@ export default {
type: Number, type: Number,
default: 0 default: 0
}, },
disabled: Boolean, disabled: Boolean
plaintext: Boolean
}, },
data() { data() {
return { return {
@ -47,11 +46,7 @@ export default {
methods: { methods: {
updateText() { updateText() {
if (this.tooltip) { if (this.tooltip) {
if (this.plaintext) { this.tooltip.innerHTML = this.text
this.tooltip.textContent = this.text
} else {
this.tooltip.innerHTML = this.text
}
this.setTooltipPosition(this.tooltip) this.setTooltipPosition(this.tooltip)
} }
}, },
@ -63,11 +58,7 @@ export default {
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white text-xs rounded-sm shadow-lg max-w-xs text-center hidden sm:block' tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white text-xs rounded-sm shadow-lg max-w-xs text-center hidden sm:block'
tooltip.style.zIndex = 100 tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)' tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
if (this.plaintext) { tooltip.innerHTML = this.text
tooltip.textContent = this.text
} else {
tooltip.innerHTML = this.text
}
tooltip.addEventListener('mouseover', this.cancelHide) tooltip.addEventListener('mouseover', this.cancelHide)
tooltip.addEventListener('mouseleave', this.hideTooltip) tooltip.addEventListener('mouseleave', this.hideTooltip)

View file

@ -31,7 +31,7 @@
</div> </div>
</div> </div>
</trix-toolbar> </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-attachment-add="handleAttachmentAdd" /> <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" />
<input type="hidden" :name="inputName" :id="computedId" :value="editorContent" /> <input type="hidden" :name="inputName" :id="computedId" :value="editorContent" />
</div> </div>
</template> </template>
@ -316,10 +316,6 @@ export default {
if (this.$refs.trix && this.$refs.trix.blur) { if (this.$refs.trix && this.$refs.trix.blur) {
this.$refs.trix.blur() this.$refs.trix.blur()
} }
},
handleAttachmentAdd(event) {
// Prevent pasting in images/any files from the browser
event.attachment.remove()
} }
}, },
mounted() { mounted() {

View file

@ -85,7 +85,7 @@ export default {
nextRun() { nextRun() {
if (!this.cronExpression) return '' if (!this.cronExpression) return ''
const parsed = this.$getNextScheduledDate(this.cronExpression) const parsed = this.$getNextScheduledDate(this.cronExpression)
return this.$formatJsDatetime(parsed, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) || '' return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || ''
}, },
description() { description() {
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return '' if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''

View file

@ -143,18 +143,10 @@ export default {
localStorage.setItem('embedMetadataCodec', val) localStorage.setItem('embedMetadataCodec', val)
}, },
getEncodingOptions() { getEncodingOptions() {
if (this.showAdvancedView) { return {
return { codec: this.selectedCodec || 'aac',
codec: this.customCodec || this.selectedCodec || 'aac', bitrate: this.selectedBitrate || '128k',
bitrate: this.customBitrate || this.selectedBitrate || '128k', channels: this.selectedChannels || 2
channels: this.customChannels || this.selectedChannels || 2
}
} else {
return {
codec: this.selectedCodec || 'aac',
bitrate: this.selectedBitrate || '128k',
channels: this.selectedChannels || 2
}
} }
}, },
setPreset() { setPreset() {
@ -170,7 +162,7 @@ export default {
} else { } else {
// Find closest bitrate rounding up // Find closest bitrate rounding up
const bitratesToMatch = [32, 64, 128, 192] const bitratesToMatch = [32, 64, 128, 192]
const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate) || 192 const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate)
this.selectedBitrate = closestBitrate + 'k' this.selectedBitrate = closestBitrate + 'k'
} }

View file

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

View file

@ -132,10 +132,10 @@ export default {
editAuthor(author) { editAuthor(author) {
this.$store.commit('globals/showEditAuthorModal', author) this.$store.commit('globals/showEditAuthorModal', author)
}, },
editItem(libraryItem, tab = 'details') { editItem(libraryItem) {
var itemIds = this.items.map((e) => e.id) var itemIds = this.items.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', itemIds) this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModalOnTab', { libraryItem, tab: tab || 'details' }) this.$store.commit('showEditModal', libraryItem)
}, },
selectItem(payload) { selectItem(payload) {
this.$emit('selectEntity', payload) this.$emit('selectEntity', payload)

View file

@ -248,4 +248,4 @@ export default {
transform: scale(0); transform: scale(0);
} }
} }
</style> </style>

View file

@ -2,7 +2,7 @@
<div> <div>
<ui-multi-select-query-input v-model="seriesItems" text-key="displayName" :label="$strings.LabelSeries" :disabled="disabled" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" /> <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" :original-series-sequence="originalSeriesSequence" @submit="submitSeriesForm" /> <modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" @submit="submitSeriesForm" />
</div> </div>
</template> </template>
@ -18,7 +18,6 @@ export default {
data() { data() {
return { return {
selectedSeries: null, selectedSeries: null,
originalSeriesSequence: null,
showSeriesForm: false showSeriesForm: false
} }
}, },
@ -60,7 +59,6 @@ export default {
..._series ..._series
} }
this.originalSeriesSequence = _series.sequence
this.showSeriesForm = true this.showSeriesForm = true
}, },
addNewSeries() { addNewSeries() {
@ -70,7 +68,6 @@ export default {
sequence: '' sequence: ''
} }
this.originalSeriesSequence = null
this.showSeriesForm = true this.showSeriesForm = true
}, },
submitSeriesForm() { submitSeriesForm() {
@ -109,4 +106,4 @@ export default {
} }
} }
} }
</script> </script>

View file

@ -40,7 +40,6 @@ describe('LazySeriesCard', () => {
}, },
$store: { $store: {
getters: { getters: {
getServerSetting: () => 'MM/dd/yyyy',
'user/getUserCanUpdate': true, 'user/getUserCanUpdate': true,
'user/getUserMediaProgress': (id) => null, 'user/getUserMediaProgress': (id) => null,
'user/getSizeMultiplier': 1, 'user/getSizeMultiplier': 1,

View file

@ -33,7 +33,6 @@ export default {
return { return {
socket: null, socket: null,
isSocketConnected: false, isSocketConnected: false,
isSocketAuthenticated: false,
isFirstSocketConnection: true, isFirstSocketConnection: true,
socketConnectionToastId: null, socketConnectionToastId: null,
currentLang: null, currentLang: null,
@ -82,28 +81,9 @@ export default {
document.body.classList.add('app-bar') 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) { updateSocketConnectionToast(content, type, timeout) {
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) { if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
const toastUpdateOptions = { this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
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 { } else {
this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null }) this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })
} }
@ -129,7 +109,7 @@ export default {
this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null) this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)
}, },
reconnect() { reconnect() {
console.log('[SOCKET] reconnected') console.error('[SOCKET] reconnected')
}, },
reconnectAttempt(val) { reconnectAttempt(val) {
console.log(`[SOCKET] reconnect attempt ${val}`) console.log(`[SOCKET] reconnect attempt ${val}`)
@ -140,10 +120,6 @@ export default {
reconnectFailed() { reconnectFailed() {
console.error('[SOCKET] reconnect failed') console.error('[SOCKET] reconnect failed')
}, },
authFailed(payload) {
console.error('[SOCKET] auth failed', payload.message)
this.isSocketAuthenticated = false
},
init(payload) { init(payload) {
console.log('Init Payload', payload) console.log('Init Payload', payload)
@ -151,7 +127,7 @@ export default {
this.$store.commit('users/setUsersOnline', payload.usersOnline) this.$store.commit('users/setUsersOnline', payload.usersOnline)
} }
this.isSocketAuthenticated = true this.$eventBus.$emit('socket_init')
}, },
streamOpen(stream) { streamOpen(stream) {
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream) if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
@ -199,7 +175,7 @@ export default {
} }
} else { } else {
console.error('User has no more accessible libraries') console.error('User has no more accessible libraries')
this.$store.commit('libraries/setCurrentLibrary', { id: null }) this.$store.commit('libraries/setCurrentLibrary', null)
} }
} }
}, },
@ -371,24 +347,13 @@ export default {
}, },
customMetadataProviderAdded(provider) { customMetadataProviderAdded(provider) {
if (!provider?.id) return if (!provider?.id) return
// Refresh providers cache this.$store.commit('scanners/addCustomMetadataProvider', provider)
this.$store.dispatch('scanners/refreshProviders')
}, },
customMetadataProviderRemoved(provider) { customMetadataProviderRemoved(provider) {
if (!provider?.id) return if (!provider?.id) return
// Refresh providers cache this.$store.commit('scanners/removeCustomMetadataProvider', provider)
this.$store.dispatch('scanners/refreshProviders')
}, },
initializeSocket() { 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({ this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
persist: 'main', persist: 'main',
@ -399,7 +364,6 @@ export default {
path: `${this.$config.routerBasePath}/socket.io` path: `${this.$config.routerBasePath}/socket.io`
}) })
this.$root.socket = this.socket this.$root.socket = this.socket
this.isSocketAuthenticated = false
console.log('Socket initialized') console.log('Socket initialized')
// Pre-defined socket events // Pre-defined socket events
@ -413,7 +377,6 @@ export default {
// Event received after authorizing socket // Event received after authorizing socket
this.socket.on('init', this.init) this.socket.on('init', this.init)
this.socket.on('auth_failed', this.authFailed)
// Stream Listeners // Stream Listeners
this.socket.on('stream_open', this.streamOpen) this.socket.on('stream_open', this.streamOpen)
@ -608,7 +571,6 @@ export default {
this.updateBodyClass() this.updateBodyClass()
this.resize() this.resize()
this.$eventBus.$on('change-lang', this.changeLanguage) this.$eventBus.$on('change-lang', this.changeLanguage)
this.$eventBus.$on('token_refreshed', this.tokenRefreshed)
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown) window.addEventListener('keydown', this.keyDown)
@ -632,7 +594,6 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('change-lang', this.changeLanguage) this.$eventBus.$off('change-lang', this.changeLanguage)
this.$eventBus.$off('token_refreshed', this.tokenRefreshed)
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)
window.removeEventListener('keydown', this.keyDown) window.removeEventListener('keydown', this.keyDown)
} }

View file

@ -118,8 +118,8 @@ export default {
propsData: props, propsData: props,
parent: this, parent: this,
created() { created() {
this.$on('edit', (entity, tab) => { this.$on('edit', (entity) => {
if (_this.editEntity) _this.editEntity(entity, tab) if (_this.editEntity) _this.editEntity(entity)
}) })
this.$on('select', ({ entity, shiftKey }) => { this.$on('select', ({ entity, shiftKey }) => {
if (_this.selectEntity) _this.selectEntity(entity, shiftKey) if (_this.selectEntity) _this.selectEntity(entity, shiftKey)

View file

@ -73,8 +73,7 @@ module.exports = {
// Axios module configuration: https://go.nuxtjs.dev/config-axios // Axios module configuration: https://go.nuxtjs.dev/config-axios
axios: { axios: {
baseURL: routerBasePath, baseURL: routerBasePath
progress: false
}, },
// nuxt/pwa https://pwa.nuxtjs.org // nuxt/pwa https://pwa.nuxtjs.org

View file

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.32.1", "version": "2.22.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.32.1", "version": "2.22.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",

View file

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.32.1", "version": "2.22.0",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",

View file

@ -182,19 +182,18 @@ export default {
password: this.password, password: this.password,
newPassword: this.newPassword newPassword: this.newPassword
}) })
.then(() => { .then((res) => {
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) if (res.success) {
this.resetForm() this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
this.resetForm()
} else {
this.$toast.error(res.error || this.$strings.ToastUnknownError)
}
this.changingPassword = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to change password', error) console.error(error)
let errorMessage = this.$strings.ToastUnknownError this.$toast.error(this.$strings.ToastUnknownError)
if (error.response?.data && typeof error.response.data === 'string') {
errorMessage = error.response.data
}
this.$toast.error(errorMessage)
})
.finally(() => {
this.changingPassword = false this.changingPassword = false
}) })
}, },

View file

@ -12,24 +12,24 @@
<p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p> <p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
</div> </div>
<div class="flex flex-wrap-reverse min-[1120px]:flex-nowrap justify-center py-4 px-4"> <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="w-full max-w-3xl py-4">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-12 hidden min-w-[1120px]:block" /> <div class="w-12 hidden lg:block" />
<p class="text-lg mb-4 font-semibold">{{ $strings.HeaderChapters }}</p> <p class="text-lg mb-4 font-semibold">{{ $strings.HeaderChapters }}</p>
<div class="grow" /> <div class="grow" />
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" :label="$strings.LabelShowSeconds" class="mx-2" /> <ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" :label="$strings.LabelShowSeconds" class="mx-2" />
<div class="w-32 hidden min-[1120px]:block" /> <div class="w-32 hidden lg:block" />
</div> </div>
<div class="flex items-center mb-3 py-1 -mx-1"> <div class="flex items-center mb-3 py-1 -mx-1">
<div class="w-12 hidden min-[1120px]:block" /> <div class="w-12 hidden lg:block" />
<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="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 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> <ui-btn color="bg-primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<div class="grow" /> <div class="grow" />
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn> <ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="bg-success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn> <ui-btn v-if="hasChanges" color="bg-success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-32 hidden min-[1120px]:block" /> <div class="w-32 hidden lg:block" />
</div> </div>
<div class="overflow-hidden"> <div class="overflow-hidden">
@ -53,104 +53,54 @@
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2"> <div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="w-8 min-w-8 md:w-12 md:min-w-12"></div> <div class="w-8 min-w-8 md:w-12 md:min-w-12"></div>
<div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1 pl-8">{{ $strings.LabelStart }}</div> <div class="w-24 min-w-24 md:w-32 md:min-w-32 px-2">{{ $strings.LabelStart }}</div>
<div class="grow px-1 min-w-54">{{ $strings.LabelTitle }}</div> <div class="grow px-2">{{ $strings.LabelTitle }}</div>
<div class="w-7 min-w-7 px-1 flex items-center justify-center">
<ui-tooltip :text="allChaptersLocked ? $strings.TooltipUnlockAllChapters : $strings.TooltipLockAllChapters" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center cursor-pointer transition-colors duration-150" :class="allChaptersLocked ? 'text-orange-400 hover:text-orange-300' : 'text-gray-300 hover:text-white'" @click="toggleAllChaptersLock">
<span class="material-symbols text-xl">{{ allChaptersLocked ? 'lock' : 'lock_open' }}</span>
</button>
</ui-tooltip>
</div>
<div class="w-32"></div> <div class="w-32"></div>
</div> </div>
<div v-for="chapter in newChapters" :key="chapter.id" class="flex py-1"> <template v-for="chapter in newChapters">
<div class="w-8 min-w-8 md:w-12 md:min-w-12">#{{ chapter.id + 1 }}</div> <div :key="chapter.id" class="flex py-1">
<div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1"> <div class="w-8 min-w-8 md:w-12 md:min-w-12">#{{ chapter.id + 1 }}</div>
<div class="flex items-center gap-1"> <div class="w-24 min-w-24 md:w-32 md:min-w-32 px-1">
<ui-tooltip :text="$strings.TooltipSubtractOneSecond" direction="bottom"> <ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
<button <ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 flex-shrink-0" </div>
:class="{ 'opacity-50 cursor-not-allowed': chapter.id === 0 && chapter.start - timeIncrementAmount < 0 }" <div class="grow px-1">
@click="incrementChapterTime(chapter, -timeIncrementAmount)" <ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs min-w-52" />
:disabled="chapter.id === 0 && chapter.start - timeIncrementAmount < 0" </div>
> <div class="w-32 min-w-32 px-2 py-1">
<span class="material-symbols text-sm">remove</span> <div class="flex items-center">
</button> <ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom">
</ui-tooltip> <button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
<span class="material-symbols text-base">remove</span>
</button>
</ui-tooltip>
<div class="flex-1 min-w-0"> <ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom">
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" /> <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" /> <span class="material-symbols text-lg">add</span>
</button>
</ui-tooltip>
<ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-symbols text-base">pause</span>
<span v-else class="material-symbols text-base">play_arrow</span>
</button>
</ui-tooltip>
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
<span class="material-symbols text-lg">error_outline</span>
</button>
</ui-tooltip>
</div> </div>
<ui-tooltip :text="$strings.TooltipAddOneSecond" direction="bottom">
<button class="w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 flex-shrink-0" :class="{ 'opacity-50 cursor-not-allowed': chapter.start + timeIncrementAmount >= mediaDuration }" @click="incrementChapterTime(chapter, timeIncrementAmount)" :disabled="chapter.start + timeIncrementAmount >= mediaDuration">
<span class="material-symbols text-sm">add</span>
</button>
</ui-tooltip>
</div> </div>
</div> </div>
<div class="grow px-1"> </template>
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs min-w-52" />
</div>
<div class="w-7 min-w-7 px-1 py-1">
<div class="flex items-center justify-center">
<ui-tooltip :text="lockedChapters.has(chapter.id) ? $strings.TooltipUnlockChapter : $strings.TooltipLockChapter" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center transform hover:scale-110 duration-150 flex-shrink-0" :class="lockedChapters.has(chapter.id) ? 'text-orange-400 hover:text-orange-300' : 'text-gray-300 hover:text-white'" @click="toggleChapterLock(chapter, $event)">
<span class="material-symbols text-base">{{ lockedChapters.has(chapter.id) ? 'lock' : 'lock_open' }}</span>
</button>
</ui-tooltip>
</div>
</div>
<div class="w-32 min-w-32 px-2 py-1">
<div class="flex items-center">
<ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom">
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
<span class="material-symbols text-base">delete</span>
</button>
</ui-tooltip>
<ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
<span class="material-symbols text-lg">add_row_below</span>
</button>
</ui-tooltip>
<ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom">
<button :disabled="!getAudioTrackForTime(chapter.start)" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 disabled:opacity-50 disabled:cursor-not-allowed" @click="playChapter(chapter)">
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-symbols text-base">pause</span>
<span v-else class="material-symbols text-xl">play_arrow</span>
</button>
</ui-tooltip>
<ui-tooltip v-if="selectedChapterId === chapter.id && (isPlayingChapter || isLoadingChapter)" :text="$strings.TooltipAdjustChapterStart" direction="bottom">
<div class="ml-2 text-xs text-gray-300 font-mono min-w-10 cursor-pointer hover:text-white transition-colors duration-150" @click="adjustChapterStartTime(chapter)">{{ elapsedTime }}s</div>
</ui-tooltip>
<ui-tooltip v-if="chapter.error" :text="chapter.error" plaintext direction="left">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
<span class="material-symbols text-lg">error_outline</span>
</button>
</ui-tooltip>
</div>
</div>
</div>
<div class="flex items-center mt-4 mb-2">
<div class="w-8 min-w-8 md:w-12 md:min-w-12"></div>
<div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1"></div>
<div class="flex items-center gap-2 grow px-1">
<ui-text-input v-model="bulkChapterInput" :placeholder="$strings.PlaceholderBulkChapterInput" class="text-xs grow min-w-52" @keyup.enter="handleBulkChapterAdd" />
</div>
<div class="w-39 min-w-39 px-1 py-1">
<ui-tooltip :text="$strings.TooltipAddChapters" direction="bottom" class="inline-block align-middle">
<button class="w-5 h-5 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150 flex-shrink-0" :aria-label="$strings.TooltipAddChapters" :class="{ 'opacity-50 cursor-not-allowed': !bulkChapterInput.trim() }" :disabled="!bulkChapterInput.trim()" @click="handleBulkChapterAdd">
<span class="material-symbols text-lg">add</span>
</button>
</ui-tooltip>
</div>
</div>
</div> </div>
<div class="w-full max-w-3xl min-[1120px]:max-w-xl py-4 px-2"> <div class="w-full max-w-xl py-4 px-2">
<div class="flex items-center mb-4 py-1"> <div class="flex items-center mb-4 py-1">
<p class="text-lg font-semibold">{{ $strings.HeaderAudioTracks }}</p> <p class="text-lg font-semibold">{{ $strings.HeaderAudioTracks }}</p>
<div class="grow" /> <div class="grow" />
@ -160,19 +110,23 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2"> <div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="grow min-[1120px]:max-w-64 xl:max-w-sm">{{ $strings.LabelFilename }}</div> <div class="grow">{{ $strings.LabelFilename }}</div>
<div class="w-20">{{ $strings.LabelDuration }}</div> <div class="w-20">{{ $strings.LabelDuration }}</div>
<div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div> <div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div>
</div> </div>
<div v-for="track in audioTracks" :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''"> <template v-for="track in audioTracks">
<div class="pr-2 grow min-[1120px]:max-w-64 xl:max-w-sm"> <div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''">
<p class="text-xs truncate">{{ track.metadata.filename }}</p> <div class="grow max-w-[calc(100%-80px)] pr-2">
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
</div>
<div class="w-20" style="min-width: 80px">
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
</div>
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px">
<span v-if="(track.chapters || []).length" class="material-symbols text-success text-sm">check</span>
</div>
</div> </div>
<div class="w-20" style="min-width: 80px"> </template>
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
</div>
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px"><span v-if="(track.chapters || []).length" class="material-symbols text-success text-sm">check</span></div>
</div>
</div> </div>
</div> </div>
@ -180,7 +134,6 @@
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
<!-- audible chapter lookup modal -->
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters"> <modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none"> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
@ -206,16 +159,12 @@
</div> </div>
</div> </div>
<div v-else class="w-full p-4"> <div v-else class="w-full p-4">
<div class="flex mb-4"> <div class="flex justify-between mb-4">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white flex-shrink-0" :aria-label="$strings.ButtonBack" @click="resetChapterLookupData">
<span class="material-symbols text-lg">arrow_back</span>
</button>
<p> <p>
{{ $strings.LabelDurationFound }} <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span> {{ $strings.LabelDurationFound }} <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span
<br /> ><br />
<span class="font-semibold" :class="{ 'text-warning': chapters.length !== chapterData.chapters.length }">{{ chapterData.chapters.length }}</span> {{ $strings.LabelChaptersFound }} <span class="font-semibold" :class="{ 'text-warning': chapters.length !== chapterData.chapters.length }">{{ chapterData.chapters.length }}</span> {{ $strings.LabelChaptersFound }}
</p> </p>
<div class="grow" />
<p> <p>
{{ $strings.LabelYourAudiobookDuration }}: <span class="font-semibold">{{ $secondsToTimestamp(mediaDurationRounded) }}</span {{ $strings.LabelYourAudiobookDuration }}: <span class="font-semibold">{{ $secondsToTimestamp(mediaDurationRounded) }}</span
><br /> ><br />
@ -249,49 +198,17 @@
<p class="pl-2">{{ $strings.MessageChapterStartIsAfter }}</p> <p class="pl-2">{{ $strings.MessageChapterStartIsAfter }}</p>
</div> </div>
</div> </div>
<div class="flex items-center pt-2 justify-between"> <div class="flex items-center pt-2">
<div class="flex items-center gap-2"> <ui-btn small color="bg-primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn>
<ui-btn small color="bg-primary" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn> <ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center">
<ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center"> <span class="material-symbols text-xl text-gray-200">info</span>
<span class="material-symbols text-xl text-gray-200">info</span> </ui-tooltip>
</ui-tooltip> <div class="grow" />
</div>
<ui-btn small color="bg-success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn> <ui-btn small color="bg-success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn>
</div> </div>
</div> </div>
</div> </div>
</modals-modal> </modals-modal>
<!-- create bulk chapters modal -->
<modals-modal v-model="showBulkChapterModal" name="bulk-chapters" :width="400">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderBulkChapterModal }}</p>
</div>
</template>
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative p-6">
<div class="flex flex-col space-y-8">
<p class="text-base">{{ $strings.MessageBulkChapterPattern }}</p>
<div v-if="detectedPattern" class="text-sm text-gray-400 bg-gray-800 p-2 rounded">
<strong>{{ $strings.LabelDetectedPattern }}</strong> "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber, detectedPattern) }}{{ detectedPattern.after }}"
<br />
<strong>{{ $strings.LabelNextChapters }}</strong>
"{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 1, detectedPattern) }}{{ detectedPattern.after }}", "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 2, detectedPattern) }}{{ detectedPattern.after }}", etc.
</div>
<div class="flex px-1 items-center">
<label class="text-base font-medium">{{ $strings.LabelNumberOfChapters }}</label>
<div class="grow" />
<ui-text-input v-model="bulkChapterCount" type="number" min="1" max="50" class="w-14" :style="{ height: `2em` }" @keyup.enter="addBulkChapters" />
</div>
<div class="flex px-1 items-center">
<ui-btn small @click="showBulkChapterModal = false">{{ $strings.ButtonCancel }}</ui-btn>
<div class="grow" />
<ui-btn small color="bg-success" @click="addBulkChapters">{{ $strings.ButtonAddChapters }}</ui-btn>
</div>
</div>
</div>
</modals-modal>
</div> </div>
</template> </template>
@ -348,17 +265,7 @@ export default {
removeBranding: false, removeBranding: false,
showSecondInputs: false, showSecondInputs: false,
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'], audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],
hasChanges: false, hasChanges: false
timeIncrementAmount: 1,
elapsedTime: 0,
playStartTime: null,
elapsedTimeInterval: null,
lockedChapters: new Set(),
lastSelectedLockIndex: null,
bulkChapterInput: '',
showBulkChapterModal: false,
bulkChapterCount: 1,
detectedPattern: null
} }
}, },
computed: { computed: {
@ -397,18 +304,9 @@ export default {
}, },
selectedChapterId() { selectedChapterId() {
return this.selectedChapter ? this.selectedChapter.id : null return this.selectedChapter ? this.selectedChapter.id : null
},
allChaptersLocked() {
return this.newChapters.length > 0 && this.newChapters.every((chapter) => this.lockedChapters.has(chapter.id))
} }
}, },
methods: { methods: {
formatNumberWithPadding(number, pattern) {
if (!pattern || !pattern.hasLeadingZeros || !pattern.originalPadding) {
return number.toString()
}
return number.toString().padStart(pattern.originalPadding, '0')
},
setChaptersFromTracks() { setChaptersFromTracks() {
let currentStartTime = 0 let currentStartTime = 0
let index = 0 let index = 0
@ -423,7 +321,7 @@ export default {
currentStartTime += track.duration currentStartTime += track.duration
} }
this.newChapters = chapters this.newChapters = chapters
this.lockedChapters = new Set()
this.checkChapters() this.checkChapters()
}, },
toggleRemoveBranding() { toggleRemoveBranding() {
@ -436,22 +334,19 @@ export default {
const amount = Number(this.shiftAmount) const amount = Number(this.shiftAmount)
// Check if any unlocked chapters would be affected negatively const lastChapter = this.newChapters[this.newChapters.length - 1]
const unlockedChapters = this.newChapters.filter((chap) => !this.lockedChapters.has(chap.id)) if (lastChapter.start + amount > this.mediaDurationRounded) {
this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountLast)
return
}
if (unlockedChapters.length === 0) { if (this.newChapters[1].start + amount <= 0) {
this.$toast.warning(this.$strings.ToastChaptersAllLocked) this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountStart)
return return
} }
for (let i = 0; i < this.newChapters.length; i++) { for (let i = 0; i < this.newChapters.length; i++) {
const chap = this.newChapters[i] const chap = this.newChapters[i]
// Skip locked chapters
if (this.lockedChapters.has(chap.id)) {
continue
}
chap.end = Math.min(chap.end + amount, this.mediaDuration) chap.end = Math.min(chap.end + amount, this.mediaDuration)
if (i > 0) { if (i > 0) {
chap.start = Math.max(0, chap.start + amount) chap.start = Math.max(0, chap.start + amount)
@ -459,83 +354,6 @@ export default {
} }
this.checkChapters() this.checkChapters()
}, },
incrementChapterTime(chapter, amount) {
if (chapter.id === 0 && chapter.start + amount < 0) {
return
}
if (chapter.start + amount >= this.mediaDuration) {
return
}
chapter.start = Math.max(0, chapter.start + amount)
this.checkChapters()
},
adjustChapterStartTime(chapter) {
const newStartTime = chapter.start + this.elapsedTime
chapter.start = newStartTime
this.checkChapters()
this.$toast.success(this.$strings.ToastChapterStartTimeAdjusted.replace('{0}', this.elapsedTime))
this.destroyAudioEl()
},
startElapsedTimeTracking() {
this.elapsedTime = 0
this.playStartTime = Date.now()
this.elapsedTimeInterval = setInterval(() => {
this.elapsedTime = Math.floor((Date.now() - this.playStartTime) / 1000)
}, 100)
},
stopElapsedTimeTracking() {
if (this.elapsedTimeInterval) {
clearInterval(this.elapsedTimeInterval)
this.elapsedTimeInterval = null
}
this.elapsedTime = 0
this.playStartTime = null
},
toggleChapterLock(chapter, event) {
const chapterId = chapter.id
if (event.shiftKey && this.lastSelectedLockIndex !== null) {
const startIndex = Math.min(this.lastSelectedLockIndex, chapterId)
const endIndex = Math.max(this.lastSelectedLockIndex, chapterId)
const shouldLock = !this.lockedChapters.has(chapterId)
for (let i = startIndex; i <= endIndex; i++) {
if (shouldLock) {
this.lockedChapters.add(i)
} else {
this.lockedChapters.delete(i)
}
}
} else {
if (this.lockedChapters.has(chapterId)) {
this.lockedChapters.delete(chapterId)
} else {
this.lockedChapters.add(chapterId)
}
}
this.lastSelectedLockIndex = chapterId
this.lockedChapters = new Set(this.lockedChapters)
},
lockAllChapters() {
this.newChapters.forEach((chapter) => {
this.lockedChapters.add(chapter.id)
})
this.lockedChapters = new Set(this.lockedChapters)
},
unlockAllChapters() {
this.lockedChapters.clear()
this.lockedChapters = new Set(this.lockedChapters)
},
toggleAllChaptersLock() {
if (this.allChaptersLocked) {
this.unlockAllChapters()
} else {
this.lockAllChapters()
}
},
editItem() { editItem() {
this.$store.commit('showEditModal', this.libraryItem) this.$store.commit('showEditModal', this.libraryItem)
}, },
@ -550,10 +368,6 @@ export default {
this.checkChapters() this.checkChapters()
}, },
removeChapter(chapter) { removeChapter(chapter) {
if (this.lockedChapters.has(chapter.id)) {
this.$toast.warning(this.$strings.ToastChapterLocked)
return
}
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id) this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
this.checkChapters() this.checkChapters()
}, },
@ -594,14 +408,6 @@ export default {
this.hasChanges = hasChanges this.hasChanges = hasChanges
}, },
getAudioTrackForTime(time) {
if (typeof time !== 'number') {
return null
}
return this.tracks.find((at) => {
return time >= at.startOffset && time < at.startOffset + at.duration
})
},
playChapter(chapter) { playChapter(chapter) {
console.log('Play Chapter', chapter.id) console.log('Play Chapter', chapter.id)
if (this.selectedChapterId === chapter.id) { if (this.selectedChapterId === chapter.id) {
@ -616,12 +422,9 @@ export default {
this.destroyAudioEl() this.destroyAudioEl()
} }
const audioTrack = this.getAudioTrackForTime(chapter.start) const audioTrack = this.tracks.find((at) => {
if (!audioTrack) { return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
console.error('No audio track found for chapter', chapter) })
return
}
this.selectedChapter = chapter this.selectedChapter = chapter
this.isLoadingChapter = true this.isLoadingChapter = true
@ -648,7 +451,6 @@ export default {
console.log('Audio playing') console.log('Audio playing')
this.isLoadingChapter = false this.isLoadingChapter = false
this.isPlayingChapter = true this.isPlayingChapter = true
this.startElapsedTimeTracking()
}) })
audioEl.addEventListener('ended', () => { audioEl.addEventListener('ended', () => {
console.log('Audio ended') console.log('Audio ended')
@ -671,10 +473,6 @@ export default {
this.selectedChapter = null this.selectedChapter = null
this.isPlayingChapter = false this.isPlayingChapter = false
this.isLoadingChapter = false this.isLoadingChapter = false
this.stopElapsedTimeTracking()
},
resetChapterLookupData() {
this.chapterData = null
}, },
saveChapters() { saveChapters() {
this.checkChapters() this.checkChapters()
@ -708,7 +506,11 @@ export default {
this.saving = false this.saving = false
if (data.updated) { if (data.updated) {
this.$toast.success(this.$strings.ToastChaptersUpdated) this.$toast.success(this.$strings.ToastChaptersUpdated)
this.reloadLibraryItem() if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
this.$router.push(`/item/${this.libraryItem.id}`)
}
} else { } else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
} }
@ -721,7 +523,7 @@ export default {
}, },
applyChapterNamesOnly() { applyChapterNamesOnly() {
this.newChapters.forEach((chapter, index) => { this.newChapters.forEach((chapter, index) => {
if (this.chapterData.chapters[index] && !this.lockedChapters.has(chapter.id)) { if (this.chapterData.chapters[index]) {
chapter.title = this.chapterData.chapters[index].title chapter.title = this.chapterData.chapters[index].title
} }
}) })
@ -733,7 +535,7 @@ export default {
}, },
applyChapterData() { applyChapterData() {
let index = 0 let index = 0
const audibleChapters = this.chapterData.chapters this.newChapters = this.chapterData.chapters
.filter((chap) => chap.startOffsetSec < this.mediaDuration) .filter((chap) => chap.startOffsetSec < this.mediaDuration)
.map((chap) => { .map((chap) => {
return { return {
@ -743,21 +545,6 @@ export default {
title: chap.title title: chap.title
} }
}) })
const merged = []
let audibleIdx = 0
for (let i = 0; i < Math.max(this.newChapters.length, audibleChapters.length); i++) {
const isLocked = this.lockedChapters.has(i)
if (isLocked && this.newChapters[i]) {
merged.push({ ...this.newChapters[i], id: i })
} else if (audibleChapters[audibleIdx]) {
merged.push({ ...audibleChapters[audibleIdx], id: i })
audibleIdx++
} else if (this.newChapters[i]) {
merged.push({ ...this.newChapters[i], id: i })
}
}
this.newChapters = merged
this.showFindChaptersModal = false this.showFindChaptersModal = false
this.chapterData = null this.chapterData = null
@ -785,7 +572,7 @@ export default {
if (data.error) { if (data.error) {
this.asinError = this.$getString(data.stringKey) this.asinError = this.$getString(data.stringKey)
} else { } else {
console.log('Chapter data', { ...data }) console.log('Chapter data', data)
this.chapterData = this.removeBranding ? this.removeBrandingFromData(data) : data this.chapterData = this.removeBranding ? this.removeBrandingFromData(data) : data
} }
}) })
@ -822,11 +609,6 @@ export default {
data.chapters.pop() data.chapters.pop()
} }
// Remove Branding durations from Runtime totals
data.runtimeLengthMs -= introDuration + outroDuration
data.runtimeLengthSec = Math.floor(data.runtimeLengthMs / 1000)
console.log('Brandless Chapter data', data)
return data return data
} catch { } catch {
return data return data
@ -856,7 +638,6 @@ export default {
} }
] ]
} }
this.lockedChapters = new Set()
this.checkChapters() this.checkChapters()
}, },
removeAllChaptersClick() { removeAllChaptersClick() {
@ -881,7 +662,11 @@ export default {
.then((data) => { .then((data) => {
if (data.updated) { if (data.updated) {
this.$toast.success(this.$strings.ToastChaptersRemoved) this.$toast.success(this.$strings.ToastChaptersRemoved)
this.reloadLibraryItem() if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
this.$router.push(`/item/${this.libraryItem.id}`)
}
} else { } else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
} }
@ -894,91 +679,6 @@ export default {
this.saving = false this.saving = false
}) })
}, },
handleBulkChapterAdd() {
const input = this.bulkChapterInput.trim()
if (!input) return
const numberMatch = input.match(/(\d+)/)
if (numberMatch) {
// Extract the base pattern and number, preserving zero-padding
const originalNumberString = numberMatch[1]
const foundNumber = parseInt(originalNumberString)
const numberIndex = numberMatch.index
const beforeNumber = input.substring(0, numberIndex)
const afterNumber = input.substring(numberIndex + originalNumberString.length)
this.detectedPattern = {
before: beforeNumber,
after: afterNumber,
startingNumber: foundNumber,
originalPadding: originalNumberString.length,
hasLeadingZeros: originalNumberString.length > 1 && originalNumberString.startsWith('0')
}
this.bulkChapterCount = 1
this.showBulkChapterModal = true
} else {
this.addSingleChapterFromInput(input)
}
},
addSingleChapterFromInput(title) {
// Find the last chapter to determine where to add the new one
const lastChapter = this.newChapters[this.newChapters.length - 1]
const newStart = lastChapter ? lastChapter.end : 0
const newEnd = Math.min(newStart + 300, this.mediaDuration)
const newChapter = {
id: this.newChapters.length,
start: newStart,
end: newEnd,
title: title
}
this.newChapters.push(newChapter)
this.bulkChapterInput = ''
this.checkChapters()
},
addBulkChapters() {
const count = parseInt(this.bulkChapterCount)
if (!count || count < 1 || count > 150) {
this.$toast.error(this.$strings.ToastBulkChapterInvalidCount)
return
}
const { before, after, startingNumber, originalPadding, hasLeadingZeros } = this.detectedPattern
const lastChapter = this.newChapters[this.newChapters.length - 1]
const baseStart = lastChapter ? lastChapter.start + 1 : 0
// Add multiple chapters with the detected pattern
for (let i = 0; i < count; i++) {
const chapterNumber = startingNumber + i
let formattedNumber = chapterNumber.toString()
// Apply zero-padding if the original had leading zeros
if (hasLeadingZeros && originalPadding > 1) {
formattedNumber = chapterNumber.toString().padStart(originalPadding, '0')
}
const newStart = baseStart + i
const newEnd = Math.min(newStart + i + i, this.mediaDuration)
const newChapter = {
id: this.newChapters.length,
start: newStart,
end: newEnd,
title: `${before}${formattedNumber}${after}`
}
this.newChapters.push(newChapter)
}
this.bulkChapterInput = ''
this.showBulkChapterModal = false
this.detectedPattern = null
this.checkChapters()
},
libraryItemUpdated(libraryItem) { libraryItemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItem.id) { if (libraryItem.id === this.libraryItem.id) {
if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) { if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) {
@ -986,18 +686,6 @@ export default {
} }
this.libraryItem = libraryItem this.libraryItem = libraryItem
} }
},
reloadLibraryItem() {
this.$axios
.$get(`/api/items/${this.libraryItem.id}?expanded=1`)
.then((data) => {
this.libraryItem = data
this.initChapters()
})
.catch((error) => {
console.error('Failed to reload library item', error)
this.$toast.error(this.$strings.ToastFailedToLoadData)
})
} }
}, },
mounted() { mounted() {

View file

@ -28,14 +28,14 @@
<div class="flex justify-center flex-wrap lg:flex-nowrap gap-4"> <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="w-full max-w-2xl border border-white/10 bg-bg">
<div class="flex py-2 px-4"> <div class="flex py-2 px-4">
<div class="w-28 min-w-28 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div> <div class="w-1/3 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 class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
</div> </div>
<div class="w-full max-h-72 overflow-auto"> <div class="w-full max-h-72 overflow-auto">
<template v-for="(value, key, index) in metadataObject"> <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 :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary/25' : ''">
<div class="w-28 min-w-28 font-semibold">{{ key }}</div> <div class="w-1/3 font-semibold">{{ key }}</div>
<div class="grow"> <div class="w-2/3">
{{ value }} {{ value }}
</div> </div>
</div> </div>
@ -45,18 +45,18 @@
<div class="w-full max-w-2xl border border-white/10 bg-bg"> <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="flex py-2 px-4 bg-primary/25">
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div> <div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div> <div class="w-24 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 class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
</div> </div>
<div class="w-full max-h-72 overflow-auto"> <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> <p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
<template v-for="(chapter, index) in metadataChapters"> <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 :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="grow font-semibold">{{ chapter.title }}</div>
<div class="w-16 min-w-16"> <div class="w-24">
{{ $secondsToTimestamp(chapter.start) }} {{ $secondsToTimestamp(chapter.start) }}
</div> </div>
<div class="w-16 min-w-16"> <div class="w-24">
{{ $secondsToTimestamp(chapter.end) }} {{ $secondsToTimestamp(chapter.end) }}
</div> </div>
</div> </div>
@ -356,8 +356,6 @@ export default {
const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions() const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
this.encodingOptions = encodeOptions
const queryParams = new URLSearchParams(encodeOptions) const queryParams = new URLSearchParams(encodeOptions)
this.processing = true this.processing = true

View file

@ -53,7 +53,6 @@ export default {
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
else if (pageName === 'stats') return this.$strings.HeaderYourStats else if (pageName === 'stats') return this.$strings.HeaderYourStats
else if (pageName === 'users') return this.$strings.HeaderUsers 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 === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail else if (pageName === 'email') return this.$strings.HeaderEmail

View file

@ -1,84 +0,0 @@
<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>

View file

@ -131,26 +131,35 @@
</div> </div>
<div class="grow py-2"> <div class="grow py-2">
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-72" @input="(val) => updateSettingsKey('dateFormat', val)" /> <ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
<p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ dateExample }}</p> <p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ dateExample }}</p>
</div> </div>
<div class="grow py-2"> <div class="grow py-2">
<ui-dropdown :label="$strings.LabelSettingsTimeFormat" v-model="newServerSettings.timeFormat" :items="timeFormats" small class="max-w-72" @input="(val) => updateSettingsKey('timeFormat', val)" /> <ui-dropdown :label="$strings.LabelSettingsTimeFormat" v-model="newServerSettings.timeFormat" :items="timeFormats" small class="max-w-52" @input="(val) => updateSettingsKey('timeFormat', val)" />
<p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ timeExample }}</p> <p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ timeExample }}</p>
</div> </div>
<div class="py-2"> <div class="py-2">
<ui-dropdown :label="$strings.LabelLanguageDefaultServer" ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-72" @input="updateServerLanguage" /> <ui-dropdown :label="$strings.LabelLanguageDefaultServer" ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
</div> </div>
<div class="pt-4"> <!-- old experimental features -->
<h2 class="font-semibold">{{ $strings.HeaderSettingsSecurity }}</h2> <!-- <div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsExperimental }}</h2>
</div> </div>
<div class="py-2"> <div class="flex items-center py-2">
<ui-multi-select v-model="newServerSettings.allowedOrigins" :items="newServerSettings.allowedOrigins" :label="$strings.LabelCorsAllowed" class="max-w-72" @input="updateCorsOrigins" /> <ui-toggle-switch labeledBy="settings-experimental-features" v-model="showExperimentalFeatures" />
</div> <ui-tooltip :text="$strings.LabelSettingsExperimentalFeaturesHelp">
<p class="pl-4">
<span id="settings-experimental-features">{{ $strings.LabelSettingsExperimentalFeatures }}</span>
<a :aria-label="$strings.LabelSettingsExperimentalFeaturesHelp" href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span class="material-symbols icon-text">info</span>
</a>
</p>
</ui-tooltip>
</div> -->
</div> </div>
</div> </div>
</app-settings-content> </app-settings-content>
@ -247,8 +256,7 @@ export default {
return this.$store.state.serverSettings return this.$store.state.serverSettings
}, },
providers() { providers() {
// Use book cover providers for the cover provider dropdown return this.$store.state.scanners.providers
return this.$store.state.scanners.bookCoverProviders || []
}, },
dateFormats() { dateFormats() {
return this.$store.state.globals.dateFormats return this.$store.state.globals.dateFormats
@ -315,27 +323,6 @@ export default {
updateServerLanguage(val) { updateServerLanguage(val) {
this.updateSettingsKey('language', val) this.updateSettingsKey('language', val)
}, },
updateCorsOrigins(val) {
const validOrigins = []
const invalidOrigins = []
val.forEach((origin) => {
const trimmedOrigin = origin.trim().toLowerCase()
try {
new URL(trimmedOrigin)
validOrigins.push(trimmedOrigin)
} catch {
invalidOrigins.push(trimmedOrigin)
}
})
if (invalidOrigins.length > 0) {
this.$toast.error(this.$strings.ToastInvalidUrls)
}
this.newServerSettings.allowedOrigins = validOrigins
this.updateSettingsKey('allowedOrigins', validOrigins)
},
updateSettingsKey(key, val) { updateSettingsKey(key, val) {
if (key === 'scannerDisableWatcher') { if (key === 'scannerDisableWatcher') {
this.newServerSettings.scannerDisableWatcher = val this.newServerSettings.scannerDisableWatcher = val
@ -365,7 +352,6 @@ export default {
initServerSettings() { initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])] this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
this.newServerSettings.allowedOrigins = [...(this.newServerSettings.allowedOrigins || [])]
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
@ -417,8 +403,6 @@ export default {
}, },
mounted() { mounted() {
this.initServerSettings() this.initServerSettings()
// Fetch providers if not already loaded (for cover provider dropdown)
this.$store.dispatch('scanners/fetchProviders')
} }
} }
</script> </script>

View file

@ -78,10 +78,10 @@ export default {
}, },
computed: { computed: {
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
timeFormat() { timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat') return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {

View file

@ -6,82 +6,80 @@
</div> </div>
<div v-if="listeningSessions.length" class="block max-w-full relative"> <div v-if="listeningSessions.length" class="block max-w-full relative">
<div class="overflow-x-auto"> <table class="userSessionsTable">
<table class="userSessionsTable"> <tr class="bg-primary/40">
<tr class="bg-primary/40"> <th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
<th class="w-6 min-w-6 text-left hidden md:table-cell h-11"> <ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
<ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" /> </th>
</th> <th v-if="numSelected" class="grow text-left" :colspan="7">
<th v-if="numSelected" class="grow text-left" :colspan="7"> <div class="flex items-center">
<div class="flex items-center"> <p>{{ $getString('MessageSelected', [numSelected]) }}</p>
<p>{{ $getString('MessageSelected', [numSelected]) }}</p> <div class="grow" />
<div class="grow" /> <ui-btn small color="bg-error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
<ui-btn small color="bg-error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn> </div>
</div> </th>
</th> <th v-if="!numSelected" class="grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
<th v-if="!numSelected" class="grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')"> <div class="inline-flex items-center">
<div class="inline-flex items-center"> {{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> </div>
</div> </th>
</th> <th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
<th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th> <th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')">
<th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')"> <div class="inline-flex items-center">
<div class="inline-flex items-center"> {{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
{{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> </div>
</div> </th>
</th> <th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th> <th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')">
<th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')"> <div class="inline-flex items-center">
<div class="inline-flex items-center"> {{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
{{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> </div>
</div> </th>
</th> <th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')">
<th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')"> <div class="inline-flex items-center">
<div class="inline-flex items-center"> {{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> </div>
</div> </th>
</th> <th v-if="!numSelected" class="grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
<th v-if="!numSelected" class="grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')"> <div class="inline-flex items-center">
<div class="inline-flex items-center"> {{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> </div>
</div> </th>
</th> </tr>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)"> <tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)">
<td class="hidden md:table-cell py-1 max-w-6 relative"> <td class="hidden md:table-cell py-1 max-w-6 relative">
<ui-checkbox v-model="session.selected" small checkbox-bg="bg" /> <ui-checkbox v-model="session.selected" small checkbox-bg="bg" />
<!-- overlay of the checkbox so that the entire box is clickable --> <!-- overlay of the checkbox so that the entire box is clickable -->
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" /> <div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
</td> </td>
<td class="py-1 grow sm:grow-0 sm:w-48 sm:max-w-48"> <td class="py-1 grow sm:grow-0 sm:w-48 sm:max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p> <p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p> <p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td> </td>
<td class="hidden md:table-cell w-20 min-w-20"> <td class="hidden md:table-cell w-20 min-w-20">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p> <p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p> <p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td> </td>
<td class="hidden md:table-cell w-26 min-w-26"> <td class="hidden md:table-cell w-26 min-w-26">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p> <p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td> </td>
<td class="hidden sm:table-cell max-w-32 min-w-32"> <td class="hidden sm:table-cell max-w-32 min-w-32">
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" /> <p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
</td> </td>
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32"> <td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p> <p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td> </td>
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)"> <td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td> </td>
<td class="text-center hidden sm:table-cell"> <td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)"> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p> <p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip> </ui-tooltip>
</td> </td>
</tr> </tr>
</table> </table>
</div>
<!-- table bottom options --> <!-- table bottom options -->
<div class="flex items-center my-2"> <div class="flex items-center my-2">
<div class="grow" /> <div class="grow" />
@ -252,10 +250,10 @@ export default {
return user?.username || null return user?.username || null
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
timeFormat() { timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat') return this.$store.state.serverSettings.timeFormat
}, },
numSelected() { numSelected() {
return this.listeningSessions.filter((s) => s.selected).length return this.listeningSessions.filter((s) => s.selected).length

View file

@ -13,10 +13,8 @@
<widgets-online-indicator :value="!!userOnline" /> <widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1> <h1 class="text-xl pl-2">{{ username }}</h1>
</div> </div>
<div v-if="legacyToken" class="text-xs space-y-2 mt-4"> <div v-if="userToken" class="flex text-xs mt-4">
<ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy /> <ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly show-copy />
<p class="text-warning" v-html="$strings.MessageAuthenticationLegacyTokenWarning" />
</div> </div>
<div class="w-full h-px bg-white/10 my-2" /> <div class="w-full h-px bg-white/10 my-2" />
<div class="py-2"> <div class="py-2">
@ -102,11 +100,8 @@ export default {
} }
}, },
computed: { computed: {
legacyToken() {
return this.user.token
},
userToken() { userToken() {
return this.user.accessToken return this.user.token
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
@ -134,10 +129,10 @@ export default {
return this.listeningSessions.sessions[0] return this.listeningSessions.sessions[0]
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
timeFormat() { timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat') return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {

View file

@ -19,41 +19,39 @@
<div class="py-2"> <div class="py-2">
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningSessions }}</h1> <h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningSessions }}</h1>
<div v-if="listeningSessions.length"> <div v-if="listeningSessions.length">
<div class="overflow-x-auto"> <table class="userSessionsTable">
<table class="userSessionsTable"> <tr class="bg-primary/40">
<tr class="bg-primary/40"> <th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th> <th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th> <th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th> <th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th> <th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th> <th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th> </tr>
</tr> <tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)"> <td class="py-1 max-w-48">
<td class="py-1 max-w-48"> <p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p> <p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p> </td>
</td> <td class="hidden md:table-cell">
<td class="hidden md:table-cell"> <p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p> </td>
</td> <td class="hidden sm:table-cell min-w-32 max-w-32">
<td class="hidden sm:table-cell min-w-32 max-w-32"> <p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" /> </td>
</td> <td class="text-center">
<td class="text-center"> <p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p> </td>
</td> <td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)"> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> </td>
</td> <td class="text-center hidden sm:table-cell">
<td class="text-center hidden sm:table-cell"> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)"> <p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p> </ui-tooltip>
</ui-tooltip> </td>
</td> </tr>
</tr> </table>
</table>
</div>
<div class="flex items-center justify-end py-1"> <div class="flex items-center justify-end py-1">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" /> <ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p> <p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p>
@ -100,10 +98,10 @@ export default {
return this.$store.getters['users/getIsUserOnline'](this.user.id) return this.$store.getters['users/getIsUserOnline'](this.user.id)
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
timeFormat() { timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat') return this.$store.state.serverSettings.timeFormat
} }
}, },
methods: { methods: {

View file

@ -193,7 +193,7 @@ export default {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}` return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
}, },
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
@ -819,17 +819,6 @@ export default {
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
max-height: calc(6 * 1lh); 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 { #item-description.show-full {
-webkit-line-clamp: unset; -webkit-line-clamp: unset;
max-height: 999rem; max-height: 999rem;

View file

@ -10,7 +10,7 @@
</tr> </tr>
<tr v-for="narrator in narrators" :key="narrator.id"> <tr v-for="narrator in narrators" :key="narrator.id">
<td> <td>
<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> <p v-if="selectedNarrator?.id !== narrator.id" class="text-sm md:text-base text-gray-100">{{ narrator.name }}</p>
<form v-else @submit.prevent="saveClick"> <form v-else @submit.prevent="saveClick">
<ui-text-input v-model="newNarratorName" /> <ui-text-input v-model="newNarratorName" />
</form> </form>

View file

@ -141,7 +141,7 @@ export default {
return episodeIds return episodeIds
}, },
dateFormat() { dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat') return this.$store.state.serverSettings.dateFormat
} }
}, },
methods: { methods: {

View file

@ -22,7 +22,6 @@ export default {
}) })
results = { results = {
podcasts: results?.podcast || [], podcasts: results?.podcast || [],
episodes: results?.episodes || [],
books: results?.book || [], books: results?.book || [],
authors: results?.authors || [], authors: results?.authors || [],
series: results?.series || [], series: results?.series || [],
@ -62,7 +61,6 @@ export default {
}) })
this.results = { this.results = {
podcasts: results?.podcast || [], podcasts: results?.podcast || [],
episodes: results?.episodes || [],
books: results?.book || [], books: results?.book || [],
authors: results?.authors || [], authors: results?.authors || [],
series: results?.series || [], series: results?.series || [],

View file

@ -40,15 +40,6 @@
<p v-if="error" class="text-error text-center py-2">{{ error }}</p> <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"> <form v-show="login_local" @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label> <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" /> <ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
@ -94,10 +85,7 @@ export default {
MetadataPath: '', MetadataPath: '',
login_local: true, login_local: true,
login_openid: false, login_openid: false,
authFormData: null, authFormData: null
// New JWT auth system re-login flags
showNewAuthSystemMessage: false,
showNewAuthSystemAdminMessage: false
} }
}, },
watch: { watch: {
@ -189,20 +177,13 @@ export default {
require('@/plugins/chromecast.js').default(this) require('@/plugins/chromecast.js').default(this)
} }
this.$store.commit('libraries/setLastLoad', 0) // Ensure libraries get loaded again when switching users this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
this.$store.commit('libraries/setCurrentLibrary', { id: userDefaultLibraryId })
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
// Access token only returned from login, not authorize
if (user.accessToken) {
this.$store.commit('user/setAccessToken', user.accessToken)
}
this.$store.dispatch('user/loadUserSettings') this.$store.dispatch('user/loadUserSettings')
}, },
async submitForm() { async submitForm() {
this.error = null this.error = null
this.showNewAuthSystemMessage = false
this.showNewAuthSystemAdminMessage = false
this.processing = true this.processing = true
const payload = { const payload = {
@ -229,8 +210,6 @@ export default {
this.processing = true this.processing = true
this.$store.commit('user/setAccessToken', token)
return this.$axios return this.$axios
.$post('/api/authorize', null, { .$post('/api/authorize', null, {
headers: { headers: {
@ -238,24 +217,14 @@ export default {
} }
}) })
.then((res) => { .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.setUser(res)
this.processing = false
return true return true
}) })
.catch((error) => { .catch((error) => {
console.error('Authorize error', error) console.error('Authorize error', error)
return false
})
.finally(() => {
this.processing = false this.processing = false
return false
}) })
}, },
checkStatus() { checkStatus() {
@ -299,8 +268,8 @@ export default {
} }
if (authMethods.includes('openid')) { if (authMethods.includes('openid')) {
// Auto redirect unless query string ?autoLaunch=0 OR when explicity requested through ?autoLaunch=1 // Auto redirect unless query string ?autoLaunch=0
if ((this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') || this.$route.query?.autoLaunch == '1') { if (this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') {
window.location.href = this.openidAuthUri window.location.href = this.openidAuthUri
} }
@ -311,9 +280,8 @@ export default {
} }
}, },
async mounted() { async mounted() {
// Token passed as query parameter after successful oidc login if (this.$route.query?.setToken) {
if (this.$route.query?.accessToken) { localStorage.setItem('token', this.$route.query.setToken)
localStorage.setItem('token', this.$route.query.accessToken)
} }
if (localStorage.getItem('token')) { if (localStorage.getItem('token')) {
if (await this.checkAuth()) return // if valid user no need to check status if (await this.checkAuth()) return // if valid user no need to check status

View file

@ -155,7 +155,7 @@ export default {
}, },
providers() { providers() {
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.bookProviders return this.$store.state.scanners.providers
}, },
canFetchMetadata() { canFetchMetadata() {
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
@ -297,15 +297,6 @@ export default {
ref.setUploadStatus(status) ref.setUploadStatus(status)
} }
}, },
updateItemCardProgress(index, progress) {
var ref = this.$refs[`itemCard-${index}`]
if (ref && ref.length) ref = ref[0]
if (!ref) {
console.error('Book card ref not found', index, this.$refs)
} else {
ref.setUploadProgress(progress)
}
},
async uploadItem(item) { async uploadItem(item) {
var form = new FormData() var form = new FormData()
form.set('title', item.title) form.set('title', item.title)
@ -321,20 +312,8 @@ export default {
form.set(`${index++}`, file) form.set(`${index++}`, file)
}) })
const config = {
onUploadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) {
const progress = {
loaded: progressEvent.loaded,
total: progressEvent.total
}
this.updateItemCardProgress(item.index, progress)
}
}
}
return this.$axios return this.$axios
.$post('/api/upload', form, config) .$post('/api/upload', form)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
console.error('Failed to upload item', error) console.error('Failed to upload item', error)
@ -380,14 +359,15 @@ export default {
// Check if path already exists before starting upload // Check if path already exists before starting upload
// uploading fails if path already exists // uploading fails if path already exists
for (const item of items) { for (const item of items) {
const filepath = Path.join(this.selectedFolder.fullPath, item.directory)
const exists = await this.$axios const exists = await this.$axios
.$post(`/api/filesystem/pathexists`, { directory: item.directory, folderPath: this.selectedFolder.fullPath }) .$post(`/api/filesystem/pathexists`, { filepath, directory: item.directory, folderPath: this.selectedFolder.fullPath })
.then((data) => { .then((data) => {
if (data.exists) { if (data.exists) {
if (data.libraryItemTitle) { if (data.libraryItemTitle) {
this.$toast.error(this.$getString('ToastUploaderItemExistsInSubdirectoryError', [data.libraryItemTitle])) this.$toast.error(this.$getString('ToastUploaderItemExistsInSubdirectoryError', [data.libraryItemTitle]))
} else { } else {
this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [Path.join(this.selectedFolder.fullPath, item.directory)])) this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [filepath]))
} }
} }
return data.exists return data.exists
@ -415,8 +395,6 @@ export default {
this.setMetadataProvider() this.setMetadataProvider()
this.setDefaultFolder() this.setDefaultFolder()
// Fetch providers if not already loaded
this.$store.dispatch('scanners/fetchProviders')
window.addEventListener('dragenter', this.dragenter) window.addEventListener('dragenter', this.dragenter)
window.addEventListener('dragleave', this.dragleave) window.addEventListener('dragleave', this.dragleave)
window.addEventListener('dragover', this.dragover) window.addEventListener('dragover', this.dragover)

View file

@ -1,19 +1,4 @@
export default function ({ $axios, store, $root, app }) { export default function ({ $axios, store, $config }) {
// 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) => { $axios.onRequest((config) => {
if (!config.url) { if (!config.url) {
console.error('Axios request invalid config', config) console.error('Axios request invalid config', config)
@ -22,7 +7,7 @@ export default function ({ $axios, store, $root, app }) {
if (config.url.startsWith('http:') || config.url.startsWith('https:')) { if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return return
} }
const bearerToken = store.getters['user/getToken'] const bearerToken = store.state.user.user?.token || null
if (bearerToken) { if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}` config.headers.common['Authorization'] = `Bearer ${bearerToken}`
} }
@ -32,79 +17,9 @@ export default function ({ $axios, store, $root, app }) {
} }
}) })
$axios.onError(async (error) => { $axios.onError((error) => {
const originalRequest = error.config
const code = parseInt(error.response && error.response.status) const code = parseInt(error.response && error.response.status)
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
console.error('Axios error', code, message) 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)
}) })
} }

View file

@ -5,8 +5,6 @@ import { supplant } from './utils'
const defaultCode = 'en-us' const defaultCode = 'en-us'
const languageCodeMap = { const languageCodeMap = {
ar: { label: 'عربي', dateFnsLocale: 'ar' },
be: { label: 'Беларуская', dateFnsLocale: 'be' },
bg: { label: 'Български', dateFnsLocale: 'bg' }, bg: { label: 'Български', dateFnsLocale: 'bg' },
bn: { label: 'বাংলা', dateFnsLocale: 'bn' }, bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
ca: { label: 'Català', dateFnsLocale: 'ca' }, ca: { label: 'Català', dateFnsLocale: 'ca' },
@ -23,16 +21,13 @@ const languageCodeMap = {
it: { label: 'Italiano', dateFnsLocale: 'it' }, it: { label: 'Italiano', dateFnsLocale: 'it' },
lt: { label: 'Lietuvių', dateFnsLocale: 'lt' }, lt: { label: 'Lietuvių', dateFnsLocale: 'lt' },
hu: { label: 'Magyar', dateFnsLocale: 'hu' }, hu: { label: 'Magyar', dateFnsLocale: 'hu' },
ko: { label: '한국어', dateFnsLocale: 'ko' },
nl: { label: 'Nederlands', dateFnsLocale: 'nl' }, nl: { label: 'Nederlands', dateFnsLocale: 'nl' },
no: { label: 'Norsk', dateFnsLocale: 'no' }, no: { label: 'Norsk', dateFnsLocale: 'no' },
pl: { label: 'Polski', dateFnsLocale: 'pl' }, pl: { label: 'Polski', dateFnsLocale: 'pl' },
'pt-br': { label: 'Português (Brasil)', dateFnsLocale: 'ptBR' }, 'pt-br': { label: 'Português (Brasil)', dateFnsLocale: 'ptBR' },
ru: { label: 'Русский', dateFnsLocale: 'ru' }, ru: { label: 'Русский', dateFnsLocale: 'ru' },
sk: { label: 'Slovenčina', dateFnsLocale: 'sk' },
sl: { label: 'Slovenščina', dateFnsLocale: 'sl' }, sl: { label: 'Slovenščina', dateFnsLocale: 'sl' },
sv: { label: 'Svenska', dateFnsLocale: 'sv' }, sv: { label: 'Svenska', dateFnsLocale: 'sv' },
tr: { label: 'Türkçe', dateFnsLocale: 'tr' },
uk: { label: 'Українська', dateFnsLocale: 'uk' }, uk: { label: 'Українська', dateFnsLocale: 'uk' },
'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' }, 'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' },
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' }, 'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
@ -50,7 +45,6 @@ const podcastSearchRegionMap = {
au: { label: 'Australia' }, au: { label: 'Australia' },
br: { label: 'Brasil' }, br: { label: 'Brasil' },
be: { label: 'België / Belgique / Belgien' }, be: { label: 'België / Belgique / Belgien' },
by: { label: 'Беларусь' },
cz: { label: 'Česko' }, cz: { label: 'Česko' },
dk: { label: 'Danmark' }, dk: { label: 'Danmark' },
de: { label: 'Deutschland' }, de: { label: 'Deutschland' },
@ -70,7 +64,6 @@ const podcastSearchRegionMap = {
pt: { label: 'Portugal' }, pt: { label: 'Portugal' },
ru: { label: 'Россия' }, ru: { label: 'Россия' },
ch: { label: 'Schweiz / Suisse / Svizzera' }, ch: { label: 'Schweiz / Suisse / Svizzera' },
sk: { label: 'Slovensko' },
se: { label: 'Sverige' }, se: { label: 'Sverige' },
vn: { label: 'Việt Nam' }, vn: { label: 'Việt Nam' },
ua: { label: 'Україна' }, ua: { label: 'Україна' },

View file

@ -37,48 +37,6 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false, useMilliseconds =
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}` return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
} }
Vue.prototype.$elapsedPrettyLocalized = (seconds, useFullNames = false, useMilliseconds = false) => {
if (isNaN(seconds) || seconds === null) return ''
try {
const df = new Intl.DurationFormat(Vue.prototype.$languageCodes.current, {
style: useFullNames ? 'long' : 'short'
})
const duration = {}
if (seconds < 60) {
if (useMilliseconds && seconds < 1) {
duration.milliseconds = Math.floor(seconds * 1000)
} else {
duration.seconds = Math.floor(seconds)
}
} else if (seconds < 3600) {
// 1 hour
duration.minutes = Math.floor(seconds / 60)
} else if (seconds < 86400) {
// 1 day
duration.hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (minutes > 0) {
duration.minutes = minutes
}
} else {
duration.days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
if (hours > 0) {
duration.hours = hours
}
}
return df.format(duration)
} catch (error) {
// Handle not supported
console.warn('Intl.DurationFormat not supported, not localizing duration')
return Vue.prototype.$elapsedPretty(seconds, useFullNames, useMilliseconds)
}
}
Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => { Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {
if (!seconds) { if (!seconds) {
return alwaysIncludeHours ? '00:00:00' : '0:00' return alwaysIncludeHours ? '00:00:00' : '0:00'

View file

@ -117,6 +117,7 @@ export const actions = {
const library = data.library const library = data.library
const filterData = data.filterdata const filterData = data.filterdata
const issues = data.issues || 0 const issues = data.issues || 0
const customMetadataProviders = data.customMetadataProviders || []
const numUserPlaylists = data.numUserPlaylists const numUserPlaylists = data.numUserPlaylists
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true }) dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
@ -130,7 +131,9 @@ export const actions = {
commit('setLibraryIssues', issues) commit('setLibraryIssues', issues)
commit('setLibraryFilterData', filterData) commit('setLibraryFilterData', filterData)
commit('setNumUserPlaylists', numUserPlaylists) commit('setNumUserPlaylists', numUserPlaylists)
commit('setCurrentLibrary', { id: libraryId }) commit('scanners/setCustomMetadataProviders', customMetadataProviders, { root: true })
commit('setCurrentLibrary', libraryId)
return data return data
}) })
.catch((error) => { .catch((error) => {
@ -156,7 +159,7 @@ export const actions = {
.$get(`/api/libraries`) .$get(`/api/libraries`)
.then((data) => { .then((data) => {
commit('set', data.libraries) commit('set', data.libraries)
commit('setLastLoad', new Date()) commit('setLastLoad')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
@ -173,14 +176,14 @@ export const mutations = {
setFoldersLastUpdate(state) { setFoldersLastUpdate(state) {
state.folderLastUpdate = Date.now() state.folderLastUpdate = Date.now()
}, },
setLastLoad(state, date) { setLastLoad(state) {
state.lastLoad = date state.lastLoad = Date.now()
}, },
setLibraryIssues(state, val) { setLibraryIssues(state, val) {
state.issues = val state.issues = val
}, },
setCurrentLibrary(state, { id }) { setCurrentLibrary(state, val) {
state.currentLibraryId = id state.currentLibraryId = val
}, },
set(state, libraries) { set(state, libraries) {
state.libraries = libraries state.libraries = libraries

View file

@ -1,60 +1,126 @@
export const state = () => ({ export const state = () => ({
bookProviders: [], providers: [
podcastProviders: [], {
bookCoverProviders: [], text: 'Google Books',
podcastCoverProviders: [], value: 'google'
providersLoaded: false },
{
text: 'Open Library',
value: 'openlibrary'
},
{
text: 'iTunes',
value: 'itunes'
},
{
text: 'Audible.com',
value: 'audible'
},
{
text: 'Audible.ca',
value: 'audible.ca'
},
{
text: 'Audible.co.uk',
value: 'audible.uk'
},
{
text: 'Audible.com.au',
value: 'audible.au'
},
{
text: 'Audible.fr',
value: 'audible.fr'
},
{
text: 'Audible.de',
value: 'audible.de'
},
{
text: 'Audible.co.jp',
value: 'audible.jp'
},
{
text: 'Audible.it',
value: 'audible.it'
},
{
text: 'Audible.co.in',
value: 'audible.in'
},
{
text: 'Audible.es',
value: 'audible.es'
},
{
text: 'FantLab.ru',
value: 'fantlab'
}
],
podcastProviders: [
{
text: 'iTunes',
value: 'itunes'
}
],
coverOnlyProviders: [
{
text: 'AudiobookCovers.com',
value: 'audiobookcovers'
}
]
}) })
export const getters = { export const getters = {
checkBookProviderExists: (state) => (providerValue) => { checkBookProviderExists: state => (providerValue) => {
return state.bookProviders.some((p) => p.value === providerValue) return state.providers.some(p => p.value === providerValue)
}, },
checkPodcastProviderExists: (state) => (providerValue) => { checkPodcastProviderExists: state => (providerValue) => {
return state.podcastProviders.some((p) => p.value === providerValue) return state.podcastProviders.some(p => p.value === providerValue)
},
areProvidersLoaded: (state) => state.providersLoaded
}
export const actions = {
async fetchProviders({ commit, state }) {
// Only fetch if not already loaded
if (state.providersLoaded) {
return
}
try {
const response = await this.$axios.$get('/api/search/providers')
if (response?.providers) {
commit('setAllProviders', response.providers)
}
} catch (error) {
console.error('Failed to fetch providers', error)
}
},
async refreshProviders({ commit, state }) {
// if providers are not loaded, do nothing - they will be fetched when required (
if (!state.providersLoaded) {
return
}
try {
const response = await this.$axios.$get('/api/search/providers')
if (response?.providers) {
commit('setAllProviders', response.providers)
}
} catch (error) {
console.error('Failed to refresh providers', error)
}
} }
} }
export const actions = {}
export const mutations = { export const mutations = {
setAllProviders(state, providers) { addCustomMetadataProvider(state, provider) {
state.bookProviders = providers.books || [] if (provider.mediaType === 'book') {
state.podcastProviders = providers.podcasts || [] if (state.providers.some(p => p.value === provider.slug)) return
state.bookCoverProviders = providers.booksCovers || [] state.providers.push({
state.podcastCoverProviders = providers.podcasts || [] // Use same as bookCovers since podcasts use iTunes only text: provider.name,
state.providersLoaded = true value: provider.slug
})
} else {
if (state.podcastProviders.some(p => p.value === provider.slug)) return
state.podcastProviders.push({
text: provider.name,
value: provider.slug
})
}
},
removeCustomMetadataProvider(state, provider) {
if (provider.mediaType === 'book') {
state.providers = state.providers.filter(p => p.value !== provider.slug)
} else {
state.podcastProviders = state.podcastProviders.filter(p => p.value !== provider.slug)
}
},
setCustomMetadataProviders(state, providers) {
if (!providers?.length) return
const mediaType = providers[0].mediaType
if (mediaType === 'book') {
// clear previous values, and add new values to the end
state.providers = state.providers.filter((p) => !p.value.startsWith('custom-'))
state.providers = [
...state.providers,
...providers.map((p) => ({
text: p.name,
value: p.slug
}))
]
} else {
// Podcast providers not supported yet
}
} }
} }

View file

@ -1,6 +1,5 @@
export const state = () => ({ export const state = () => ({
user: null, user: null,
accessToken: null,
settings: { settings: {
orderBy: 'media.metadata.title', orderBy: 'media.metadata.title',
orderDesc: false, orderDesc: false,
@ -26,19 +25,19 @@ export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root', getIsRoot: (state) => state.user && state.user.type === 'root',
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
getToken: (state) => { getToken: (state) => {
return state.accessToken || null return state.user?.token || null
}, },
getUserMediaProgress: getUserMediaProgress:
(state) => (state) =>
(libraryItemId, episodeId = null) => { (libraryItemId, episodeId = null) => {
if (!state.user?.mediaProgress) return null if (!state.user.mediaProgress) return null
return state.user.mediaProgress.find((li) => { return state.user.mediaProgress.find((li) => {
if (episodeId && li.episodeId !== episodeId) return false if (episodeId && li.episodeId !== episodeId) return false
return li.libraryItemId == libraryItemId return li.libraryItemId == libraryItemId
}) })
}, },
getUserBookmarksForItem: (state) => (libraryItemId) => { getUserBookmarksForItem: (state) => (libraryItemId) => {
if (!state.user?.bookmarks) return [] if (!state.user.bookmarks) return []
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId) return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
}, },
getUserSetting: (state) => (key) => { getUserSetting: (state) => (key) => {
@ -59,9 +58,6 @@ export const getters = {
getUserCanAccessAllLibraries: (state) => { getUserCanAccessAllLibraries: (state) => {
return !!state.user?.permissions?.accessAllLibraries return !!state.user?.permissions?.accessAllLibraries
}, },
getUserCanAccessExplicitContent: (state) => {
return !!state.user?.permissions?.accessExplicitContent
},
getLibrariesAccessible: (state, getters) => { getLibrariesAccessible: (state, getters) => {
if (!state.user) return [] if (!state.user) return []
if (getters.getUserCanAccessAllLibraries) return [] if (getters.getUserCanAccessAllLibraries) return []
@ -92,7 +88,7 @@ export const actions = {
if (state.settings.orderBy == 'media.duration') { if (state.settings.orderBy == 'media.duration') {
settingsUpdate.orderBy = 'media.numTracks' settingsUpdate.orderBy = 'media.numTracks'
} }
if (state.settings.orderBy == 'media.metadata.publishedYear' || state.settings.orderBy == 'progress') { if (state.settings.orderBy == 'media.metadata.publishedYear') {
settingsUpdate.orderBy = 'media.metadata.title' settingsUpdate.orderBy = 'media.metadata.title'
} }
const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'publishedDecades', 'languages', 'progress', 'issues', 'ebooks', 'abridged'] const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'publishedDecades', 'languages', 'progress', 'issues', 'ebooks', 'abridged']
@ -146,42 +142,22 @@ export const actions = {
} catch (error) { } catch (error) {
console.error('Failed to load userSettings from local storage', 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('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 = { export const mutations = {
setUser(state, user) { setUser(state, user) {
state.user = user state.user = user
}, if (user) {
setAccessToken(state, token) { if (user.token) localStorage.setItem('token', user.token)
if (!token) {
localStorage.removeItem('token')
state.accessToken = null
} else { } else {
state.accessToken = token localStorage.removeItem('token')
localStorage.setItem('token', token)
} }
}, },
setUserToken(state, token) {
state.user.token = token
localStorage.setItem('token', token)
},
updateMediaProgress(state, { id, data }) { updateMediaProgress(state, { id, data }) {
if (!state.user) return if (!state.user) return
if (!data) { if (!data) {

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
{ {
"ButtonAdd": "Дадаць", "ButtonAdd": "Дадаць",
"ButtonAddApiKey": "Дадаць API-ключ",
"ButtonAddChapters": "Дадаць раздзелы", "ButtonAddChapters": "Дадаць раздзелы",
"ButtonAddDevice": "Дадаць прыладу", "ButtonAddDevice": "Дадаць прыладу",
"ButtonAddLibrary": "Дадаць бібліятэку", "ButtonAddLibrary": "Дадаць бібліятэку",
@ -21,7 +20,6 @@
"ButtonChooseAFolder": "Выбраць тэчку", "ButtonChooseAFolder": "Выбраць тэчку",
"ButtonChooseFiles": "Выбраць файлы", "ButtonChooseFiles": "Выбраць файлы",
"ButtonClearFilter": "Ачысціць фільтр", "ButtonClearFilter": "Ачысціць фільтр",
"ButtonClose": "Закрыць",
"ButtonCloseFeed": "Закрыць стужку", "ButtonCloseFeed": "Закрыць стужку",
"ButtonCloseSession": "Закрыць адкрыты сеанс", "ButtonCloseSession": "Закрыць адкрыты сеанс",
"ButtonCollections": "Калекцыі", "ButtonCollections": "Калекцыі",
@ -71,7 +69,7 @@
"ButtonQueueAddItem": "Дадаць у чаргу", "ButtonQueueAddItem": "Дадаць у чаргу",
"ButtonQueueRemoveItem": "Выдаліць з чаргі", "ButtonQueueRemoveItem": "Выдаліць з чаргі",
"ButtonQuickEmbed": "Хуткае ўбудаванне", "ButtonQuickEmbed": "Хуткае ўбудаванне",
"ButtonQuickEmbedMetadata": "Хуткае ўбудаванне метададзеных", "ButtonQuickEmbedMetadata": "Хуткае ўбудаванне метаданых",
"ButtonQuickMatch": "Хуткі пошук", "ButtonQuickMatch": "Хуткі пошук",
"ButtonReScan": "Паўторнае сканаванне", "ButtonReScan": "Паўторнае сканаванне",
"ButtonRead": "Чытаць", "ButtonRead": "Чытаць",
@ -100,9 +98,8 @@
"ButtonSetChaptersFromTracks": "Усталяваць раздзелы з трэкаў", "ButtonSetChaptersFromTracks": "Усталяваць раздзелы з трэкаў",
"ButtonShare": "Падзяліцца", "ButtonShare": "Падзяліцца",
"ButtonShiftTimes": "Карэкцыя часу", "ButtonShiftTimes": "Карэкцыя часу",
"ButtonShow": "Паказаць",
"ButtonStartM4BEncode": "Пачаць кадзіраванне ў M4B", "ButtonStartM4BEncode": "Пачаць кадзіраванне ў M4B",
"ButtonStartMetadataEmbed": "Пачаць убудаванне метададзеных", "ButtonStartMetadataEmbed": "Пачаць убудаванне метаданых",
"ButtonStats": "Статыстыка", "ButtonStats": "Статыстыка",
"ButtonSubmit": "Адправіць", "ButtonSubmit": "Адправіць",
"ButtonTest": "Тэст", "ButtonTest": "Тэст",
@ -110,7 +107,7 @@
"ButtonUpload": "Загрузіць", "ButtonUpload": "Загрузіць",
"ButtonUploadBackup": "Загрузіць рэзервовую копію", "ButtonUploadBackup": "Загрузіць рэзервовую копію",
"ButtonUploadCover": "Загрузіць вокладку", "ButtonUploadCover": "Загрузіць вокладку",
"ButtonUploadOPMLFile": "Загрузіць файл OPML", "ButtonUploadOPMLFile": "Загрузіць OPML файл",
"ButtonUserDelete": "Выдаліць карыстальніка {0}", "ButtonUserDelete": "Выдаліць карыстальніка {0}",
"ButtonUserEdit": "Рэдагаваць карыстальніка {0}", "ButtonUserEdit": "Рэдагаваць карыстальніка {0}",
"ButtonViewAll": "Прагледзець усе", "ButtonViewAll": "Прагледзець усе",
@ -119,9 +116,8 @@
"ErrorUploadFetchMetadataNoResults": "Не ўдалося атрымаць метададзеныя паспрабуйце абнавіць назву і/або аўтара", "ErrorUploadFetchMetadataNoResults": "Не ўдалося атрымаць метададзеныя паспрабуйце абнавіць назву і/або аўтара",
"ErrorUploadLacksTitle": "Павінна быць назва", "ErrorUploadLacksTitle": "Павінна быць назва",
"HeaderAccount": "Уліковы запіс", "HeaderAccount": "Уліковы запіс",
"HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метададзенных", "HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метаданных",
"HeaderAdvanced": "Дадаткова", "HeaderAdvanced": "Дадаткова",
"HeaderApiKeys": "API-ключы",
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise", "HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
"HeaderAudioTracks": "Аўдыядарожкі", "HeaderAudioTracks": "Аўдыядарожкі",
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг", "HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
@ -161,11 +157,9 @@
"HeaderManageGenres": "Кіраванне жанрамі", "HeaderManageGenres": "Кіраванне жанрамі",
"HeaderManageTags": "Кіраванне тэгамі", "HeaderManageTags": "Кіраванне тэгамі",
"HeaderMapDetails": "Падрабязнасці адлюстравання", "HeaderMapDetails": "Падрабязнасці адлюстравання",
"HeaderMatch": "Супадзенне",
"HeaderMetadataOrderOfPrecedence": "Парадак прыярытэтнасці метададзеных", "HeaderMetadataOrderOfPrecedence": "Парадак прыярытэтнасці метададзеных",
"HeaderMetadataToEmbed": "Метададзеныя для ўбудавання", "HeaderMetadataToEmbed": "Метададзеныя для ўбудавання",
"HeaderNewAccount": "Новы ўліковы запіс", "HeaderNewAccount": "Новы ўліковы запіс",
"HeaderNewApiKey": "Новы API-ключ",
"HeaderNewLibrary": "Новая бібліятэка", "HeaderNewLibrary": "Новая бібліятэка",
"HeaderNotificationCreate": "Стварыць апавяшчэнне", "HeaderNotificationCreate": "Стварыць апавяшчэнне",
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне", "HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
@ -181,10 +175,9 @@
"HeaderPlaylist": "Спіс прайгравання", "HeaderPlaylist": "Спіс прайгравання",
"HeaderPlaylistItems": "Элементы спіса прайгравання", "HeaderPlaylistItems": "Элементы спіса прайгравання",
"HeaderPodcastsToAdd": "Падкасты для дадання", "HeaderPodcastsToAdd": "Падкасты для дадання",
"HeaderPresets": "Прадустаноўкі",
"HeaderPreviewCover": "Прадпрагляд вокладкі", "HeaderPreviewCover": "Прадпрагляд вокладкі",
"HeaderRSSFeedGeneral": "Падрабязнасці RSS", "HeaderRSSFeedGeneral": "Падрабязнасці RSS",
"HeaderRSSFeedIsOpen": "RSS-стужка адкрытая", "HeaderRSSFeedIsOpen": "RSS-стужка адкрыта",
"HeaderRSSFeeds": "RSS-стужкі", "HeaderRSSFeeds": "RSS-стужкі",
"HeaderRemoveEpisode": "Выдаліць эпізод", "HeaderRemoveEpisode": "Выдаліць эпізод",
"HeaderRemoveEpisodes": "Выдаліць {0} эпізодаў", "HeaderRemoveEpisodes": "Выдаліць {0} эпізодаў",
@ -210,7 +203,6 @@
"HeaderTableOfContents": "Змест", "HeaderTableOfContents": "Змест",
"HeaderTools": "Інструменты", "HeaderTools": "Інструменты",
"HeaderUpdateAccount": "Абнавіць уліковы запіс", "HeaderUpdateAccount": "Абнавіць уліковы запіс",
"HeaderUpdateApiKey": "Абнавіць API-ключ",
"HeaderUpdateAuthor": "Абнавіць аўтара", "HeaderUpdateAuthor": "Абнавіць аўтара",
"HeaderUpdateDetails": "Абнавіць падрабязнасці", "HeaderUpdateDetails": "Абнавіць падрабязнасці",
"HeaderUpdateLibrary": "Абнавіць бібліятэку", "HeaderUpdateLibrary": "Абнавіць бібліятэку",
@ -235,15 +227,10 @@
"LabelAddedDate": "Дададзена {0}", "LabelAddedDate": "Дададзена {0}",
"LabelAdminUsersOnly": "Толькі для адміністратараў", "LabelAdminUsersOnly": "Толькі для адміністратараў",
"LabelAll": "Усе", "LabelAll": "Усе",
"LabelAllEpisodesDownloaded": "Усе эпізоды спампаваныя",
"LabelAllUsers": "Усе карыстальнікі", "LabelAllUsers": "Усе карыстальнікі",
"LabelAllUsersExcludingGuests": "Усе карыстальнікі, акрамя гасцей", "LabelAllUsersExcludingGuests": "Усе карыстальнікі, акрамя гасцей",
"LabelAllUsersIncludingGuests": "Усе карыстальнікі, уключаючы гасцей", "LabelAllUsersIncludingGuests": "Усе карыстальнікі, уключаючы гасцей",
"LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы", "LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы",
"LabelApiKeyCreated": "API-ключ \"{0}\" паспяхова створаны.",
"LabelApiKeyCreatedDescription": "Пераканайцеся, што вы скапіявалі API-ключ зараз, бо паўторна яго ўбачыць не атрымаецца.",
"LabelApiKeyUser": "Дзейнічаць ад імя карыстальніка",
"LabelApiKeyUserDescription": "Гэты API-ключ будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
"LabelApiToken": "Токен API", "LabelApiToken": "Токен API",
"LabelAppend": "Дадаць", "LabelAppend": "Дадаць",
"LabelAudioBitrate": "Бітрэйт аўдыё (напрыклад, 128к)", "LabelAudioBitrate": "Бітрэйт аўдыё (напрыклад, 128к)",
@ -255,108 +242,39 @@
"LabelAuthors": "Аўтары", "LabelAuthors": "Аўтары",
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў", "LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў",
"LabelAutoFetchMetadata": "Аўтаматычнае атрыманне метададзеных", "LabelAutoFetchMetadata": "Аўтаматычнае атрыманне метададзеных",
"LabelAutoFetchMetadataHelp": "Атрыманне звестак пра назву, аўтара і серыю для падыходнага фарматавання перад загрузкай. Далей можа быць неабходна дапоўніць метададзеныя.",
"LabelAutoLaunch": "Аўтазапуск",
"LabelAutoLaunchDescription": "Аўтаматычна перанакіроўваць да пастаўшчыка аўтэнтыфікацыі пры переходзе на старонку ўваходу (ручное пераключэнне праз шлях <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Аўтарэгістрацыя",
"LabelAutoRegisterDescription": "Аўтаматычна ствараць новых карыстальнікаў пасля ўваходу ў сістэму",
"LabelBackToUser": "Вярнуцца да карыстальніка",
"LabelBackupAudioFiles": "Рэзервовае капіраванне аўдыёфайлаў", "LabelBackupAudioFiles": "Рэзервовае капіраванне аўдыёфайлаў",
"LabelBackupLocation": "Месцазнаходжанне рэзервовых копій", "LabelBackupLocation": "Месцазнаходжанне рэзервовых копій",
"LabelBackupsEnableAutomaticBackups": "Аўтаматычнае рэзервовае капіраванне",
"LabelBackupsEnableAutomaticBackupsHelp": "Рэзервовыя копіі захаваныя ў /metadata/backups",
"LabelBackupsMaxBackupSize": "Максімальны памер рэзервовай копіі (у ГБ) (0 — неабмежавана)",
"LabelBackupsMaxBackupSizeHelp": "Для таго, каб пазбегнуць няправільных налад, рэзервовыя копіі не будуць створаны, калі іх памер будзе больш за дапушчальны.",
"LabelBackupsNumberToKeep": "Колькасць захаваных рэзервовых копій",
"LabelBackupsNumberToKeepHelp": "Адначасова будзе выдаляцца толькі 1 рэзервовая копія, таму, калі ў вас іх больш, вам варта выдаліць іх уручную.", "LabelBackupsNumberToKeepHelp": "Адначасова будзе выдаляцца толькі 1 рэзервовая копія, таму, калі ў вас іх больш, вам варта выдаліць іх уручную.",
"LabelBitrate": "Бітрэйт",
"LabelBonus": "Бонус",
"LabelBooks": "Кнігі", "LabelBooks": "Кнігі",
"LabelButtonText": "Тэкст кнопкі",
"LabelByAuthor": "ад {0}",
"LabelChangePassword": "Змяніць пароль",
"LabelChannels": "Каналы",
"LabelChapterCount": "{0} раздзелаў",
"LabelChapterTitle": "Назва раздзела",
"LabelChapters": "Раздзелы", "LabelChapters": "Раздзелы",
"LabelChaptersFound": "раздзелаў знойдзена",
"LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі",
"LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне",
"LabelClosePlayer": "Зачыніць прайгравальнік", "LabelClosePlayer": "Зачыніць прайгравальнік",
"LabelCodec": "Кодэк",
"LabelCollapseSeries": "Згарнуць серыі", "LabelCollapseSeries": "Згарнуць серыі",
"LabelCollapseSubSeries": "Згарнуць падсерыі",
"LabelCollection": "Калекцыя",
"LabelCollections": "Калекцыі",
"LabelComplete": "Завершана", "LabelComplete": "Завершана",
"LabelConfirmPassword": "Пацвердзіце пароль",
"LabelContinueListening": "Працягваць слухаць", "LabelContinueListening": "Працягваць слухаць",
"LabelContinueReading": "Працягнуць чытанне", "LabelContinueReading": "Працягнуць чытанне",
"LabelContinueSeries": "Працягнуць серыі", "LabelContinueSeries": "Працягнуць серыі",
"LabelCover": "Вокладка",
"LabelCoverImageURL": "URL выявы вокладкі",
"LabelCoverProvider": "Крыніца вокладак",
"LabelCreatedAt": "Дата стварэння",
"LabelCronExpression": "Запіс Cron",
"LabelCurrent": "Бягучы",
"LabelCurrently": "Бягучы:",
"LabelCustomCronExpression": "Уласны запіс Cron:",
"LabelDatetime": "Дата і час", "LabelDatetime": "Дата і час",
"LabelDays": "Дзён",
"LabelDeleteFromFileSystemCheckbox": "Выдаліць з файлавай сістэмы (зніміце галачку, каб выдаліць толькі з базы даных)",
"LabelDescription": "Апісанне", "LabelDescription": "Апісанне",
"LabelDeselectAll": "Скасаваць выбар усяго",
"LabelDevice": "Прылада",
"LabelDeviceInfo": "Інфармацыя пра прыладу",
"LabelDeviceIsAvailableTo": "Прылада даступная для...",
"LabelDirectory": "Каталог",
"LabelDiscFromFilename": "Дыск з імя файла", "LabelDiscFromFilename": "Дыск з імя файла",
"LabelDiscFromMetadata": "Дыск па метададзеных",
"LabelDiscover": "Знайсці", "LabelDiscover": "Знайсці",
"LabelDownload": "Спампаваць", "LabelDownload": "Спампаваць",
"LabelDownloadNEpisodes": "Спампована {0} эпізодаў", "LabelDownloadNEpisodes": "Спампована {0} эпізодаў",
"LabelDownloadable": "Спампоўваецца", "LabelDownloadable": "Спампоўваецца",
"LabelDuration": "Працягласць", "LabelDuration": "Працягласць",
"LabelDurationComparisonExactMatch": "(дакладнае супадзенне)",
"LabelDurationComparisonLonger": "(на {0} даўжэй)",
"LabelDurationComparisonShorter": "(на {0} карацей)",
"LabelDurationFound": "Знойдзеная працягласць:",
"LabelEbook": "Электронная кніга", "LabelEbook": "Электронная кніга",
"LabelEbooks": "Электронныя кнігі", "LabelEbooks": "Электронныя кнігі",
"LabelEdit": "Рэдагаваць",
"LabelEmail": "Электронная пошта",
"LabelEmailSettingsFromAddress": "Адрас адпраўніка",
"LabelEmailSettingsRejectUnauthorized": "Адхіляць неаўтарызаваныя сертыфікаты",
"LabelEmailSettingsRejectUnauthorizedHelp": "Адключэнне праверкі SSL-сертыфіката можа зрабіць ваша злучэнне ўразлівым перад пагрозамі бяспекі, такімі як атакі \"чалавек пасярэдзіне\". Адключайце гэтую опцыю толькі калі цалкам разумееце наступствы і ўпэўнены ў надзейнасці паштовага сервера.",
"LabelEmailSettingsSecure": "Бяспечныя",
"LabelEmailSettingsSecureHelp": "Калі ўключана, злучэнне будзе выкарыстоўваць TLS пры падключэнні да сервера. Калі выключана, TLS будзе выкарыстоўвацца толькі ў выпадку падтрымкі пашырэння STARTTLS на серверы. У большасці выпадкаў усталюйце значэнне true пры падключэнні да порта 465. Для партоў 587 або 25 не ўключайце яго. (інфармацыя з nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Тэставы адрас",
"LabelEmbeddedCover": "Убудаваная вокладка",
"LabelEnable": "Уключыць", "LabelEnable": "Уключыць",
"LabelEncodingBackupLocation": "Рэзервовая копія вашых арыгінальных аўдыёфайлаў будзе захавана ў:", "LabelEncodingBackupLocation": "Рэзервовая копія вашых арыгінальных аўдыёфайлаў будзе захавана ў:",
"LabelEncodingChaptersNotEmbedded": "Раздзелы не ўбудаваны ў шматдарожкавыя аўдыякнігі.", "LabelEncodingChaptersNotEmbedded": "Раздзелы не ўбудаваны ў шматдарожкавыя аўдыякнігі.",
"LabelEncodingClearItemCache": "Пераканайцеся, што перыядычна ачышчаеце кэш элементаў.",
"LabelEncodingFinishedM4B": "Гатовы файл M4B будзе змешчаны ў вашу тэчку з аўдыякнігамі па адрасе:", "LabelEncodingFinishedM4B": "Гатовы файл M4B будзе змешчаны ў вашу тэчку з аўдыякнігамі па адрасе:",
"LabelEncodingInfoEmbedded": "Метададзеныя будуць убудаваны ў аўдыядарожкі ўнутры вашай тэчкі з аўдыякнігамі.", "LabelEncodingInfoEmbedded": "Метаданыя будуць убудаваны ў аўдыядарожкі ўнутры вашай тэчкі з аўдыякнігамі.",
"LabelEncodingStartedNavigation": "Пасля запуску задачы вы можаце перайсці на іншую старонку.",
"LabelEncodingTimeWarning": "Кадаванне можа заняць да 30 хвілін.", "LabelEncodingTimeWarning": "Кадаванне можа заняць да 30 хвілін.",
"LabelEnd": "Канец", "LabelEnd": "Канец",
"LabelEndOfChapter": "Канец раздзела", "LabelEndOfChapter": "Канец раздзела",
"LabelEpisode": "Эпізод", "LabelEpisode": "Эпізод",
"LabelEpisodeNotLinkedToRssFeed": "Эпізод не звязаны з RSS-стужкай", "LabelEpisodeNotLinkedToRssFeed": "Эпізод не звязаны з RSS-стужкай",
"LabelEpisodeUrlFromRssFeed": "URL эпізоду з RSS-стужкі", "LabelEpisodeUrlFromRssFeed": "URL эпізоду з RSS-стужкі",
"LabelEpisodic": "Эпізадычны",
"LabelExample": "Прыклад",
"LabelExpandSeries": "Разгарнуць серыю",
"LabelExpandSubSeries": "Разгарнуць падсерыі",
"LabelExpired": "Пратэрмінаваны",
"LabelExpiresAt": "Тэрмін дзеяння заканчваецца ў",
"LabelExpiresInSeconds": "Тэрмін дзеяння заканчваецца праз (секунд)",
"LabelExpiresNever": "Ніколі",
"LabelExplicit": "Відверты",
"LabelExportOPML": "Экспарт OPML",
"LabelFeedURL": "URL стужкі", "LabelFeedURL": "URL стужкі",
"LabelFetchingMetadata": "Атрыманне метададзеных",
"LabelFile": "Файл", "LabelFile": "Файл",
"LabelFileBirthtime": "Час стварэння файла", "LabelFileBirthtime": "Час стварэння файла",
"LabelFileModified": "Час змянення файла", "LabelFileModified": "Час змянення файла",
@ -371,7 +289,6 @@
"LabelHasSupplementaryEbook": "Мае дадатковую электронную кнігу", "LabelHasSupplementaryEbook": "Мае дадатковую электронную кнігу",
"LabelHideSubtitles": "Схаваць падзагалоўкі", "LabelHideSubtitles": "Схаваць падзагалоўкі",
"LabelHost": "Хост", "LabelHost": "Хост",
"LabelImageURLFromTheWeb": "URL выявы з інтэрнэту",
"LabelInProgress": "У працэсе", "LabelInProgress": "У працэсе",
"LabelIncomplete": "Незавершана", "LabelIncomplete": "Незавершана",
"LabelIntervalCustomDailyWeekly": "Карыстальніцкі штодзённы/штотыднёвы", "LabelIntervalCustomDailyWeekly": "Карыстальніцкі штодзённы/штотыднёвы",
@ -402,7 +319,6 @@
"LabelLibraryFilterSublistEmpty": "Не {0}", "LabelLibraryFilterSublistEmpty": "Не {0}",
"LabelLibraryItem": "Элемент бібліятэкі", "LabelLibraryItem": "Элемент бібліятэкі",
"LabelLibraryName": "Імя бібліятэкі", "LabelLibraryName": "Імя бібліятэкі",
"LabelLibrarySortByProgress": "Прагрэс абноўлены",
"LabelLimit": "Абмежаванне", "LabelLimit": "Абмежаванне",
"LabelLineSpacing": "Міжрадковы інтэрвал", "LabelLineSpacing": "Міжрадковы інтэрвал",
"LabelListenAgain": "Паслухаць зноў", "LabelListenAgain": "Паслухаць зноў",
@ -411,8 +327,6 @@
"LabelMaxEpisodesToKeepHelp": "Значэнне 0 не ўстанаўлівае максімальнага абмежавання. Пасля аўтаматычнай спампоўкі новага эпізоду будзе выдалены самы стары эпізод, калі ў вас больш за X эпізодаў. Пры кожнай новай спампоўцы будзе выдаляцца толькі 1 эпізод.", "LabelMaxEpisodesToKeepHelp": "Значэнне 0 не ўстанаўлівае максімальнага абмежавання. Пасля аўтаматычнай спампоўкі новага эпізоду будзе выдалены самы стары эпізод, калі ў вас больш за X эпізодаў. Пры кожнай новай спампоўцы будзе выдаляцца толькі 1 эпізод.",
"LabelMediaPlayer": "Медыяпрайгравальнік", "LabelMediaPlayer": "Медыяпрайгравальнік",
"LabelMediaType": "Тып медыя", "LabelMediaType": "Тып медыя",
"LabelMetadataOrderOfPrecedenceDescription": "Крыніцы метададзеных з вышэйшым прыярытэтам будуць замяняць крыніцы з ніжэйшым прыярытэтам",
"LabelMetadataProvider": "Пастаўшчык метададзеных",
"LabelMissing": "Адсутнічае", "LabelMissing": "Адсутнічае",
"LabelMore": "Больш", "LabelMore": "Больш",
"LabelMoreInfo": "Больш інфармацыі", "LabelMoreInfo": "Больш інфармацыі",
@ -421,7 +335,6 @@
"LabelNarrators": "Чытальнікі", "LabelNarrators": "Чытальнікі",
"LabelNewestAuthors": "Новыя аўтары", "LabelNewestAuthors": "Новыя аўтары",
"LabelNewestEpisodes": "Новыя эпізоды", "LabelNewestEpisodes": "Новыя эпізоды",
"LabelNoCustomMetadataProviders": "Няма карыстацкіх пастаўшчыкоў метададзеных",
"LabelNotFinished": "Не скончана", "LabelNotFinished": "Не скончана",
"LabelNotStarted": "Не пачата", "LabelNotStarted": "Не пачата",
"LabelNotificationsMaxFailedAttemptsHelp": "Апавяшчэнні адключаюцца пасля таго, як не ўдаецца іх адправіць гэтулькі разоў", "LabelNotificationsMaxFailedAttemptsHelp": "Апавяшчэнні адключаюцца пасля таго, як не ўдаецца іх адправіць гэтулькі разоў",
@ -440,7 +353,7 @@
"LabelPublishedDate": "Апублікавана {0}", "LabelPublishedDate": "Апублікавана {0}",
"LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка", "LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка",
"LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка", "LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка",
"LabelRSSFeedOpen": "RSS-стужка адкрыта", "LabelRSSFeedOpen": "RSS-стужка адкрытая",
"LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю", "LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю",
"LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі", "LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі",
"LabelRSSFeedURL": "URL RSS-стужкі", "LabelRSSFeedURL": "URL RSS-стужкі",
@ -479,7 +392,6 @@
"LabelSettingsAudiobooksOnly": "Толькі аўдыякнігі", "LabelSettingsAudiobooksOnly": "Толькі аўдыякнігі",
"LabelSettingsAudiobooksOnlyHelp": "Уключэнне гэтай налады будзе ігнараваць файлы электронных кніг, калі толькі яны не знаходзяцца ў тэчцы з аўдыякнігамі. У такім выпадку яны будуць пазначаны як дадатковыя электронныя кнігі.", "LabelSettingsAudiobooksOnlyHelp": "Уключэнне гэтай налады будзе ігнараваць файлы электронных кніг, калі толькі яны не знаходзяцца ў тэчцы з аўдыякнігамі. У такім выпадку яны будуць пазначаны як дадатковыя электронныя кнігі.",
"LabelSettingsBookshelfViewHelp": "Рэалістычны дызайн з драўлянымі паліцамі", "LabelSettingsBookshelfViewHelp": "Рэалістычны дызайн з драўлянымі паліцамі",
"LabelSettingsEnableWatcherForLibrary": "Аўтаматычна правяраць бібліятэку на змены",
"LabelSettingsEnableWatcherHelp": "Адключае аўтаматычнае дадаванне/абнаўленне элементаў пры выяўленні змен у файлах. *Патрабуецца перазапуск сервера", "LabelSettingsEnableWatcherHelp": "Адключае аўтаматычнае дадаванне/абнаўленне элементаў пры выяўленні змен у файлах. *Патрабуецца перазапуск сервера",
"LabelSettingsEpubsAllowScriptedContent": "Дазволіць скрыптавы кантэнт у EPUB", "LabelSettingsEpubsAllowScriptedContent": "Дазволіць скрыптавы кантэнт у EPUB",
"LabelSettingsEpubsAllowScriptedContentHelp": "Дазволіць EPUB-файлам выконваць скрыпты. Рэкамендуецца пакінуць гэтую наладу выключанай, калі вы не давяраеце крыніцы EPUB-файлаў.", "LabelSettingsEpubsAllowScriptedContentHelp": "Дазволіць EPUB-файлам выконваць скрыпты. Рэкамендуецца пакінуць гэтую наладу выключанай, калі вы не давяраеце крыніцы EPUB-файлаў.",
@ -497,11 +409,6 @@
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Палка \"Працягнуць серыю\" на галоўнай старонцы паказвае першую не пачатую кнігу ў серыях, дзе завершана хаця б адна кніга і няма кніг у працэсе чытання. Уключэнне гэтай налады дазволіць працягваць серыю з самай апошняй завершанай кнігі замест першай не пачатай.", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Палка \"Працягнуць серыю\" на галоўнай старонцы паказвае першую не пачатую кнігу ў серыях, дзе завершана хаця б адна кніга і няма кніг у працэсе чытання. Уключэнне гэтай налады дазволіць працягваць серыю з самай апошняй завершанай кнігі замест першай не пачатай.",
"LabelSettingsParseSubtitles": "Разабраць падзагалоўкі", "LabelSettingsParseSubtitles": "Разабраць падзагалоўкі",
"LabelSettingsParseSubtitlesHelp": "Выдзяляць падзагаловак з назваў тэчак аўдыякніг.<br>Падзагаловак павінен быць аддзелены знакам \" - \".<br>Напрыклад, \"Назва кнігі - Падзагаловак тут\" мае падзагаловак \"Падзагаловак тут\"", "LabelSettingsParseSubtitlesHelp": "Выдзяляць падзагаловак з назваў тэчак аўдыякніг.<br>Падзагаловак павінен быць аддзелены знакам \" - \".<br>Напрыклад, \"Назва кнігі - Падзагаловак тут\" мае падзагаловак \"Падзагаловак тут\"",
"LabelSettingsPreferMatchedMetadata": "Аддаваць перавагу супадаючым метададзеным",
"LabelSettingsPreferMatchedMetadataHelp": "Супадаючыя дадзеныя будуць замяняць дэталі элемента пры выкарыстанні функцыі Хуткі пошук. Па змаўчанні Хуткі пошук запаўняе толькі адсутныя дэталі.",
"LabelSettingsStoreCoversWithItemHelp": "Па змаўчанні вокладкі захоўваюцца ў /metadata/items, уключэнне гэтай опцыі забяспечыць захоўванне вокладак у тэчцы элемента вашай бібліятэкі. Захоўвацца будзе толькі адзін файл з назвай \"cover\"",
"LabelSettingsStoreMetadataWithItem": "Захоўваць метададзеныя разам з элементам",
"LabelSettingsStoreMetadataWithItemHelp": "Па змаўчанні метададзеныя захоўваюцца ў /metadata/items. Уключэнне гэтай опцыі забяспечыць захоўванне файлаў метададзеных у тэчках элементаў вашай бібліятэкі",
"LabelSettingsTimeFormat": "Фармат часу", "LabelSettingsTimeFormat": "Фармат часу",
"LabelShareDownloadableHelp": "Дазваляе карыстальнікам, якія маюць спасылку на доступ, спампаваць ZIP-файл элемента бібліятэкі.", "LabelShareDownloadableHelp": "Дазваляе карыстальнікам, якія маюць спасылку на доступ, спампаваць ZIP-файл элемента бібліятэкі.",
"LabelShowAll": "Паказаць усё", "LabelShowAll": "Паказаць усё",
@ -531,7 +438,7 @@
"LabelTags": "Меткі", "LabelTags": "Меткі",
"LabelTagsAccessibleToUser": "Меткі, даступныя карыстальніку", "LabelTagsAccessibleToUser": "Меткі, даступныя карыстальніку",
"LabelTagsNotAccessibleToUser": "Меткі, недаступныя карыстальніку", "LabelTagsNotAccessibleToUser": "Меткі, недаступныя карыстальніку",
"LabelTasks": "Запушчаныя задачы", "LabelTasks": "Выконваюцца задачы",
"LabelTextEditorBulletedList": "Маркіраваны спіс", "LabelTextEditorBulletedList": "Маркіраваны спіс",
"LabelTextEditorLink": "Спасылка", "LabelTextEditorLink": "Спасылка",
"LabelTextEditorNumberedList": "Нумараваны спіс", "LabelTextEditorNumberedList": "Нумараваны спіс",
@ -550,14 +457,11 @@
"LabelTimeRemaining": "Засталося {0}", "LabelTimeRemaining": "Засталося {0}",
"LabelTimeToShift": "Час зрушэння ў секундах", "LabelTimeToShift": "Час зрушэння ў секундах",
"LabelTitle": "Назва", "LabelTitle": "Назва",
"LabelToolsEmbedMetadata": "Убудаваць метададзеныя", "LabelToolsSplitM4bDescription": "Стварэнне MP3 з M4B, падзеленага па раздзелах, з убудаванымі метаданымі, вокладкай і раздзеламі.",
"LabelToolsEmbedMetadataDescription": "Убудаваць метададзеныя ў аўдыёфайлы, уключаючы выяву вокладкі і раздзелы.",
"LabelToolsMakeM4bDescription": "Стварыць аўдыёкнігу ў фармаце .M4B з убудаванымі метададзенымі, выявай вокладкі і раздзеламі.",
"LabelToolsSplitM4bDescription": "Стварэнне MP3 з M4B, падзеленага па раздзелах, з убудаванымі метададзенымі, выявай вокладкі і раздзеламі.",
"LabelTotalDuration": "Агульная працягласць", "LabelTotalDuration": "Агульная працягласць",
"LabelTotalTimeListened": "Агульны час праслухоўвання", "LabelTotalTimeListened": "Агульны час праслухоўвання",
"LabelTrackFromFilename": "Дарожка з імя файла", "LabelTrackFromFilename": "Дарожка з імя файла",
"LabelTrackFromMetadata": "Дарожка з метададзеных", "LabelTrackFromMetadata": "Дарожка з метаданых",
"LabelTracks": "Дарожкі", "LabelTracks": "Дарожкі",
"LabelTracksMultiTrack": "Шматдарожкавы", "LabelTracksMultiTrack": "Шматдарожкавы",
"LabelTracksNone": "Няма дарожак", "LabelTracksNone": "Няма дарожак",
@ -606,30 +510,19 @@
"MessageBackupsLocationPathEmpty": "Шлях да месцазнаходжання рэзервовых копій не можа быць пустым", "MessageBackupsLocationPathEmpty": "Шлях да месцазнаходжання рэзервовых копій не можа быць пустым",
"MessageBatchEditPopulateMapDetailsAllHelp": "Запоўніце ўключаныя палі дадзенымі з усіх элементаў. Палі з некалькімі значэннямі будуць аб'яднаны", "MessageBatchEditPopulateMapDetailsAllHelp": "Запоўніце ўключаныя палі дадзенымі з усіх элементаў. Палі з некалькімі значэннямі будуць аб'яднаны",
"MessageBatchEditPopulateMapDetailsItemHelp": "Запоўніце ўключаныя палі падрабязнасцей карты дадзенымі з гэтага элемента", "MessageBatchEditPopulateMapDetailsItemHelp": "Запоўніце ўключаныя палі падрабязнасцей карты дадзенымі з гэтага элемента",
"MessageBatchQuickMatchDescription": "Хуткі пошук паспрабуе дадаць адсутныя вокладкі і метададзеныя для выбраных элементаў. Уключыце ніжэй выкладзеныя опцыі, каб дазволіць Хуткаму пошуку замяняць існуючыя вокладкі і/або метададзеныя.",
"MessageBookshelfNoRSSFeeds": "Няма адкрытых RSS-стужак", "MessageBookshelfNoRSSFeeds": "Няма адкрытых RSS-стужак",
"MessageChapterErrorStartGteDuration": "Няправільны час пачатку: ён павінен быць меншым за працягласць аўдыякнігі", "MessageChapterErrorStartGteDuration": "Няправільны час пачатку: ён павінен быць меншым за працягласць аўдыякнігі",
"MessageChapterErrorStartLtPrev": "Няправільны час пачатку: ён павінен быць большым або роўным часу пачатку папярэдняга раздзела", "MessageChapterErrorStartLtPrev": "Няправільны час пачатку: ён павінен быць большым або роўным часу пачатку папярэдняга раздзела",
"MessageConfirmCloseFeed": "Вы ўпэўнены, што жадаеце закрыць гэтую стужку?", "MessageConfirmCloseFeed": "Вы ўпэўнены, што жадаеце закрыць гэтую стужку?",
"MessageConfirmDeleteMetadataProvider": "Ці ўпэўненыя вы, што жадаеце выдаліць карыстацкага пастаўшчыка метададзеных \"{0}\"?",
"MessageConfirmEmbedMetadataInAudioFiles": "Ці ўпэўненыя вы, што жадаеце ўбудаваць метададзеныя ў {0} аўдыёфайлаў?",
"MessageConfirmPurgeCache": "Ачышчэнне кэша выдаліць увесь каталог па адрасе <code>/metadata/cache</code>. <br /><br /> Ці сапраўды вы жадаеце выдаліць каталог кэша?",
"MessageConfirmPurgeItemsCache": "Ачышчэнне кэша элементаў выдаліць увесь каталог па адрасе <code>/metadata/cache/items</code>. <br /> Вы ўпэўнены?",
"MessageConfirmQuickMatchEpisodes": "Хуткае супадзенне эпізодаў перазапіша дэталі, калі супадзенне будзе знойдзена. Будуць абноўлены толькі эпізоды, якія не супадаюць. Вы ўпэўнены?",
"MessageConfirmRemoveListeningSessions": "Вы ўпэўнены, што жадаеце выдаліць {0} сеансаў праслухоўвання?", "MessageConfirmRemoveListeningSessions": "Вы ўпэўнены, што жадаеце выдаліць {0} сеансаў праслухоўвання?",
"MessageConfirmRemoveMetadataFiles": "Ці ўпэўненыя вы, што жадаеце выдаліць усе файлы метададзеных{0} у тэчках элементаў вашай бібліятэкі?",
"MessageConfirmRemovePlaylist": "Вы ўпэўненыя, што жадаеце выдаліць свой спіс прайгравання \"{0}\"?", "MessageConfirmRemovePlaylist": "Вы ўпэўненыя, што жадаеце выдаліць свой спіс прайгравання \"{0}\"?",
"MessageConfirmSendEbookToDevice": "Вы ўпэўнены, што хочаце адправіць {0} электронную кнігу \"{1}\" на прыладу \"{2}\"?", "MessageConfirmSendEbookToDevice": "Вы ўпэўнены, што хочаце адправіць {0} электронную кнігу \"{1}\" на прыладу \"{2}\"?",
"MessageDownloadingEpisode": "Спампоўка эпізоду", "MessageDownloadingEpisode": "Спампоўка эпізоду",
"MessageEmbedQueue": "У чарзе на ўбудаванне метададзеных (у чарзе {0})",
"MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі", "MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі",
"MessageEreaderDevices": "Каб забяспечыць дастаўку электронных кніг, вам можа спатрэбіцца дадаць вышэйзгаданы адрас электроннай пошты як дазволенага адпраўніка для кожнай прылады, пералічанай ніжэй.", "MessageEreaderDevices": "Каб забяспечыць дастаўку электронных кніг, вам можа спатрэбіцца дадаць вышэйзгаданы адрас электроннай пошты як дазволенага адпраўніка для кожнай прылады, пералічанай ніжэй.",
"MessageFeedURLWillBe": "URL стужкі будзе {0}", "MessageFeedURLWillBe": "URL стужкі будзе {0}",
"MessageFetching": "Атрыманне...", "MessageFetching": "Атрыманне...",
"MessageInvalidAsin": "Няправільны ASIN",
"MessageItemsUpdated": "{0} элементаў абноўлена",
"MessageLoading": "Загрузка...", "MessageLoading": "Загрузка...",
"MessageLogsDescription": "Журналы захоўваюцца ў каталогу <code>/metadata/logs</code> у фармаце JSON. Журналы памылак захоўваюцца ў файле <code>/metadata/logs/crashlogs.txt</code>.",
"MessageMapChapterTitles": "Супаставіць назвы раздзелаў з вашымі існуючымі раздзеламі аўдыякнігі без змянення часовых метак", "MessageMapChapterTitles": "Супаставіць назвы раздзелаў з вашымі існуючымі раздзеламі аўдыякнігі без змянення часовых метак",
"MessageMarkAsFinished": "Пазначыць як скончана", "MessageMarkAsFinished": "Пазначыць як скончана",
"MessageNoBookmarks": "Няма закладак", "MessageNoBookmarks": "Няма закладак",
@ -643,54 +536,26 @@
"MessageNoMediaProgress": "Няма прагрэсу медыя", "MessageNoMediaProgress": "Няма прагрэсу медыя",
"MessageNoPodcastFeed": "Няправільны падкаст: Няма стужкі", "MessageNoPodcastFeed": "Няправільны падкаст: Няма стужкі",
"MessageNoPodcastsFound": "Падкасты не знойдзены", "MessageNoPodcastsFound": "Падкасты не знойдзены",
"MessageNoTasksRunning": "Няма запушчаных задач",
"MessageNoUpdatesWereNecessary": "Абнаўленні не патрабаваліся", "MessageNoUpdatesWereNecessary": "Абнаўленні не патрабаваліся",
"MessageNoUserPlaylists": "У вас няма спісаў прайгравання", "MessageNoUserPlaylists": "У вас няма спісаў прайгравання",
"MessageNoUserPlaylistsHelp": "Спісы прайгравання прыватныя. Толькі карыстальнік, які іх стварыў, можа іх бачыць.", "MessageNoUserPlaylistsHelp": "Спісы прайгравання прыватныя. Толькі карыстальнік, які іх стварыў, можа іх бачыць.",
"MessageOpmlPreviewNote": "Заўвага: гэта папярэдні прагляд разабранага файла OPML. Фактычная назва падкаста будзе ўзятая з RSS-стужкі.", "MessageOpmlPreviewNote": "Заўвага: гэта папярэдні прагляд разабранага OPML-файла. Фактычная назва падкаста будзе ўзятая з RSS-стужкі.",
"MessagePlaylistCreateFromCollection": "Стварыць спіс прайгравання з калекцыі", "MessagePlaylistCreateFromCollection": "Стварыць спіс прайгравання з калекцыі",
"MessagePodcastHasNoRSSFeedForMatching": "У падкаста няма URL RSS-стужкі для супадзення", "MessagePodcastHasNoRSSFeedForMatching": "У падкаста няма URL RSS-стужкі для супадзення",
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі", "MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
"MessageQuickMatchDescription": "Запоўніць пустыя дэталі элемента і вокладку першым вынікам супадзення з '{0}'. Не замяняе дэталі, калі опцыя «Аддаваць перавагу супадаючым метададзеным» на серверы не ўключана.",
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на", "MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на",
"MessageRestoreBackupWarning": "Аднаўленне рэзервовай копіі перазапіша ўсю базу даных, размешчаную ў /config, а таксама выявы вокладкі ў /metadata/items і /metadata/authors. <br /><br /> Рэзервовыя копіі не змяняюць файлы ў вашых тэчках бібліятэкі. Калі вы ўключылі наладкі сервера для захоўвання воклак і метададзеных у тэчках бібліятэкі, гэтыя файлы не будуць захаваныя ў рэзервовых копіях і не зменяцца. <br /><br /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.",
"MessageScheduleRunEveryWeekdayAtTime": "Выконваць кожныя {0} у {1}", "MessageScheduleRunEveryWeekdayAtTime": "Выконваць кожныя {0} у {1}",
"MessageStartPlaybackAtTime": "Пачаць прайграванне для \"{0}\" з {1}?", "MessageStartPlaybackAtTime": "Пачаць прайграванне для \"{0}\" з {1}?",
"MessageTaskAudioFileNotWritable": "Аўдыёфайл \"{0}\" недаступны для запісу",
"MessageTaskCanceledByUser": "Задача скасавана карыстальнікам", "MessageTaskCanceledByUser": "Задача скасавана карыстальнікам",
"MessageTaskDownloadingEpisodeDescription": "Спампоўка эпізоду \"{0}\"", "MessageTaskDownloadingEpisodeDescription": "Спампоўка эпізоду \"{0}\"",
"MessageTaskEmbeddingMetadata": "Убудаванне метададзеных",
"MessageTaskEmbeddingMetadataDescription": "Убудаванне метададзеных у аўдыёкнігу \"{0}\"",
"MessageTaskEncodingM4b": "Кадаванне M4B",
"MessageTaskEncodingM4bDescription": "Кадаванне аўдыякнігі \"{0}\" у адзін файл m4b",
"MessageTaskFailed": "Не ўдалося",
"MessageTaskFailedToBackupAudioFile": "Не ўдалося зрабіць рэзервовую копію аўдыёфайла \"{0}\"",
"MessageTaskFailedToCreateCacheDirectory": "Не ўдалося стварыць каталог кэша",
"MessageTaskFailedToEmbedMetadataInFile": "Не ўдалося ўбудаваць метададзеныя ў файл \"{0}\"",
"MessageTaskFailedToMergeAudioFiles": "Не ўдалося аб’яднаць аўдыёфайлы",
"MessageTaskFailedToMoveM4bFile": "Не ўдалося перамясціць файл m4b",
"MessageTaskFailedToWriteMetadataFile": "Не ўдалося захаваць файл метададзеных",
"MessageTaskMatchingBooksInLibrary": "Пошук супадзенняў кніг у бібліятэцы \"{0}\"",
"MessageTaskNoFilesToScan": "Няма файлаў для сканавання",
"MessageTaskOpmlImport": "Імпарт OPML",
"MessageTaskOpmlImportDescription": "Стварэнне падкастаў з {0} RSS-стужак", "MessageTaskOpmlImportDescription": "Стварэнне падкастаў з {0} RSS-стужак",
"MessageTaskOpmlImportFeed": "Імпарт стужкі OPML", "MessageTaskOpmlImportFeed": "Імпарт стужкі з OPML",
"MessageTaskOpmlImportFeedDescription": "Імпартаванне RSS-стужкі \"{0}\"", "MessageTaskOpmlImportFeedDescription": "Імпартаванне RSS-стужкі \"{0}\"",
"MessageTaskOpmlImportFeedFailed": "Не ўдалося атрымаць стужку падкаста", "MessageTaskOpmlImportFeedFailed": "Не ўдалося атрымаць стужку падкаста",
"MessageTaskOpmlImportFeedPodcastDescription": "Стварэнне падкаста \"{0}\"", "MessageTaskOpmlImportFeedPodcastDescription": "Стварэнне падкаста \"{0}\"",
"MessageTaskOpmlImportFeedPodcastExists": "Падкаст ужо існуе па гэтым шляху", "MessageTaskOpmlImportFeedPodcastExists": "Падкаст ужо існуе па гэтым шляху",
"MessageTaskOpmlImportFeedPodcastFailed": "Не ўдалося стварыць падкаст", "MessageTaskOpmlImportFeedPodcastFailed": "Не ўдалося стварыць падкаст",
"MessageTaskOpmlImportFinished": "Дададзена {0} падкастаў", "MessageTaskOpmlParseNoneFound": "У OPML-файле не знойдзена стужак",
"MessageTaskOpmlParseFailed": "Не ўдалося разабраць файл OPML",
"MessageTaskOpmlParseFastFail": "Неправільны файл OPML: тэг <opml> не знойдзены АБО тэг <outline> не знойдзены",
"MessageTaskOpmlParseNoneFound": "У файле OPML не знойдзена стужак",
"MessageTaskScanItemsAdded": "{0} дададзена",
"MessageTaskScanItemsMissing": "{0} адсутнічае",
"MessageTaskScanItemsUpdated": "{0} абноўлена",
"MessageTaskScanNoChangesNeeded": "Змены не патрабуюцца",
"MessageTaskScanningFileChanges": "Сканіраванне змяненняў у файле \"{0}\"",
"MessageTaskScanningLibrary": "Сканіраванне бібліятэкі \"{0}\"",
"MessageTaskTargetDirectoryNotWritable": "Мэтавы каталог недаступны для запісу",
"NoteChapterEditorTimes": "Заўвага: Час пачатку першага раздзела павінен заставацца 0:00, а час пачатку апошняга раздзела не можа перавышаць працягласць гэтай аўдыякнігі.", "NoteChapterEditorTimes": "Заўвага: Час пачатку першага раздзела павінен заставацца 0:00, а час пачатку апошняга раздзела не можа перавышаць працягласць гэтай аўдыякнігі.",
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS", "NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш вашых эпізодаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.", "NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш вашых эпізодаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
@ -702,11 +567,6 @@
"StatsBooksListenedTo": "кнігі, якія былі праслуханы", "StatsBooksListenedTo": "кнігі, якія былі праслуханы",
"StatsCollectionGrewTo": "Ваша калекцыя кніг павялічылася да…", "StatsCollectionGrewTo": "Ваша калекцыя кніг павялічылася да…",
"ToastAccountUpdateSuccess": "Уліковы запіс абноўлены", "ToastAccountUpdateSuccess": "Уліковы запіс абноўлены",
"ToastAuthorImageRemoveSuccess": "Выява аўтара выдалена",
"ToastAuthorUpdateSuccess": "Аўтар абноўлены",
"ToastAuthorUpdateSuccessNoImageFound": "Аўтар абноўлены (малюнак не знойдзены)",
"ToastBackupInvalidMaxKeep": "Няправільная колькасць рэзервовых копій для захоўвання",
"ToastBackupInvalidMaxSize": "Няправільны максімальны памер рэзервовай копіі",
"ToastBookmarkCreateFailed": "Не ўдалося стварыць закладку", "ToastBookmarkCreateFailed": "Не ўдалося стварыць закладку",
"ToastDateTimeInvalidOrIncomplete": "Дата і час указаны некарэктна або не цалкам", "ToastDateTimeInvalidOrIncomplete": "Дата і час указаны некарэктна або не цалкам",
"ToastDeviceTestEmailFailed": "Не ўдалося адправіць тэставае электроннае пісьмо", "ToastDeviceTestEmailFailed": "Не ўдалося адправіць тэставае электроннае пісьмо",
@ -714,7 +574,6 @@
"ToastEncodeCancelSucces": "Кадаванне скасавана", "ToastEncodeCancelSucces": "Кадаванне скасавана",
"ToastEpisodeDownloadQueueClearFailed": "Не ўдалося ачысціць чаргу", "ToastEpisodeDownloadQueueClearFailed": "Не ўдалося ачысціць чаргу",
"ToastEpisodeDownloadQueueClearSuccess": "Чарга спампоўкі эпізодаў ачышчана", "ToastEpisodeDownloadQueueClearSuccess": "Чарга спампоўкі эпізодаў ачышчана",
"ToastInvalidImageUrl": "Няправільны URL выявы",
"ToastInvalidMaxEpisodesToDownload": "Няправільная максімальная колькасць эпізодаў для спампоўкі", "ToastInvalidMaxEpisodesToDownload": "Няправільная максімальная колькасць эпізодаў для спампоўкі",
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як Скончана", "ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як Скончана",
"ToastItemMarkedAsFinishedSuccess": "Элемент пазначаны як Завершаны", "ToastItemMarkedAsFinishedSuccess": "Элемент пазначаны як Завершаны",
@ -743,8 +602,6 @@
"ToastPlaylistCreateSuccess": "Спіс прайгравання створаны", "ToastPlaylistCreateSuccess": "Спіс прайгравання створаны",
"ToastPlaylistRemoveSuccess": "Спіс прайгравання выдалены", "ToastPlaylistRemoveSuccess": "Спіс прайгравання выдалены",
"ToastPlaylistUpdateSuccess": "Спіс прайгравання абноўлены", "ToastPlaylistUpdateSuccess": "Спіс прайгравання абноўлены",
"ToastPodcastCreateFailed": "Не ўдалося стварыць падкаст",
"ToastPodcastCreateSuccess": "Падкаст паспяхова створаны",
"ToastPodcastGetFeedFailed": "Не ўдалося атрымаць стужку падкаста", "ToastPodcastGetFeedFailed": "Не ўдалося атрымаць стужку падкаста",
"ToastPodcastNoEpisodesInFeed": "У RSS-стужцы не знойдзена эпізодаў", "ToastPodcastNoEpisodesInFeed": "У RSS-стужцы не знойдзена эпізодаў",
"ToastPodcastNoRssFeed": "У падкаста няма RSS-стужкі", "ToastPodcastNoRssFeed": "У падкаста няма RSS-стужкі",
@ -753,7 +610,6 @@
"ToastSendEbookToDeviceFailed": "Не ўдалося адправіць электронную кнігу на прыладу", "ToastSendEbookToDeviceFailed": "Не ўдалося адправіць электронную кнігу на прыладу",
"ToastSendEbookToDeviceSuccess": "Электронная кніга адпраўлена на прыладу \"{0}\"", "ToastSendEbookToDeviceSuccess": "Электронная кніга адпраўлена на прыладу \"{0}\"",
"ToastSleepTimerDone": "Таймер сну скончыўся... Хр-р-р", "ToastSleepTimerDone": "Таймер сну скончыўся... Хр-р-р",
"ToastUploaderItemExistsInSubdirectoryError": "Элемент \"{0}\" выкарыстоўвае падкаталог шляху загрузкі.",
"ToastUserPasswordMustChange": "Новы пароль не можа супадаць са старым", "ToastUserPasswordMustChange": "Новы пароль не можа супадаць са старым",
"ToastUserRootRequireName": "Неабходна ўвесці імя карыстальніка адміністратара" "ToastUserRootRequireName": "Неабходна ўвесці імя карыстальніка адміністратара"
} }

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