mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-02-25 18:55:32 -06:00
Add video chapters support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
51
server/server/controllers/api/videos/chapters.ts
Normal file
51
server/server/controllers/api/videos/chapters.ts
Normal 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)
|
||||
}
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
26
server/server/helpers/custom-validators/video-chapters.ts
Normal file
26
server/server/helpers/custom-validators/video-chapters.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 }))
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -465,6 +465,9 @@ const CONSTRAINTS_FIELDS = {
|
||||
},
|
||||
VIDEO_PASSWORD: {
|
||||
LENGTH: { min: 2, max: 100 }
|
||||
},
|
||||
VIDEO_CHAPTERS: {
|
||||
TITLE: { min: 1, max: 100 } // Length
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -60,6 +60,8 @@ export class APVideoCreator extends APVideoAbstractBuilder {
|
||||
return { autoBlacklisted, videoCreated }
|
||||
})
|
||||
|
||||
await this.updateChaptersOutsideTransaction(videoCreated)
|
||||
|
||||
return { autoBlacklisted, videoCreated }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
99
server/server/lib/video-chapters.ts
Normal file
99
server/server/lib/video-chapters.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
36
server/server/middlewares/cache/cache.ts
vendored
36
server/server/middlewares/cache/cache.ts
vendored
@@ -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()
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
]
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
95
server/server/models/video/video-chapter.ts
Normal file
95
server/server/models/video/video-chapter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
3
server/server/types/models/video/video-chapter.ts
Normal file
3
server/server/types/models/video/video-chapter.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
|
||||
|
||||
export type MVideoChapter = Omit<VideoChapterModel, 'Video'>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user