mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
Merge e1ceac63e2 into 6f03467f35
This commit is contained in:
commit
e2017a6b31
7 changed files with 277 additions and 31 deletions
73
.github/workflows/build-and-publish.yaml
vendored
Normal file
73
.github/workflows/build-and-publish.yaml
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '5108-chunked-upload-support'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '5108-chunked-upload-support'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
# This automatically resolves to kc9wwh/audiobookshelf
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-build:
|
||||||
|
name: Test Node Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20' # Standard for modern ABS
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test --if-present
|
||||||
|
|
||||||
|
docker-publish:
|
||||||
|
name: Build and Push to GHCR
|
||||||
|
needs: test-and-build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write # Critical: allows pushing the image to ghcr.io
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha
|
||||||
|
# This creates a tag like ghcr.io/kc9wwh/audiobookshelf:5108-chunked-upload-support
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
@ -307,6 +307,14 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async uploadItem(item) {
|
async uploadItem(item) {
|
||||||
|
const CHUNK_SIZE = 50 * 1024 * 1024 // 50MB
|
||||||
|
let allFilesSuccessful = true
|
||||||
|
|
||||||
|
for (let fileIndex = 0; fileIndex < item.files.length; fileIndex++) {
|
||||||
|
const file = item.files[fileIndex]
|
||||||
|
|
||||||
|
if (file.size <= CHUNK_SIZE) {
|
||||||
|
// Legacy upload for small files
|
||||||
var form = new FormData()
|
var form = new FormData()
|
||||||
form.set('title', item.title)
|
form.set('title', item.title)
|
||||||
if (!this.selectedLibraryIsPodcast) {
|
if (!this.selectedLibraryIsPodcast) {
|
||||||
|
|
@ -315,25 +323,17 @@ export default {
|
||||||
}
|
}
|
||||||
form.set('library', this.selectedLibraryId)
|
form.set('library', this.selectedLibraryId)
|
||||||
form.set('folder', this.selectedFolderId)
|
form.set('folder', this.selectedFolderId)
|
||||||
|
form.set(`${fileIndex}`, file)
|
||||||
var index = 0
|
|
||||||
item.files.forEach((file) => {
|
|
||||||
form.set(`${index++}`, file)
|
|
||||||
})
|
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
if (progressEvent.lengthComputable) {
|
if (progressEvent.lengthComputable) {
|
||||||
const progress = {
|
this.updateItemCardProgress(item.index, { loaded: progressEvent.loaded, total: progressEvent.total })
|
||||||
loaded: progressEvent.loaded,
|
|
||||||
total: progressEvent.total
|
|
||||||
}
|
|
||||||
this.updateItemCardProgress(item.index, progress)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.$axios
|
const success = await this.$axios
|
||||||
.$post('/api/upload', form, config)
|
.$post('/api/upload', form, config)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
@ -341,6 +341,67 @@ export default {
|
||||||
this.$toast.error(error.response?.data || 'Oops, something went wrong...')
|
this.$toast.error(error.response?.data || 'Oops, something went wrong...')
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
if (!success) allFilesSuccessful = false
|
||||||
|
} else {
|
||||||
|
// Chunked upload
|
||||||
|
const fileId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2) + Date.now().toString(36)
|
||||||
|
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
|
||||||
|
|
||||||
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||||
|
const start = chunkIndex * CHUNK_SIZE
|
||||||
|
const end = Math.min(start + CHUNK_SIZE, file.size)
|
||||||
|
const chunk = file.slice(start, end)
|
||||||
|
|
||||||
|
var chunkForm = new FormData()
|
||||||
|
chunkForm.set('fileId', fileId)
|
||||||
|
chunkForm.set('chunkIndex', chunkIndex)
|
||||||
|
chunkForm.set('totalChunks', totalChunks)
|
||||||
|
chunkForm.set('filename', file.name)
|
||||||
|
chunkForm.set('title', item.title)
|
||||||
|
if (!this.selectedLibraryIsPodcast) {
|
||||||
|
chunkForm.set('author', item.author || '')
|
||||||
|
chunkForm.set('series', item.series || '')
|
||||||
|
}
|
||||||
|
chunkForm.set('library', this.selectedLibraryId)
|
||||||
|
chunkForm.set('folder', this.selectedFolderId)
|
||||||
|
chunkForm.set('chunk', chunk)
|
||||||
|
|
||||||
|
let retryCount = 0
|
||||||
|
const maxRetries = 1
|
||||||
|
let chunkSuccess = false
|
||||||
|
|
||||||
|
while (retryCount <= maxRetries && !chunkSuccess) {
|
||||||
|
try {
|
||||||
|
const config = {
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (progressEvent.lengthComputable) {
|
||||||
|
// Calculate overall progress for this specific file
|
||||||
|
const totalLoaded = chunkIndex * CHUNK_SIZE + progressEvent.loaded
|
||||||
|
this.updateItemCardProgress(item.index, { loaded: totalLoaded, total: file.size })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$axios.$post('/api/upload/chunk', chunkForm, config)
|
||||||
|
chunkSuccess = true
|
||||||
|
} catch (error) {
|
||||||
|
retryCount++
|
||||||
|
console.error(`Failed to upload chunk ${chunkIndex} for ${file.name} (Attempt ${retryCount})`, error)
|
||||||
|
if (retryCount > maxRetries) {
|
||||||
|
this.$toast.error(`Failed to upload part of ${file.name}. Upload aborted.`)
|
||||||
|
allFilesSuccessful = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chunkSuccess) {
|
||||||
|
break // Break the outer chunk loop if retries exhausted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allFilesSuccessful
|
||||||
},
|
},
|
||||||
validateItems() {
|
validateItems() {
|
||||||
var itemData = []
|
var itemData = []
|
||||||
|
|
|
||||||
6
index.js
6
index.js
|
|
@ -1,3 +1,9 @@
|
||||||
|
// Node 25 compatibility: SlowBuffer is removed
|
||||||
|
const buffer = require('buffer')
|
||||||
|
if (!buffer.SlowBuffer) {
|
||||||
|
buffer.SlowBuffer = buffer.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
const optionDefinitions = [
|
const optionDefinitions = [
|
||||||
{ name: 'config', alias: 'c', type: String },
|
{ name: 'config', alias: 'c', type: String },
|
||||||
{ name: 'metadata', alias: 'm', type: String },
|
{ name: 'metadata', alias: 'm', type: String },
|
||||||
|
|
|
||||||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -2113,6 +2113,21 @@
|
||||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
"devOptional": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,91 @@ class MiscController {
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/upload/chunk
|
||||||
|
* Handle chunked upload
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async handleChunkUpload(req, res) {
|
||||||
|
if (!req.user.canUpload) {
|
||||||
|
Logger.warn(`User "${req.user.username}" attempted to upload without permission`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
if (!req.files || !Object.values(req.files).length) {
|
||||||
|
Logger.error('Invalid request, no files')
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fileId, chunkIndex, totalChunks, filename, library: libraryId, folder: folderId, title, author, series } = req.body
|
||||||
|
|
||||||
|
if (!fileId || chunkIndex === undefined || !totalChunks || !filename || !libraryId || !folderId || !title) {
|
||||||
|
return res.status(400).send('Invalid request body for chunk upload')
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = await Database.libraryModel.findByIdWithFolders(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return res.status(404).send('Library not found')
|
||||||
|
}
|
||||||
|
if (!req.user.checkCanAccessLibrary(library.id)) {
|
||||||
|
Logger.error(`[MiscController] User "${req.user.username}" attempting to upload to library "${library.id}" without access`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = Path.join(global.MetadataPath, 'tmp', 'uploads', fileId)
|
||||||
|
await fs.ensureDir(tmpDir)
|
||||||
|
|
||||||
|
const file = Object.values(req.files)[0]
|
||||||
|
const chunkPath = Path.join(tmpDir, `${fileId}_${chunkIndex}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await file.mv(chunkPath)
|
||||||
|
|
||||||
|
// Reassembly logic
|
||||||
|
if (parseInt(chunkIndex) === parseInt(totalChunks) - 1) {
|
||||||
|
const folder = library.libraryFolders.find((fold) => fold.id === folderId)
|
||||||
|
if (!folder) return res.status(404).send('Folder not found')
|
||||||
|
|
||||||
|
const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
|
||||||
|
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
|
||||||
|
const outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts])
|
||||||
|
await fs.ensureDir(outputDirectory)
|
||||||
|
|
||||||
|
const finalFilePath = Path.join(outputDirectory, sanitizeFilename(filename))
|
||||||
|
const writeStream = fs.createWriteStream(finalFilePath)
|
||||||
|
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
const currentChunkPath = Path.join(tmpDir, `${fileId}_${i}`)
|
||||||
|
const data = await fs.readFile(currentChunkPath)
|
||||||
|
if (!writeStream.write(data)) {
|
||||||
|
await new Promise((resolve) => writeStream.once('drain', resolve))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeStream.end()
|
||||||
|
|
||||||
|
writeStream.on('finish', async () => {
|
||||||
|
Logger.info(`Successfully merged ${totalChunks} chunks for file ${filename}`)
|
||||||
|
await fs.remove(tmpDir) // Cleanup
|
||||||
|
res.sendStatus(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
writeStream.on('error', async (error) => {
|
||||||
|
Logger.error(`Error merging chunks for file ${filename}`, error)
|
||||||
|
await fs.remove(tmpDir).catch((e) => Logger.error('Failed to clean up temp dir on merge error', e)) // Cleanup on error
|
||||||
|
res.status(500).send('Error merging file')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
res.sendStatus(200) // Chunk saved, waiting for more
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to move chunk ${chunkIndex} for file ${fileId}`, error)
|
||||||
|
await fs.remove(tmpDir).catch((e) => Logger.error('Failed to clean up temp dir on chunk error', e)) // Cleanup
|
||||||
|
return res.status(500).send('Failed to save chunk')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: /api/tasks
|
* GET: /api/tasks
|
||||||
* Get tasks for task manager
|
* Get tasks for task manager
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,19 @@ function bufferEq(a, b) {
|
||||||
}
|
}
|
||||||
|
|
||||||
bufferEq.install = function () {
|
bufferEq.install = function () {
|
||||||
Buffer.prototype.equal = SlowBuffer.prototype.equal = function equal(that) {
|
Buffer.prototype.equal = function equal(that) {
|
||||||
return bufferEq(this, that);
|
return bufferEq(this, that);
|
||||||
};
|
};
|
||||||
|
if (SlowBuffer && SlowBuffer.prototype) {
|
||||||
|
SlowBuffer.prototype.equal = Buffer.prototype.equal;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var origBufEqual = Buffer.prototype.equal;
|
var origBufEqual = Buffer.prototype.equal;
|
||||||
var origSlowBufEqual = SlowBuffer.prototype.equal;
|
var origSlowBufEqual = SlowBuffer && SlowBuffer.prototype ? SlowBuffer.prototype.equal : undefined;
|
||||||
bufferEq.restore = function () {
|
bufferEq.restore = function () {
|
||||||
Buffer.prototype.equal = origBufEqual;
|
Buffer.prototype.equal = origBufEqual;
|
||||||
|
if (SlowBuffer && SlowBuffer.prototype) {
|
||||||
SlowBuffer.prototype.equal = origSlowBufEqual;
|
SlowBuffer.prototype.equal = origSlowBufEqual;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,7 @@ class ApiRouter {
|
||||||
// Misc Routes
|
// Misc Routes
|
||||||
//
|
//
|
||||||
this.router.post('/upload', MiscController.handleUpload.bind(this))
|
this.router.post('/upload', MiscController.handleUpload.bind(this))
|
||||||
|
this.router.post('/upload/chunk', MiscController.handleChunkUpload.bind(this))
|
||||||
this.router.get('/tasks', MiscController.getTasks.bind(this))
|
this.router.get('/tasks', MiscController.getTasks.bind(this))
|
||||||
this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
|
this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
|
||||||
this.router.patch('/sorting-prefixes', MiscController.updateSortingPrefixes.bind(this))
|
this.router.patch('/sorting-prefixes', MiscController.updateSortingPrefixes.bind(this))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue