aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/server
diff options
context:
space:
mode:
Diffstat (limited to 'server/server')
-rw-r--r--server/server/controllers/activitypub/client.ts65
-rw-r--r--server/server/controllers/api/videos/chapters.ts51
-rw-r--r--server/server/controllers/api/videos/index.ts2
-rw-r--r--server/server/controllers/api/videos/update.ts11
-rw-r--r--server/server/controllers/api/videos/upload.ts9
-rw-r--r--server/server/helpers/activity-pub-utils.ts11
-rw-r--r--server/server/helpers/custom-validators/activitypub/video-chapters.ts15
-rw-r--r--server/server/helpers/custom-validators/video-chapters.ts26
-rw-r--r--server/server/helpers/youtube-dl/youtube-dl-info-builder.ts11
-rw-r--r--server/server/initializers/constants.ts3
-rw-r--r--server/server/initializers/database.ts2
-rw-r--r--server/server/lib/activitypub/url.ts5
-rw-r--r--server/server/lib/activitypub/videos/shared/abstract-builder.ts36
-rw-r--r--server/server/lib/activitypub/videos/shared/creator.ts2
-rw-r--r--server/server/lib/activitypub/videos/updater.ts2
-rw-r--r--server/server/lib/internal-event-emitter.ts4
-rw-r--r--server/server/lib/job-queue/handlers/video-import.ts6
-rw-r--r--server/server/lib/video-chapters.ts99
-rw-r--r--server/server/lib/video-pre-import.ts24
-rw-r--r--server/server/middlewares/cache/cache.ts38
-rw-r--r--server/server/middlewares/validators/feeds.ts11
-rw-r--r--server/server/middlewares/validators/videos/index.ts1
-rw-r--r--server/server/middlewares/validators/videos/video-chapters.ts34
-rw-r--r--server/server/models/video/formatter/video-activity-pub-format.ts2
-rw-r--r--server/server/models/video/video-chapter.ts95
-rw-r--r--server/server/types/models/account/account.ts2
-rw-r--r--server/server/types/models/user/user.ts2
-rw-r--r--server/server/types/models/video/index.ts3
-rw-r--r--server/server/types/models/video/video-channel-sync.ts2
-rw-r--r--server/server/types/models/video/video-channel.ts (renamed from server/server/types/models/video/video-channels.ts)0
-rw-r--r--server/server/types/models/video/video-chapter.ts3
-rw-r--r--server/server/types/models/video/video-playlist.ts2
-rw-r--r--server/server/types/models/video/video.ts2
33 files changed, 547 insertions, 34 deletions
diff --git a/server/server/controllers/activitypub/client.ts b/server/server/controllers/activitypub/client.ts
index 5d5e43bf5..1d5d269a9 100644
--- a/server/server/controllers/activitypub/client.ts
+++ b/server/server/controllers/activitypub/client.ts
@@ -1,6 +1,13 @@
1import cors from 'cors' 1import cors from 'cors'
2import express from 'express' 2import express from 'express'
3import { VideoCommentObject, VideoPlaylistPrivacy, VideoPrivacy, VideoRateType } from '@peertube/peertube-models' 3import {
4 VideoChapterObject,
5 VideoChaptersObject,
6 VideoCommentObject,
7 VideoPlaylistPrivacy,
8 VideoPrivacy,
9 VideoRateType
10} from '@peertube/peertube-models'
4import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js' 11import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
5import { getContextFilter } from '@server/lib/activitypub/context.js' 12import { getContextFilter } from '@server/lib/activitypub/context.js'
6import { getServerActor } from '@server/models/application/application.js' 13import { getServerActor } from '@server/models/application/application.js'
@@ -12,12 +19,18 @@ import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/act
12import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js' 19import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js'
13import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js' 20import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js'
14import { 21import {
22 getLocalVideoChaptersActivityPubUrl,
15 getLocalVideoCommentsActivityPubUrl, 23 getLocalVideoCommentsActivityPubUrl,
16 getLocalVideoDislikesActivityPubUrl, 24 getLocalVideoDislikesActivityPubUrl,
17 getLocalVideoLikesActivityPubUrl, 25 getLocalVideoLikesActivityPubUrl,
18 getLocalVideoSharesActivityPubUrl 26 getLocalVideoSharesActivityPubUrl
19} from '../../lib/activitypub/url.js' 27} from '../../lib/activitypub/url.js'
20import { cacheRoute } from '../../middlewares/cache/cache.js' 28import {
29 apVideoChaptersSetCacheKey,
30 buildAPVideoChaptersGroupsCache,
31 cacheRoute,
32 cacheRouteFactory
33} from '../../middlewares/cache/cache.js'
21import { 34import {
22 activityPubRateLimiter, 35 activityPubRateLimiter,
23 asyncMiddleware, 36 asyncMiddleware,
@@ -42,6 +55,8 @@ import { VideoCommentModel } from '../../models/video/video-comment.js'
42import { VideoPlaylistModel } from '../../models/video/video-playlist.js' 55import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
43import { VideoShareModel } from '../../models/video/video-share.js' 56import { VideoShareModel } from '../../models/video/video-share.js'
44import { activityPubResponse } from './utils.js' 57import { activityPubResponse } from './utils.js'
58import { VideoChapterModel } from '@server/models/video/video-chapter.js'
59import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
45 60
46const activityPubClientRouter = express.Router() 61const activityPubClientRouter = express.Router()
47activityPubClientRouter.use(cors()) 62activityPubClientRouter.use(cors())
@@ -145,6 +160,27 @@ activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity
145 asyncMiddleware(videoCommentController) 160 asyncMiddleware(videoCommentController)
146) 161)
147 162
163// ---------------------------------------------------------------------------
164
165const { middleware: chaptersCacheRouteMiddleware, instance: chaptersApiCache } = cacheRouteFactory()
166
167InternalEventEmitter.Instance.on('chapters-updated', ({ video }) => {
168 if (video.remote) return
169
170 chaptersApiCache.clearGroupSafe(buildAPVideoChaptersGroupsCache({ videoId: video.uuid }))
171})
172
173activityPubClientRouter.get('/videos/watch/:id/chapters',
174 executeIfActivityPub,
175 activityPubRateLimiter,
176 apVideoChaptersSetCacheKey,
177 chaptersCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
178 asyncMiddleware(videosCustomGetValidator('only-video')),
179 asyncMiddleware(videoChaptersController)
180)
181
182// ---------------------------------------------------------------------------
183
148activityPubClientRouter.get( 184activityPubClientRouter.get(
149 [ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ], 185 [ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ],
150 executeIfActivityPub, 186 executeIfActivityPub,
@@ -390,6 +426,31 @@ async function videoCommentController (req: express.Request, res: express.Respon
390 return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res) 426 return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res)
391} 427}
392 428
429async function videoChaptersController (req: express.Request, res: express.Response) {
430 const video = res.locals.onlyVideo
431
432 if (redirectIfNotOwned(video.url, res)) return
433
434 const chapters = await VideoChapterModel.listChaptersOfVideo(video.id)
435
436 const hasPart: VideoChapterObject[] = []
437
438 if (chapters.length !== 0) {
439 for (let i = 0; i < chapters.length - 1; i++) {
440 hasPart.push(chapters[i].toActivityPubJSON({ video, nextChapter: chapters[i + 1] }))
441 }
442
443 hasPart.push(chapters[chapters.length - 1].toActivityPubJSON({ video: res.locals.onlyVideo, nextChapter: null }))
444 }
445
446 const chaptersObject: VideoChaptersObject = {
447 id: getLocalVideoChaptersActivityPubUrl(video),
448 hasPart
449 }
450
451 return activityPubResponse(activityPubContextify(chaptersObject, 'Chapters', getContextFilter()), res)
452}
453
393async function videoRedundancyController (req: express.Request, res: express.Response) { 454async function videoRedundancyController (req: express.Request, res: express.Response) {
394 const videoRedundancy = res.locals.videoRedundancy 455 const videoRedundancy = res.locals.videoRedundancy
395 456
diff --git a/server/server/controllers/api/videos/chapters.ts b/server/server/controllers/api/videos/chapters.ts
new file mode 100644
index 000000000..f744a2b56
--- /dev/null
+++ b/server/server/controllers/api/videos/chapters.ts
@@ -0,0 +1,51 @@
1import express from 'express'
2import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js'
3import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js'
4import { VideoChapterModel } from '@server/models/video/video-chapter.js'
5import { HttpStatusCode, VideoChapterUpdate } from '@peertube/peertube-models'
6import { sequelizeTypescript } from '@server/initializers/database.js'
7import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
8import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
9import { replaceChapters } from '@server/lib/video-chapters.js'
10
11const videoChaptersRouter = express.Router()
12
13videoChaptersRouter.get('/:id/chapters',
14 asyncMiddleware(videosCustomGetValidator('only-video')),
15 asyncMiddleware(listVideoChapters)
16)
17
18videoChaptersRouter.put('/:videoId/chapters',
19 authenticate,
20 asyncMiddleware(updateVideoChaptersValidator),
21 asyncRetryTransactionMiddleware(replaceVideoChapters)
22)
23
24// ---------------------------------------------------------------------------
25
26export {
27 videoChaptersRouter
28}
29
30// ---------------------------------------------------------------------------
31
32async function listVideoChapters (req: express.Request, res: express.Response) {
33 const chapters = await VideoChapterModel.listChaptersOfVideo(res.locals.onlyVideo.id)
34
35 return res.json({ chapters: chapters.map(c => c.toFormattedJSON()) })
36}
37
38async function replaceVideoChapters (req: express.Request, res: express.Response) {
39 const body = req.body as VideoChapterUpdate
40 const video = res.locals.videoAll
41
42 await retryTransactionWrapper(() => {
43 return sequelizeTypescript.transaction(async t => {
44 await replaceChapters({ video, chapters: body.chapters, transaction: t })
45
46 await federateVideoIfNeeded(video, false, t)
47 })
48 })
49
50 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
51}
diff --git a/server/server/controllers/api/videos/index.ts b/server/server/controllers/api/videos/index.ts
index f8e3d9cb5..508cbb7c5 100644
--- a/server/server/controllers/api/videos/index.ts
+++ b/server/server/controllers/api/videos/index.ts
@@ -49,6 +49,7 @@ import { transcodingRouter } from './transcoding.js'
49import { updateRouter } from './update.js' 49import { updateRouter } from './update.js'
50import { uploadRouter } from './upload.js' 50import { uploadRouter } from './upload.js'
51import { viewRouter } from './view.js' 51import { viewRouter } from './view.js'
52import { videoChaptersRouter } from './chapters.js'
52 53
53const auditLogger = auditLoggerFactory('videos') 54const auditLogger = auditLoggerFactory('videos')
54const videosRouter = express.Router() 55const videosRouter = express.Router()
@@ -73,6 +74,7 @@ videosRouter.use('/', tokenRouter)
73videosRouter.use('/', videoPasswordRouter) 74videosRouter.use('/', videoPasswordRouter)
74videosRouter.use('/', storyboardRouter) 75videosRouter.use('/', storyboardRouter)
75videosRouter.use('/', videoSourceRouter) 76videosRouter.use('/', videoSourceRouter)
77videosRouter.use('/', videoChaptersRouter)
76 78
77videosRouter.get('/categories', 79videosRouter.get('/categories',
78 openapiOperationDoc({ operationId: 'getCategories' }), 80 openapiOperationDoc({ operationId: 'getCategories' }),
diff --git a/server/server/controllers/api/videos/update.ts b/server/server/controllers/api/videos/update.ts
index 491175d74..5adc5e8e5 100644
--- a/server/server/controllers/api/videos/update.ts
+++ b/server/server/controllers/api/videos/update.ts
@@ -22,6 +22,7 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist.js'
22import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js' 22import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
23import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' 23import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
24import { VideoModel } from '../../../models/video/video.js' 24import { VideoModel } from '../../../models/video/video.js'
25import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
25 26
26const lTags = loggerTagsFactory('api', 'video') 27const lTags = loggerTagsFactory('api', 'video')
27const auditLogger = auditLoggerFactory('videos') 28const auditLogger = auditLoggerFactory('videos')
@@ -67,6 +68,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
67 // Refresh video since thumbnails to prevent concurrent updates 68 // Refresh video since thumbnails to prevent concurrent updates
68 const video = await VideoModel.loadFull(videoFromReq.id, t) 69 const video = await VideoModel.loadFull(videoFromReq.id, t)
69 70
71 const oldDescription = video.description
70 const oldVideoChannel = video.VideoChannel 72 const oldVideoChannel = video.VideoChannel
71 73
72 const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [ 74 const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [
@@ -127,6 +129,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
127 // Schedule an update in the future? 129 // Schedule an update in the future?
128 await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t) 130 await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
129 131
132 if (oldDescription !== video.description) {
133 await replaceChaptersFromDescriptionIfNeeded({
134 newDescription: videoInstanceUpdated.description,
135 transaction: t,
136 video,
137 oldDescription
138 })
139 }
140
130 await autoBlacklistVideoIfNeeded({ 141 await autoBlacklistVideoIfNeeded({
131 video: videoInstanceUpdated, 142 video: videoInstanceUpdated,
132 user: res.locals.oauth.token.User, 143 user: res.locals.oauth.token.User,
diff --git a/server/server/controllers/api/videos/upload.ts b/server/server/controllers/api/videos/upload.ts
index 47f06e336..3d87deb1b 100644
--- a/server/server/controllers/api/videos/upload.ts
+++ b/server/server/controllers/api/videos/upload.ts
@@ -34,6 +34,8 @@ import {
34} from '../../../middlewares/index.js' 34} from '../../../middlewares/index.js'
35import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' 35import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
36import { VideoModel } from '../../../models/video/video.js' 36import { VideoModel } from '../../../models/video/video.js'
37import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
38import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
37 39
38const lTags = loggerTagsFactory('api', 'video') 40const lTags = loggerTagsFactory('api', 'video')
39const auditLogger = auditLoggerFactory('videos') 41const auditLogger = auditLoggerFactory('videos')
@@ -143,6 +145,9 @@ async function addVideo (options: {
143 const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' }) 145 const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
144 const originalFilename = videoPhysicalFile.originalname 146 const originalFilename = videoPhysicalFile.originalname
145 147
148 const containerChapters = await getChaptersFromContainer(videoPhysicalFile.path)
149 logger.debug(`Got ${containerChapters.length} chapters from video "${video.name}" container`, { containerChapters, ...lTags(video.uuid) })
150
146 // Move physical file 151 // Move physical file
147 const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile) 152 const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
148 await move(videoPhysicalFile.path, destination) 153 await move(videoPhysicalFile.path, destination)
@@ -188,6 +193,10 @@ async function addVideo (options: {
188 }, sequelizeOptions) 193 }, sequelizeOptions)
189 } 194 }
190 195
196 if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction: t })) {
197 await replaceChapters({ video, chapters: containerChapters, transaction: t })
198 }
199
191 await autoBlacklistVideoIfNeeded({ 200 await autoBlacklistVideoIfNeeded({
192 video, 201 video,
193 user, 202 user,
diff --git a/server/server/helpers/activity-pub-utils.ts b/server/server/helpers/activity-pub-utils.ts
index acc5c304b..cda40fdaa 100644
--- a/server/server/helpers/activity-pub-utils.ts
+++ b/server/server/helpers/activity-pub-utils.ts
@@ -79,6 +79,8 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
79 79
80 uploadDate: 'sc:uploadDate', 80 uploadDate: 'sc:uploadDate',
81 81
82 hasParts: 'sc:hasParts',
83
82 views: { 84 views: {
83 '@type': 'sc:Number', 85 '@type': 'sc:Number',
84 '@id': 'pt:views' 86 '@id': 'pt:views'
@@ -195,7 +197,14 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
195 Announce: buildContext(), 197 Announce: buildContext(),
196 Comment: buildContext(), 198 Comment: buildContext(),
197 Delete: buildContext(), 199 Delete: buildContext(),
198 Rate: buildContext() 200 Rate: buildContext(),
201
202 Chapters: buildContext({
203 name: 'sc:name',
204 hasPart: 'sc:hasPart',
205 endOffset: 'sc:endOffset',
206 startOffset: 'sc:startOffset'
207 })
199} 208}
200 209
201async function getContextData (type: ContextType, contextFilter: ContextFilter) { 210async function getContextData (type: ContextType, contextFilter: ContextFilter) {
diff --git a/server/server/helpers/custom-validators/activitypub/video-chapters.ts b/server/server/helpers/custom-validators/activitypub/video-chapters.ts
new file mode 100644
index 000000000..38009991b
--- /dev/null
+++ b/server/server/helpers/custom-validators/activitypub/video-chapters.ts
@@ -0,0 +1,15 @@
1import { isArray } from '../misc.js'
2import { isVideoChapterTitleValid, isVideoChapterTimecodeValid } from '../video-chapters.js'
3import { isActivityPubUrlValid } from './misc.js'
4import { VideoChaptersObject } from '@peertube/peertube-models'
5
6export function isVideoChaptersObjectValid (object: VideoChaptersObject) {
7 if (!object) return false
8 if (!isActivityPubUrlValid(object.id)) return false
9
10 if (!isArray(object.hasPart)) return false
11
12 return object.hasPart.every(part => {
13 return isVideoChapterTitleValid(part.name) && isVideoChapterTimecodeValid(part.startOffset)
14 })
15}
diff --git a/server/server/helpers/custom-validators/video-chapters.ts b/server/server/helpers/custom-validators/video-chapters.ts
new file mode 100644
index 000000000..8bdd2d7b8
--- /dev/null
+++ b/server/server/helpers/custom-validators/video-chapters.ts
@@ -0,0 +1,26 @@
1import { isArray } from './misc.js'
2import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models'
3import { Unpacked } from '@peertube/peertube-typescript-utils'
4import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
5import validator from 'validator'
6
7export function areVideoChaptersValid (value: VideoChapter[]) {
8 if (!isArray(value)) return false
9 if (!value.every(v => isVideoChapterValid(v))) return false
10
11 const timecodes = value.map(c => c.timecode)
12
13 return new Set(timecodes).size === timecodes.length
14}
15
16export function isVideoChapterValid (value: Unpacked<VideoChapterUpdate['chapters']>) {
17 return isVideoChapterTimecodeValid(value.timecode) && isVideoChapterTitleValid(value.title)
18}
19
20export function isVideoChapterTitleValid (value: any) {
21 return validator.default.isLength(value + '', CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE)
22}
23
24export function isVideoChapterTimecodeValid (value: any) {
25 return validator.default.isInt(value + '', { min: 0 })
26}
diff --git a/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts b/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts
index 0287f6183..66993d2ee 100644
--- a/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts
+++ b/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts
@@ -1,6 +1,7 @@
1import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js' 1import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js'
2import { peertubeTruncate } from '../core-utils.js' 2import { peertubeTruncate } from '../core-utils.js'
3import { isUrlValid } from '../custom-validators/activitypub/misc.js' 3import { isUrlValid } from '../custom-validators/activitypub/misc.js'
4import { isArray } from '../custom-validators/misc.js'
4 5
5export type YoutubeDLInfo = { 6export type YoutubeDLInfo = {
6 name?: string 7 name?: string
@@ -16,6 +17,11 @@ export type YoutubeDLInfo = {
16 webpageUrl?: string 17 webpageUrl?: string
17 18
18 urls?: string[] 19 urls?: string[]
20
21 chapters?: {
22 timecode: number
23 title: string
24 }[]
19} 25}
20 26
21export class YoutubeDLInfoBuilder { 27export class YoutubeDLInfoBuilder {
@@ -83,7 +89,10 @@ export class YoutubeDLInfoBuilder {
83 urls: this.buildAvailableUrl(obj), 89 urls: this.buildAvailableUrl(obj),
84 originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj), 90 originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj),
85 ext: obj.ext, 91 ext: obj.ext,
86 webpageUrl: obj.webpage_url 92 webpageUrl: obj.webpage_url,
93 chapters: isArray(obj.chapters)
94 ? obj.chapters.map((c: { start_time: number, title: string }) => ({ timecode: c.start_time, title: c.title }))
95 : []
87 } 96 }
88 } 97 }
89 98
diff --git a/server/server/initializers/constants.ts b/server/server/initializers/constants.ts
index 34392dbc8..027b927c2 100644
--- a/server/server/initializers/constants.ts
+++ b/server/server/initializers/constants.ts
@@ -465,6 +465,9 @@ const CONSTRAINTS_FIELDS = {
465 }, 465 },
466 VIDEO_PASSWORD: { 466 VIDEO_PASSWORD: {
467 LENGTH: { min: 2, max: 100 } 467 LENGTH: { min: 2, max: 100 }
468 },
469 VIDEO_CHAPTERS: {
470 TITLE: { min: 1, max: 100 } // Length
468 } 471 }
469} 472}
470 473
diff --git a/server/server/initializers/database.ts b/server/server/initializers/database.ts
index fe399a633..0294e2d29 100644
--- a/server/server/initializers/database.ts
+++ b/server/server/initializers/database.ts
@@ -59,6 +59,7 @@ import { VideoTagModel } from '../models/video/video-tag.js'
59import { VideoModel } from '../models/video/video.js' 59import { VideoModel } from '../models/video/video.js'
60import { VideoViewModel } from '../models/view/video-view.js' 60import { VideoViewModel } from '../models/view/video-view.js'
61import { CONFIG } from './config.js' 61import { CONFIG } from './config.js'
62import { VideoChapterModel } from '@server/models/video/video-chapter.js'
62 63
63pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string 64pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
64 65
@@ -137,6 +138,7 @@ async function initDatabaseModels (silent: boolean) {
137 VideoShareModel, 138 VideoShareModel,
138 VideoFileModel, 139 VideoFileModel,
139 VideoSourceModel, 140 VideoSourceModel,
141 VideoChapterModel,
140 VideoCaptionModel, 142 VideoCaptionModel,
141 VideoBlacklistModel, 143 VideoBlacklistModel,
142 VideoTagModel, 144 VideoTagModel,
diff --git a/server/server/lib/activitypub/url.ts b/server/server/lib/activitypub/url.ts
index 73f6f4849..aff104804 100644
--- a/server/server/lib/activitypub/url.ts
+++ b/server/server/lib/activitypub/url.ts
@@ -80,6 +80,10 @@ function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
80 return video.url + '/comments' 80 return video.url + '/comments'
81} 81}
82 82
83function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
84 return video.url + '/chapters'
85}
86
83function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) { 87function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
84 return video.url + '/likes' 88 return video.url + '/likes'
85} 89}
@@ -167,6 +171,7 @@ export {
167 getDeleteActivityPubUrl, 171 getDeleteActivityPubUrl,
168 getLocalVideoSharesActivityPubUrl, 172 getLocalVideoSharesActivityPubUrl,
169 getLocalVideoCommentsActivityPubUrl, 173 getLocalVideoCommentsActivityPubUrl,
174 getLocalVideoChaptersActivityPubUrl,
170 getLocalVideoLikesActivityPubUrl, 175 getLocalVideoLikesActivityPubUrl,
171 getLocalVideoDislikesActivityPubUrl, 176 getLocalVideoDislikesActivityPubUrl,
172 getLocalVideoViewerActivityPubUrl, 177 getLocalVideoViewerActivityPubUrl,
diff --git a/server/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/server/lib/activitypub/videos/shared/abstract-builder.ts
index 4397e578f..2c0ad99ac 100644
--- a/server/server/lib/activitypub/videos/shared/abstract-builder.ts
+++ b/server/server/lib/activitypub/videos/shared/abstract-builder.ts
@@ -1,6 +1,12 @@
1import { CreationAttributes, Transaction } from 'sequelize' 1import { CreationAttributes, Transaction } from 'sequelize'
2import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models' 2import {
3import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils.js' 3 ActivityTagObject,
4 ThumbnailType,
5 VideoChaptersObject,
6 VideoObject,
7 VideoStreamingPlaylistType_Type
8} from '@peertube/peertube-models'
9import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js'
4import { logger, LoggerTagsFn } from '@server/helpers/logger.js' 10import { logger, LoggerTagsFn } from '@server/helpers/logger.js'
5import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js' 11import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
6import { setVideoTags } from '@server/lib/video.js' 12import { setVideoTags } from '@server/lib/video.js'
@@ -29,6 +35,10 @@ import {
29 getThumbnailFromIcons 35 getThumbnailFromIcons
30} from './object-to-model-attributes.js' 36} from './object-to-model-attributes.js'
31import { getTrackerUrls, setVideoTrackers } from './trackers.js' 37import { getTrackerUrls, setVideoTrackers } from './trackers.js'
38import { fetchAP } from '../../activity.js'
39import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
40import { sequelizeTypescript } from '@server/initializers/database.js'
41import { replaceChapters } from '@server/lib/video-chapters.js'
32 42
33export abstract class APVideoAbstractBuilder { 43export abstract class APVideoAbstractBuilder {
34 protected abstract videoObject: VideoObject 44 protected abstract videoObject: VideoObject
@@ -44,7 +54,7 @@ export abstract class APVideoAbstractBuilder {
44 protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) { 54 protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
45 const miniatureIcon = getThumbnailFromIcons(this.videoObject) 55 const miniatureIcon = getThumbnailFromIcons(this.videoObject)
46 if (!miniatureIcon) { 56 if (!miniatureIcon) {
47 logger.warn('Cannot find thumbnail in video object', { object: this.videoObject }) 57 logger.warn('Cannot find thumbnail in video object', { object: this.videoObject, ...this.lTags() })
48 return undefined 58 return undefined
49 } 59 }
50 60
@@ -138,6 +148,26 @@ export abstract class APVideoAbstractBuilder {
138 video.VideoFiles = await Promise.all(upsertTasks) 148 video.VideoFiles = await Promise.all(upsertTasks)
139 } 149 }
140 150
151 protected async updateChaptersOutsideTransaction (video: MVideoFullLight) {
152 if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return
153
154 const { body } = await fetchAP<VideoChaptersObject>(this.videoObject.hasParts)
155 if (!isVideoChaptersObjectValid(body)) {
156 logger.warn('Chapters AP object is not valid, skipping', { body, ...this.lTags() })
157 return
158 }
159
160 logger.debug('Fetched chapters AP object', { body, ...this.lTags() })
161
162 return retryTransactionWrapper(() => {
163 return sequelizeTypescript.transaction(async t => {
164 const chapters = body.hasPart.map(p => ({ title: p.name, timecode: p.startOffset }))
165
166 await replaceChapters({ chapters, transaction: t, video })
167 })
168 })
169 }
170
141 protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) { 171 protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
142 const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject) 172 const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
143 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) 173 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
diff --git a/server/server/lib/activitypub/videos/shared/creator.ts b/server/server/lib/activitypub/videos/shared/creator.ts
index 5a3a46282..35e537ccc 100644
--- a/server/server/lib/activitypub/videos/shared/creator.ts
+++ b/server/server/lib/activitypub/videos/shared/creator.ts
@@ -60,6 +60,8 @@ export class APVideoCreator extends APVideoAbstractBuilder {
60 return { autoBlacklisted, videoCreated } 60 return { autoBlacklisted, videoCreated }
61 }) 61 })
62 62
63 await this.updateChaptersOutsideTransaction(videoCreated)
64
63 return { autoBlacklisted, videoCreated } 65 return { autoBlacklisted, videoCreated }
64 } 66 }
65} 67}
diff --git a/server/server/lib/activitypub/videos/updater.ts b/server/server/lib/activitypub/videos/updater.ts
index 37bf7411a..f9c5b4040 100644
--- a/server/server/lib/activitypub/videos/updater.ts
+++ b/server/server/lib/activitypub/videos/updater.ts
@@ -77,6 +77,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
77 77
78 await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t)) 78 await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
79 79
80 await this.updateChaptersOutsideTransaction(videoUpdated)
81
80 await autoBlacklistVideoIfNeeded({ 82 await autoBlacklistVideoIfNeeded({
81 video: videoUpdated, 83 video: videoUpdated,
82 user: undefined, 84 user: undefined,
diff --git a/server/server/lib/internal-event-emitter.ts b/server/server/lib/internal-event-emitter.ts
index 54f192982..db6e674d0 100644
--- a/server/server/lib/internal-event-emitter.ts
+++ b/server/server/lib/internal-event-emitter.ts
@@ -1,4 +1,4 @@
1import { MChannel, MVideo } from '@server/types/models/index.js' 1import { MChannel, MVideo, MVideoImmutable } from '@server/types/models/index.js'
2import { EventEmitter } from 'events' 2import { EventEmitter } from 'events'
3 3
4export interface PeerTubeInternalEvents { 4export interface PeerTubeInternalEvents {
@@ -9,6 +9,8 @@ export interface PeerTubeInternalEvents {
9 'channel-created': (options: { channel: MChannel }) => void 9 'channel-created': (options: { channel: MChannel }) => void
10 'channel-updated': (options: { channel: MChannel }) => void 10 'channel-updated': (options: { channel: MChannel }) => void
11 'channel-deleted': (options: { channel: MChannel }) => void 11 'channel-deleted': (options: { channel: MChannel }) => void
12
13 'chapters-updated': (options: { video: MVideoImmutable }) => void
12} 14}
13 15
14declare interface InternalEventEmitter { 16declare interface InternalEventEmitter {
diff --git a/server/server/lib/job-queue/handlers/video-import.ts b/server/server/lib/job-queue/handlers/video-import.ts
index 7d5435a3b..09d974e90 100644
--- a/server/server/lib/job-queue/handlers/video-import.ts
+++ b/server/server/lib/job-queue/handlers/video-import.ts
@@ -32,6 +32,7 @@ import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImpo
32import { getLowercaseExtension } from '@peertube/peertube-node-utils' 32import { getLowercaseExtension } from '@peertube/peertube-node-utils'
33import { 33import {
34 ffprobePromise, 34 ffprobePromise,
35 getChaptersFromContainer,
35 getVideoStreamDimensionsInfo, 36 getVideoStreamDimensionsInfo,
36 getVideoStreamDuration, 37 getVideoStreamDuration,
37 getVideoStreamFPS, 38 getVideoStreamFPS,
@@ -49,6 +50,7 @@ import { federateVideoIfNeeded } from '../../activitypub/videos/index.js'
49import { Notifier } from '../../notifier/index.js' 50import { Notifier } from '../../notifier/index.js'
50import { generateLocalVideoMiniature } from '../../thumbnail.js' 51import { generateLocalVideoMiniature } from '../../thumbnail.js'
51import { JobQueue } from '../job-queue.js' 52import { JobQueue } from '../job-queue.js'
53import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
52 54
53async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> { 55async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
54 const payload = job.data as VideoImportPayload 56 const payload = job.data as VideoImportPayload
@@ -150,6 +152,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
150 const fps = await getVideoStreamFPS(tempVideoPath, probe) 152 const fps = await getVideoStreamFPS(tempVideoPath, probe)
151 const duration = await getVideoStreamDuration(tempVideoPath, probe) 153 const duration = await getVideoStreamDuration(tempVideoPath, probe)
152 154
155 const containerChapters = await getChaptersFromContainer(tempVideoPath, probe)
156
153 // Prepare video file object for creation in database 157 // Prepare video file object for creation in database
154 const fileExt = getLowercaseExtension(tempVideoPath) 158 const fileExt = getLowercaseExtension(tempVideoPath)
155 const videoFileData = { 159 const videoFileData = {
@@ -228,6 +232,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
228 if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t) 232 if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
229 if (previewModel) await video.addAndSaveThumbnail(previewModel, t) 233 if (previewModel) await video.addAndSaveThumbnail(previewModel, t)
230 234
235 await replaceChaptersIfNotExist({ video, chapters: containerChapters, transaction: t })
236
231 // Now we can federate the video (reload from database, we need more attributes) 237 // Now we can federate the video (reload from database, we need more attributes)
232 const videoForFederation = await VideoModel.loadFull(video.uuid, t) 238 const videoForFederation = await VideoModel.loadFull(video.uuid, t)
233 await federateVideoIfNeeded(videoForFederation, true, t) 239 await federateVideoIfNeeded(videoForFederation, true, t)
diff --git a/server/server/lib/video-chapters.ts b/server/server/lib/video-chapters.ts
new file mode 100644
index 000000000..c2b091356
--- /dev/null
+++ b/server/server/lib/video-chapters.ts
@@ -0,0 +1,99 @@
1import { parseChapters, sortBy } from '@peertube/peertube-core-utils'
2import { VideoChapter } from '@peertube/peertube-models'
3import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
4import { VideoChapterModel } from '@server/models/video/video-chapter.js'
5import { MVideoImmutable } from '@server/types/models/index.js'
6import { Transaction } from 'sequelize'
7import { InternalEventEmitter } from './internal-event-emitter.js'
8
9const lTags = loggerTagsFactory('video', 'chapters')
10
11export async function replaceChapters (options: {
12 video: MVideoImmutable
13 chapters: VideoChapter[]
14 transaction: Transaction
15}) {
16 const { chapters, transaction, video } = options
17
18 await VideoChapterModel.deleteChapters(video.id, transaction)
19
20 await createChapters({ videoId: video.id, chapters, transaction })
21
22 InternalEventEmitter.Instance.emit('chapters-updated', { video })
23}
24
25export async function replaceChaptersIfNotExist (options: {
26 video: MVideoImmutable
27 chapters: VideoChapter[]
28 transaction: Transaction
29}) {
30 const { chapters, transaction, video } = options
31
32 if (await VideoChapterModel.hasVideoChapters(video.id, transaction)) return
33
34 await createChapters({ videoId: video.id, chapters, transaction })
35
36 InternalEventEmitter.Instance.emit('chapters-updated', { video })
37}
38
39export async function replaceChaptersFromDescriptionIfNeeded (options: {
40 oldDescription?: string
41 newDescription: string
42 video: MVideoImmutable
43 transaction: Transaction
44}) {
45 const { transaction, video, newDescription, oldDescription = '' } = options
46
47 const chaptersFromOldDescription = sortBy(parseChapters(oldDescription), 'timecode')
48 const existingChapters = await VideoChapterModel.listChaptersOfVideo(video.id, transaction)
49
50 logger.debug(
51 'Check if we replace chapters from description',
52 { oldDescription, chaptersFromOldDescription, newDescription, existingChapters, ...lTags(video.uuid) }
53 )
54
55 // Then we can update chapters from the new description
56 if (areSameChapters(chaptersFromOldDescription, existingChapters)) {
57 const chaptersFromNewDescription = sortBy(parseChapters(newDescription), 'timecode')
58 if (chaptersFromOldDescription.length === 0 && chaptersFromNewDescription.length === 0) return false
59
60 await replaceChapters({ video, chapters: chaptersFromNewDescription, transaction })
61
62 logger.info('Replaced chapters of video ' + video.uuid, { chaptersFromNewDescription, ...lTags(video.uuid) })
63
64 return true
65 }
66
67 return false
68}
69
70// ---------------------------------------------------------------------------
71// Private
72// ---------------------------------------------------------------------------
73
74async function createChapters (options: {
75 videoId: number
76 chapters: VideoChapter[]
77 transaction: Transaction
78}) {
79 const { chapters, transaction, videoId } = options
80
81 for (const chapter of chapters) {
82 await VideoChapterModel.create({
83 title: chapter.title,
84 timecode: chapter.timecode,
85 videoId
86 }, { transaction })
87 }
88}
89
90function areSameChapters (chapters1: VideoChapter[], chapters2: VideoChapter[]) {
91 if (chapters1.length !== chapters2.length) return false
92
93 for (let i = 0; i < chapters1.length; i++) {
94 if (chapters1[i].timecode !== chapters2[i].timecode) return false
95 if (chapters1[i].title !== chapters2[i].title) return false
96 }
97
98 return true
99}
diff --git a/server/server/lib/video-pre-import.ts b/server/server/lib/video-pre-import.ts
index 0298e121e..447ea341d 100644
--- a/server/server/lib/video-pre-import.ts
+++ b/server/server/lib/video-pre-import.ts
@@ -39,6 +39,7 @@ import {
39} from '@server/types/models/index.js' 39} from '@server/types/models/index.js'
40import { getLocalVideoActivityPubUrl } from './activitypub/url.js' 40import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
41import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js' 41import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
42import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
42 43
43class YoutubeDlImportError extends Error { 44class YoutubeDlImportError extends Error {
44 code: YoutubeDlImportError.CODE 45 code: YoutubeDlImportError.CODE
@@ -227,6 +228,29 @@ async function buildYoutubeDLImport (options: {
227 videoPasswords: importDataOverride.videoPasswords 228 videoPasswords: importDataOverride.videoPasswords
228 }) 229 })
229 230
231 await sequelizeTypescript.transaction(async transaction => {
232 // Priority to explicitely set description
233 if (importDataOverride?.description) {
234 const inserted = await replaceChaptersFromDescriptionIfNeeded({ newDescription: importDataOverride.description, video, transaction })
235 if (inserted) return
236 }
237
238 // Then priority to youtube-dl chapters
239 if (youtubeDLInfo.chapters.length !== 0) {
240 logger.info(
241 `Inserting chapters in video ${video.uuid} from youtube-dl`,
242 { chapters: youtubeDLInfo.chapters, tags: [ 'chapters', video.uuid ] }
243 )
244
245 await replaceChapters({ video, chapters: youtubeDLInfo.chapters, transaction })
246 return
247 }
248
249 if (video.description) {
250 await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction })
251 }
252 })
253
230 // Get video subtitles 254 // Get video subtitles
231 await processYoutubeSubtitles(youtubeDL, targetUrl, video.id) 255 await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
232 256
diff --git a/server/server/middlewares/cache/cache.ts b/server/server/middlewares/cache/cache.ts
index 6cf37e322..e615fc353 100644
--- a/server/server/middlewares/cache/cache.ts
+++ b/server/server/middlewares/cache/cache.ts
@@ -1,3 +1,4 @@
1import express from 'express'
1import { HttpStatusCode } from '@peertube/peertube-models' 2import { HttpStatusCode } from '@peertube/peertube-models'
2import { ApiCache, APICacheOptions } from './shared/index.js' 3import { ApiCache, APICacheOptions } from './shared/index.js'
3 4
@@ -8,13 +9,13 @@ const defaultOptions: APICacheOptions = {
8 ] 9 ]
9} 10}
10 11
11function cacheRoute (duration: string) { 12export function cacheRoute (duration: string) {
12 const instance = new ApiCache(defaultOptions) 13 const instance = new ApiCache(defaultOptions)
13 14
14 return instance.buildMiddleware(duration) 15 return instance.buildMiddleware(duration)
15} 16}
16 17
17function cacheRouteFactory (options: APICacheOptions) { 18export function cacheRouteFactory (options: APICacheOptions = {}) {
18 const instance = new ApiCache({ ...defaultOptions, ...options }) 19 const instance = new ApiCache({ ...defaultOptions, ...options })
19 20
20 return { instance, middleware: instance.buildMiddleware.bind(instance) } 21 return { instance, middleware: instance.buildMiddleware.bind(instance) }
@@ -22,17 +23,36 @@ function cacheRouteFactory (options: APICacheOptions) {
22 23
23// --------------------------------------------------------------------------- 24// ---------------------------------------------------------------------------
24 25
25function buildPodcastGroupsCache (options: { 26export function buildPodcastGroupsCache (options: {
26 channelId: number 27 channelId: number
27}) { 28}) {
28 return 'podcast-feed-' + options.channelId 29 return 'podcast-feed-' + options.channelId
29} 30}
30 31
31// --------------------------------------------------------------------------- 32export function buildAPVideoChaptersGroupsCache (options: {
33 videoId: number | string
34}) {
35 return 'ap-video-chapters-' + options.videoId
36}
32 37
33export { 38// ---------------------------------------------------------------------------
34 cacheRoute,
35 cacheRouteFactory,
36 39
37 buildPodcastGroupsCache 40export const videoFeedsPodcastSetCacheKey = [
38} 41 (req: express.Request, res: express.Response, next: express.NextFunction) => {
42 if (req.query.videoChannelId) {
43 res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
44 }
45
46 return next()
47 }
48]
49
50export const apVideoChaptersSetCacheKey = [
51 (req: express.Request, res: express.Response, next: express.NextFunction) => {
52 if (req.params.id) {
53 res.locals.apicacheGroups = [ buildAPVideoChaptersGroupsCache({ videoId: req.params.id }) ]
54 }
55
56 return next()
57 }
58]
diff --git a/server/server/middlewares/validators/feeds.ts b/server/server/middlewares/validators/feeds.ts
index ec99b6920..895dd35ba 100644
--- a/server/server/middlewares/validators/feeds.ts
+++ b/server/server/middlewares/validators/feeds.ts
@@ -3,7 +3,6 @@ import { param, query } from 'express-validator'
3import { HttpStatusCode } from '@peertube/peertube-models' 3import { HttpStatusCode } from '@peertube/peertube-models'
4import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js' 4import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js'
5import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js' 5import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js'
6import { buildPodcastGroupsCache } from '../cache/index.js'
7import { 6import {
8 areValidationErrors, 7 areValidationErrors,
9 checkCanSeeVideo, 8 checkCanSeeVideo,
@@ -114,15 +113,6 @@ const videoFeedsPodcastValidator = [
114 } 113 }
115] 114]
116 115
117const videoFeedsPodcastSetCacheKey = [
118 (req: express.Request, res: express.Response, next: express.NextFunction) => {
119 if (req.query.videoChannelId) {
120 res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
121 }
122
123 return next()
124 }
125]
126// --------------------------------------------------------------------------- 116// ---------------------------------------------------------------------------
127 117
128const videoSubscriptionFeedsValidator = [ 118const videoSubscriptionFeedsValidator = [
@@ -173,6 +163,5 @@ export {
173 feedsAccountOrChannelFiltersValidator, 163 feedsAccountOrChannelFiltersValidator,
174 videoFeedsPodcastValidator, 164 videoFeedsPodcastValidator,
175 videoSubscriptionFeedsValidator, 165 videoSubscriptionFeedsValidator,
176 videoFeedsPodcastSetCacheKey,
177 videoCommentsFeedsValidator 166 videoCommentsFeedsValidator
178} 167}
diff --git a/server/server/middlewares/validators/videos/index.ts b/server/server/middlewares/validators/videos/index.ts
index 05c6659ae..eed4f35d4 100644
--- a/server/server/middlewares/validators/videos/index.ts
+++ b/server/server/middlewares/validators/videos/index.ts
@@ -2,6 +2,7 @@ export * from './video-blacklist.js'
2export * from './video-captions.js' 2export * from './video-captions.js'
3export * from './video-channel-sync.js' 3export * from './video-channel-sync.js'
4export * from './video-channels.js' 4export * from './video-channels.js'
5export * from './video-chapters.js'
5export * from './video-comments.js' 6export * from './video-comments.js'
6export * from './video-files.js' 7export * from './video-files.js'
7export * from './video-imports.js' 8export * from './video-imports.js'
diff --git a/server/server/middlewares/validators/videos/video-chapters.ts b/server/server/middlewares/validators/videos/video-chapters.ts
new file mode 100644
index 000000000..5097e6380
--- /dev/null
+++ b/server/server/middlewares/validators/videos/video-chapters.ts
@@ -0,0 +1,34 @@
1import express from 'express'
2import { body } from 'express-validator'
3import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
4import {
5 areValidationErrors, checkUserCanManageVideo, doesVideoExist,
6 isValidVideoIdParam
7} from '../shared/index.js'
8import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js'
9
10export const updateVideoChaptersValidator = [
11 isValidVideoIdParam('videoId'),
12
13 body('chapters')
14 .custom(areVideoChaptersValid)
15 .withMessage('Chapters must have a valid title and timecode, and each timecode must be unique'),
16
17 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
18 if (areValidationErrors(req, res)) return
19 if (!await doesVideoExist(req.params.videoId, res)) return
20
21 if (res.locals.videoAll.isLive) {
22 return res.fail({
23 status: HttpStatusCode.BAD_REQUEST_400,
24 message: 'You cannot add chapters to a live video'
25 })
26 }
27
28 // Check if the user who did the request is able to update video chapters (same right as updating the video)
29 const user = res.locals.oauth.token.User
30 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
31
32 return next()
33 }
34]
diff --git a/server/server/models/video/formatter/video-activity-pub-format.ts b/server/server/models/video/formatter/video-activity-pub-format.ts
index 759e6dbbc..d19bb1880 100644
--- a/server/server/models/video/formatter/video-activity-pub-format.ts
+++ b/server/server/models/video/formatter/video-activity-pub-format.ts
@@ -13,6 +13,7 @@ import {
13} from '@peertube/peertube-models' 13} from '@peertube/peertube-models'
14import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js' 14import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js'
15import { 15import {
16 getLocalVideoChaptersActivityPubUrl,
16 getLocalVideoCommentsActivityPubUrl, 17 getLocalVideoCommentsActivityPubUrl,
17 getLocalVideoDislikesActivityPubUrl, 18 getLocalVideoDislikesActivityPubUrl,
18 getLocalVideoLikesActivityPubUrl, 19 getLocalVideoLikesActivityPubUrl,
@@ -95,6 +96,7 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
95 dislikes: getLocalVideoDislikesActivityPubUrl(video), 96 dislikes: getLocalVideoDislikesActivityPubUrl(video),
96 shares: getLocalVideoSharesActivityPubUrl(video), 97 shares: getLocalVideoSharesActivityPubUrl(video),
97 comments: getLocalVideoCommentsActivityPubUrl(video), 98 comments: getLocalVideoCommentsActivityPubUrl(video),
99 hasParts: getLocalVideoChaptersActivityPubUrl(video),
98 100
99 attributedTo: [ 101 attributedTo: [
100 { 102 {
diff --git a/server/server/models/video/video-chapter.ts b/server/server/models/video/video-chapter.ts
new file mode 100644
index 000000000..6e59abec9
--- /dev/null
+++ b/server/server/models/video/video-chapter.ts
@@ -0,0 +1,95 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { MVideo, MVideoChapter } from '@server/types/models/index.js'
3import { VideoChapter, VideoChapterObject } from '@peertube/peertube-models'
4import { AttributesOnly } from '@peertube/peertube-typescript-utils'
5import { VideoModel } from './video.js'
6import { Transaction } from 'sequelize'
7import { getSort } from '../shared/sort.js'
8
9@Table({
10 tableName: 'videoChapter',
11 indexes: [
12 {
13 fields: [ 'videoId', 'timecode' ],
14 unique: true
15 }
16 ]
17})
18export class VideoChapterModel extends Model<Partial<AttributesOnly<VideoChapterModel>>> {
19
20 @AllowNull(false)
21 @Column
22 timecode: number
23
24 @AllowNull(false)
25 @Column
26 title: string
27
28 @ForeignKey(() => VideoModel)
29 @Column
30 videoId: number
31
32 @BelongsTo(() => VideoModel, {
33 foreignKey: {
34 allowNull: false
35 },
36 onDelete: 'CASCADE'
37 })
38 Video: Awaited<VideoModel>
39
40 @CreatedAt
41 createdAt: Date
42
43 @UpdatedAt
44 updatedAt: Date
45
46 static deleteChapters (videoId: number, transaction: Transaction) {
47 const query = {
48 where: {
49 videoId
50 },
51 transaction
52 }
53
54 return VideoChapterModel.destroy(query)
55 }
56
57 static listChaptersOfVideo (videoId: number, transaction?: Transaction) {
58 const query = {
59 where: {
60 videoId
61 },
62 order: getSort('timecode'),
63 transaction
64 }
65
66 return VideoChapterModel.findAll<MVideoChapter>(query)
67 }
68
69 static hasVideoChapters (videoId: number, transaction: Transaction) {
70 return VideoChapterModel.findOne({
71 where: { videoId },
72 transaction
73 }).then(c => !!c)
74 }
75
76 toActivityPubJSON (this: MVideoChapter, options: {
77 video: MVideo
78 nextChapter: MVideoChapter
79 }): VideoChapterObject {
80 return {
81 name: this.title,
82 startOffset: this.timecode,
83 endOffset: options.nextChapter
84 ? options.nextChapter.timecode
85 : options.video.duration
86 }
87 }
88
89 toFormattedJSON (this: MVideoChapter): VideoChapter {
90 return {
91 timecode: this.timecode,
92 title: this.title
93 }
94 }
95}
diff --git a/server/server/types/models/account/account.ts b/server/server/types/models/account/account.ts
index 4a5e80725..a8ff058ed 100644
--- a/server/server/types/models/account/account.ts
+++ b/server/server/types/models/account/account.ts
@@ -14,7 +14,7 @@ import {
14 MActorSummaryFormattable, 14 MActorSummaryFormattable,
15 MActorUrl 15 MActorUrl
16} from '../actor/index.js' 16} from '../actor/index.js'
17import { MChannelDefault } from '../video/video-channels.js' 17import { MChannelDefault } from '../video/video-channel.js'
18import { MAccountBlocklistId } from './account-blocklist.js' 18import { MAccountBlocklistId } from './account-blocklist.js'
19 19
20type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M> 20type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M>
diff --git a/server/server/types/models/user/user.ts b/server/server/types/models/user/user.ts
index 4a655c792..3d0bee1aa 100644
--- a/server/server/types/models/user/user.ts
+++ b/server/server/types/models/user/user.ts
@@ -11,7 +11,7 @@ import {
11 MAccountIdActorId, 11 MAccountIdActorId,
12 MAccountUrl 12 MAccountUrl
13} from '../account/index.js' 13} from '../account/index.js'
14import { MChannelFormattable } from '../video/video-channels.js' 14import { MChannelFormattable } from '../video/video-channel.js'
15import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js' 15import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js'
16 16
17type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M> 17type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M>
diff --git a/server/server/types/models/video/index.ts b/server/server/types/models/video/index.ts
index f88198b67..0eeb7aad2 100644
--- a/server/server/types/models/video/index.ts
+++ b/server/server/types/models/video/index.ts
@@ -10,7 +10,8 @@ export * from './video-blacklist.js'
10export * from './video-caption.js' 10export * from './video-caption.js'
11export * from './video-change-ownership.js' 11export * from './video-change-ownership.js'
12export * from './video-channel-sync.js' 12export * from './video-channel-sync.js'
13export * from './video-channels.js' 13export * from './video-channel.js'
14export * from './video-chapter.js'
14export * from './video-comment.js' 15export * from './video-comment.js'
15export * from './video-file.js' 16export * from './video-file.js'
16export * from './video-import.js' 17export * from './video-import.js'
diff --git a/server/server/types/models/video/video-channel-sync.ts b/server/server/types/models/video/video-channel-sync.ts
index 2b3a3930f..7e4f9373b 100644
--- a/server/server/types/models/video/video-channel-sync.ts
+++ b/server/server/types/models/video/video-channel-sync.ts
@@ -1,6 +1,6 @@
1import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js' 1import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
2import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils' 2import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils'
3import { MChannelAccountDefault, MChannelFormattable } from './video-channels.js' 3import { MChannelAccountDefault, MChannelFormattable } from './video-channel.js'
4 4
5type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M> 5type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M>
6 6
diff --git a/server/server/types/models/video/video-channels.ts b/server/server/types/models/video/video-channel.ts
index e8cb9cb26..e8cb9cb26 100644
--- a/server/server/types/models/video/video-channels.ts
+++ b/server/server/types/models/video/video-channel.ts
diff --git a/server/server/types/models/video/video-chapter.ts b/server/server/types/models/video/video-chapter.ts
new file mode 100644
index 000000000..377cf213a
--- /dev/null
+++ b/server/server/types/models/video/video-chapter.ts
@@ -0,0 +1,3 @@
1import { VideoChapterModel } from '@server/models/video/video-chapter.js'
2
3export type MVideoChapter = Omit<VideoChapterModel, 'Video'>
diff --git a/server/server/types/models/video/video-playlist.ts b/server/server/types/models/video/video-playlist.ts
index 3d99bf4e5..152904d22 100644
--- a/server/server/types/models/video/video-playlist.ts
+++ b/server/server/types/models/video/video-playlist.ts
@@ -3,7 +3,7 @@ import { PickWith } from '@peertube/peertube-typescript-utils'
3import { VideoPlaylistModel } from '../../../models/video/video-playlist.js' 3import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
4import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js' 4import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js'
5import { MThumbnail } from './thumbnail.js' 5import { MThumbnail } from './thumbnail.js'
6import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channels.js' 6import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channel.js'
7 7
8type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K, M> 8type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K, M>
9 9
diff --git a/server/server/types/models/video/video.ts b/server/server/types/models/video/video.ts
index b7f8652be..f9141681b 100644
--- a/server/server/types/models/video/video.ts
+++ b/server/server/types/models/video/video.ts
@@ -16,7 +16,7 @@ import {
16 MChannelFormattable, 16 MChannelFormattable,
17 MChannelHostOnly, 17 MChannelHostOnly,
18 MChannelUserId 18 MChannelUserId
19} from './video-channels.js' 19} from './video-channel.js'
20import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js' 20import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js'
21import { MVideoLive } from './video-live.js' 21import { MVideoLive } from './video-live.js'
22import { 22import {