This commit is contained in:
Jozsef Kiraly 2026-02-22 22:22:28 +00:00 committed by GitHub
commit 7f00696f35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2202 additions and 0 deletions

View file

@ -162,6 +162,11 @@ class Database {
return this.models.device
}
/** @type {typeof import('./models/AudioClip')} */
get audioClipModel() {
return this.models.audioClip
}
/**
* Check if db file exists
* @returns {boolean}
@ -345,6 +350,7 @@ class Database {
require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').init(this.sequelize)
require('./models/AudioClip').init(this.sequelize)
return this.sequelize.sync({ force, alter: false })
}

View file

@ -473,5 +473,171 @@ class MeController {
const data = await userStats.getStatsForYear(req.user.id, year)
res.json(data)
}
/**
* GET: /api/me/clips
* Get all clips for the authenticated user
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getClips(req, res) {
try {
const clips = await Database.audioClipModel.getClipsForUser(req.user.id)
res.json({ clips: clips.map((c) => c.toJSON()) })
} catch (error) {
Logger.error(`[MeController] Failed to get clips:`, error)
res.status(500).send('Failed to get clips')
}
}
/**
* GET: /api/me/items/:id/clips
* Get all clips for a specific library item
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getItemClips(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
try {
const episodeId = req.query.episodeId || null
const clips = await Database.audioClipModel.getClipsForItem(req.user.id, req.params.id, episodeId)
res.json({ clips: clips.map((c) => c.toJSON()) })
} catch (error) {
Logger.error(`[MeController] Failed to get clips for item:`, error)
res.status(500).send('Failed to get clips')
}
}
/**
* POST: /api/me/items/:id/clips
* Create a new clip for a library item
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async createClip(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const { startTime, endTime, title, note, episodeId } = req.body
// Validate required fields
if (isNullOrNaN(startTime)) {
Logger.error(`[MeController] createClip invalid startTime`, startTime)
return res.status(400).send('Invalid start time')
}
if (isNullOrNaN(endTime)) {
Logger.error(`[MeController] createClip invalid endTime`, endTime)
return res.status(400).send('Invalid end time')
}
if (!title || typeof title !== 'string') {
Logger.error(`[MeController] createClip invalid title`, title)
return res.status(400).send('Invalid title')
}
try {
const clip = await Database.audioClipModel.createClip(req.user.id, req.params.id, startTime, endTime, title, note, episodeId)
SocketAuthority.clientEmitter(req.user.id, 'clip_created', clip.toJSON())
res.json(clip.toJSON())
} catch (error) {
Logger.error(`[MeController] Failed to create clip:`, error)
res.status(400).send(error.message || 'Failed to create clip')
}
}
/**
* PATCH: /api/me/clips/:clipId
* Update an existing clip
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateClip(req, res) {
const clipId = req.params.clipId
// Check if clip exists and belongs to user
const existingClip = await Database.audioClipModel.findByPk(clipId)
if (!existingClip) {
Logger.error(`[MeController] updateClip not found for clip id "${clipId}"`)
return res.sendStatus(404)
}
if (existingClip.userId !== req.user.id) {
Logger.error(`[MeController] updateClip forbidden - clip does not belong to user`)
return res.sendStatus(403)
}
const { startTime, endTime, title, note } = req.body
const updates = {}
if (startTime !== undefined) {
if (isNullOrNaN(startTime)) {
Logger.error(`[MeController] updateClip invalid startTime`, startTime)
return res.status(400).send('Invalid start time')
}
updates.startTime = startTime
}
if (endTime !== undefined) {
if (isNullOrNaN(endTime)) {
Logger.error(`[MeController] updateClip invalid endTime`, endTime)
return res.status(400).send('Invalid end time')
}
updates.endTime = endTime
}
if (title !== undefined) {
if (typeof title !== 'string') {
Logger.error(`[MeController] updateClip invalid title`, title)
return res.status(400).send('Invalid title')
}
updates.title = title
}
if (note !== undefined) {
updates.note = note
}
try {
const clip = await Database.audioClipModel.updateClip(clipId, updates)
SocketAuthority.clientEmitter(req.user.id, 'clip_updated', clip.toJSON())
res.json(clip.toJSON())
} catch (error) {
Logger.error(`[MeController] Failed to update clip:`, error)
res.status(400).send(error.message || 'Failed to update clip')
}
}
/**
* DELETE: /api/me/clips/:clipId
* Delete a clip
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async deleteClip(req, res) {
const clipId = req.params.clipId
// Check if clip exists and belongs to user
const existingClip = await Database.audioClipModel.findByPk(clipId)
if (!existingClip) {
Logger.error(`[MeController] deleteClip not found for clip id "${clipId}"`)
return res.sendStatus(404)
}
if (existingClip.userId !== req.user.id) {
Logger.error(`[MeController] deleteClip forbidden - clip does not belong to user`)
return res.sendStatus(403)
}
try {
await Database.audioClipModel.deleteClip(clipId)
SocketAuthority.clientEmitter(req.user.id, 'clip_removed', { id: clipId })
res.sendStatus(200)
} catch (error) {
Logger.error(`[MeController] Failed to delete clip:`, error)
res.status(500).send('Failed to delete clip')
}
}
}
module.exports = new MeController()

View file

@ -0,0 +1,171 @@
const uuidv4 = require('uuid').v4
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a sequelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
/**
* This upward migration creates the audioClips table and migrates existing bookmarks to clips.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
logger.info('[2.31.0 migration] UPGRADE BEGIN: 2.31.0-audio-clips')
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
// Create audioClips table
logger.info('[2.31.0 migration] Creating audioClips table')
await queryInterface.createTable('audioClips', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
libraryItemId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'libraryItems',
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
episodeId: {
type: DataTypes.UUID,
allowNull: true
},
startTime: {
type: DataTypes.REAL,
allowNull: false
},
endTime: {
type: DataTypes.REAL,
allowNull: false
},
title: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: ''
},
note: {
type: DataTypes.TEXT,
allowNull: true
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
}
})
// Create indexes
logger.info('[2.31.0 migration] Creating indexes on audioClips table')
await queryInterface.addIndex('audioClips', ['userId'], {
name: 'audio_clips_user_id'
})
await queryInterface.addIndex('audioClips', ['libraryItemId'], {
name: 'audio_clips_library_item_id'
})
await queryInterface.addIndex('audioClips', ['startTime'], {
name: 'audio_clips_start_time'
})
await queryInterface.addIndex('audioClips', ['userId', 'libraryItemId'], {
name: 'audio_clips_user_library_item'
})
// Migrate existing bookmarks to clips
logger.info('[2.31.0 migration] Migrating bookmarks to clips')
const [users] = await queryInterface.sequelize.query('SELECT id, bookmarks FROM users WHERE bookmarks IS NOT NULL AND bookmarks != "[]"')
let totalMigrated = 0
const now = new Date()
for (const user of users) {
try {
const bookmarks = JSON.parse(user.bookmarks || '[]')
if (!Array.isArray(bookmarks) || bookmarks.length === 0) {
continue
}
logger.info(`[2.31.0 migration] Migrating ${bookmarks.length} bookmarks for user ${user.id}`)
for (const bookmark of bookmarks) {
if (!bookmark.libraryItemId || typeof bookmark.time !== 'number') {
logger.warn(`[2.31.0 migration] Skipping invalid bookmark for user ${user.id}:`, bookmark)
continue
}
// Create clip with 5-second default duration
const clipId = uuidv4()
const startTime = Number(bookmark.time)
const endTime = startTime + 10 // Default 10-second clip
const title = bookmark.title || 'Migrated Bookmark'
const createdAt = bookmark.createdAt ? new Date(bookmark.createdAt) : now
await queryInterface.sequelize.query(
`INSERT INTO audioClips (id, userId, libraryItemId, episodeId, startTime, endTime, title, note, createdAt, updatedAt)
VALUES (?, ?, ?, NULL, ?, ?, ?, '', ?, ?)`,
{
replacements: [clipId, user.id, bookmark.libraryItemId, startTime, endTime, title, createdAt, now]
}
)
totalMigrated++
}
} catch (error) {
logger.error(`[2.31.0 migration] Error migrating bookmarks for user ${user.id}:`, error)
}
}
logger.info(`[2.31.0 migration] Successfully migrated ${totalMigrated} bookmarks to clips`)
logger.info('[2.31.0 migration] UPGRADE END: 2.31.0-audio-clips')
}
/**
* This downward migration removes the audioClips table.
* Note: This does not restore bookmarks.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
logger.info('[2.31.0 migration] DOWNGRADE BEGIN: 2.31.0-audio-clips')
// Drop indexes
logger.info('[2.31.0 migration] Dropping indexes')
await queryInterface.removeIndex('audioClips', 'audio_clips_user_id')
await queryInterface.removeIndex('audioClips', 'audio_clips_library_item_id')
await queryInterface.removeIndex('audioClips', 'audio_clips_start_time')
await queryInterface.removeIndex('audioClips', 'audio_clips_user_library_item')
// Drop table
logger.info('[2.31.0 migration] Dropping audioClips table')
await queryInterface.dropTable('audioClips')
logger.info('[2.31.0 migration] DOWNGRADE END: 2.31.0-audio-clips')
}
module.exports = { up, down }

329
server/models/AudioClip.js Normal file
View file

@ -0,0 +1,329 @@
const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger')
/**
* @typedef AudioClipObject
* @property {UUIDV4} id
* @property {UUIDV4} userId
* @property {UUIDV4} libraryItemId
* @property {UUIDV4} [episodeId]
* @property {number} startTime
* @property {number} endTime
* @property {string} title
* @property {string} [note]
* @property {Date} createdAt
* @property {Date} updatedAt
*/
class AudioClip extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {UUIDV4} */
this.userId
/** @type {UUIDV4} */
this.libraryItemId
/** @type {UUIDV4} */
this.episodeId
/** @type {number} */
this.startTime
/** @type {number} */
this.endTime
/** @type {string} */
this.title
/** @type {string} */
this.note
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
userId: {
type: DataTypes.UUID,
allowNull: false
},
libraryItemId: {
type: DataTypes.UUID,
allowNull: false
},
episodeId: {
type: DataTypes.UUID,
allowNull: true
},
startTime: {
type: DataTypes.REAL,
allowNull: false
},
endTime: {
type: DataTypes.REAL,
allowNull: false
},
title: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: ''
},
note: {
type: DataTypes.TEXT,
allowNull: true
}
},
{
sequelize,
modelName: 'audioClip',
indexes: [
{
name: 'audio_clips_user_id',
fields: ['userId']
},
{
name: 'audio_clips_library_item_id',
fields: ['libraryItemId']
},
{
name: 'audio_clips_start_time',
fields: ['startTime']
},
{
name: 'audio_clips_user_library_item',
fields: ['userId', 'libraryItemId']
}
]
}
)
const { user, libraryItem } = sequelize.models
user.hasMany(AudioClip, {
foreignKey: 'userId',
onDelete: 'CASCADE'
})
AudioClip.belongsTo(user, {
foreignKey: 'userId'
})
libraryItem.hasMany(AudioClip, {
foreignKey: 'libraryItemId',
onDelete: 'CASCADE'
})
AudioClip.belongsTo(libraryItem, {
foreignKey: 'libraryItemId'
})
}
/**
* Get duration of clip in seconds
* @returns {number}
*/
getDuration() {
return this.endTime - this.startTime
}
/**
* Check if time range is valid
* @returns {boolean}
*/
isValidTimeRange() {
return this.startTime >= 0 && this.endTime > this.startTime
}
/**
* Create a new audio clip
* @param {string} userId
* @param {string} libraryItemId
* @param {number} startTime - Start time in seconds
* @param {number} endTime - End time in seconds
* @param {string} title
* @param {string} [note]
* @param {string} [episodeId]
* @returns {Promise<AudioClip>}
*/
static async createClip(userId, libraryItemId, startTime, endTime, title, note, episodeId = null) {
// Validate time range
if (startTime < 0) {
throw new Error('Start time must be non-negative')
}
if (endTime <= startTime) {
throw new Error('End time must be greater than start time')
}
// TODO: Validate against library item duration?
// Warn if clip duration is very long (more than 10 minutes)
const duration = endTime - startTime
if (duration > 600) {
Logger.warn(`[AudioClip] Creating clip with long duration: ${duration}s`)
}
try {
const clip = await this.create({
userId,
libraryItemId,
episodeId,
startTime,
endTime,
title: title || '',
note: note || null
})
Logger.info(`[AudioClip] Created clip ${clip.id} for user ${userId}`)
return clip
} catch (error) {
Logger.error(`[AudioClip] Failed to create clip:`, error)
throw error
}
}
/**
* Update an existing audio clip
* @param {string} clipId
* @param {Object} updates
* @param {number} [updates.startTime]
* @param {number} [updates.endTime]
* @param {string} [updates.title]
* @param {string} [updates.note]
* @returns {Promise<AudioClip>}
*/
static async updateClip(clipId, updates) {
const clip = await this.findByPk(clipId)
if (!clip) {
throw new Error('Clip not found')
}
// If updating time range, validate it
const newStartTime = updates.startTime !== undefined ? updates.startTime : clip.startTime
const newEndTime = updates.endTime !== undefined ? updates.endTime : clip.endTime
if (newStartTime < 0) {
throw new Error('Start time must be non-negative')
}
if (newEndTime <= newStartTime) {
throw new Error('End time must be greater than start time')
}
try {
await clip.update(updates)
Logger.info(`[AudioClip] Updated clip ${clipId}`)
return clip
} catch (error) {
Logger.error(`[AudioClip] Failed to update clip ${clipId}:`, error)
throw error
}
}
/**
* Delete an audio clip
* @param {string} clipId
* @returns {Promise<boolean>}
*/
static async deleteClip(clipId) {
try {
const deleted = await this.destroy({
where: { id: clipId }
})
if (deleted > 0) {
Logger.info(`[AudioClip] Deleted clip ${clipId}`)
return true
}
return false
} catch (error) {
Logger.error(`[AudioClip] Failed to delete clip ${clipId}:`, error)
throw error
}
}
/**
* Get all clips for a specific library item
* @param {string} userId
* @param {string} libraryItemId
* @param {string} [episodeId]
* @returns {Promise<AudioClip[]>}
*/
static async getClipsForItem(userId, libraryItemId, episodeId = null) {
try {
const queryOptions = {
where: {
userId,
libraryItemId
},
order: [['createdAt', 'DESC']]
}
if (episodeId) {
queryOptions.where.episodeId = episodeId
}
const clips = await this.findAll(queryOptions)
return clips
} catch (error) {
Logger.error(`[AudioClip] Failed to get clips for item ${libraryItemId}:`, error)
throw error
}
}
/**
* Get all clips for a user
* @param {string} userId
* @param {Object} [options]
* @param {number} [options.limit]
* @param {number} [options.offset]
* @returns {Promise<AudioClip[]>}
*/
static async getClipsForUser(userId, options = {}) {
try {
const queryOptions = {
where: { userId },
order: [['createdAt', 'DESC']]
}
if (options.limit) {
queryOptions.limit = options.limit
}
if (options.offset) {
queryOptions.offset = options.offset
}
const clips = await this.findAll(queryOptions)
return clips
} catch (error) {
Logger.error(`[AudioClip] Failed to get clips for user ${userId}:`, error)
throw error
}
}
/**
* Convert to JSON
* @returns {AudioClipObject}
*/
toJSON() {
const clip = this.get({ plain: true })
return {
id: clip.id,
userId: clip.userId,
libraryItemId: clip.libraryItemId,
episodeId: clip.episodeId,
startTime: clip.startTime,
endTime: clip.endTime,
title: clip.title,
note: clip.note,
createdAt: clip.createdAt?.toISOString(),
updatedAt: clip.updatedAt?.toISOString()
}
}
}
module.exports = AudioClip

View file

@ -182,6 +182,11 @@ class ApiRouter {
this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
this.router.get('/me/clips', MeController.getClips.bind(this))
this.router.get('/me/item/:id/clips', MeController.getItemClips.bind(this))
this.router.post('/me/item/:id/clip', MeController.createClip.bind(this))
this.router.patch('/me/clip/:clipId', MeController.updateClip.bind(this))
this.router.delete('/me/clip/:clipId', MeController.deleteClip.bind(this))
this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this))
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))