Add video chapters support

This commit is contained in:
Chocobozzz
2023-08-28 10:55:04 +02:00
parent 7113f32a87
commit 77b70702d2
101 changed files with 1957 additions and 158 deletions

View File

@@ -1,6 +1,13 @@
import cors from 'cors'
import express from 'express'
import { VideoCommentObject, VideoPlaylistPrivacy, VideoPrivacy, VideoRateType } from '@peertube/peertube-models'
import {
VideoChapterObject,
VideoChaptersObject,
VideoCommentObject,
VideoPlaylistPrivacy,
VideoPrivacy,
VideoRateType
} from '@peertube/peertube-models'
import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
import { getContextFilter } from '@server/lib/activitypub/context.js'
import { getServerActor } from '@server/models/application/application.js'
@@ -12,12 +19,18 @@ import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/act
import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js'
import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js'
import {
getLocalVideoChaptersActivityPubUrl,
getLocalVideoCommentsActivityPubUrl,
getLocalVideoDislikesActivityPubUrl,
getLocalVideoLikesActivityPubUrl,
getLocalVideoSharesActivityPubUrl
} from '../../lib/activitypub/url.js'
import { cacheRoute } from '../../middlewares/cache/cache.js'
import {
apVideoChaptersSetCacheKey,
buildAPVideoChaptersGroupsCache,
cacheRoute,
cacheRouteFactory
} from '../../middlewares/cache/cache.js'
import {
activityPubRateLimiter,
asyncMiddleware,
@@ -42,6 +55,8 @@ import { VideoCommentModel } from '../../models/video/video-comment.js'
import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
import { VideoShareModel } from '../../models/video/video-share.js'
import { activityPubResponse } from './utils.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
const activityPubClientRouter = express.Router()
activityPubClientRouter.use(cors())
@@ -145,6 +160,27 @@ activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity
asyncMiddleware(videoCommentController)
)
// ---------------------------------------------------------------------------
const { middleware: chaptersCacheRouteMiddleware, instance: chaptersApiCache } = cacheRouteFactory()
InternalEventEmitter.Instance.on('chapters-updated', ({ video }) => {
if (video.remote) return
chaptersApiCache.clearGroupSafe(buildAPVideoChaptersGroupsCache({ videoId: video.uuid }))
})
activityPubClientRouter.get('/videos/watch/:id/chapters',
executeIfActivityPub,
activityPubRateLimiter,
apVideoChaptersSetCacheKey,
chaptersCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
asyncMiddleware(videosCustomGetValidator('only-video')),
asyncMiddleware(videoChaptersController)
)
// ---------------------------------------------------------------------------
activityPubClientRouter.get(
[ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ],
executeIfActivityPub,
@@ -390,6 +426,31 @@ async function videoCommentController (req: express.Request, res: express.Respon
return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res)
}
async function videoChaptersController (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
if (redirectIfNotOwned(video.url, res)) return
const chapters = await VideoChapterModel.listChaptersOfVideo(video.id)
const hasPart: VideoChapterObject[] = []
if (chapters.length !== 0) {
for (let i = 0; i < chapters.length - 1; i++) {
hasPart.push(chapters[i].toActivityPubJSON({ video, nextChapter: chapters[i + 1] }))
}
hasPart.push(chapters[chapters.length - 1].toActivityPubJSON({ video: res.locals.onlyVideo, nextChapter: null }))
}
const chaptersObject: VideoChaptersObject = {
id: getLocalVideoChaptersActivityPubUrl(video),
hasPart
}
return activityPubResponse(activityPubContextify(chaptersObject, 'Chapters', getContextFilter()), res)
}
async function videoRedundancyController (req: express.Request, res: express.Response) {
const videoRedundancy = res.locals.videoRedundancy

View File

@@ -0,0 +1,51 @@
import express from 'express'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js'
import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import { HttpStatusCode, VideoChapterUpdate } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
import { replaceChapters } from '@server/lib/video-chapters.js'
const videoChaptersRouter = express.Router()
videoChaptersRouter.get('/:id/chapters',
asyncMiddleware(videosCustomGetValidator('only-video')),
asyncMiddleware(listVideoChapters)
)
videoChaptersRouter.put('/:videoId/chapters',
authenticate,
asyncMiddleware(updateVideoChaptersValidator),
asyncRetryTransactionMiddleware(replaceVideoChapters)
)
// ---------------------------------------------------------------------------
export {
videoChaptersRouter
}
// ---------------------------------------------------------------------------
async function listVideoChapters (req: express.Request, res: express.Response) {
const chapters = await VideoChapterModel.listChaptersOfVideo(res.locals.onlyVideo.id)
return res.json({ chapters: chapters.map(c => c.toFormattedJSON()) })
}
async function replaceVideoChapters (req: express.Request, res: express.Response) {
const body = req.body as VideoChapterUpdate
const video = res.locals.videoAll
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
await replaceChapters({ video, chapters: body.chapters, transaction: t })
await federateVideoIfNeeded(video, false, t)
})
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -49,6 +49,7 @@ import { transcodingRouter } from './transcoding.js'
import { updateRouter } from './update.js'
import { uploadRouter } from './upload.js'
import { viewRouter } from './view.js'
import { videoChaptersRouter } from './chapters.js'
const auditLogger = auditLoggerFactory('videos')
const videosRouter = express.Router()
@@ -73,6 +74,7 @@ videosRouter.use('/', tokenRouter)
videosRouter.use('/', videoPasswordRouter)
videosRouter.use('/', storyboardRouter)
videosRouter.use('/', videoSourceRouter)
videosRouter.use('/', videoChaptersRouter)
videosRouter.get('/categories',
openapiOperationDoc({ operationId: 'getCategories' }),

View File

@@ -22,6 +22,7 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist.js'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
import { VideoModel } from '../../../models/video/video.js'
import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
@@ -67,6 +68,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
// Refresh video since thumbnails to prevent concurrent updates
const video = await VideoModel.loadFull(videoFromReq.id, t)
const oldDescription = video.description
const oldVideoChannel = video.VideoChannel
const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [
@@ -127,6 +129,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
// Schedule an update in the future?
await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
if (oldDescription !== video.description) {
await replaceChaptersFromDescriptionIfNeeded({
newDescription: videoInstanceUpdated.description,
transaction: t,
video,
oldDescription
})
}
await autoBlacklistVideoIfNeeded({
video: videoInstanceUpdated,
user: res.locals.oauth.token.User,

View File

@@ -34,6 +34,8 @@ import {
} from '../../../middlewares/index.js'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
import { VideoModel } from '../../../models/video/video.js'
import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
@@ -143,6 +145,9 @@ async function addVideo (options: {
const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
const originalFilename = videoPhysicalFile.originalname
const containerChapters = await getChaptersFromContainer(videoPhysicalFile.path)
logger.debug(`Got ${containerChapters.length} chapters from video "${video.name}" container`, { containerChapters, ...lTags(video.uuid) })
// Move physical file
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
await move(videoPhysicalFile.path, destination)
@@ -188,6 +193,10 @@ async function addVideo (options: {
}, sequelizeOptions)
}
if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction: t })) {
await replaceChapters({ video, chapters: containerChapters, transaction: t })
}
await autoBlacklistVideoIfNeeded({
video,
user,

View File

@@ -79,6 +79,8 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
uploadDate: 'sc:uploadDate',
hasParts: 'sc:hasParts',
views: {
'@type': 'sc:Number',
'@id': 'pt:views'
@@ -195,7 +197,14 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
Announce: buildContext(),
Comment: buildContext(),
Delete: buildContext(),
Rate: buildContext()
Rate: buildContext(),
Chapters: buildContext({
name: 'sc:name',
hasPart: 'sc:hasPart',
endOffset: 'sc:endOffset',
startOffset: 'sc:startOffset'
})
}
async function getContextData (type: ContextType, contextFilter: ContextFilter) {

View File

@@ -0,0 +1,15 @@
import { isArray } from '../misc.js'
import { isVideoChapterTitleValid, isVideoChapterTimecodeValid } from '../video-chapters.js'
import { isActivityPubUrlValid } from './misc.js'
import { VideoChaptersObject } from '@peertube/peertube-models'
export function isVideoChaptersObjectValid (object: VideoChaptersObject) {
if (!object) return false
if (!isActivityPubUrlValid(object.id)) return false
if (!isArray(object.hasPart)) return false
return object.hasPart.every(part => {
return isVideoChapterTitleValid(part.name) && isVideoChapterTimecodeValid(part.startOffset)
})
}

View File

@@ -0,0 +1,26 @@
import { isArray } from './misc.js'
import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models'
import { Unpacked } from '@peertube/peertube-typescript-utils'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
import validator from 'validator'
export function areVideoChaptersValid (value: VideoChapter[]) {
if (!isArray(value)) return false
if (!value.every(v => isVideoChapterValid(v))) return false
const timecodes = value.map(c => c.timecode)
return new Set(timecodes).size === timecodes.length
}
export function isVideoChapterValid (value: Unpacked<VideoChapterUpdate['chapters']>) {
return isVideoChapterTimecodeValid(value.timecode) && isVideoChapterTitleValid(value.title)
}
export function isVideoChapterTitleValid (value: any) {
return validator.default.isLength(value + '', CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE)
}
export function isVideoChapterTimecodeValid (value: any) {
return validator.default.isInt(value + '', { min: 0 })
}

View File

@@ -1,6 +1,7 @@
import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js'
import { peertubeTruncate } from '../core-utils.js'
import { isUrlValid } from '../custom-validators/activitypub/misc.js'
import { isArray } from '../custom-validators/misc.js'
export type YoutubeDLInfo = {
name?: string
@@ -16,6 +17,11 @@ export type YoutubeDLInfo = {
webpageUrl?: string
urls?: string[]
chapters?: {
timecode: number
title: string
}[]
}
export class YoutubeDLInfoBuilder {
@@ -83,7 +89,10 @@ export class YoutubeDLInfoBuilder {
urls: this.buildAvailableUrl(obj),
originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj),
ext: obj.ext,
webpageUrl: obj.webpage_url
webpageUrl: obj.webpage_url,
chapters: isArray(obj.chapters)
? obj.chapters.map((c: { start_time: number, title: string }) => ({ timecode: c.start_time, title: c.title }))
: []
}
}

View File

@@ -465,6 +465,9 @@ const CONSTRAINTS_FIELDS = {
},
VIDEO_PASSWORD: {
LENGTH: { min: 2, max: 100 }
},
VIDEO_CHAPTERS: {
TITLE: { min: 1, max: 100 } // Length
}
}

View File

@@ -59,6 +59,7 @@ import { VideoTagModel } from '../models/video/video-tag.js'
import { VideoModel } from '../models/video/video.js'
import { VideoViewModel } from '../models/view/video-view.js'
import { CONFIG } from './config.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@@ -137,6 +138,7 @@ async function initDatabaseModels (silent: boolean) {
VideoShareModel,
VideoFileModel,
VideoSourceModel,
VideoChapterModel,
VideoCaptionModel,
VideoBlacklistModel,
VideoTagModel,

View File

@@ -80,6 +80,10 @@ function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
return video.url + '/comments'
}
function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
return video.url + '/chapters'
}
function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
return video.url + '/likes'
}
@@ -167,6 +171,7 @@ export {
getDeleteActivityPubUrl,
getLocalVideoSharesActivityPubUrl,
getLocalVideoCommentsActivityPubUrl,
getLocalVideoChaptersActivityPubUrl,
getLocalVideoLikesActivityPubUrl,
getLocalVideoDislikesActivityPubUrl,
getLocalVideoViewerActivityPubUrl,

View File

@@ -1,6 +1,12 @@
import { CreationAttributes, Transaction } from 'sequelize'
import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models'
import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils.js'
import {
ActivityTagObject,
ThumbnailType,
VideoChaptersObject,
VideoObject,
VideoStreamingPlaylistType_Type
} from '@peertube/peertube-models'
import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger, LoggerTagsFn } from '@server/helpers/logger.js'
import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
import { setVideoTags } from '@server/lib/video.js'
@@ -29,6 +35,10 @@ import {
getThumbnailFromIcons
} from './object-to-model-attributes.js'
import { getTrackerUrls, setVideoTrackers } from './trackers.js'
import { fetchAP } from '../../activity.js'
import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { replaceChapters } from '@server/lib/video-chapters.js'
export abstract class APVideoAbstractBuilder {
protected abstract videoObject: VideoObject
@@ -44,7 +54,7 @@ export abstract class APVideoAbstractBuilder {
protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
const miniatureIcon = getThumbnailFromIcons(this.videoObject)
if (!miniatureIcon) {
logger.warn('Cannot find thumbnail in video object', { object: this.videoObject })
logger.warn('Cannot find thumbnail in video object', { object: this.videoObject, ...this.lTags() })
return undefined
}
@@ -138,6 +148,26 @@ export abstract class APVideoAbstractBuilder {
video.VideoFiles = await Promise.all(upsertTasks)
}
protected async updateChaptersOutsideTransaction (video: MVideoFullLight) {
if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return
const { body } = await fetchAP<VideoChaptersObject>(this.videoObject.hasParts)
if (!isVideoChaptersObjectValid(body)) {
logger.warn('Chapters AP object is not valid, skipping', { body, ...this.lTags() })
return
}
logger.debug('Fetched chapters AP object', { body, ...this.lTags() })
return retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
const chapters = body.hasPart.map(p => ({ title: p.name, timecode: p.startOffset }))
await replaceChapters({ chapters, transaction: t, video })
})
})
}
protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))

View File

@@ -60,6 +60,8 @@ export class APVideoCreator extends APVideoAbstractBuilder {
return { autoBlacklisted, videoCreated }
})
await this.updateChaptersOutsideTransaction(videoCreated)
return { autoBlacklisted, videoCreated }
}
}

View File

@@ -77,6 +77,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
await this.updateChaptersOutsideTransaction(videoUpdated)
await autoBlacklistVideoIfNeeded({
video: videoUpdated,
user: undefined,

View File

@@ -1,4 +1,4 @@
import { MChannel, MVideo } from '@server/types/models/index.js'
import { MChannel, MVideo, MVideoImmutable } from '@server/types/models/index.js'
import { EventEmitter } from 'events'
export interface PeerTubeInternalEvents {
@@ -9,6 +9,8 @@ export interface PeerTubeInternalEvents {
'channel-created': (options: { channel: MChannel }) => void
'channel-updated': (options: { channel: MChannel }) => void
'channel-deleted': (options: { channel: MChannel }) => void
'chapters-updated': (options: { video: MVideoImmutable }) => void
}
declare interface InternalEventEmitter {

View File

@@ -32,6 +32,7 @@ import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImpo
import { getLowercaseExtension } from '@peertube/peertube-node-utils'
import {
ffprobePromise,
getChaptersFromContainer,
getVideoStreamDimensionsInfo,
getVideoStreamDuration,
getVideoStreamFPS,
@@ -49,6 +50,7 @@ import { federateVideoIfNeeded } from '../../activitypub/videos/index.js'
import { Notifier } from '../../notifier/index.js'
import { generateLocalVideoMiniature } from '../../thumbnail.js'
import { JobQueue } from '../job-queue.js'
import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
const payload = job.data as VideoImportPayload
@@ -150,6 +152,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
const fps = await getVideoStreamFPS(tempVideoPath, probe)
const duration = await getVideoStreamDuration(tempVideoPath, probe)
const containerChapters = await getChaptersFromContainer(tempVideoPath, probe)
// Prepare video file object for creation in database
const fileExt = getLowercaseExtension(tempVideoPath)
const videoFileData = {
@@ -228,6 +232,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
if (previewModel) await video.addAndSaveThumbnail(previewModel, t)
await replaceChaptersIfNotExist({ video, chapters: containerChapters, transaction: t })
// Now we can federate the video (reload from database, we need more attributes)
const videoForFederation = await VideoModel.loadFull(video.uuid, t)
await federateVideoIfNeeded(videoForFederation, true, t)

View File

@@ -0,0 +1,99 @@
import { parseChapters, sortBy } from '@peertube/peertube-core-utils'
import { VideoChapter } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import { MVideoImmutable } from '@server/types/models/index.js'
import { Transaction } from 'sequelize'
import { InternalEventEmitter } from './internal-event-emitter.js'
const lTags = loggerTagsFactory('video', 'chapters')
export async function replaceChapters (options: {
video: MVideoImmutable
chapters: VideoChapter[]
transaction: Transaction
}) {
const { chapters, transaction, video } = options
await VideoChapterModel.deleteChapters(video.id, transaction)
await createChapters({ videoId: video.id, chapters, transaction })
InternalEventEmitter.Instance.emit('chapters-updated', { video })
}
export async function replaceChaptersIfNotExist (options: {
video: MVideoImmutable
chapters: VideoChapter[]
transaction: Transaction
}) {
const { chapters, transaction, video } = options
if (await VideoChapterModel.hasVideoChapters(video.id, transaction)) return
await createChapters({ videoId: video.id, chapters, transaction })
InternalEventEmitter.Instance.emit('chapters-updated', { video })
}
export async function replaceChaptersFromDescriptionIfNeeded (options: {
oldDescription?: string
newDescription: string
video: MVideoImmutable
transaction: Transaction
}) {
const { transaction, video, newDescription, oldDescription = '' } = options
const chaptersFromOldDescription = sortBy(parseChapters(oldDescription), 'timecode')
const existingChapters = await VideoChapterModel.listChaptersOfVideo(video.id, transaction)
logger.debug(
'Check if we replace chapters from description',
{ oldDescription, chaptersFromOldDescription, newDescription, existingChapters, ...lTags(video.uuid) }
)
// Then we can update chapters from the new description
if (areSameChapters(chaptersFromOldDescription, existingChapters)) {
const chaptersFromNewDescription = sortBy(parseChapters(newDescription), 'timecode')
if (chaptersFromOldDescription.length === 0 && chaptersFromNewDescription.length === 0) return false
await replaceChapters({ video, chapters: chaptersFromNewDescription, transaction })
logger.info('Replaced chapters of video ' + video.uuid, { chaptersFromNewDescription, ...lTags(video.uuid) })
return true
}
return false
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function createChapters (options: {
videoId: number
chapters: VideoChapter[]
transaction: Transaction
}) {
const { chapters, transaction, videoId } = options
for (const chapter of chapters) {
await VideoChapterModel.create({
title: chapter.title,
timecode: chapter.timecode,
videoId
}, { transaction })
}
}
function areSameChapters (chapters1: VideoChapter[], chapters2: VideoChapter[]) {
if (chapters1.length !== chapters2.length) return false
for (let i = 0; i < chapters1.length; i++) {
if (chapters1[i].timecode !== chapters2[i].timecode) return false
if (chapters1[i].title !== chapters2[i].title) return false
}
return true
}

View File

@@ -39,6 +39,7 @@ import {
} from '@server/types/models/index.js'
import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
class YoutubeDlImportError extends Error {
code: YoutubeDlImportError.CODE
@@ -227,6 +228,29 @@ async function buildYoutubeDLImport (options: {
videoPasswords: importDataOverride.videoPasswords
})
await sequelizeTypescript.transaction(async transaction => {
// Priority to explicitely set description
if (importDataOverride?.description) {
const inserted = await replaceChaptersFromDescriptionIfNeeded({ newDescription: importDataOverride.description, video, transaction })
if (inserted) return
}
// Then priority to youtube-dl chapters
if (youtubeDLInfo.chapters.length !== 0) {
logger.info(
`Inserting chapters in video ${video.uuid} from youtube-dl`,
{ chapters: youtubeDLInfo.chapters, tags: [ 'chapters', video.uuid ] }
)
await replaceChapters({ video, chapters: youtubeDLInfo.chapters, transaction })
return
}
if (video.description) {
await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction })
}
})
// Get video subtitles
await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)

View File

@@ -1,3 +1,4 @@
import express from 'express'
import { HttpStatusCode } from '@peertube/peertube-models'
import { ApiCache, APICacheOptions } from './shared/index.js'
@@ -8,13 +9,13 @@ const defaultOptions: APICacheOptions = {
]
}
function cacheRoute (duration: string) {
export function cacheRoute (duration: string) {
const instance = new ApiCache(defaultOptions)
return instance.buildMiddleware(duration)
}
function cacheRouteFactory (options: APICacheOptions) {
export function cacheRouteFactory (options: APICacheOptions = {}) {
const instance = new ApiCache({ ...defaultOptions, ...options })
return { instance, middleware: instance.buildMiddleware.bind(instance) }
@@ -22,17 +23,36 @@ function cacheRouteFactory (options: APICacheOptions) {
// ---------------------------------------------------------------------------
function buildPodcastGroupsCache (options: {
export function buildPodcastGroupsCache (options: {
channelId: number
}) {
return 'podcast-feed-' + options.channelId
}
export function buildAPVideoChaptersGroupsCache (options: {
videoId: number | string
}) {
return 'ap-video-chapters-' + options.videoId
}
// ---------------------------------------------------------------------------
export {
cacheRoute,
cacheRouteFactory,
export const videoFeedsPodcastSetCacheKey = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.videoChannelId) {
res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
}
buildPodcastGroupsCache
}
return next()
}
]
export const apVideoChaptersSetCacheKey = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.params.id) {
res.locals.apicacheGroups = [ buildAPVideoChaptersGroupsCache({ videoId: req.params.id }) ]
}
return next()
}
]

View File

@@ -3,7 +3,6 @@ import { param, query } from 'express-validator'
import { HttpStatusCode } from '@peertube/peertube-models'
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js'
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js'
import { buildPodcastGroupsCache } from '../cache/index.js'
import {
areValidationErrors,
checkCanSeeVideo,
@@ -114,15 +113,6 @@ const videoFeedsPodcastValidator = [
}
]
const videoFeedsPodcastSetCacheKey = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.videoChannelId) {
res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
}
return next()
}
]
// ---------------------------------------------------------------------------
const videoSubscriptionFeedsValidator = [
@@ -173,6 +163,5 @@ export {
feedsAccountOrChannelFiltersValidator,
videoFeedsPodcastValidator,
videoSubscriptionFeedsValidator,
videoFeedsPodcastSetCacheKey,
videoCommentsFeedsValidator
}

View File

@@ -2,6 +2,7 @@ export * from './video-blacklist.js'
export * from './video-captions.js'
export * from './video-channel-sync.js'
export * from './video-channels.js'
export * from './video-chapters.js'
export * from './video-comments.js'
export * from './video-files.js'
export * from './video-imports.js'

View File

@@ -0,0 +1,34 @@
import express from 'express'
import { body } from 'express-validator'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import {
areValidationErrors, checkUserCanManageVideo, doesVideoExist,
isValidVideoIdParam
} from '../shared/index.js'
import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js'
export const updateVideoChaptersValidator = [
isValidVideoIdParam('videoId'),
body('chapters')
.custom(areVideoChaptersValid)
.withMessage('Chapters must have a valid title and timecode, and each timecode must be unique'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (res.locals.videoAll.isLive) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'You cannot add chapters to a live video'
})
}
// Check if the user who did the request is able to update video chapters (same right as updating the video)
const user = res.locals.oauth.token.User
if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
return next()
}
]

View File

@@ -13,6 +13,7 @@ import {
} from '@peertube/peertube-models'
import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js'
import {
getLocalVideoChaptersActivityPubUrl,
getLocalVideoCommentsActivityPubUrl,
getLocalVideoDislikesActivityPubUrl,
getLocalVideoLikesActivityPubUrl,
@@ -95,6 +96,7 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
dislikes: getLocalVideoDislikesActivityPubUrl(video),
shares: getLocalVideoSharesActivityPubUrl(video),
comments: getLocalVideoCommentsActivityPubUrl(video),
hasParts: getLocalVideoChaptersActivityPubUrl(video),
attributedTo: [
{

View File

@@ -0,0 +1,95 @@
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { MVideo, MVideoChapter } from '@server/types/models/index.js'
import { VideoChapter, VideoChapterObject } from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { VideoModel } from './video.js'
import { Transaction } from 'sequelize'
import { getSort } from '../shared/sort.js'
@Table({
tableName: 'videoChapter',
indexes: [
{
fields: [ 'videoId', 'timecode' ],
unique: true
}
]
})
export class VideoChapterModel extends Model<Partial<AttributesOnly<VideoChapterModel>>> {
@AllowNull(false)
@Column
timecode: number
@AllowNull(false)
@Column
title: string
@ForeignKey(() => VideoModel)
@Column
videoId: number
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
Video: Awaited<VideoModel>
@CreatedAt
createdAt: Date
@UpdatedAt
updatedAt: Date
static deleteChapters (videoId: number, transaction: Transaction) {
const query = {
where: {
videoId
},
transaction
}
return VideoChapterModel.destroy(query)
}
static listChaptersOfVideo (videoId: number, transaction?: Transaction) {
const query = {
where: {
videoId
},
order: getSort('timecode'),
transaction
}
return VideoChapterModel.findAll<MVideoChapter>(query)
}
static hasVideoChapters (videoId: number, transaction: Transaction) {
return VideoChapterModel.findOne({
where: { videoId },
transaction
}).then(c => !!c)
}
toActivityPubJSON (this: MVideoChapter, options: {
video: MVideo
nextChapter: MVideoChapter
}): VideoChapterObject {
return {
name: this.title,
startOffset: this.timecode,
endOffset: options.nextChapter
? options.nextChapter.timecode
: options.video.duration
}
}
toFormattedJSON (this: MVideoChapter): VideoChapter {
return {
timecode: this.timecode,
title: this.title
}
}
}

View File

@@ -14,7 +14,7 @@ import {
MActorSummaryFormattable,
MActorUrl
} from '../actor/index.js'
import { MChannelDefault } from '../video/video-channels.js'
import { MChannelDefault } from '../video/video-channel.js'
import { MAccountBlocklistId } from './account-blocklist.js'
type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M>

View File

@@ -11,7 +11,7 @@ import {
MAccountIdActorId,
MAccountUrl
} from '../account/index.js'
import { MChannelFormattable } from '../video/video-channels.js'
import { MChannelFormattable } from '../video/video-channel.js'
import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js'
type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M>

View File

@@ -10,7 +10,8 @@ export * from './video-blacklist.js'
export * from './video-caption.js'
export * from './video-change-ownership.js'
export * from './video-channel-sync.js'
export * from './video-channels.js'
export * from './video-channel.js'
export * from './video-chapter.js'
export * from './video-comment.js'
export * from './video-file.js'
export * from './video-import.js'

View File

@@ -1,6 +1,6 @@
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils'
import { MChannelAccountDefault, MChannelFormattable } from './video-channels.js'
import { MChannelAccountDefault, MChannelFormattable } from './video-channel.js'
type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M>

View File

@@ -0,0 +1,3 @@
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
export type MVideoChapter = Omit<VideoChapterModel, 'Video'>

View File

@@ -3,7 +3,7 @@ import { PickWith } from '@peertube/peertube-typescript-utils'
import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js'
import { MThumbnail } from './thumbnail.js'
import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channels.js'
import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channel.js'
type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K, M>

View File

@@ -16,7 +16,7 @@ import {
MChannelFormattable,
MChannelHostOnly,
MChannelUserId
} from './video-channels.js'
} from './video-channel.js'
import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js'
import { MVideoLive } from './video-live.js'
import {