Compare commits

...

34 Commits

Author SHA1 Message Date
advplyr ae8f3aa918 Version bump 2.0.20 2022-06-05 10:59:01 -05:00
advplyr 5d4047c171 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-05 10:16:19 -05:00
advplyr 6f80591afd Fix:Switching to next track pausing player #685 2022-06-05 10:06:07 -05:00
advplyr 788d867ec3 Merge pull request #681 from jmt-gh/m4b_no_coverart
Fix cover art not being generated for M4B export
2022-06-04 20:56:04 -05:00
jmt-gh 3bc3914fd9 Fix cover art not being generated for m4b export
This commit fixes an issue where cover art was not being generated
properly when creating an M4B audiobook.

More context can be found in discord:
https://discord.com/channels/942908292873723984/981321213882282035/982777444631195681
2022-06-04 17:50:26 -07:00
advplyr 3d821dacb7 Fix:Sessions table cleanup 2022-06-04 15:51:00 -05:00
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 1bd657f07d Fix:Mark as finished once media has ended #635 2022-06-02 16:31:52 -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
advplyr c3b33ea37a Fix:Sanitize filename to remove line breaks and check filename length is not too long #663 2022-06-01 20:14:10 -05:00
advplyr 36bd6e649a Fix:Remove podcast episode to also remove library file #636 2022-06-01 17:45:52 -05:00
advplyr 4621c78573 Update:Show version number in bottom of siderail #660 and save previous version data to continue showing if update is available 2022-06-01 17:15:13 -05:00
advplyr c88bbf1ce4 Fix:Authors landing page available on refresh #659 2022-06-01 16:29:29 -05:00
advplyr d37b25a6f6 Update audio player to player ui and separate out components 2022-05-31 20:13:46 -05:00
advplyr 792268f5ee Merge branch 'master' into video 2022-05-31 18:53:30 -05:00
advplyr 5f2d6f4d5e Add:Support for wav #652 2022-05-31 18:45:40 -05:00
Selfhost Alt 1350a91fba Handle another type of corrupted backup file 2022-05-30 23:53:00 -07:00
advplyr acf22ca4fa Testing video media type 2022-05-30 19:26:53 -05:00
advplyr 705aac40d7 Remove experimental set bookshelf texture 2022-05-30 09:58:02 -05:00
advplyr 7456052620 Fix:Match update cover image #648 2022-05-30 09:52:42 -05:00
79 changed files with 2105 additions and 513 deletions
+21 -15
View File
@@ -3,8 +3,15 @@
name: Build and Push Docker Image name: Build and Push Docker Image
on: on:
# Allows you to run workflow manually from Actions tab
workflow_dispatch:
inputs:
tags:
description: 'Docker Tag'
required: true
default: 'latest'
push: push:
branches: [master] branches: [main,master]
tags: tags:
- 'v*.*.*' - 'v*.*.*'
# Only build when files in these directories have been changed # Only build when files in these directories have been changed
@@ -13,8 +20,6 @@ on:
- server/** - server/**
- index.js - index.js
- package.json - package.json
# Allows you to run workflow manually from Actions tab
workflow_dispatch:
jobs: jobs:
build: build:
@@ -23,24 +28,25 @@ jobs:
steps: steps:
- name: Check out - name: Check out
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v4
with: with:
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
tags: | tags: |
type=edge,branch=master type=edge,branch=master
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v2 uses: actions/cache@v3
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}
@@ -48,28 +54,28 @@ jobs:
${{ runner.os }}-buildx- ${{ runner.os }}-buildx-
- name: Login to Dockerhub - name: Login to Dockerhub
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }} password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to ghcr - name: Login to ghcr
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_PASSWORD }} password: ${{ secrets.GHCR_PASSWORD }}
- name: Build image - name: Build image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
context: . context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true push: true
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache - name: Move cache
run: | run: |
rm -rf /tmp/.buildx-cache rm -rf /tmp/.buildx-cache
+14 -4
View File
@@ -7,13 +7,23 @@ RUN npm run generate
### STAGE 1: Build server ### ### STAGE 1: Build server ###
FROM node:16-alpine FROM node:16-alpine
RUN apk update && apk add --no-cache --update ffmpeg
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
curl \
tzdata \
ffmpeg
COPY --from=build /client/dist /client/dist COPY --from=build /client/dist /client/dist
COPY index.js index.js COPY index.js package* /
COPY package-lock.json package-lock.json
COPY package.json package.json
COPY server server COPY server server
RUN npm ci --only=production RUN npm ci --only=production
EXPOSE 80 EXPOSE 80
HEALTHCHECK \
--interval=30s \
--timeout=3s \
--start-period=10s \
CMD curl -f http://127.0.0.1/ping || exit 1
CMD ["npm", "start"] CMD ["npm", "start"]
-3
View File
@@ -147,9 +147,6 @@ export default {
} }
}, },
methods: { methods: {
toggleBookshelfTexture() {
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
},
cancelSelectionMode() { cancelSelectionMode() {
if (this.processingBatchDelete) return if (this.processingBatchDelete) return
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('setSelectedLibraryItems', [])
@@ -2,10 +2,6 @@
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative"> <div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
<!-- Cover size widget --> <!-- Cover size widget -->
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" /> <widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
</div>
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
@@ -100,9 +96,6 @@ export default {
} }
}, },
methods: { methods: {
showBookshelfTextureModal() {
this.$store.commit('globals/setShowBookshelfTextureModal', true)
},
async init() { async init() {
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0 this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
+5
View File
@@ -64,6 +64,11 @@ export default {
title: 'Users', title: 'Users',
path: '/config/users' path: '/config/users'
}, },
{
id: 'config-sessions',
title: 'Sessions',
path: '/config/sessions'
},
{ {
id: 'config-backups', id: 'config-backups',
title: 'Backups', title: 'Backups',
-10
View File
@@ -22,13 +22,6 @@
</div> </div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" /> <widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
<p class="text-sm py-0.5">Texture</p>
</div>
</div>
</div> </div>
</template> </template>
@@ -206,9 +199,6 @@ export default {
} }
}, },
methods: { methods: {
showBookshelfTextureModal() {
this.$store.commit('globals/setShowBookshelfTextureModal', true)
},
clearFilter() { clearFilter() {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' }) this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
}, },
+27
View File
@@ -73,6 +73,12 @@
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p> <p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
</div> </div>
</nuxt-link> </nuxt-link>
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
<p class="font-mono text-xs text-center text-gray-300 leading-3 mb-1">v{{ $config.version }}</p>
<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>
</div>
</div> </div>
</template> </template>
@@ -82,6 +88,12 @@ export default {
return {} return {}
}, },
computed: { computed: {
Source() {
return this.$store.state.Source
},
isMobileLandscape() {
return this.$store.state.globals.isMobileLandscape
},
isShowingBookshelfToolbar() { isShowingBookshelfToolbar() {
if (!this.$route.name) return false if (!this.$route.name) return false
return this.$route.name.startsWith('library') return this.$route.name.startsWith('library')
@@ -131,6 +143,21 @@ export default {
}, },
numIssues() { numIssues() {
return this.$store.state.libraries.issues || 0 return this.$store.state.libraries.issues || 0
},
versionData() {
return this.$store.state.versionData || {}
},
hasUpdate() {
return !!this.versionData.hasUpdate
},
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() {
return this.versionData.githubTagUrl
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
} }
}, },
methods: {}, methods: {},
+11 -5
View File
@@ -1,14 +1,15 @@
<template> <template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2"> <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }"> <div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</nuxt-link> </nuxt-link>
<div class="flex items-start pl-24 mb-6 md:mb-0"> <div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'">
<div> <div>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
{{ title }} {{ title }}
</nuxt-link> </nuxt-link>
<div class="text-gray-400 flex items-center"> <div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span> <span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor">{{ podcastAuthor }}</p> <p v-if="podcastAuthor">{{ podcastAuthor }}</p>
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base"> <p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
@@ -25,7 +26,7 @@
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span> <span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
</div> </div>
<audio-player <player-ui
ref="audioPlayer" ref="audioPlayer"
:chapters="chapters" :chapters="chapters"
:paused="!isPlaying" :paused="!isPlaying"
@@ -70,7 +71,8 @@ export default {
sleepTimerRemaining: 0, sleepTimerRemaining: 0,
sleepTimer: null, sleepTimer: null,
displayTitle: null, displayTitle: null,
initialPlaybackRate: 1 initialPlaybackRate: 1,
syncFailedToast: null
} }
}, },
computed: { computed: {
@@ -379,6 +381,10 @@ export default {
}, },
pauseItem() { pauseItem() {
this.playerHandler.pause() 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() { mounted() {
+1 -1
View File
@@ -214,7 +214,7 @@ export default {
return this.filterData.languages || [] return this.filterData.languages || []
}, },
progress() { progress() {
return ['Finished', 'In Progress', 'Not Started'] return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
}, },
missing() { missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language'] return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
@@ -1,68 +0,0 @@
<template>
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Bookshelf Texture</p>
</div>
</template>
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
<div class="overflow-y-hidden overflow-x-auto">
<div class="flex -mx-1">
<template v-for="texture in textures">
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
<span class="material-icons text-4xl text-success">check</span>
</div>
</div>
</template>
</div>
</div>
<!-- <div class="flex pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn>
</div> -->
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
processing: false
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showBookshelfTextureModal
},
set(val) {
this.$store.commit('globals/setShowBookshelfTextureModal', val)
}
},
selectedBookshelfTexture() {
return this.$store.state.selectedBookshelfTexture
}
},
methods: {
init() {},
setTexture(img) {
this.$store.dispatch('setBookshelfTexture', img)
}
},
mounted() {}
}
</script>
@@ -87,7 +87,7 @@
<p class="mb-1">{{ playMethodName }}</p> <p class="mb-1">{{ playMethodName }}</p>
<p class="mb-1">{{ _session.mediaPlayer }}</p> <p class="mb-1">{{ _session.mediaPlayer }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p> <p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p> <p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p> <p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p> <p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
@@ -127,6 +127,9 @@ export default {
deviceInfo() { deviceInfo() {
return this._session.deviceInfo || {} return this._session.deviceInfo || {}
}, },
hasDeviceInfo() {
return Object.keys(this.deviceInfo).length
},
osDisplayName() { osDisplayName() {
if (!this.deviceInfo.osName) return null if (!this.deviceInfo.osName) return null
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}` return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
+16 -10
View File
@@ -366,14 +366,18 @@ export default {
}, },
selectMatch(match) { selectMatch(match) {
if (match && match.series) { if (match && match.series) {
match.series = match.series.map((se) => { if (!match.series.length) {
return { delete match.series
id: `new-${Math.floor(Math.random() * 10000)}`, } else {
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series, match.series = match.series.map((se) => {
name: se.series, return {
sequence: se.volumeNumber || '' 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 this.selectedMatch = match
@@ -405,7 +409,9 @@ export default {
updatePayload.metadata.series = seriesPayload updatePayload.metadata.series = seriesPayload
} else if (key === 'author' && !this.isPodcast) { } 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 = [] var authorPayload = []
this.selectedMatch[key].forEach((authorName) => this.selectedMatch[key].forEach((authorName) =>
authorPayload.push({ authorPayload.push({
@@ -437,7 +443,7 @@ export default {
} }
this.isProcessing = true this.isProcessing = true
if (updatePayload.cover) { if (updatePayload.metadata.cover) {
var coverPayload = { var coverPayload = {
url: updatePayload.metadata.cover url: updatePayload.metadata.cover
} }
@@ -0,0 +1,73 @@
<template>
<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-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-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>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
<span class="material-icons">autorenew</span>
</div>
</template>
<div class="flex-grow" />
</div>
</template>
<script>
export default {
props: {
loading: Boolean,
seekLoading: Boolean,
playbackRate: Number,
paused: Boolean,
hasNextChapter: Boolean
},
data() {
return {}
},
computed: {},
methods: {
playPause() {
this.$emit('playPause')
},
prevChapter() {
this.$emit('prevChapter')
},
nextChapter() {
if (!this.hasNextChapter) return
this.$emit('nextChapter')
},
jumpBackward() {
this.$emit('jumpBackward')
},
jumpForward() {
this.$emit('jumpForward')
},
playbackRateUpdated(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
},
playbackRateChanged(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
}
},
mounted() {}
}
</script>
+185
View File
@@ -0,0 +1,185 @@
<template>
<div class="relative">
<!-- Track -->
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" />
</div>
</div>
</div>
</template>
<script>
export default {
props: {
loading: Boolean,
duration: Number,
chapters: {
type: Array,
default: () => []
}
},
data() {
return {
trackWidth: 0,
currentTime: 0,
percentReady: 0,
bufferTime: 0,
chapterTicks: [],
trackOffsetLeft: 16, // Track is 16px from edge
playedTrackWidth: 0,
readyTrackWidth: 0,
bufferTrackWidth: 0
}
},
watch: {
duration: {
immediate: true,
handler() {
this.setChapterTicks()
}
}
},
computed: {},
methods: {
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.$emit('seek', time)
},
setBufferTime(time) {
this.bufferTime = time
this.updateBufferTrack()
},
updateBufferTrack() {
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
},
setPercentageReady(percent) {
this.percentReady = percent
this.updateReadyTrack()
},
updateReadyTrack() {
var widthReady = Math.round(this.trackWidth * this.percentReady)
if (this.readyTrackWidth === widthReady) return
this.readyTrackWidth = widthReady
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
},
setCurrentTime(time) {
this.currentTime = time
this.updatePlayedTrackWidth()
},
updatePlayedTrackWidth() {
var perc = this.currentTime / this.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
if (this.$refs.playedTrack) this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
setChapterTicks() {
this.chapterTicks = this.chapters.map((chap) => {
var perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.duration
console.log('Mousemove track', this.trackWidth, this.duration)
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
var posLeft = offsetX - width / 2
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
posLeft = window.innerWidth - width - this.trackOffsetLeft
} else if (posLeft < -this.trackOffsetLeft) {
posLeft = -this.trackOffsetLeft
}
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampArrow) {
var width = this.$refs.hoverTimestampArrow.clientWidth
var posLeft = offsetX - width / 2
this.$refs.hoverTimestampArrow.style.opacity = 1
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
}
},
mouseleaveTrack() {
if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0
}
if (this.$refs.hoverTimestampArrow) {
this.$refs.hoverTimestampArrow.style.opacity = 0
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0
}
},
setTrackWidth() {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
},
windowResize() {
this.setTrackWidth()
}
},
mounted() {
this.setTrackWidth()
window.addEventListener('resize', this.windowResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.windowResize)
}
}
</script>
@@ -2,6 +2,8 @@
<div class="w-full -mt-6"> <div class="w-full -mt-6">
<div class="w-full relative mb-1"> <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"> <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> -->
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" /> <controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')"> <div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
@@ -21,57 +23,11 @@
</div> </div>
</div> </div>
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2"> <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 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">
<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">
<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>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template>
<template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
<span class="material-icons">autorenew</span>
</div>
</template>
<div class="flex-grow" />
</div>
</div> </div>
<div class="relative">
<!-- Track -->
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp --> <player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" />
</div>
</div>
</div>
<div class="flex"> <div class="flex">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p> <p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p> <p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
@@ -106,17 +62,11 @@ export default {
return { return {
volume: 1, volume: 1,
playbackRate: 1, playbackRate: 1,
trackWidth: 0,
playedTrackWidth: 0,
bufferTrackWidth: 0,
readyTrackWidth: 0,
audioEl: null, audioEl: null,
seekLoading: false, seekLoading: false,
showChaptersModal: false, showChaptersModal: false,
currentTime: 0, currentTime: 0,
trackOffsetLeft: 16, // Track is 16px from edge duration: 0
duration: 0,
chapterTicks: []
} }
}, },
computed: { computed: {
@@ -153,24 +103,46 @@ export default {
}, },
currentChapterName() { currentChapterName() {
return this.currentChapter ? this.currentChapter.title : '' return this.currentChapter ? this.currentChapter.title : ''
},
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: { methods: {
toggleFullscreen(isFullscreen) {
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
var videoPlayerEl = document.getElementById('video-player')
if (videoPlayerEl) {
if (isFullscreen) {
videoPlayerEl.style.width = '100vw'
videoPlayerEl.style.height = '100vh'
videoPlayerEl.style.top = '0px'
videoPlayerEl.style.left = '0px'
} else {
videoPlayerEl.style.width = '384px'
videoPlayerEl.style.height = '216px'
videoPlayerEl.style.top = 'unset'
videoPlayerEl.style.bottom = '80px'
videoPlayerEl.style.left = '16px'
}
}
},
setDuration(duration) { setDuration(duration) {
this.duration = duration this.duration = duration
this.chapterTicks = this.chapters.map((chap) => {
var perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
}, },
setCurrentTime(time) { setCurrentTime(time) {
this.currentTime = time this.currentTime = time
this.updateTimestamp() this.updateTimestamp()
this.updatePlayedTrack() if (this.$refs.trackbar) this.$refs.trackbar.setCurrentTime(time)
}, },
playPause() { playPause() {
this.$emit('playPause') this.$emit('playPause')
@@ -223,67 +195,28 @@ export default {
seek(time) { seek(time) {
this.$emit('seek', time) this.$emit('seek', time)
}, },
playbackRateUpdated(playbackRate) {
this.setPlaybackRate(playbackRate)
},
playbackRateChanged(playbackRate) {
this.setPlaybackRate(playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.duration
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
var posLeft = offsetX - width / 2
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
posLeft = window.innerWidth - width - this.trackOffsetLeft
} else if (posLeft < -this.trackOffsetLeft) {
posLeft = -this.trackOffsetLeft
}
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampArrow) {
var width = this.$refs.hoverTimestampArrow.clientWidth
var posLeft = offsetX - width / 2
this.$refs.hoverTimestampArrow.style.opacity = 1
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
}
},
mouseleaveTrack() {
if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0
}
if (this.$refs.hoverTimestampArrow) {
this.$refs.hoverTimestampArrow.style.opacity = 0
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0
}
},
restart() { restart() {
this.seek(0) 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() { setStreamReady() {
this.readyTrackWidth = this.trackWidth if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
}, },
setChunksReady(chunks, numSegments) { setChunksReady(chunks, numSegments) {
var largestSeg = 0 var largestSeg = 0
@@ -298,10 +231,7 @@ export default {
} }
} }
var percentageReady = largestSeg / numSegments var percentageReady = largestSeg / numSegments
var widthReady = Math.round(this.trackWidth * percentageReady) if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
if (this.readyTrackWidth === widthReady) return
this.readyTrackWidth = widthReady
this.$refs.readyTrack.style.width = widthReady + 'px'
}, },
updateTimestamp() { updateTimestamp() {
var ts = this.$refs.currentTimestamp var ts = this.$refs.currentTimestamp
@@ -312,36 +242,9 @@ export default {
var currTimeClean = this.$secondsToTimestamp(this.currentTime) var currTimeClean = this.$secondsToTimestamp(this.currentTime)
ts.innerText = currTimeClean ts.innerText = currTimeClean
}, },
updatePlayedTrack() {
var perc = this.currentTime / this.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
setBufferTime(bufferTime) { setBufferTime(bufferTime) {
if (!this.audioEl) { if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
return
}
var bufferlen = (bufferTime / this.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
}, },
showChapters() { showChapters() {
if (!this.chapters.length) return if (!this.chapters.length) return
@@ -350,14 +253,6 @@ export default {
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.$emit('setPlaybackRate', this.playbackRate) this.$emit('setPlaybackRate', this.playbackRate)
this.setTrackWidth()
},
setTrackWidth() {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) { if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
@@ -365,6 +260,11 @@ export default {
} }
}, },
closePlayer() { closePlayer() {
if (this.isFullscreen) {
this.toggleFullscreen(false)
return
}
if (this.loading) return if (this.loading) return
this.$emit('close') this.$emit('close')
}, },
@@ -379,19 +279,14 @@ export default {
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate() else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate() else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer() else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
},
windowResize() {
this.setTrackWidth()
} }
}, },
mounted() { mounted() {
window.addEventListener('resize', this.windowResize)
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init() this.init()
this.$eventBus.$on('player-hotkey', this.hotkey) this.$eventBus.$on('player-hotkey', this.hotkey)
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.windowResize)
this.$store.commit('user/removeSettingsListener', 'audioplayer') this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey) this.$eventBus.$off('player-hotkey', this.hotkey)
} }
+12 -3
View File
@@ -1,5 +1,5 @@
<template> <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"> <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"> <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" /> <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, outlined: Boolean,
borderless: Boolean, borderless: Boolean,
loading: Boolean loading: Boolean,
iconFontSize: {
type: String,
default: ''
},
size: {
type: Number,
default: 9
}
}, },
data() { data() {
return {} return {}
}, },
computed: { computed: {
className() { className() {
var classes = [] var classes = [`h-${this.size} w-${this.size}`]
if (!this.borderless) { if (!this.borderless) {
classes.push(`bg-${this.bgColor} border border-gray-600`) classes.push(`bg-${this.bgColor} border border-gray-600`)
} }
return classes.join(' ') return classes.join(' ')
}, },
fontSize() { fontSize() {
if (this.iconFontSize) return this.iconFontSize
if (this.icon === 'edit') return '1.25rem' if (this.icon === 'edit') return '1.25rem'
return '1.4rem' return '1.4rem'
} }
+3 -1
View File
@@ -42,7 +42,8 @@ export default {
editable: { editable: {
type: Boolean, type: Boolean,
default: true default: true
} },
showAllWhenEmpty: Boolean
}, },
data() { data() {
return { return {
@@ -72,6 +73,7 @@ export default {
itemsToShow() { itemsToShow() {
if (!this.editable) return this.items if (!this.editable) return this.items
if (!this.textInput || this.textInput === this.input) { if (!this.textInput || this.textInput === this.input) {
if (this.showAllWhenEmpty) return this.items
return [] return []
} }
return this.items.filter((i) => { return this.items.filter((i) => {
+11 -18
View File
@@ -12,7 +12,6 @@
<modals-item-edit-modal /> <modals-item-edit-modal />
<modals-user-collections-modal /> <modals-user-collections-modal />
<modals-edit-collection-modal /> <modals-edit-collection-modal />
<modals-bookshelf-texture-modal />
<modals-podcast-edit-episode /> <modals-podcast-edit-episode />
<modals-podcast-view-episode /> <modals-podcast-view-episode />
<modals-authors-edit-modal /> <modals-authors-edit-modal />
@@ -516,23 +515,12 @@ export default {
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight }) this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
}, },
checkVersionUpdate() { checkVersionUpdate() {
// Version check is only run if time since last check was 5 minutes this.$store
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes .dispatch('checkForUpdate')
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0 .then((res) => {
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) { if (res && res.hasUpdate) this.showUpdateToast(res)
this.$store })
.dispatch('checkForUpdate') .catch((err) => console.error(err))
.then((res) => {
localStorage.setItem('lastVerCheck', Date.now())
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
}
} }
}, },
beforeMount() { beforeMount() {
@@ -552,6 +540,11 @@ export default {
} }
this.checkVersionUpdate() this.checkVersionUpdate()
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.0.18", "version": "2.0.20",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.0.18", "version": "2.0.20",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+3 -3
View File
@@ -1,13 +1,13 @@
<template> <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="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-48 min-w-48">
<div class="w-full h-52"> <div class="w-full h-52">
<covers-author-image :author="author" rounded="0" /> <covers-author-image :author="author" rounded="0" />
</div> </div>
</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"> <div class="flex items-center mb-8">
<h1 class="text-2xl">{{ author.name }}</h1> <h1 class="text-2xl">{{ author.name }}</h1>
+196
View File
@@ -0,0 +1,196 @@
<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" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">Item</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-32 min-w-32">Listened</th>
<th class="w-16 min-w-16">Last Time</th>
<th class="flex-grow 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 max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<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 text-gray-200">{{ $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 scoped>
.userSessionsTable {
border-collapse: collapse;
width: 100%;
max-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>
+3 -1
View File
@@ -138,7 +138,9 @@ export default {
this.$copyToClipboard(str, this) this.$copyToClipboard(str, this)
}, },
async init() { 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) console.error('Failed to load listening sesions', err)
return [] return []
}) })
+68 -41
View File
@@ -17,40 +17,47 @@
<div class="w-full h-px bg-white bg-opacity-10 my-2" /> <div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-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> <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
<table v-if="listeningSessions.length" class="userSessionsTable"> <div v-if="listeningSessions.length">
<tr class="bg-primary bg-opacity-40"> <table class="userSessionsTable">
<th class="flex-grow text-left">Item</th> <tr class="bg-primary bg-opacity-40">
<th class="w-32 text-left hidden md:table-cell">Play Method</th> <th class="w-48 min-w-48 text-left">Item</th>
<th class="w-40 text-left hidden sm:table-cell">Device Info</th> <th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-20">Listened</th> <th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-20">Last Time</th> <th class="w-32 min-w-32">Listened</th>
<th class="w-40 hidden sm:table-cell">Last Update</th> <th class="w-16 min-w-16">Last Time</th>
</tr> <th class="flex-grow hidden sm:table-cell">Last Update</th>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)"> </tr>
<td class="py-1"> <tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p> <td class="py-1 max-w-48">
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</p> <p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
</td> <p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
<td class="hidden md:table-cell"> </td>
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p> <td class="hidden md:table-cell">
</td> <p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
<td class="hidden sm:table-cell"> </td>
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" /> <td class="hidden sm:table-cell">
</td> <p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
<td class="text-center"> </td>
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p> <td class="text-center">
</td> <p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
<td class="text-center"> </td>
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> <td class="text-center">
</td> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
<td class="text-center hidden sm:table-cell"> </td>
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')"> <td class="text-center hidden sm:table-cell">
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
</ui-tooltip> <p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</td> </ui-tooltip>
</tr> </td>
</table> </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> <p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div> </div>
</div> </div>
@@ -75,7 +82,10 @@ export default {
return { return {
showSessionModal: false, showSessionModal: false,
selectedSession: null, selectedSession: null,
listeningSessions: [] listeningSessions: [],
numPages: 0,
total: 0,
currentPage: 0
} }
}, },
computed: { computed: {
@@ -87,6 +97,12 @@ export default {
} }
}, },
methods: { methods: {
prevPage() {
this.loadSessions(this.currentPage - 1)
},
nextPage() {
this.loadSessions(this.currentPage + 1)
},
showSession(session) { showSession(session) {
this.selectedSession = session this.selectedSession = session
this.showSessionModal = true this.showSessionModal = true
@@ -108,13 +124,23 @@ export default {
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local' else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown' return 'Unknown'
}, },
async init() { async loadSessions(page) {
console.log(navigator) const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=10`).catch((err) => {
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
console.error('Failed to load listening sesions', 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() { mounted() {
@@ -123,10 +149,11 @@ export default {
} }
</script> </script>
<style> <style scoped>
.userSessionsTable { .userSessionsTable {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
max-width: 100%;
border: 1px solid #474747; border: 1px solid #474747;
} }
.userSessionsTable tr:first-child { .userSessionsTable tr:first-child {
+15 -6
View File
@@ -31,11 +31,13 @@
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p> <p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
</div> </div>
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p> <template v-if="!isVideo">
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl"> <p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link> <p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
</p> by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p> </p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
</template>
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link> <nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
@@ -251,6 +253,9 @@ export default {
isPodcast() { isPodcast() {
return this.libraryItem.mediaType === 'podcast' return this.libraryItem.mediaType === 'podcast'
}, },
isVideo() {
return this.libraryItem.mediaType === 'video'
},
isMissing() { isMissing() {
return this.libraryItem.isMissing return this.libraryItem.isMissing
}, },
@@ -258,11 +263,12 @@ export default {
return this.libraryItem.isInvalid return this.libraryItem.isInvalid
}, },
invalidAudioFiles() { invalidAudioFiles() {
if (this.isPodcast) return [] if (this.isPodcast || this.isVideo) return []
return this.libraryItem.media.audioFiles.filter((af) => af.invalid) return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
}, },
showPlayButton() { showPlayButton() {
if (this.isMissing || this.isInvalid) return false if (this.isMissing || this.isInvalid) return false
if (this.isVideo) return !!this.videoFile
if (this.isPodcast) return this.podcastEpisodes.length if (this.isPodcast) return this.podcastEpisodes.length
return this.tracks.length return this.tracks.length
}, },
@@ -348,6 +354,9 @@ export default {
ebookFile() { ebookFile() {
return this.media.ebookFile return this.media.ebookFile
}, },
videoFile() {
return this.media.videoFile
},
showExperimentalReadAlert() { showExperimentalReadAlert() {
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
}, },
+2
View File
@@ -124,6 +124,8 @@ export default class CastPlayer extends EventEmitter {
async seek(time, playWhenReady) { async seek(time, playWhenReady) {
if (!this.player) return if (!this.player) return
this.playWhenReady = playWhenReady
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) { if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
// Change Track // Change Track
var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate) var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)
@@ -1,7 +1,7 @@
import Hls from 'hls.js' import Hls from 'hls.js'
import EventEmitter from 'events' import EventEmitter from 'events'
export default class LocalPlayer extends EventEmitter { export default class LocalAudioPlayer extends EventEmitter {
constructor(ctx) { constructor(ctx) {
super() super()
@@ -71,7 +71,6 @@ export default class LocalPlayer extends EventEmitter {
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`) console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
// Has next track // Has next track
this.currentTrackIndex++ this.currentTrackIndex++
this.playWhenReady = true
this.startTime = this.currentTrack.startOffset this.startTime = this.currentTrack.startOffset
this.loadCurrentTrack() this.loadCurrentTrack()
} else { } else {
@@ -89,6 +88,7 @@ export default class LocalPlayer extends EventEmitter {
} }
this.emit('stateChange', 'LOADED') this.emit('stateChange', 'LOADED')
if (this.playWhenReady) { if (this.playWhenReady) {
this.playWhenReady = false this.playWhenReady = false
this.play() this.play()
@@ -205,10 +205,12 @@ export default class LocalPlayer extends EventEmitter {
} }
play() { play() {
this.playWhenReady = true
if (this.player) this.player.play() if (this.player) this.player.play()
} }
pause() { pause() {
this.playWhenReady = false
if (this.player) this.player.pause() if (this.player) this.player.pause()
} }
@@ -229,8 +231,11 @@ export default class LocalPlayer extends EventEmitter {
this.player.playbackRate = playbackRate this.player.playbackRate = playbackRate
} }
seek(time) { seek(time, playWhenReady) {
if (!this.player) return if (!this.player) return
this.playWhenReady = playWhenReady
if (this.isHlsTranscode) { if (this.isHlsTranscode) {
// Seeking HLS stream // Seeking HLS stream
var offsetTime = time - (this.currentTrack.startOffset || 0) var offsetTime = time - (this.currentTrack.startOffset || 0)
@@ -255,7 +260,6 @@ export default class LocalPlayer extends EventEmitter {
this.player.currentTime = Math.max(0, offsetTime) this.player.currentTime = Math.max(0, offsetTime)
} }
} }
} }
setVolume(volume) { setVolume(volume) {
+260
View File
@@ -0,0 +1,260 @@
import Hls from 'hls.js'
import EventEmitter from 'events'
export default class LocalVideoPlayer extends EventEmitter {
constructor(ctx) {
super()
this.ctx = ctx
this.player = null
this.libraryItem = null
this.videoTrack = null
this.isHlsTranscode = null
this.hlsInstance = null
this.usingNativeplayer = false
this.startTime = 0
this.playWhenReady = false
this.defaultPlaybackRate = 1
this.playableMimeTypes = []
this.initialize()
}
initialize() {
if (document.getElementById('video-player')) {
document.getElementById('video-player').remove()
}
var videoEl = document.createElement('video')
videoEl.id = 'video-player'
// videoEl.style.display = 'none'
videoEl.className = 'absolute bg-black z-50'
videoEl.style.height = '216px'
videoEl.style.width = '384px'
videoEl.style.bottom = '80px'
videoEl.style.left = '16px'
document.body.appendChild(videoEl)
this.player = videoEl
this.player.addEventListener('play', this.evtPlay.bind(this))
this.player.addEventListener('pause', this.evtPause.bind(this))
this.player.addEventListener('progress', this.evtProgress.bind(this))
this.player.addEventListener('ended', this.evtEnded.bind(this))
this.player.addEventListener('error', this.evtError.bind(this))
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
var mimeTypes = ['video/mp4']
var mimeTypeCanPlayMap = {}
mimeTypes.forEach((mt) => {
var canPlay = this.player.canPlayType(mt)
mimeTypeCanPlayMap[mt] = canPlay
if (canPlay) this.playableMimeTypes.push(mt)
})
console.log(`[LocalVideoPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
}
evtPlay() {
this.emit('stateChange', 'PLAYING')
}
evtPause() {
this.emit('stateChange', 'PAUSED')
}
evtProgress() {
var lastBufferTime = this.getLastBufferedTime()
this.emit('buffertimeUpdate', lastBufferTime)
}
evtEnded() {
console.log(`[LocalVideoPlayer] Ended`)
this.emit('finished')
}
evtError(error) {
console.error('Player error', error)
this.emit('error', error)
}
evtLoadedMetadata(data) {
if (!this.isHlsTranscode) {
this.player.currentTime = this.startTime
}
this.emit('stateChange', 'LOADED')
if (this.playWhenReady) {
this.playWhenReady = false
this.play()
}
}
evtTimeupdate() {
if (this.player.paused) {
this.emit('timeupdate', this.getCurrentTime())
}
}
destroy() {
this.destroyHlsInstance()
if (this.player) {
this.player.remove()
}
}
set(libraryItem, videoTrack, isHlsTranscode, startTime, playWhenReady = false) {
this.libraryItem = libraryItem
this.videoTrack = videoTrack
this.isHlsTranscode = isHlsTranscode
this.playWhenReady = playWhenReady
this.startTime = startTime
if (this.hlsInstance) {
this.destroyHlsInstance()
}
if (this.isHlsTranscode) {
this.setHlsStream()
} else {
this.setDirectPlay()
}
}
setHlsStream() {
// iOS does not support Media Elements but allows for HLS in the native video player
if (!Hls.isSupported()) {
console.warn('HLS is not supported - fallback to using video element')
this.usingNativeplayer = true
this.player.src = this.videoTrack.relativeContentUrl
this.player.currentTime = this.startTime
return
}
var hlsOptions = {
startPosition: this.startTime || -1
// No longer needed because token is put in a query string
// xhrSetup: (xhr) => {
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
// }
}
this.hlsInstance = new Hls(hlsOptions)
this.hlsInstance.attachMedia(this.player)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
this.hlsInstance.loadSource(this.videoTrack.relativeContentUrl)
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[HLS] Manifest Parsed')
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details, data)
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.log('[HLS] Destroying HLS Instance')
})
})
}
setDirectPlay() {
this.player.src = this.videoTrack.relativeContentUrl
console.log(`[LocalVideoPlayer] Loading track src ${this.videoTrack.relativeContentUrl}`)
this.player.load()
}
destroyHlsInstance() {
if (!this.hlsInstance) return
if (this.hlsInstance.destroy) {
var temp = this.hlsInstance
temp.destroy()
}
this.hlsInstance = null
}
async resetStream(startTime) {
this.destroyHlsInstance()
await new Promise((resolve) => setTimeout(resolve, 1000))
this.set(this.libraryItem, this.videoTrack, this.isHlsTranscode, startTime, true)
}
playPause() {
if (!this.player) return
if (this.player.paused) this.play()
else this.pause()
}
play() {
if (this.player) this.player.play()
}
pause() {
if (this.player) this.player.pause()
}
getCurrentTime() {
return this.player ? this.player.currentTime : 0
}
getDuration() {
return this.videoTrack.duration
}
setPlaybackRate(playbackRate) {
if (!this.player) return
this.defaultPlaybackRate = playbackRate
this.player.playbackRate = playbackRate
}
seek(time) {
if (!this.player) return
this.player.currentTime = Math.max(0, time)
}
setVolume(volume) {
if (!this.player) return
this.player.volume = volume
}
// Utils
isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
return true
}
return false
}
getBufferedRanges() {
if (!this.player) return []
const ranges = []
const seekable = this.player.buffered || []
let offset = 0
for (let i = 0, length = seekable.length; i < length; i++) {
let start = seekable.start(i)
let end = seekable.end(i)
if (!this.isValidDuration(start)) {
start = 0
}
if (!this.isValidDuration(end)) {
end = 0
continue
}
ranges.push({
start: start + offset,
end: end + offset
})
}
return ranges
}
getLastBufferedTime() {
var bufferedRanges = this.getBufferedRanges()
if (!bufferedRanges.length) return 0
var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)
if (buff) return buff.end
var last = bufferedRanges[bufferedRanges.length - 1]
return last.end
}
}
+55 -22
View File
@@ -1,6 +1,8 @@
import LocalPlayer from './LocalPlayer' import LocalAudioPlayer from './LocalAudioPlayer'
import LocalVideoPlayer from './LocalVideoPlayer'
import CastPlayer from './CastPlayer' import CastPlayer from './CastPlayer'
import AudioTrack from './AudioTrack' import AudioTrack from './AudioTrack'
import VideoTrack from './VideoTrack'
export default class PlayerHandler { export default class PlayerHandler {
constructor(ctx) { constructor(ctx) {
@@ -14,9 +16,11 @@ export default class PlayerHandler {
this.player = null this.player = null
this.playerState = 'IDLE' this.playerState = 'IDLE'
this.isHlsTranscode = false this.isHlsTranscode = false
this.isVideo = false
this.currentSessionId = null this.currentSessionId = null
this.startTime = 0 this.startTime = 0
this.failedProgressSyncs = 0
this.lastSyncTime = 0 this.lastSyncTime = 0
this.lastSyncedAt = 0 this.lastSyncedAt = 0
this.listeningTimeSinceSync = 0 this.listeningTimeSinceSync = 0
@@ -34,7 +38,7 @@ export default class PlayerHandler {
return this.libraryItem && (this.player instanceof CastPlayer) return this.libraryItem && (this.player instanceof CastPlayer)
} }
get isPlayingLocalItem() { get isPlayingLocalItem() {
return this.libraryItem && (this.player instanceof LocalPlayer) return this.libraryItem && (this.player instanceof LocalAudioPlayer)
} }
get userToken() { get userToken() {
return this.ctx.$store.getters['user/getToken'] return this.ctx.$store.getters['user/getToken']
@@ -48,16 +52,17 @@ export default class PlayerHandler {
} }
load(libraryItem, episodeId, playWhenReady, playbackRate) { load(libraryItem, episodeId, playWhenReady, playbackRate) {
if (!this.player) this.switchPlayer()
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.episodeId = episodeId this.episodeId = episodeId
this.playWhenReady = playWhenReady this.playWhenReady = playWhenReady
this.initialPlaybackRate = playbackRate this.initialPlaybackRate = playbackRate
this.prepare() this.isVideo = libraryItem.mediaType === 'video'
if (!this.player) this.switchPlayer(playWhenReady)
else this.prepare()
} }
switchPlayer() { switchPlayer(playWhenReady) {
if (this.isCasting && !(this.player instanceof CastPlayer)) { if (this.isCasting && !(this.player instanceof CastPlayer)) {
console.log('[PlayerHandler] Switching to cast player') console.log('[PlayerHandler] Switching to cast player')
@@ -73,10 +78,10 @@ export default class PlayerHandler {
if (this.libraryItem) { if (this.libraryItem) {
// libraryItem was already loaded - prepare for cast // libraryItem was already loaded - prepare for cast
this.playWhenReady = false this.playWhenReady = playWhenReady
this.prepare() this.prepare()
} }
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) { } else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) {
console.log('[PlayerHandler] Switching to local player') console.log('[PlayerHandler] Switching to local player')
this.stopPlayInterval() this.stopPlayInterval()
@@ -85,12 +90,18 @@ export default class PlayerHandler {
if (this.player) { if (this.player) {
this.player.destroy() this.player.destroy()
} }
this.player = new LocalPlayer(this.ctx)
if (this.isVideo) {
this.player = new LocalVideoPlayer(this.ctx)
} else {
this.player = new LocalAudioPlayer(this.ctx)
}
this.setPlayerListeners() this.setPlayerListeners()
if (this.libraryItem) { if (this.libraryItem) {
// libraryItem was already loaded - prepare for local play // libraryItem was already loaded - prepare for local play
this.playWhenReady = false this.playWhenReady = playWhenReady
this.prepare() this.prepare()
} }
} }
@@ -106,7 +117,7 @@ export default class PlayerHandler {
playerError() { playerError() {
// Switch to HLS stream on error // Switch to HLS stream on error
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalPlayer)) { if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
console.log(`[PlayerHandler] Audio player error switching to HLS stream`) console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
this.prepare(true) this.prepare(true)
} }
@@ -155,7 +166,7 @@ export default class PlayerHandler {
supportedMimeTypes: this.player.playableMimeTypes, supportedMimeTypes: this.player.playableMimeTypes,
mediaPlayer: this.isCasting ? 'chromecast' : 'html5', mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode, forceTranscode,
forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
} }
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play` var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
@@ -166,31 +177,46 @@ export default class PlayerHandler {
} }
prepareOpenSession(session, playbackRate) { // Session opened on init socket prepareOpenSession(session, playbackRate) { // Session opened on init socket
if (!this.player) this.switchPlayer()
this.libraryItem = session.libraryItem this.libraryItem = session.libraryItem
this.isVideo = session.libraryItem.mediaType === 'video'
this.playWhenReady = false this.playWhenReady = false
this.initialPlaybackRate = playbackRate this.initialPlaybackRate = playbackRate
if (!this.player) this.switchPlayer()
this.prepareSession(session) this.prepareSession(session)
} }
prepareSession(session) { prepareSession(session) {
this.failedProgressSyncs = 0
this.startTime = session.currentTime this.startTime = session.currentTime
this.currentSessionId = session.id this.currentSessionId = session.id
this.displayTitle = session.displayTitle this.displayTitle = session.displayTitle
this.displayAuthor = session.displayAuthor this.displayAuthor = session.displayAuthor
console.log('[PlayerHandler] Preparing Session', session) console.log('[PlayerHandler] Preparing Session', session)
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true if (session.videoTrack) {
this.isHlsTranscode = true var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
}
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
} else {
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
}
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
} }
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
// browser media session api // browser media session api
this.ctx.setMediaSession() this.ctx.setMediaSession()
} }
@@ -262,8 +288,15 @@ export default class PlayerHandler {
currentTime currentTime
} }
this.listeningTimeSinceSync = 0 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) console.error('Failed to update session progress', error)
this.failedProgressSyncs++
if (this.failedProgressSyncs >= 2) {
this.ctx.showFailedProgressSyncs()
this.failedProgressSyncs = 0
}
}) })
} }
+32
View File
@@ -0,0 +1,32 @@
export default class VideoTrack {
constructor(track, userToken) {
this.index = track.index || 0
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
this.duration = track.duration || 0
this.title = track.title || ''
this.contentUrl = track.contentUrl || null
this.mimeType = track.mimeType
this.metadata = track.metadata || {}
this.userToken = userToken
}
get fullContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
}
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
}
get relativeContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
}
return this.contentUrl + `?token=${this.userToken}`
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
const SupportedFileTypes = { const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'], image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff'], audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'], info: ['nfo'],
text: ['txt'], text: ['txt'],
+23 -6
View File
@@ -1,4 +1,5 @@
import Vue from 'vue' import Vue from 'vue'
import Path from 'path'
import vClickOutside from 'v-click-outside' import vClickOutside from 'v-click-outside'
import { formatDistance, format, addDays, isDate } from 'date-fns' import { formatDistance, format, addDays, isDate } from 'date-fns'
@@ -119,20 +120,36 @@ Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
if (typeof input !== 'string') { if (typeof input !== 'string') {
return false return false
} }
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
const MAX_FILENAME_LEN = 240
var replacement = '' var replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g; var illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g; var controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/; var reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/; var windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g
var sanitized = input var sanitized = input
.replace(':', colonReplacement) // Replace first occurrence of a colon .replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement) .replace(illegalRe, replacement)
.replace(controlRe, replacement) .replace(controlRe, replacement)
.replace(reservedRe, replacement) .replace(reservedRe, replacement)
.replace(lineBreaks, replacement)
.replace(windowsReservedRe, replacement) .replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement); .replace(windowsTrailingRe, replacement)
if (sanitized.length > MAX_FILENAME_LEN) {
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
var ext = Path.extname(sanitized)
var basename = Path.basename(sanitized, ext)
basename = basename.slice(0, basename.length - lenToRemove)
sanitized = basename + ext
}
return sanitized return sanitized
} }
+6 -3
View File
@@ -23,14 +23,17 @@ function parseSemver(ver) {
} }
return null return null
} }
export const currentVersion = packagejson.version
export async function checkForUpdate() { export async function checkForUpdate() {
if (!packagejson.version) { if (!packagejson.version) {
return return null
} }
var currVerObj = parseSemver('v' + packagejson.version) var currVerObj = parseSemver('v' + packagejson.version)
if (!currVerObj) { if (!currVerObj) {
console.error('Invalid version', packagejson.version) console.error('Invalid version', packagejson.version)
return return null
} }
var largestVer = null var largestVer = null
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => { await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
@@ -49,7 +52,7 @@ export async function checkForUpdate() {
}) })
if (!largestVer) { if (!largestVer) {
console.error('No valid version tags to compare with') console.error('No valid version tags to compare with')
return return null
} }
return { return {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

-4
View File
@@ -11,7 +11,6 @@ export const state = () => ({
selectedEpisode: null, selectedEpisode: null,
selectedCollection: null, selectedCollection: null,
selectedAuthor: null, selectedAuthor: null,
showBookshelfTextureModal: false,
isCasting: false, // Actively casting isCasting: false, // Actively casting
isChromecastInitialized: false // Script loaded isChromecastInitialized: false // Script loaded
}) })
@@ -64,9 +63,6 @@ export const mutations = {
setSelectedEpisode(state, episode) { setSelectedEpisode(state, episode) {
state.selectedEpisode = episode state.selectedEpisode = episode
}, },
setShowBookshelfTextureModal(state, val) {
state.showBookshelfTextureModal = val
},
showEditAuthorModal(state, author) { showEditAuthorModal(state, author) {
state.selectedAuthor = author state.selectedAuthor = author
state.showEditAuthorModal = true state.showEditAuthorModal = true
+43 -19
View File
@@ -1,4 +1,4 @@
import { checkForUpdate } from '@/plugins/version' import { checkForUpdate, currentVersion } from '@/plugins/version'
import Vue from 'vue' import Vue from 'vue'
export const state = () => ({ export const state = () => ({
@@ -8,6 +8,7 @@ export const state = () => ({
streamLibraryItem: null, streamLibraryItem: null,
streamEpisodeId: null, streamEpisodeId: null,
streamIsPlaying: false, streamIsPlaying: false,
playerIsFullscreen: false,
editModalTab: 'details', editModalTab: 'details',
showEditModal: false, showEditModal: false,
showEReader: false, showEReader: false,
@@ -21,7 +22,6 @@ export const state = () => ({
bookshelfBookIds: [], bookshelfBookIds: [],
openModal: null, openModal: null,
innerModalOpen: false, innerModalOpen: false,
selectedBookshelfTexture: '/textures/wood_default.jpg',
lastBookshelfScrollData: {} lastBookshelfScrollData: {}
}) })
@@ -65,20 +65,44 @@ export const actions = {
}) })
}, },
checkForUpdate({ commit }) { checkForUpdate({ commit }) {
return checkForUpdate() const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
.then((res) => { var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
commit('setVersionData', res) var savedVersionData = localStorage.getItem('versionData')
return res if (savedVersionData) {
}) try {
.catch((error) => { savedVersionData = JSON.parse(localStorage.getItem('versionData'))
console.error('Update check failed', error) } catch (error) {
return false console.error('Failed to parse version data', error)
}) savedVersionData = null
}, localStorage.removeItem('versionData')
setBookshelfTexture({ commit, state }, img) { }
let root = document.documentElement; }
commit('setBookshelfTexture', img)
root.style.setProperty('--bookshelf-texture-img', `url(${img})`); var shouldCheckForUpdate = Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF
if (!shouldCheckForUpdate && savedVersionData && savedVersionData.version !== currentVersion) {
// Version mismatch between saved data so check for update anyway
shouldCheckForUpdate = true
}
if (shouldCheckForUpdate) {
return checkForUpdate()
.then((res) => {
if (res) {
localStorage.setItem('lastVerCheck', Date.now())
localStorage.setItem('versionData', JSON.stringify(res))
commit('setVersionData', res)
}
return res && res.hasUpdate
})
.catch((error) => {
console.error('Update check failed', error)
return false
})
} else if (savedVersionData) {
commit('setVersionData', savedVersionData)
}
return null
} }
} }
@@ -86,6 +110,9 @@ export const mutations = {
setSource(state, source) { setSource(state, source) {
state.Source = source state.Source = source
}, },
setPlayerIsFullscreen(state, val) {
state.playerIsFullscreen = val
},
setLastBookshelfScrollData(state, { scrollTop, path, name }) { setLastBookshelfScrollData(state, { scrollTop, path, name }) {
state.lastBookshelfScrollData[name] = { scrollTop, path } state.lastBookshelfScrollData[name] = { scrollTop, path }
}, },
@@ -180,8 +207,5 @@ export const mutations = {
}, },
setInnerModalOpen(state, val) { setInnerModalOpen(state, val) {
state.innerModalOpen = val state.innerModalOpen = val
},
setBookshelfTexture(state, val) {
state.selectedBookshelfTexture = val
} }
} }
+1
View File
@@ -39,6 +39,7 @@ module.exports = {
'6': '1.5rem', '6': '1.5rem',
'12': '3rem', '12': '3rem',
'16': '4rem', '16': '4rem',
'20': '5rem',
'24': '6rem', '24': '6rem',
'32': '8rem', '32': '8rem',
'48': '12rem', '48': '12rem',
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.0.18", "version": "2.0.20",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.0.18", "version": "2.0.20",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+5 -4
View File
@@ -69,7 +69,7 @@ docker run -d \
-e AUDIOBOOKSHELF_GID=100 \ -e AUDIOBOOKSHELF_GID=100 \
-p 13378:80 \ -p 13378:80 \
-v </path/to/audiobooks>:/audiobooks \ -v </path/to/audiobooks>:/audiobooks \
-v </path/to/your/podcasts>:/podcasts \ -v </path/to/podcasts>:/podcasts \
-v </path/to/config>:/config \ -v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \ -v </path/to/metadata>:/metadata \
--name audiobookshelf \ --name audiobookshelf \
@@ -90,6 +90,7 @@ docker start audiobookshelf
### docker-compose.yml ### ### docker-compose.yml ###
services: services:
audiobookshelf: audiobookshelf:
container_name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:latest image: ghcr.io/advplyr/audiobookshelf:latest
environment: environment:
- AUDIOBOOKSHELF_UID=99 - AUDIOBOOKSHELF_UID=99
@@ -97,8 +98,8 @@ services:
ports: ports:
- 13378:80 - 13378:80
volumes: volumes:
- </path/to/your/audiobooks>:/audiobooks - </path/to/audiobooks>:/audiobooks
- </path/to/your/podcasts>:/podcasts - </path/to/podcasts>:/podcasts
- </path/to/config>:/config - </path/to/config>:/config
- </path/to/metadata>:/metadata - </path/to/metadata>:/metadata
``` ```
@@ -195,7 +196,7 @@ server
proxy_redirect http:// https://; proxy_redirect http:// https://;
} }
} }
``` ```
### Apache Reverse Proxy ### Apache Reverse Proxy
+9
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) { selectUserSessions(userId) {
return this.sessionsDb.select((session) => session.userId === userId).then((results) => { return this.sessionsDb.select((session) => session.userId === userId).then((results) => {
return results.data || [] return results.data || []
+1
View File
@@ -209,6 +209,7 @@ class Server {
const dyanimicRoutes = [ const dyanimicRoutes = [
'/item/:id', '/item/:id',
'/item/:id/manage', '/item/:id/manage',
'/author/:id',
'/audiobook/:id/chapters', '/audiobook/:id/chapters',
'/audiobook/:id/edit', '/audiobook/:id/edit',
'/library/:library', '/library/:library',
+1 -1
View File
@@ -184,7 +184,7 @@ class LibraryItemController {
// POST: api/items/:id/play // POST: api/items/:id/play
startPlaybackSession(req, res) { startPlaybackSession(req, res) {
if (!req.libraryItem.media.numTracks) { if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`) Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
+26 -2
View File
@@ -1,5 +1,5 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const { isObject } = require('../utils/index') const { isObject, toNumber } = require('../utils/index')
class MeController { class MeController {
constructor() { } constructor() { }
@@ -7,7 +7,22 @@ class MeController {
// GET: api/me/listening-sessions // GET: api/me/listening-sessions
async getListeningSessions(req, res) { async getListeningSessions(req, res) {
var listeningSessions = await this.getUserListeningSessionsHelper(req.user.id) 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 // GET: api/me/listening-stats
@@ -16,6 +31,15 @@ class MeController {
res.json(listeningStats) 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 // DELETE: api/me/progress/:id
async removeMediaProgress(req, res) { async removeMediaProgress(req, res) {
var wasRemoved = req.user.removeMediaProgress(req.params.id) var wasRemoved = req.user.removeMediaProgress(req.params.id)
+5 -1
View File
@@ -219,7 +219,11 @@ class PodcastController {
}) })
} }
libraryItem.media.removeEpisode(episodeId) // Remove episode from Podcast and library file
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
if (episodeRemoved && episodeRemoved.audioFile) {
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
}
await this.db.updateLibraryItem(libraryItem) await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded()) this.emitter('item_updated', libraryItem.toJSONExpanded())
+41
View File
@@ -1,4 +1,5 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const { toNumber } = require('../utils/index')
class SessionController { class SessionController {
constructor() { } constructor() { }
@@ -7,6 +8,46 @@ class SessionController {
return res.json(req.session) 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 // POST: api/session/:id/sync
sync(req, res) { sync(req, res) {
this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res) this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res)
+18 -2
View File
@@ -1,7 +1,7 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const User = require('../objects/user/User') const User = require('../objects/user/User')
const { getId } = require('../utils/index') const { getId, toNumber } = require('../utils/index')
class UserController { class UserController {
constructor() { } constructor() { }
@@ -142,8 +142,24 @@ class UserController {
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) { if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
return res.sendStatus(403) return res.sendStatus(403)
} }
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id) 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 // GET: api/users/:id/listening-stats
+2 -2
View File
@@ -151,7 +151,7 @@ class AbMergeManager {
input: coverPath, input: coverPath,
options: ['-f image2pipe'] options: ['-f image2pipe']
}) })
ffmpegOptions.push('-vf [2:v]crop=trunc(iw/2)*2:trunc(ih/2)*2') ffmpegOptions.push('-c:v copy')
ffmpegOptions.push('-map 2:v') ffmpegOptions.push('-map 2:v')
} }
@@ -281,4 +281,4 @@ class AbMergeManager {
this.downloads = this.downloads.filter(d => d.id !== download.id) this.downloads = this.downloads.filter(d => d.id !== download.id)
} }
} }
module.exports = AbMergeManager module.exports = AbMergeManager
+2 -2
View File
@@ -84,7 +84,7 @@ class AudioMetadataMangaer {
Ffmpeg premapped id3 tags: https://wiki.multimedia.cx/index.php/FFmpeg_Metadata 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 = { var workerData = {
inputs: ffmpegInputs, inputs: ffmpegInputs,
options: ffmpegOptions, options: ffmpegOptions,
@@ -137,4 +137,4 @@ class AudioMetadataMangaer {
}) })
} }
} }
module.exports = AudioMetadataMangaer module.exports = AudioMetadataMangaer
+3
View File
@@ -141,6 +141,9 @@ class BackupManager {
if (error.message === "Bad archive") { if (error.message === "Bad archive") {
Logger.warn(`[BackupManager] Backup appears to be corrupted: ${fullFilePath}`) Logger.warn(`[BackupManager] Backup appears to be corrupted: ${fullFilePath}`)
continue; continue;
} else if (error.message === "unexpected end of file") {
Logger.warn(`[BackupManager] Backup appears to be corrupted: ${fullFilePath}`)
continue;
} else { } else {
throw error throw error
} }
+27 -18
View File
@@ -119,29 +119,38 @@ class PlaybackSessionManager {
const newPlaybackSession = new PlaybackSession() const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId) newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
var audioTracks = [] if (libraryItem.mediaType === 'video') {
if (shouldDirectPlay) { if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`) Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId) newPlaybackSession.videoTrack = libraryItem.media.getVideoTrack()
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
// HLS not supported for video yet
}
} else { } else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`) var audioTracks = []
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime, this.clientEmitter.bind(this)) if (shouldDirectPlay) {
await stream.generatePlaylist() Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
stream.start() // Start transcode audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime, this.clientEmitter.bind(this))
await stream.generatePlaylist()
stream.start() // Start transcode
audioTracks = [stream.getAudioTrack()] audioTracks = [stream.getAudioTrack()]
newPlaybackSession.stream = stream newPlaybackSession.stream = stream
newPlaybackSession.playMethod = PlayMethod.TRANSCODE newPlaybackSession.playMethod = PlayMethod.TRANSCODE
stream.on('closed', () => { stream.on('closed', () => {
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`) Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`)
newPlaybackSession.stream = null newPlaybackSession.stream = null
}) })
}
newPlaybackSession.audioTracks = audioTracks
} }
newPlaybackSession.audioTracks = audioTracks
// Will save on the first sync // Will save on the first sync
user.currentSessionId = newPlaybackSession.id user.currentSessionId = newPlaybackSession.id
+4 -4
View File
@@ -143,13 +143,13 @@ class PodcastManager {
async probeAudioFile(libraryFile) { async probeAudioFile(libraryFile) {
var path = libraryFile.metadata.path var path = libraryFile.metadata.path
var audioProbeData = await prober.probe(path) var mediaProbeData = await prober.probe(path)
if (audioProbeData.error) { if (mediaProbeData.error) {
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, audioProbeData.error) Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
return false return false
} }
var newAudioFile = new AudioFile() var newAudioFile = new AudioFile()
newAudioFile.setDataFromProbe(libraryFile, audioProbeData) newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
return newAudioFile return newAudioFile
} }
+1 -1
View File
@@ -56,7 +56,7 @@ class Library {
else if (this.icon.endsWith('s') && availableIcons.includes(this.icon.slice(0, -1))) this.icon = this.icon.slice(0, -1) else if (this.icon.endsWith('s') && availableIcons.includes(this.icon.slice(0, -1))) this.icon = this.icon.slice(0, -1)
else this.icon = 'database' else this.icon = 'database'
} }
if (!this.mediaType || (this.mediaType !== 'podcast' && this.mediaType !== 'book')) { if (!this.mediaType || (this.mediaType !== 'podcast' && this.mediaType !== 'book' && this.mediaType !== 'video')) {
this.mediaType = 'book' this.mediaType = 'book'
} }
} }
+22 -8
View File
@@ -6,6 +6,7 @@ const abmetadataGenerator = require('../utils/abmetadataGenerator')
const LibraryFile = require('./files/LibraryFile') const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book') const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast') const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediaTypes/Video')
const { areEquivalent, copyValue, getId } = require('../utils/index') const { areEquivalent, copyValue, getId } = require('../utils/index')
class LibraryItem { class LibraryItem {
@@ -67,11 +68,12 @@ class LibraryItem {
this.mediaType = libraryItem.mediaType this.mediaType = libraryItem.mediaType
if (this.mediaType === 'book') { if (this.mediaType === 'book') {
this.media = new Book(libraryItem.media) this.media = new Book(libraryItem.media)
this.media.libraryItemId = this.id
} else if (this.mediaType === 'podcast') { } else if (this.mediaType === 'podcast') {
this.media = new Podcast(libraryItem.media) this.media = new Podcast(libraryItem.media)
this.media.libraryItemId = this.id } else if (this.mediaType === 'video') {
this.media = new Video(libraryItem.media)
} }
this.media.libraryItemId = this.id
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f)) this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
} }
@@ -175,16 +177,15 @@ class LibraryItem {
// Data comes from scandir library item data // Data comes from scandir library item data
setData(libraryMediaType, payload) { setData(libraryMediaType, payload) {
this.id = getId('li') this.id = getId('li')
if (libraryMediaType === 'podcast') { this.mediaType = libraryMediaType
this.mediaType = 'podcast' if (libraryMediaType === 'video') {
this.media = new Video()
} else if (libraryMediaType === 'podcast') {
this.media = new Podcast() this.media = new Podcast()
this.media.libraryItemId = this.id
} else { } else {
this.mediaType = 'book'
this.media = new Book() this.media = new Book()
this.media.libraryItemId = this.id
} }
this.media.libraryItemId = this.id
for (const key in payload) { for (const key in payload) {
if (key === 'libraryFiles') { if (key === 'libraryFiles') {
@@ -460,6 +461,8 @@ class LibraryItem {
// Saves metadata.abs file // Saves metadata.abs file
async saveMetadata() { async saveMetadata() {
if (this.mediaType === 'video') return
if (this.isSavingMetadata) return if (this.isSavingMetadata) return
this.isSavingMetadata = true this.isSavingMetadata = true
@@ -479,5 +482,16 @@ class LibraryItem {
return success return success
}) })
} }
removeLibraryFile(ino) {
if (!ino) return false
var libraryFile = this.libraryFiles.find(lf => lf.ino === ino)
if (libraryFile) {
this.libraryFiles = this.libraryFiles.filter(lf => lf.ino !== ino)
this.updatedAt = Date.now()
return true
}
return false
}
} }
module.exports = LibraryItem module.exports = LibraryItem
+5
View File
@@ -4,6 +4,7 @@ const { PlayMethod } = require('../utils/constants')
const BookMetadata = require('./metadata/BookMetadata') const BookMetadata = require('./metadata/BookMetadata')
const PodcastMetadata = require('./metadata/PodcastMetadata') const PodcastMetadata = require('./metadata/PodcastMetadata')
const DeviceInfo = require('./DeviceInfo') const DeviceInfo = require('./DeviceInfo')
const VideoMetadata = require('./metadata/VideoMetadata')
class PlaybackSession { class PlaybackSession {
constructor(session) { constructor(session) {
@@ -38,6 +39,7 @@ class PlaybackSession {
// Not saved in DB // Not saved in DB
this.lastSave = 0 this.lastSave = 0
this.audioTracks = [] this.audioTracks = []
this.videoTrack = null
this.stream = null this.stream = null
if (session) { if (session) {
@@ -97,6 +99,7 @@ class PlaybackSession {
startedAt: this.startedAt, startedAt: this.startedAt,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map(at => at.toJSON()), audioTracks: this.audioTracks.map(at => at.toJSON()),
videoTrack: this.videoTrack ? this.videoTrack.toJSON() : null,
libraryItem: libraryItem.toJSONExpanded() libraryItem: libraryItem.toJSONExpanded()
} }
} }
@@ -120,6 +123,8 @@ class PlaybackSession {
this.mediaMetadata = new BookMetadata(session.mediaMetadata) this.mediaMetadata = new BookMetadata(session.mediaMetadata)
} else if (this.mediaType === 'podcast') { } else if (this.mediaType === 'podcast') {
this.mediaMetadata = new PodcastMetadata(session.mediaMetadata) this.mediaMetadata = new PodcastMetadata(session.mediaMetadata)
} else if (this.mediaType === 'video') {
this.mediaMetadata = new VideoMetadata(session.mediaMetadata)
} }
} }
this.displayTitle = session.displayTitle || '' this.displayTitle = session.displayTitle || ''
-1
View File
@@ -21,7 +21,6 @@ class PodcastEpisodeDownload {
toJSONForClient() { toJSONForClient() {
return { return {
id: this.id, id: this.id,
// podcastEpisode: this.podcastEpisode ? this.podcastEpisode.toJSON() : null,
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.bestFilename : null, episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.bestFilename : null,
url: this.url, url: this.url,
libraryItemId: this.libraryItem ? this.libraryItem.id : null, libraryItemId: this.libraryItem ? this.libraryItem.id : null,
+2 -1
View File
@@ -40,13 +40,14 @@ class LibraryFile {
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image' if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio' if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook' if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
if (globals.SupportedVideoTypes.includes(this.metadata.format)) return 'video'
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text' if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata' if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
return 'unknown' return 'unknown'
} }
get isMediaFile() { get isMediaFile() {
return this.fileType === 'audio' || this.fileType === 'ebook' return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video'
} }
get isOPFFile() { get isOPFFile() {
+109
View File
@@ -0,0 +1,109 @@
const { VideoMimeType } = require('../../utils/constants')
const FileMetadata = require('../metadata/FileMetadata')
class VideoFile {
constructor(data) {
this.index = null
this.ino = null
this.metadata = null
this.addedAt = null
this.updatedAt = null
this.format = null
this.duration = null
this.bitRate = null
this.language = null
this.codec = null
this.timeBase = null
this.frameRate = null
this.width = null
this.height = null
this.embeddedCoverArt = null
this.invalid = false
this.error = null
if (data) {
this.construct(data)
}
}
toJSON() {
return {
index: this.index,
ino: this.ino,
metadata: this.metadata.toJSON(),
addedAt: this.addedAt,
updatedAt: this.updatedAt,
invalid: !!this.invalid,
error: this.error || null,
format: this.format,
duration: this.duration,
bitRate: this.bitRate,
language: this.language,
codec: this.codec,
timeBase: this.timeBase,
frameRate: this.frameRate,
width: this.width,
height: this.height,
embeddedCoverArt: this.embeddedCoverArt,
mimeType: this.mimeType
}
}
construct(data) {
this.index = data.index
this.ino = data.ino
this.metadata = new FileMetadata(data.metadata || {})
this.addedAt = data.addedAt
this.updatedAt = data.updatedAt
this.invalid = !!data.invalid
this.error = data.error || null
this.format = data.format
this.duration = data.duration
this.bitRate = data.bitRate
this.language = data.language
this.codec = data.codec || null
this.timeBase = data.timeBase
this.frameRate = data.frameRate
this.width = data.width
this.height = data.height
this.embeddedCoverArt = data.embeddedCoverArt || null
}
get mimeType() {
var format = this.metadata.format.toUpperCase()
if (VideoMimeType[format]) {
return VideoMimeType[format]
} else {
return VideoMimeType.MP4
}
}
clone() {
return new VideoFile(this.toJSON())
}
setDataFromProbe(libraryFile, probeData) {
this.ino = libraryFile.ino || null
this.metadata = libraryFile.metadata.clone()
this.addedAt = Date.now()
this.updatedAt = Date.now()
const videoStream = probeData.videoStream
this.format = probeData.format
this.duration = probeData.duration
this.bitRate = videoStream.bit_rate || probeData.bitRate || null
this.language = probeData.language
this.codec = videoStream.codec || null
this.timeBase = videoStream.time_base
this.frameRate = videoStream.frame_rate || null
this.width = videoStream.width || null
this.height = videoStream.height || null
this.embeddedCoverArt = probeData.embeddedCoverArt
}
}
module.exports = VideoFile
+42
View File
@@ -0,0 +1,42 @@
const Path = require('path')
const { encodeUriPath } = require('../../utils/index')
class VideoTrack {
constructor() {
this.index = null
this.duration = null
this.title = null
this.contentUrl = null
this.mimeType = null
this.metadata = null
}
toJSON() {
return {
index: this.index,
duration: this.duration,
title: this.title,
contentUrl: this.contentUrl,
mimeType: this.mimeType,
metadata: this.metadata ? this.metadata.toJSON() : null
}
}
setData(itemId, videoFile) {
this.index = videoFile.index
this.duration = videoFile.duration
this.title = videoFile.metadata.filename || ''
this.contentUrl = Path.join(`/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
this.mimeType = videoFile.mimeType
this.metadata = videoFile.metadata.clone()
}
setFromStream(title, duration, contentUrl) {
this.index = 1
this.duration = duration
this.title = title
this.contentUrl = contentUrl
this.mimeType = 'application/vnd.apple.mpegurl'
}
}
module.exports = VideoTrack
+5 -1
View File
@@ -240,7 +240,11 @@ class Podcast {
} }
removeEpisode(episodeId) { removeEpisode(episodeId) {
this.episodes = this.episodes.filter(ep => ep.id !== episodeId) const episode = this.episodes.find(ep => ep.id === episodeId)
if (episode) {
this.episodes = this.episodes.filter(ep => ep.id !== episodeId)
}
return episode
} }
getPlaybackTitle(episodeId) { getPlaybackTitle(episodeId) {
+145
View File
@@ -0,0 +1,145 @@
const Logger = require('../../Logger')
const VideoFile = require('../files/VideoFile')
const VideoTrack = require('../files/VideoTrack')
const VideoMetadata = require('../metadata/VideoMetadata')
const { areEquivalent, copyValue } = require('../../utils/index')
class Video {
constructor(video) {
this.libraryItemId = null
this.metadata = null
this.coverPath = null
this.tags = []
this.episodes = []
this.autoDownloadEpisodes = false
this.lastEpisodeCheck = 0
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (video) {
this.construct(video)
}
}
construct(video) {
this.libraryItemId = video.libraryItemId
this.metadata = new VideoMetadata(video.metadata)
this.coverPath = video.coverPath
this.tags = [...video.tags]
this.videoFile = new VideoFile(video.videoFile)
}
toJSON() {
return {
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
videoFile: this.videoFile.toJSON()
}
}
toJSONMinified() {
return {
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
videoFile: this.videoFile.toJSON(),
size: this.size
}
}
toJSONExpanded() {
return {
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
videoFile: this.videoFile.toJSON(),
size: this.size
}
}
get size() {
return this.videoFile.metadata.size
}
get hasMediaEntities() {
return true
}
get shouldSearchForCover() {
return false
}
get hasEmbeddedCoverArt() {
return false
}
get hasIssues() {
return false
}
get duration() {
return 0
}
update(payload) {
var json = this.toJSON()
var hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'metadata') {
if (this.metadata.update(payload.metadata)) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[Video] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
updateCover(coverPath) {
coverPath = coverPath.replace(/\\/g, '/')
if (this.coverPath === coverPath) return false
this.coverPath = coverPath
return true
}
removeFileWithInode(inode) {
}
findFileWithInode(inode) {
return null
}
setVideoFile(videoFile) {
this.videoFile = videoFile
}
setData(mediaMetadata) {
this.metadata = new VideoMetadata()
if (mediaMetadata.metadata) {
this.metadata.setData(mediaMetadata.metadata)
}
this.coverPath = mediaMetadata.coverPath || null
}
getPlaybackTitle() {
return this.metadata.title
}
getPlaybackAuthor() {
return ''
}
getVideoTrack() {
var track = new VideoTrack()
track.setData(this.libraryItemId, this.videoFile)
return track
}
}
module.exports = Video
+97
View File
@@ -0,0 +1,97 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue } = require('../../utils/index')
class VideoMetadata {
constructor(metadata) {
this.title = null
this.description = null
this.explicit = false
this.language = null
if (metadata) {
this.construct(metadata)
}
}
construct(metadata) {
this.title = metadata.title
this.description = metadata.description
this.explicit = metadata.explicit
this.language = metadata.language || null
}
toJSON() {
return {
title: this.title,
description: this.description,
explicit: this.explicit,
language: this.language
}
}
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titleIgnorePrefix,
description: this.description,
explicit: this.explicit,
language: this.language
}
}
toJSONExpanded() {
return this.toJSONMinified()
}
clone() {
return new VideoMetadata(this.toJSON())
}
get titleIgnorePrefix() {
if (!this.title) return ''
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
for (const prefix of prefixesToIgnore) {
// e.g. for prefix "the". If title is "The Book Title" return "Book Title, The"
if (this.title.toLowerCase().startsWith(`${prefix} `)) {
return this.title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
}
}
return this.title
}
searchQuery(query) { // Returns key if match is found
var keysToCheck = ['title']
for (var key of keysToCheck) {
if (this[key] && String(this[key]).toLowerCase().includes(query)) {
return {
matchKey: key,
matchText: this[key]
}
}
}
return null
}
setData(mediaMetadata = {}) {
this.title = mediaMetadata.title || null
this.description = mediaMetadata.description || null
this.explicit = !!mediaMetadata.explicit
this.language = mediaMetadata.language || null
}
update(payload) {
var json = this.toJSON()
var hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[VideoMetadata] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = VideoMetadata
+1 -1
View File
@@ -91,7 +91,7 @@ class MediaProgress {
var timeRemaining = this.duration - this.currentTime var timeRemaining = this.duration - this.currentTime
// If time remaining is less than 5 seconds then mark as finished // If time remaining is less than 5 seconds then mark as finished
if ((this.progress >= 1 || (!isNaN(timeRemaining) && timeRemaining < 5)) && !this.isFinished) { if ((this.progress >= 1 || (!isNaN(timeRemaining) && timeRemaining < 5))) {
this.isFinished = true this.isFinished = true
this.finishedAt = Date.now() this.finishedAt = Date.now()
this.progress = 1 this.progress = 1
+1
View File
@@ -343,6 +343,7 @@ class User {
checkCanAccessLibraryItem(libraryItem) { checkCanAccessLibraryItem(libraryItem) {
if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false
if (libraryItem.media.metadata.explicit && !this.canAccessExplicitContent) return false if (libraryItem.media.metadata.explicit && !this.canAccessExplicitContent) return false
return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags) return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags)
} }
+16
View File
@@ -131,6 +131,7 @@ class ApiRouter {
// //
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this)) this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
this.router.get('/me/listening-stats', MeController.getListeningStats.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/batch/update', MeController.batchUpdateMediaProgress.bind(this))
this.router.patch('/me/progress/:id', MeController.createUpdateMediaProgress.bind(this)) this.router.patch('/me/progress/:id', MeController.createUpdateMediaProgress.bind(this))
this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this)) this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this))
@@ -173,6 +174,8 @@ class ApiRouter {
// //
// Playback Session Routes // 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/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/:id/close', SessionController.middleware.bind(this), SessionController.close.bind(this))
this.router.post('/session/local', SessionController.syncLocal.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) 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) { async getUserListeningStatsHelpers(userId) {
const today = date.format(new Date(), 'YYYY-MM-DD') const today = date.format(new Date(), 'YYYY-MM-DD')
+277
View File
@@ -0,0 +1,277 @@
const Path = require('path')
const AudioFile = require('../objects/files/AudioFile')
const VideoFile = require('../objects/files/VideoFile')
const prober = require('../utils/prober')
const Logger = require('../Logger')
const { LogLevel } = require('../utils/constants')
class MediaFileScanner {
constructor() { }
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
const { title, author, series, publishedYear } = mediaMetadataFromScan
const { filename, path } = audioLibraryFile.metadata
var partbasename = Path.basename(filename, Path.extname(filename))
// Remove title, author, series, and publishedYear from filename if there
if (title) partbasename = partbasename.replace(title, '')
if (author) partbasename = partbasename.replace(author, '')
if (series) partbasename = partbasename.replace(series, '')
if (publishedYear) partbasename = partbasename.replace(publishedYear)
// Look for disc number
var discNumber = null
var discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
if (discMatch && discMatch.length > 2 && discMatch[2]) {
if (!isNaN(discMatch[2])) {
discNumber = Number(discMatch[2])
}
// Remove disc number from filename
partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '')
}
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
var pathdir = Path.dirname(path).split('/').pop()
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
var discFromFolder = Number(pathdir.replace(/cd/i, ''))
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
}
var numbersinpath = partbasename.match(/\d{1,4}/g)
var trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
return {
trackNumber,
discNumber
}
}
getAverageScanDurationMs(results) {
if (!results.length) return 0
var total = 0
for (let i = 0; i < results.length; i++) total += results[i].elapsed
return Math.floor(total / results.length)
}
async scan(mediaType, libraryFile, mediaMetadataFromScan, verbose = false) {
var probeStart = Date.now()
var probeData = await prober.probe(libraryFile.metadata.path, verbose)
if (probeData.error) {
Logger.error(`[MediaFileScanner] ${probeData.error} : "${libraryFile.metadata.path}"`)
return null
}
if (mediaType === 'video') {
if (!probeData.videoStream) {
Logger.error('[MediaFileScanner] Invalid video file no video stream')
return null
}
var videoFile = new VideoFile()
videoFile.setDataFromProbe(libraryFile, probeData)
return {
videoFile,
elapsed: Date.now() - probeStart
}
} else {
if (!probeData.audioStream) {
Logger.error('[MediaFileScanner] Invalid audio file no audio stream')
return null
}
var audioFile = new AudioFile()
audioFile.trackNumFromMeta = probeData.trackNumber
audioFile.discNumFromMeta = probeData.discNumber
if (mediaType === 'book') {
const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, libraryFile)
audioFile.trackNumFromFilename = trackNumber
audioFile.discNumFromFilename = discNumber
}
audioFile.setDataFromProbe(libraryFile, probeData)
return {
audioFile,
elapsed: Date.now() - probeStart
}
}
}
// Returns array of { MediaFile, elapsed, averageScanDuration } from audio file scan objects
async executeMediaFileScans(mediaType, mediaLibraryFiles, scanData) {
var mediaMetadataFromScan = scanData.media.metadata || null
var proms = []
for (let i = 0; i < mediaLibraryFiles.length; i++) {
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan))
}
var scanStart = Date.now()
var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))
return {
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),
elapsed: Date.now() - scanStart,
averageScanDuration: this.getAverageScanDurationMs(results)
}
}
isSequential(nums) {
if (!nums || !nums.length) return false
if (nums.length === 1) return true
var prev = nums[0]
for (let i = 1; i < nums.length; i++) {
if (nums[i] - prev > 1) return false
prev = nums[i]
}
return true
}
removeDupes(nums) {
if (!nums || !nums.length) return []
if (nums.length === 1) return nums
var nodupes = [nums[0]]
nums.forEach((num) => {
if (num > nodupes[nodupes.length - 1]) nodupes.push(num)
})
return nodupes
}
runSmartTrackOrder(libraryItem, audioFiles) {
var discsFromFilename = []
var tracksFromFilename = []
var discsFromMeta = []
var tracksFromMeta = []
audioFiles.forEach((af) => {
if (af.discNumFromFilename !== null) discsFromFilename.push(af.discNumFromFilename)
if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta)
if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename)
if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta)
af.validateTrackIndex() // Sets error if no valid track number
})
discsFromFilename.sort((a, b) => a - b)
discsFromMeta.sort((a, b) => a - b)
tracksFromFilename.sort((a, b) => a - b)
tracksFromMeta.sort((a, b) => a - b)
var discKey = null
if (discsFromMeta.length === audioFiles.length && this.isSequential(discsFromMeta)) {
discKey = 'discNumFromMeta'
} else if (discsFromFilename.length === audioFiles.length && this.isSequential(discsFromFilename)) {
discKey = 'discNumFromFilename'
}
var trackKey = null
tracksFromFilename = this.removeDupes(tracksFromFilename)
tracksFromMeta = this.removeDupes(tracksFromMeta)
if (tracksFromFilename.length > tracksFromMeta.length) {
trackKey = 'trackNumFromFilename'
} else {
trackKey = 'trackNumFromMeta'
}
if (discKey !== null) {
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using disc key ${discKey} and track key ${trackKey}`)
audioFiles.sort((a, b) => {
let Dx = a[discKey] - b[discKey]
if (Dx === 0) Dx = a[trackKey] - b[trackKey]
return Dx
})
} else {
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using track key ${trackKey}`)
audioFiles.sort((a, b) => a[trackKey] - b[trackKey])
}
for (let i = 0; i < audioFiles.length; i++) {
audioFiles[i].index = i + 1
var existingAF = libraryItem.media.findFileWithInode(audioFiles[i].ino)
if (existingAF) {
if (existingAF.updateFromScan) existingAF.updateFromScan(audioFiles[i])
} else {
libraryItem.media.addAudioFile(audioFiles[i])
}
}
}
async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, libraryScan = null) {
var hasUpdated = false
var mediaScanResult = await this.executeMediaFileScans(libraryItem.mediaType, mediaLibraryFiles, scanData)
if (libraryItem.mediaType === 'video') {
if (mediaScanResult.videoFiles.length) {
// TODO: Check for updates etc
hasUpdated = true
libraryItem.media.setVideoFile(mediaScanResult.videoFiles[0])
}
} else if (mediaScanResult.audioFiles.length) {
if (libraryScan) {
libraryScan.addLog(LogLevel.DEBUG, `Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`)
}
var totalAudioFilesToInclude = mediaScanResult.audioFiles.length
var newAudioFiles = mediaScanResult.audioFiles.filter(af => {
return !libraryItem.media.findFileWithInode(af.ino)
})
// Book: Adding audio files to book media
if (libraryItem.mediaType === 'book') {
if (newAudioFiles.length) {
// Single Track Audiobooks
if (totalAudioFilesToInclude === 1) {
var af = mediaScanResult.audioFiles[0]
af.index = 1
libraryItem.media.addAudioFile(af)
hasUpdated = true
} else {
this.runSmartTrackOrder(libraryItem, mediaScanResult.audioFiles)
hasUpdated = true
}
} else {
// Only update metadata not index
mediaScanResult.audioFiles.forEach((af) => {
var existingAF = libraryItem.media.findFileWithInode(af.ino)
if (existingAF) {
af.index = existingAF.index
if (existingAF.updateFromScan && existingAF.updateFromScan(af)) {
hasUpdated = true
}
}
})
}
// Set book details from audio file ID3 tags, optional prefer
if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) {
hasUpdated = true
}
if (hasUpdated) {
libraryItem.media.rebuildTracks()
}
} else { // Podcast Media Type
var existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino))
if (newAudioFiles.length) {
var newIndex = libraryItem.media.episodes.length + 1
newAudioFiles.forEach((newAudioFile) => {
libraryItem.media.addNewEpisodeFromAudioFile(newAudioFile, newIndex++)
})
libraryItem.media.reorderEpisodes()
hasUpdated = true
}
// Update audio file metadata for audio files already there
existingAudioFiles.forEach((af) => {
var peAudioFile = libraryItem.media.findFileWithInode(af.ino)
if (peAudioFile.updateFromScan && peAudioFile.updateFromScan(af)) {
hasUpdated = true
}
})
}
}
return hasUpdated
}
}
module.exports = new MediaFileScanner()
@@ -1,11 +1,15 @@
const AudioFileMetadata = require('../objects/metadata/AudioMetaTags') const AudioFileMetadata = require('../objects/metadata/AudioMetaTags')
class AudioProbeData { class MediaProbeData {
constructor() { constructor() {
this.embeddedCoverArt = null this.embeddedCoverArt = null
this.format = null this.format = null
this.duration = null this.duration = null
this.size = null this.size = null
this.audioStream = null
this.videoStream = null
this.bitRate = null this.bitRate = null
this.codec = null this.codec = null
this.timeBase = null this.timeBase = null
@@ -35,6 +39,10 @@ class AudioProbeData {
this.format = data.format this.format = data.format
this.duration = data.duration this.duration = data.duration
this.size = data.size this.size = data.size
this.audioStream = audioStream
this.videoStream = this.embeddedCoverArt ? null : data.video_stream || null
this.bitRate = audioStream.bit_rate || data.bit_rate this.bitRate = audioStream.bit_rate || data.bit_rate
this.codec = audioStream.codec this.codec = audioStream.codec
this.timeBase = audioStream.time_base this.timeBase = audioStream.time_base
@@ -78,4 +86,4 @@ class AudioProbeData {
} }
} }
} }
module.exports = AudioProbeData module.exports = MediaProbeData
+14 -14
View File
@@ -8,7 +8,7 @@ const { comparePaths } = require('../utils/index')
const { getIno } = require('../utils/fileUtils') const { getIno } = require('../utils/fileUtils')
const { ScanResult, LogLevel } = require('../utils/constants') const { ScanResult, LogLevel } = require('../utils/constants')
const AudioFileScanner = require('./AudioFileScanner') const MediaFileScanner = require('./MediaFileScanner')
const BookFinder = require('../finders/BookFinder') const BookFinder = require('../finders/BookFinder')
const LibraryItem = require('../objects/LibraryItem') const LibraryItem = require('../objects/LibraryItem')
const LibraryScan = require('./LibraryScan') const LibraryScan = require('./LibraryScan')
@@ -80,7 +80,7 @@ class Scanner {
// Scan all audio files // Scan all audio files
if (libraryItem.hasAudioFiles) { if (libraryItem.hasAudioFiles) {
var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio') var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio')
if (await AudioFileScanner.scanAudioFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) { if (await MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) {
hasUpdated = true hasUpdated = true
} }
@@ -240,18 +240,18 @@ class Scanner {
if (!hasMediaFile) { if (!hasMediaFile) {
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`) libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
} else { } else {
var audioFileSize = 0 var mediaFileSize = 0
dataFound.libraryFiles.filter(lf => lf.fileType == 'audio').forEach(lf => audioFileSize += lf.metadata.size) dataFound.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video').forEach(lf => mediaFileSize += lf.metadata.size)
// If this item will go over max size then push current chunk // If this item will go over max size then push current chunk
if (audioFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) { if (mediaFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
newItemDataToScanChunks.push(newItemDataToScan) newItemDataToScanChunks.push(newItemDataToScan)
newItemDataToScanSize = 0 newItemDataToScanSize = 0
newItemDataToScan = [] newItemDataToScan = []
} }
newItemDataToScan.push(dataFound) newItemDataToScan.push(dataFound)
newItemDataToScanSize += audioFileSize newItemDataToScanSize += mediaFileSize
if (newItemDataToScanSize >= MaxSizePerChunk) { if (newItemDataToScanSize >= MaxSizePerChunk) {
newItemDataToScanChunks.push(newItemDataToScan) newItemDataToScanChunks.push(newItemDataToScan)
newItemDataToScanSize = 0 newItemDataToScanSize = 0
@@ -337,7 +337,7 @@ class Scanner {
// forceRescan all existing audio files - will probe and update ID3 tag metadata // forceRescan all existing audio files - will probe and update ID3 tag metadata
var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio') var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) { if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) {
if (await AudioFileScanner.scanAudioFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) { if (await MediaFileScanner.scanMediaFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
hasUpdated = true hasUpdated = true
} }
} }
@@ -345,7 +345,7 @@ class Scanner {
var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio') var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio') var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
if (newAudioFiles.length || removedAudioFiles.length) { if (newAudioFiles.length || removedAudioFiles.length) {
if (await AudioFileScanner.scanAudioFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) { if (await MediaFileScanner.scanMediaFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
hasUpdated = true hasUpdated = true
} }
} }
@@ -386,9 +386,9 @@ class Scanner {
var libraryItem = new LibraryItem() var libraryItem = new LibraryItem()
libraryItem.setData(libraryMediaType, libraryItemData) libraryItem.setData(libraryMediaType, libraryItemData)
var audioFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio') var mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video')
if (audioFiles.length) { if (mediaFiles.length) {
await AudioFileScanner.scanAudioFiles(audioFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan) await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
} }
await libraryItem.syncFiles(preferOpfMetadata) await libraryItem.syncFiles(preferOpfMetadata)
@@ -408,7 +408,7 @@ class Scanner {
} }
// Scan for cover if enabled and has no cover // Scan for cover if enabled and has no cover
if (libraryMediaType !== 'podcast') { if (libraryMediaType === 'book') {
if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) { if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
var updatedCover = await this.searchForCover(libraryItem, libraryScan) var updatedCover = await this.searchForCover(libraryItem, libraryScan)
libraryItem.media.updateLastCoverSearch(updatedCover) libraryItem.media.updateLastCoverSearch(updatedCover)
@@ -696,7 +696,7 @@ class Scanner {
} }
// Add or set author if not set // 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] if (!Array.isArray(matchData.author)) matchData.author = [matchData.author]
const authorPayload = [] const authorPayload = []
for (let index = 0; index < matchData.author.length; index++) { for (let index = 0; index < matchData.author.length; index++) {
@@ -714,7 +714,7 @@ class Scanner {
} }
// Add or set series if not set // 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 }] if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, volumeNumber: matchData.volumeNumber }]
const seriesPayload = [] const seriesPayload = []
for (let index = 0; index < matchData.series.length; index++) { for (let index = 0; index < matchData.series.length; index++) {
+4
View File
@@ -44,4 +44,8 @@ module.exports.AudioMimeType = {
FLAC: 'audio/flac', FLAC: 'audio/flac',
WMA: 'audio/x-ms-wma', WMA: 'audio/x-ms-wma',
AIFF: 'audio/x-aiff' AIFF: 'audio/x-aiff'
}
module.exports.VideoMimeType = {
MP4: 'video/mp4'
} }
+20 -6
View File
@@ -181,19 +181,33 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
return false return false
} }
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
const MAX_FILENAME_LEN = 240
var replacement = '' var replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g; var illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g; var controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/; var reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/; var windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g
sanitized = filename sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon .replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement) .replace(illegalRe, replacement)
.replace(controlRe, replacement) .replace(controlRe, replacement)
.replace(reservedRe, replacement) .replace(reservedRe, replacement)
.replace(lineBreaks, replacement)
.replace(windowsReservedRe, replacement) .replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement); .replace(windowsTrailingRe, replacement)
if (sanitized.length > MAX_FILENAME_LEN) {
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
var ext = Path.extname(sanitized)
var basename = Path.basename(sanitized, ext)
basename = basename.slice(0, basename.length - lenToRemove)
sanitized = basename + ext
}
return sanitized return sanitized
} }
+2 -1
View File
@@ -1,7 +1,8 @@
const globals = { const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff'], SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
SupportedVideoTypes: ['mp4'],
TextFileTypes: ['txt', 'nfo'], TextFileTypes: ['txt', 'nfo'],
MetadataFileTypes: ['opf', 'abs', 'xml'] MetadataFileTypes: ['opf', 'abs', 'xml']
} }
+5
View File
@@ -124,4 +124,9 @@ module.exports.copyValue = (val) => {
module.exports.encodeUriPath = (path) => { module.exports.encodeUriPath = (path) => {
return path.replace(/\\/g, '/').replace(/%/g, '%25').replace(/#/g, '%23') 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)
} }
+3 -2
View File
@@ -32,6 +32,7 @@ module.exports = {
var itemProgress = user.getMediaProgress(li.id) var itemProgress = user.getMediaProgress(li.id)
if (filter === 'Finished' && (itemProgress && itemProgress.isFinished)) return true if (filter === 'Finished' && (itemProgress && itemProgress.isFinished)) return true
if (filter === 'Not Started' && !itemProgress) 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 if (filter === 'In Progress' && (itemProgress && itemProgress.inProgress)) return true
return false return false
}) })
@@ -85,7 +86,7 @@ module.exports = {
if (series && !data.series.find(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name }) if (series && !data.series.find(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name })
}) })
} }
if (mediaMetadata.genres.length) { if (mediaMetadata.genres && mediaMetadata.genres.length) {
mediaMetadata.genres.forEach((genre) => { mediaMetadata.genres.forEach((genre) => {
if (genre && !data.genres.includes(genre)) data.genres.push(genre) if (genre && !data.genres.includes(genre)) data.genres.push(genre)
}) })
@@ -399,7 +400,7 @@ module.exports = {
} }
} }
} }
} else { } else if (libraryItem.isBook) {
// Book categories // Book categories
// Newest series // Newest series
+5 -5
View File
@@ -1,5 +1,5 @@
const ffprobe = require('node-ffprobe') const ffprobe = require('node-ffprobe')
const AudioProbeData = require('../scanner/AudioProbeData') const MediaProbeData = require('../scanner/MediaProbeData')
const Logger = require('../Logger') const Logger = require('../Logger')
@@ -274,7 +274,7 @@ function parseProbeData(data, verbose = false) {
} }
} }
// Updated probe returns AudioProbeData object // Updated probe returns MediaProbeData object
function probe(filepath, verbose = false) { function probe(filepath, verbose = false) {
if (process.env.FFPROBE_PATH) { if (process.env.FFPROBE_PATH) {
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
@@ -283,12 +283,12 @@ function probe(filepath, verbose = false) {
return ffprobe(filepath) return ffprobe(filepath)
.then(raw => { .then(raw => {
var rawProbeData = parseProbeData(raw, verbose) var rawProbeData = parseProbeData(raw, verbose)
if (!rawProbeData || !rawProbeData.audio_stream) { if (!rawProbeData || (!rawProbeData.audio_stream && !rawProbeData.video_stream)) {
return { return {
error: rawProbeData ? 'Invalid audio file: no audio streams found' : 'Probe Failed' error: rawProbeData ? 'Invalid media file: no audio or video streams found' : 'Probe Failed'
} }
} else { } else {
var probeData = new AudioProbeData() var probeData = new MediaProbeData()
probeData.setData(rawProbeData) probeData.setData(rawProbeData)
return probeData return probeData
} }
+10 -7
View File
@@ -11,6 +11,7 @@ function isMediaFile(mediaType, ext) {
if (!ext) return false if (!ext) return false
var extclean = ext.slice(1).toLowerCase() var extclean = ext.slice(1).toLowerCase()
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean) if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
else if (mediaType === 'video') return globals.SupportedVideoTypes.includes(extclean)
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean) return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
} }
@@ -72,7 +73,7 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
// Step 1: Filter out non-book-media files in root dir (with depth of 0) // Step 1: Filter out non-book-media files in root dir (with depth of 0)
var itemsFiltered = fileItems.filter(i => { var itemsFiltered = fileItems.filter(i => {
return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension)) return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video') && isMediaFile(mediaType, i.extension))
}) })
// Step 2: Seperate media files and other files // Step 2: Seperate media files and other files
@@ -136,7 +137,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
} }
function cleanFileObjects(libraryItemPath, files) { function cleanFileObjects(libraryItemPath, files) {
return Promise.all(files.map(async(file) => { return Promise.all(files.map(async (file) => {
var filePath = Path.posix.join(libraryItemPath, file) var filePath = Path.posix.join(libraryItemPath, file)
var newLibraryFile = new LibraryFile() var newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(filePath, file) await newLibraryFile.setDataFromPath(filePath, file)
@@ -314,9 +315,11 @@ function getPodcastDataFromDir(folderPath, relPath) {
function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) { function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) {
if (libraryMediaType === 'podcast') { if (libraryMediaType === 'podcast') {
return getPodcastDataFromDir(folderPath, relPath) return getPodcastDataFromDir(folderPath, relPath)
} else { } else if (libraryMediaType === 'book') {
var parseSubtitle = !!serverSettings.scannerParseSubtitle var parseSubtitle = !!serverSettings.scannerParseSubtitle
return getBookDataFromDir(folderPath, relPath, parseSubtitle) return getBookDataFromDir(folderPath, relPath, parseSubtitle)
} else {
return this.getPodcastDataFromDir(folderPath, relPath)
} }
} }
@@ -333,10 +336,10 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
if (isSingleMediaItem) { // Single media item in root of folder if (isSingleMediaItem) { // Single media item in root of folder
fileItems = [ fileItems = [
{ {
fullpath: libraryItemPath, fullpath: libraryItemPath,
path: libraryItemDir // actually the relPath (only filename here) path: libraryItemDir // actually the relPath (only filename here)
} }
] ]
libraryItemData = { libraryItemData = {
path: libraryItemPath, // full path path: libraryItemPath, // full path
relPath: libraryItemDir, // only filename relPath: libraryItemDir, // only filename