Compare commits

...

17 Commits

Author SHA1 Message Date
advplyr
e0546c6164 Version bump 2.0.19 2022-06-04 14:42:36 -05:00
advplyr
be7ccfb209 Merge pull request #678 from jmt-gh/issue_676_chapter_metadata
Support embedding updated chapter metadata (issue #676)
2022-06-04 14:02:44 -05:00
advplyr
938a8c6f80 Fix:Casing typo in LibraryItem 2022-06-04 13:00:51 -05:00
advplyr
5cd343cb01 Add:All listening sessions config page 2022-06-04 12:44:42 -05:00
jmt-gh
ab0094a53b Support embedding updated chapter metadata (676)
This commit resolves issue #676. The embed metadata tool was missing the
flag that tells ffmpeg to not only update the "top" metadata, but also
the chapter metadata.
2022-06-04 10:17:42 -07:00
advplyr
2d5e4ebcf0 Add:Audio player next/prev chapter buttons 2022-06-04 12:07:38 -05:00
advplyr
3171ce5aba Update:Paginated listening sessions 2022-06-04 10:52:37 -05:00
advplyr
0e1692d26b Fix:Matching authors with multiple authors split by comma #667 2022-06-03 19:21:31 -05:00
advplyr
e8cd18eac2 Add:Alert when progress is not syncing 2022-06-03 19:11:13 -05:00
advplyr
bf928692d5 Update:API route for getting playback session and getting media progress 2022-06-03 18:59:42 -05:00
advplyr
792490b629 Merge pull request #664 from bskrtich/docker_updates
feat: Updates to docker file and gh action
2022-06-03 05:02:11 -05:00
advplyr
0d1ff35c5e Add:Not Finished progress filter #650 2022-06-02 18:20:18 -05:00
advplyr
67e02fddbd Comment out expand on player ui 2022-06-02 17:54:07 -05:00
advplyr
09beb6a2ae Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-02 16:32:42 -05:00
advplyr
2dba17a7ae Merge pull request #651 from selfhost-alt/handle-another-backup-parse-error
Gracefully handle unexpected end of file when listing backup files
2022-06-02 07:26:42 -05:00
Brandon Skrtich
4900649908 feat: Updates to docker file and gh action
* Clean up Dockerfile
* Add health check to Dockerfile
* Update gh action versions
2022-06-02 05:55:01 +00:00
Selfhost Alt
1350a91fba Handle another type of corrupted backup file 2022-05-30 23:53:00 -07:00
33 changed files with 534 additions and 106 deletions

View File

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

View File

@@ -7,13 +7,23 @@ RUN npm run generate
### STAGE 1: Build server ###
FROM node:16-alpine
RUN apk update && apk add --no-cache --update ffmpeg
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
curl \
tzdata \
ffmpeg
COPY --from=build /client/dist /client/dist
COPY index.js index.js
COPY package-lock.json package-lock.json
COPY package.json package.json
COPY index.js package* /
COPY server server
RUN npm ci --only=production
EXPOSE 80
HEALTHCHECK \
--interval=30s \
--timeout=3s \
--start-period=10s \
CMD curl -f http://127.0.0.1/ping || exit 1
CMD ["npm", "start"]

View File

@@ -64,6 +64,11 @@ export default {
title: 'Users',
path: '/config/users'
},
{
id: 'config-sessions',
title: 'Sessions',
path: '/config/sessions'
},
{
id: 'config-backups',
title: 'Backups',

View File

@@ -71,7 +71,8 @@ export default {
sleepTimerRemaining: 0,
sleepTimer: null,
displayTitle: null,
initialPlaybackRate: 1
initialPlaybackRate: 1,
syncFailedToast: null
}
},
computed: {
@@ -380,6 +381,10 @@ export default {
},
pauseItem() {
this.playerHandler.pause()
},
showFailedProgressSyncs() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
}
},
mounted() {

View File

@@ -214,7 +214,7 @@ export default {
return this.filterData.languages || []
},
progress() {
return ['Finished', 'In Progress', 'Not Started']
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
},
missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']

View File

@@ -366,14 +366,18 @@ export default {
},
selectMatch(match) {
if (match && match.series) {
match.series = match.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
name: se.series,
sequence: se.volumeNumber || ''
}
})
if (!match.series.length) {
delete match.series
} else {
match.series = match.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
name: se.series,
sequence: se.volumeNumber || ''
}
})
}
}
this.selectedMatch = match
@@ -405,7 +409,9 @@ export default {
updatePayload.metadata.series = seriesPayload
} else if (key === 'author' && !this.isPodcast) {
if (!Array.isArray(this.selectedMatch[key])) this.selectedMatch[key] = [this.selectedMatch[key]]
if (!Array.isArray(this.selectedMatch[key])) {
this.selectedMatch[key] = this.selectedMatch[key].split(',').map((au) => au.trim())
}
var authorPayload = []
this.selectedMatch[key].forEach((authorName) =>
authorPayload.push({

View File

@@ -2,18 +2,21 @@
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
<span class="material-icons text-3xl">first_page</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-3xl">replay_10</span>
</div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span>
</div>
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
<span class="material-icons text-3xl">last_page</span>
</div>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template>
<template v-else>
@@ -31,7 +34,8 @@ export default {
loading: Boolean,
seekLoading: Boolean,
playbackRate: Number,
paused: Boolean
paused: Boolean,
hasNextChapter: Boolean
},
data() {
return {}
@@ -41,8 +45,12 @@ export default {
playPause() {
this.$emit('playPause')
},
restart() {
this.$emit('restart')
prevChapter() {
this.$emit('prevChapter')
},
nextChapter() {
if (!this.hasNextChapter) return
this.$emit('nextChapter')
},
jumpBackward() {
this.$emit('jumpBackward')

View File

@@ -2,7 +2,7 @@
<div class="w-full -mt-6">
<div class="w-full relative mb-1">
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
<span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span>
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
@@ -23,7 +23,7 @@
</div>
</div>
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" @restart="restart" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
</div>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
@@ -106,6 +106,14 @@ export default {
},
isFullscreen() {
return this.$store.state.playerIsFullscreen
},
currentChapterIndex() {
if (!this.currentChapter) return 0
return this.chapters.findIndex((ch) => ch.id === this.currentChapter.id)
},
hasNextChapter() {
if (!this.chapters.length) return false
return this.currentChapterIndex < this.chapters.length - 1
}
},
methods: {
@@ -190,6 +198,23 @@ export default {
restart() {
this.seek(0)
},
prevChapter() {
if (!this.currentChapter || this.currentChapterIndex === 0) {
return this.restart()
}
var timeInCurrentChapter = this.currentTime - this.currentChapter.start
if (timeInCurrentChapter <= 3 && this.chapters[this.currentChapterIndex - 1]) {
var prevChapter = this.chapters[this.currentChapterIndex - 1]
this.seek(prevChapter.start)
} else {
this.seek(this.currentChapter.start)
}
},
nextChapter() {
if (!this.currentChapter || !this.hasNextChapter) return
var nextChapter = this.chapters[this.currentChapterIndex + 1]
this.seek(nextChapter.start)
},
setStreamReady() {
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
},

View File

@@ -1,5 +1,5 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
@@ -20,20 +20,29 @@ export default {
},
outlined: Boolean,
borderless: Boolean,
loading: Boolean
loading: Boolean,
iconFontSize: {
type: String,
default: ''
},
size: {
type: Number,
default: 9
}
},
data() {
return {}
},
computed: {
className() {
var classes = []
var classes = [`h-${this.size} w-${this.size}`]
if (!this.borderless) {
classes.push(`bg-${this.bgColor} border border-gray-600`)
}
return classes.join(' ')
},
fontSize() {
if (this.iconFontSize) return this.iconFontSize
if (this.icon === 'edit') return '1.25rem'
return '1.4rem'
}

View File

@@ -42,7 +42,8 @@ export default {
editable: {
type: Boolean,
default: true
}
},
showAllWhenEmpty: Boolean
},
data() {
return {
@@ -72,6 +73,7 @@ export default {
itemsToShow() {
if (!this.editable) return this.items
if (!this.textInput || this.textInput === this.input) {
if (this.showAllWhenEmpty) return this.items
return []
}
return this.items.filter((i) => {

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.0.18",
"version": "2.0.19",
"lockfileVersion": 2,
"requires": true,
"packages": {

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.0.18",
"version": "2.0.19",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {

View File

@@ -1,13 +1,13 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-4 md:p-8" :class="streamLibraryItem ? 'streaming' : ''">
<div class="max-w-6xl mx-auto">
<div class="flex mb-6">
<div class="flex flex-wrap sm:flex-nowrap justify-center mb-6">
<div class="w-48 min-w-48">
<div class="w-full h-52">
<covers-author-image :author="author" rounded="0" />
</div>
</div>
<div class="flex-grow px-8">
<div class="flex-grow py-4 sm:py-0 px-4 md:px-8">
<div class="flex items-center mb-8">
<h1 class="text-2xl">{{ author.name }}</h1>

View File

@@ -0,0 +1,194 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
<div class="py-2">
<div class="flex items-center mb-1">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
<div class="flex-grow" />
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
</div>
<div v-if="listeningSessions.length">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="flex-grow text-left">Item</th>
<th class="w-20 text-left">User</th>
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
<th class="w-20">Listened</th>
<th class="w-20">Last Time</th>
<th class="w-40 hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1">
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<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" />
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div>
</div>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
</div>
</template>
<script>
export default {
async asyncData({ params, redirect, app }) {
var users = await app.$axios
.$get('/api/users')
.then((users) => {
return users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})
.catch((error) => {
console.error('Failed', error)
return []
})
return {
users
}
},
data() {
return {
showSessionModal: false,
selectedSession: null,
listeningSessions: [],
numPages: 0,
total: 0,
currentPage: 0,
userFilter: null,
selectedUser: ''
}
},
computed: {
username() {
return this.user.username
},
userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
},
userItems() {
var userItems = [{ value: '', text: 'All Users' }]
return userItems.concat(this.users.map((u) => ({ value: u.id, text: u.username })))
},
filteredUserUsername() {
if (!this.userFilter) return null
var user = this.users.find((u) => u.id === this.userFilter)
return user ? user.username : null
}
},
methods: {
updateUserFilter() {
this.loadSessions(0)
},
prevPage() {
this.loadSessions(this.currentPage - 1)
},
nextPage() {
this.loadSessions(this.currentPage + 1)
},
showSession(session) {
this.selectedSession = session
this.showSessionModal = true
},
getDeviceInfoString(deviceInfo) {
if (!deviceInfo) return ''
var lines = []
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
return lines.join('<br>')
},
getPlayMethodName(playMethod) {
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
},
async loadSessions(page) {
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=10${userFilterQuery}`).catch((err) => {
console.error('Failed to load listening sesions', err)
return null
})
if (!data) {
this.$toast.error('Failed to load listening sessions')
return
}
this.numPages = data.numPages
this.total = data.total
this.currentPage = data.page
this.listeningSessions = data.sessions
this.userFilter = data.userFilter
},
init() {
this.loadSessions(0)
}
},
mounted() {
this.init()
}
}
</script>
<style>
.userSessionsTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.userSessionsTable tr:first-child {
background-color: #272727;
}
.userSessionsTable tr:not(:first-child) {
background-color: #373838;
}
.userSessionsTable tr:not(:first-child):nth-child(odd) {
background-color: #2f2f2f;
}
.userSessionsTable tr:hover:not(:first-child) {
background-color: #474747;
}
.userSessionsTable td {
padding: 4px 8px;
}
.userSessionsTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>

View File

@@ -138,7 +138,9 @@ export default {
this.$copyToClipboard(str, this)
},
async init() {
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`).then((data) => {
return data.sessions || []
}).catch((err) => {
console.error('Failed to load listening sesions', err)
return []
})

View File

@@ -17,40 +17,47 @@
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions ({{ listeningSessions.length }})</h1>
<table v-if="listeningSessions.length" class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="flex-grow text-left">Item</th>
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
<th class="w-20">Listened</th>
<th class="w-20">Last Time</th>
<th class="w-40 hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1">
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
<div v-if="listeningSessions.length">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="flex-grow text-left">Item</th>
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
<th class="w-20">Listened</th>
<th class="w-20">Last Time</th>
<th class="w-40 hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1">
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<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" />
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div>
</div>
@@ -75,7 +82,10 @@ export default {
return {
showSessionModal: false,
selectedSession: null,
listeningSessions: []
listeningSessions: [],
numPages: 0,
total: 0,
currentPage: 0
}
},
computed: {
@@ -87,6 +97,12 @@ export default {
}
},
methods: {
prevPage() {
this.loadSessions(this.currentPage - 1)
},
nextPage() {
this.loadSessions(this.currentPage + 1)
},
showSession(session) {
this.selectedSession = session
this.showSessionModal = true
@@ -108,13 +124,23 @@ export default {
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
},
async init() {
console.log(navigator)
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
async loadSessions(page) {
const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=10`).catch((err) => {
console.error('Failed to load listening sesions', err)
return []
return null
})
if (!data) {
this.$toast.error('Failed to load listening sessions')
return
}
this.numPages = data.numPages
this.total = data.total
this.currentPage = data.page
this.listeningSessions = data.sessions
},
init() {
this.loadSessions(0)
}
},
mounted() {

View File

@@ -124,6 +124,8 @@ export default class CastPlayer extends EventEmitter {
async seek(time, playWhenReady) {
if (!this.player) return
this.playWhenReady = playWhenReady
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
// Change Track
var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)

View File

@@ -71,7 +71,7 @@ export default class LocalAudioPlayer extends EventEmitter {
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
// Has next track
this.currentTrackIndex++
this.playWhenReady = true
this.playWhenReady = !this.player.paused
this.startTime = this.currentTrack.startOffset
this.loadCurrentTrack()
} else {
@@ -89,6 +89,7 @@ export default class LocalAudioPlayer extends EventEmitter {
}
this.emit('stateChange', 'LOADED')
if (this.playWhenReady) {
this.playWhenReady = false
this.play()
@@ -229,8 +230,11 @@ export default class LocalAudioPlayer extends EventEmitter {
this.player.playbackRate = playbackRate
}
seek(time) {
seek(time, playWhenReady) {
if (!this.player) return
this.playWhenReady = playWhenReady
if (this.isHlsTranscode) {
// Seeking HLS stream
var offsetTime = time - (this.currentTrack.startOffset || 0)
@@ -255,7 +259,6 @@ export default class LocalAudioPlayer extends EventEmitter {
this.player.currentTime = Math.max(0, offsetTime)
}
}
}
setVolume(volume) {

View File

@@ -20,6 +20,7 @@ export default class PlayerHandler {
this.currentSessionId = null
this.startTime = 0
this.failedProgressSyncs = 0
this.lastSyncTime = 0
this.lastSyncedAt = 0
this.listeningTimeSinceSync = 0
@@ -186,6 +187,7 @@ export default class PlayerHandler {
}
prepareSession(session) {
this.failedProgressSyncs = 0
this.startTime = session.currentTime
this.currentSessionId = session.id
this.displayTitle = session.displayTitle
@@ -286,8 +288,15 @@ export default class PlayerHandler {
currentTime
}
this.listeningTimeSinceSync = 0
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).catch((error) => {
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).then(() => {
this.failedProgressSyncs = 0
}).catch((error) => {
console.error('Failed to update session progress', error)
this.failedProgressSyncs++
if (this.failedProgressSyncs >= 2) {
this.ctx.showFailedProgressSyncs()
this.failedProgressSyncs = 0
}
})
}

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.0.18",
"version": "2.0.19",
"lockfileVersion": 2,
"requires": true,
"packages": {

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.0.18",
"version": "2.0.19",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {

View File

@@ -69,7 +69,7 @@ docker run -d \
-e AUDIOBOOKSHELF_GID=100 \
-p 13378:80 \
-v </path/to/audiobooks>:/audiobooks \
-v </path/to/your/podcasts>:/podcasts \
-v </path/to/podcasts>:/podcasts \
-v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \
--name audiobookshelf \
@@ -90,6 +90,7 @@ docker start audiobookshelf
### docker-compose.yml ###
services:
audiobookshelf:
container_name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:latest
environment:
- AUDIOBOOKSHELF_UID=99
@@ -97,8 +98,8 @@ services:
ports:
- 13378:80
volumes:
- </path/to/your/audiobooks>:/audiobooks
- </path/to/your/podcasts>:/podcasts
- </path/to/audiobooks>:/audiobooks
- </path/to/podcasts>:/podcasts
- </path/to/config>:/config
- </path/to/metadata>:/metadata
```
@@ -195,7 +196,7 @@ server
proxy_redirect http:// https://;
}
}
```
```
### Apache Reverse Proxy

View File

@@ -428,6 +428,15 @@ class Db {
})
}
getAllSessions() {
return this.sessionsDb.select(() => true).then((results) => {
return results.data || []
}).catch((error) => {
Logger.error('[Db] Failed to select sessions', error)
return []
})
}
selectUserSessions(userId) {
return this.sessionsDb.select((session) => session.userId === userId).then((results) => {
return results.data || []

View File

@@ -1,5 +1,5 @@
const Logger = require('../Logger')
const { isObject } = require('../utils/index')
const { isObject, toNumber } = require('../utils/index')
class MeController {
constructor() { }
@@ -7,7 +7,22 @@ class MeController {
// GET: api/me/listening-sessions
async getListeningSessions(req, res) {
var listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)
res.json(listeningSessions.slice(0, 10))
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
const page = toNumber(req.query.page, 0)
const start = page * itemsPerPage
const sessions = listeningSessions.slice(start, start + itemsPerPage)
const payload = {
total: listeningSessions.length,
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
page,
itemsPerPage,
sessions
}
res.json(payload)
}
// GET: api/me/listening-stats
@@ -16,6 +31,15 @@ class MeController {
res.json(listeningStats)
}
// GET: api/me/progress/:id/:episodeId?
async getMediaProgress(req, res) {
const mediaProgress = req.user.getMediaProgress(req.id, req.episodeId || null)
if (!mediaProgress) {
return res.sendStatus(404)
}
res.json(mediaProgress)
}
// DELETE: api/me/progress/:id
async removeMediaProgress(req, res) {
var wasRemoved = req.user.removeMediaProgress(req.params.id)

View File

@@ -1,4 +1,5 @@
const Logger = require('../Logger')
const { toNumber } = require('../utils/index')
class SessionController {
constructor() { }
@@ -7,6 +8,46 @@ class SessionController {
return res.json(req.session)
}
async getAllWithUserData(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[SessionController] getAllWithUserData: Non-admin user requested all session data ${req.user.id}/"${req.user.username}"`)
return res.sendStatus(404)
}
var listeningSessions = []
if (req.query.user) {
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user)
} else {
listeningSessions = await this.getAllSessionsWithUserData()
}
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
const page = toNumber(req.query.page, 0)
const start = page * itemsPerPage
const sessions = listeningSessions.slice(start, start + itemsPerPage)
const payload = {
total: listeningSessions.length,
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
page,
itemsPerPage,
sessions
}
if (req.query.user) {
payload.userFilter = req.query.user
}
res.json(payload)
}
getSession(req, res) {
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
var sessionForClient = req.session.toJSONForClient(libraryItem)
res.json(sessionForClient)
}
// POST: api/session/:id/sync
sync(req, res) {
this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res)

View File

@@ -1,7 +1,7 @@
const Logger = require('../Logger')
const User = require('../objects/user/User')
const { getId } = require('../utils/index')
const { getId, toNumber } = require('../utils/index')
class UserController {
constructor() { }
@@ -142,8 +142,24 @@ class UserController {
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
return res.sendStatus(403)
}
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
res.json(listeningSessions.slice(0, 10))
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
const page = toNumber(req.query.page, 0)
const start = page * itemsPerPage
const sessions = listeningSessions.slice(start, start + itemsPerPage)
const payload = {
total: listeningSessions.length,
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
page,
itemsPerPage,
sessions
}
res.json(payload)
}
// GET: api/users/:id/listening-stats

View File

@@ -84,7 +84,7 @@ class AudioMetadataMangaer {
Ffmpeg premapped id3 tags: https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
*/
const ffmpegOptions = ['-c copy', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags']
const ffmpegOptions = ['-c copy', '-map_chapters 1', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags']
var workerData = {
inputs: ffmpegInputs,
options: ffmpegOptions,
@@ -137,4 +137,4 @@ class AudioMetadataMangaer {
})
}
}
module.exports = AudioMetadataMangaer
module.exports = AudioMetadataMangaer

View File

@@ -141,6 +141,9 @@ class BackupManager {
if (error.message === "Bad archive") {
Logger.warn(`[BackupManager] Backup appears to be corrupted: ${fullFilePath}`)
continue;
} else if (error.message === "unexpected end of file") {
Logger.warn(`[BackupManager] Backup appears to be corrupted: ${fullFilePath}`)
continue;
} else {
throw error
}

View File

@@ -6,7 +6,7 @@ const abmetadataGenerator = require('../utils/abmetadataGenerator')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediatypes/Video')
const Video = require('./mediaTypes/Video')
const { areEquivalent, copyValue, getId } = require('../utils/index')
class LibraryItem {

View File

@@ -131,6 +131,7 @@ class ApiRouter {
//
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
this.router.get('/me/progress/:id/:episodeId?', MeController.getMediaProgress.bind(this))
this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))
this.router.patch('/me/progress/:id', MeController.createUpdateMediaProgress.bind(this))
this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this))
@@ -173,6 +174,8 @@ class ApiRouter {
//
// Playback Session Routes
//
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
this.router.get('/session/:id', SessionController.middleware.bind(this), SessionController.getSession.bind(this))
this.router.post('/session/:id/sync', SessionController.middleware.bind(this), SessionController.sync.bind(this))
this.router.post('/session/:id/close', SessionController.middleware.bind(this), SessionController.close.bind(this))
this.router.post('/session/local', SessionController.syncLocal.bind(this))
@@ -308,6 +311,19 @@ class ApiRouter {
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
}
async getAllSessionsWithUserData() {
var sessions = await this.db.getAllSessions()
sessions.sort((a, b) => b.updatedAt - a.updatedAt)
return sessions.map(se => {
var user = this.db.users.find(u => u.id === se.userId)
var _se = {
...se,
user: user ? { id: user.id, username: user.username } : null
}
return _se
})
}
async getUserListeningStatsHelpers(userId) {
const today = date.format(new Date(), 'YYYY-MM-DD')

View File

@@ -696,7 +696,7 @@ class Scanner {
}
// Add or set author if not set
if (matchData.author && !libraryItem.media.metadata.authorName || options.overrideDetails) {
if (matchData.author && (!libraryItem.media.metadata.authorName || options.overrideDetails)) {
if (!Array.isArray(matchData.author)) matchData.author = [matchData.author]
const authorPayload = []
for (let index = 0; index < matchData.author.length; index++) {
@@ -714,7 +714,7 @@ class Scanner {
}
// Add or set series if not set
if (matchData.series && !libraryItem.media.metadata.seriesName || options.overrideDetails) {
if (matchData.series && (!libraryItem.media.metadata.seriesName || options.overrideDetails)) {
if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, volumeNumber: matchData.volumeNumber }]
const seriesPayload = []
for (let index = 0; index < matchData.series.length; index++) {

View File

@@ -124,4 +124,9 @@ module.exports.copyValue = (val) => {
module.exports.encodeUriPath = (path) => {
return path.replace(/\\/g, '/').replace(/%/g, '%25').replace(/#/g, '%23')
}
module.exports.toNumber = (val, fallback = 0) => {
if (isNaN(val) || val === null) return fallback
return Number(val)
}

View File

@@ -32,6 +32,7 @@ module.exports = {
var itemProgress = user.getMediaProgress(li.id)
if (filter === 'Finished' && (itemProgress && itemProgress.isFinished)) return true
if (filter === 'Not Started' && !itemProgress) return true
if (filter === 'Not Finished' && !itemProgress || !itemProgress.isFinished) return true
if (filter === 'In Progress' && (itemProgress && itemProgress.inProgress)) return true
return false
})