diff options
Diffstat (limited to 'server')
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 @@ | |||
1 | import cors from 'cors' | 1 | import cors from 'cors' |
2 | import express from 'express' | 2 | import express from 'express' |
3 | import { VideoCommentObject, VideoPlaylistPrivacy, VideoPrivacy, VideoRateType } from '@peertube/peertube-models' | 3 | import { |
4 | VideoChapterObject, | ||
5 | VideoChaptersObject, | ||
6 | VideoCommentObject, | ||
7 | VideoPlaylistPrivacy, | ||
8 | VideoPrivacy, | ||
9 | VideoRateType | ||
10 | } from '@peertube/peertube-models' | ||
4 | import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js' | 11 | import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js' |
5 | import { getContextFilter } from '@server/lib/activitypub/context.js' | 12 | import { getContextFilter } from '@server/lib/activitypub/context.js' |
6 | import { getServerActor } from '@server/models/application/application.js' | 13 | import { getServerActor } from '@server/models/application/application.js' |
@@ -12,12 +19,18 @@ import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/act | |||
12 | import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js' | 19 | import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js' |
13 | import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js' | 20 | import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js' |
14 | import { | 21 | import { |
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' |
20 | import { cacheRoute } from '../../middlewares/cache/cache.js' | 28 | import { |
29 | apVideoChaptersSetCacheKey, | ||
30 | buildAPVideoChaptersGroupsCache, | ||
31 | cacheRoute, | ||
32 | cacheRouteFactory | ||
33 | } from '../../middlewares/cache/cache.js' | ||
21 | import { | 34 | import { |
22 | activityPubRateLimiter, | 35 | activityPubRateLimiter, |
23 | asyncMiddleware, | 36 | asyncMiddleware, |
@@ -42,6 +55,8 @@ import { VideoCommentModel } from '../../models/video/video-comment.js' | |||
42 | import { VideoPlaylistModel } from '../../models/video/video-playlist.js' | 55 | import { VideoPlaylistModel } from '../../models/video/video-playlist.js' |
43 | import { VideoShareModel } from '../../models/video/video-share.js' | 56 | import { VideoShareModel } from '../../models/video/video-share.js' |
44 | import { activityPubResponse } from './utils.js' | 57 | import { activityPubResponse } from './utils.js' |
58 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
59 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' | ||
45 | 60 | ||
46 | const activityPubClientRouter = express.Router() | 61 | const activityPubClientRouter = express.Router() |
47 | activityPubClientRouter.use(cors()) | 62 | activityPubClientRouter.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 | |||
165 | const { middleware: chaptersCacheRouteMiddleware, instance: chaptersApiCache } = cacheRouteFactory() | ||
166 | |||
167 | InternalEventEmitter.Instance.on('chapters-updated', ({ video }) => { | ||
168 | if (video.remote) return | ||
169 | |||
170 | chaptersApiCache.clearGroupSafe(buildAPVideoChaptersGroupsCache({ videoId: video.uuid })) | ||
171 | }) | ||
172 | |||
173 | activityPubClientRouter.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 | |||
148 | activityPubClientRouter.get( | 184 | activityPubClientRouter.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 | ||
429 | async 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 | |||
393 | async function videoRedundancyController (req: express.Request, res: express.Response) { | 454 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js' | ||
3 | import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js' | ||
4 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
5 | import { HttpStatusCode, VideoChapterUpdate } from '@peertube/peertube-models' | ||
6 | import { sequelizeTypescript } from '@server/initializers/database.js' | ||
7 | import { retryTransactionWrapper } from '@server/helpers/database-utils.js' | ||
8 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js' | ||
9 | import { replaceChapters } from '@server/lib/video-chapters.js' | ||
10 | |||
11 | const videoChaptersRouter = express.Router() | ||
12 | |||
13 | videoChaptersRouter.get('/:id/chapters', | ||
14 | asyncMiddleware(videosCustomGetValidator('only-video')), | ||
15 | asyncMiddleware(listVideoChapters) | ||
16 | ) | ||
17 | |||
18 | videoChaptersRouter.put('/:videoId/chapters', | ||
19 | authenticate, | ||
20 | asyncMiddleware(updateVideoChaptersValidator), | ||
21 | asyncRetryTransactionMiddleware(replaceVideoChapters) | ||
22 | ) | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | export { | ||
27 | videoChaptersRouter | ||
28 | } | ||
29 | |||
30 | // --------------------------------------------------------------------------- | ||
31 | |||
32 | async 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 | |||
38 | async 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' | |||
49 | import { updateRouter } from './update.js' | 49 | import { updateRouter } from './update.js' |
50 | import { uploadRouter } from './upload.js' | 50 | import { uploadRouter } from './upload.js' |
51 | import { viewRouter } from './view.js' | 51 | import { viewRouter } from './view.js' |
52 | import { videoChaptersRouter } from './chapters.js' | ||
52 | 53 | ||
53 | const auditLogger = auditLoggerFactory('videos') | 54 | const auditLogger = auditLoggerFactory('videos') |
54 | const videosRouter = express.Router() | 55 | const videosRouter = express.Router() |
@@ -73,6 +74,7 @@ videosRouter.use('/', tokenRouter) | |||
73 | videosRouter.use('/', videoPasswordRouter) | 74 | videosRouter.use('/', videoPasswordRouter) |
74 | videosRouter.use('/', storyboardRouter) | 75 | videosRouter.use('/', storyboardRouter) |
75 | videosRouter.use('/', videoSourceRouter) | 76 | videosRouter.use('/', videoSourceRouter) |
77 | videosRouter.use('/', videoChaptersRouter) | ||
76 | 78 | ||
77 | videosRouter.get('/categories', | 79 | videosRouter.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' | |||
22 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js' | 22 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js' |
23 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' | 23 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' |
24 | import { VideoModel } from '../../../models/video/video.js' | 24 | import { VideoModel } from '../../../models/video/video.js' |
25 | import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js' | ||
25 | 26 | ||
26 | const lTags = loggerTagsFactory('api', 'video') | 27 | const lTags = loggerTagsFactory('api', 'video') |
27 | const auditLogger = auditLoggerFactory('videos') | 28 | const 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' |
35 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' | 35 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' |
36 | import { VideoModel } from '../../../models/video/video.js' | 36 | import { VideoModel } from '../../../models/video/video.js' |
37 | import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg' | ||
38 | import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js' | ||
37 | 39 | ||
38 | const lTags = loggerTagsFactory('api', 'video') | 40 | const lTags = loggerTagsFactory('api', 'video') |
39 | const auditLogger = auditLoggerFactory('videos') | 41 | const 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 | ||
201 | async function getContextData (type: ContextType, contextFilter: ContextFilter) { | 210 | async 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 @@ | |||
1 | import { isArray } from '../misc.js' | ||
2 | import { isVideoChapterTitleValid, isVideoChapterTimecodeValid } from '../video-chapters.js' | ||
3 | import { isActivityPubUrlValid } from './misc.js' | ||
4 | import { VideoChaptersObject } from '@peertube/peertube-models' | ||
5 | |||
6 | export 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 @@ | |||
1 | import { isArray } from './misc.js' | ||
2 | import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models' | ||
3 | import { Unpacked } from '@peertube/peertube-typescript-utils' | ||
4 | import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js' | ||
5 | import validator from 'validator' | ||
6 | |||
7 | export 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 | |||
16 | export function isVideoChapterValid (value: Unpacked<VideoChapterUpdate['chapters']>) { | ||
17 | return isVideoChapterTimecodeValid(value.timecode) && isVideoChapterTitleValid(value.title) | ||
18 | } | ||
19 | |||
20 | export function isVideoChapterTitleValid (value: any) { | ||
21 | return validator.default.isLength(value + '', CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE) | ||
22 | } | ||
23 | |||
24 | export 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 @@ | |||
1 | import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js' | 1 | import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js' |
2 | import { peertubeTruncate } from '../core-utils.js' | 2 | import { peertubeTruncate } from '../core-utils.js' |
3 | import { isUrlValid } from '../custom-validators/activitypub/misc.js' | 3 | import { isUrlValid } from '../custom-validators/activitypub/misc.js' |
4 | import { isArray } from '../custom-validators/misc.js' | ||
4 | 5 | ||
5 | export type YoutubeDLInfo = { | 6 | export 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 | ||
21 | export class YoutubeDLInfoBuilder { | 27 | export 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' | |||
59 | import { VideoModel } from '../models/video/video.js' | 59 | import { VideoModel } from '../models/video/video.js' |
60 | import { VideoViewModel } from '../models/view/video-view.js' | 60 | import { VideoViewModel } from '../models/view/video-view.js' |
61 | import { CONFIG } from './config.js' | 61 | import { CONFIG } from './config.js' |
62 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
62 | 63 | ||
63 | pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string | 64 | pg.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 | ||
83 | function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) { | ||
84 | return video.url + '/chapters' | ||
85 | } | ||
86 | |||
83 | function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) { | 87 | function 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 @@ | |||
1 | import { CreationAttributes, Transaction } from 'sequelize' | 1 | import { CreationAttributes, Transaction } from 'sequelize' |
2 | import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models' | 2 | import { |
3 | import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils.js' | 3 | ActivityTagObject, |
4 | ThumbnailType, | ||
5 | VideoChaptersObject, | ||
6 | VideoObject, | ||
7 | VideoStreamingPlaylistType_Type | ||
8 | } from '@peertube/peertube-models' | ||
9 | import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js' | ||
4 | import { logger, LoggerTagsFn } from '@server/helpers/logger.js' | 10 | import { logger, LoggerTagsFn } from '@server/helpers/logger.js' |
5 | import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js' | 11 | import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js' |
6 | import { setVideoTags } from '@server/lib/video.js' | 12 | import { 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' |
31 | import { getTrackerUrls, setVideoTrackers } from './trackers.js' | 37 | import { getTrackerUrls, setVideoTrackers } from './trackers.js' |
38 | import { fetchAP } from '../../activity.js' | ||
39 | import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js' | ||
40 | import { sequelizeTypescript } from '@server/initializers/database.js' | ||
41 | import { replaceChapters } from '@server/lib/video-chapters.js' | ||
32 | 42 | ||
33 | export abstract class APVideoAbstractBuilder { | 43 | export 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 @@ | |||
1 | import { MChannel, MVideo } from '@server/types/models/index.js' | 1 | import { MChannel, MVideo, MVideoImmutable } from '@server/types/models/index.js' |
2 | import { EventEmitter } from 'events' | 2 | import { EventEmitter } from 'events' |
3 | 3 | ||
4 | export interface PeerTubeInternalEvents { | 4 | export 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 | ||
14 | declare interface InternalEventEmitter { | 16 | declare 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 | |||
32 | import { getLowercaseExtension } from '@peertube/peertube-node-utils' | 32 | import { getLowercaseExtension } from '@peertube/peertube-node-utils' |
33 | import { | 33 | import { |
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' | |||
49 | import { Notifier } from '../../notifier/index.js' | 50 | import { Notifier } from '../../notifier/index.js' |
50 | import { generateLocalVideoMiniature } from '../../thumbnail.js' | 51 | import { generateLocalVideoMiniature } from '../../thumbnail.js' |
51 | import { JobQueue } from '../job-queue.js' | 52 | import { JobQueue } from '../job-queue.js' |
53 | import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js' | ||
52 | 54 | ||
53 | async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> { | 55 | async 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 @@ | |||
1 | import { parseChapters, sortBy } from '@peertube/peertube-core-utils' | ||
2 | import { VideoChapter } from '@peertube/peertube-models' | ||
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger.js' | ||
4 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
5 | import { MVideoImmutable } from '@server/types/models/index.js' | ||
6 | import { Transaction } from 'sequelize' | ||
7 | import { InternalEventEmitter } from './internal-event-emitter.js' | ||
8 | |||
9 | const lTags = loggerTagsFactory('video', 'chapters') | ||
10 | |||
11 | export 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 | |||
25 | export 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 | |||
39 | export 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 | |||
74 | async 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 | |||
90 | function 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' |
40 | import { getLocalVideoActivityPubUrl } from './activitypub/url.js' | 40 | import { getLocalVideoActivityPubUrl } from './activitypub/url.js' |
41 | import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js' | 41 | import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js' |
42 | import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js' | ||
42 | 43 | ||
43 | class YoutubeDlImportError extends Error { | 44 | class 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 @@ | |||
1 | import express from 'express' | ||
1 | import { HttpStatusCode } from '@peertube/peertube-models' | 2 | import { HttpStatusCode } from '@peertube/peertube-models' |
2 | import { ApiCache, APICacheOptions } from './shared/index.js' | 3 | import { ApiCache, APICacheOptions } from './shared/index.js' |
3 | 4 | ||
@@ -8,13 +9,13 @@ const defaultOptions: APICacheOptions = { | |||
8 | ] | 9 | ] |
9 | } | 10 | } |
10 | 11 | ||
11 | function cacheRoute (duration: string) { | 12 | export 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 | ||
17 | function cacheRouteFactory (options: APICacheOptions) { | 18 | export 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 | ||
25 | function buildPodcastGroupsCache (options: { | 26 | export 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 | // --------------------------------------------------------------------------- | 32 | export function buildAPVideoChaptersGroupsCache (options: { |
33 | videoId: number | string | ||
34 | }) { | ||
35 | return 'ap-video-chapters-' + options.videoId | ||
36 | } | ||
32 | 37 | ||
33 | export { | 38 | // --------------------------------------------------------------------------- |
34 | cacheRoute, | ||
35 | cacheRouteFactory, | ||
36 | 39 | ||
37 | buildPodcastGroupsCache | 40 | export 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 | |||
50 | export 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' | |||
3 | import { HttpStatusCode } from '@peertube/peertube-models' | 3 | import { HttpStatusCode } from '@peertube/peertube-models' |
4 | import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js' | 4 | import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js' |
5 | import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js' | 5 | import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js' |
6 | import { buildPodcastGroupsCache } from '../cache/index.js' | ||
7 | import { | 6 | import { |
8 | areValidationErrors, | 7 | areValidationErrors, |
9 | checkCanSeeVideo, | 8 | checkCanSeeVideo, |
@@ -114,15 +113,6 @@ const videoFeedsPodcastValidator = [ | |||
114 | } | 113 | } |
115 | ] | 114 | ] |
116 | 115 | ||
117 | const 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 | ||
128 | const videoSubscriptionFeedsValidator = [ | 118 | const 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' | |||
2 | export * from './video-captions.js' | 2 | export * from './video-captions.js' |
3 | export * from './video-channel-sync.js' | 3 | export * from './video-channel-sync.js' |
4 | export * from './video-channels.js' | 4 | export * from './video-channels.js' |
5 | export * from './video-chapters.js' | ||
5 | export * from './video-comments.js' | 6 | export * from './video-comments.js' |
6 | export * from './video-files.js' | 7 | export * from './video-files.js' |
7 | export * from './video-imports.js' | 8 | export * 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 @@ | |||
1 | import express from 'express' | ||
2 | import { body } from 'express-validator' | ||
3 | import { HttpStatusCode, UserRight } from '@peertube/peertube-models' | ||
4 | import { | ||
5 | areValidationErrors, checkUserCanManageVideo, doesVideoExist, | ||
6 | isValidVideoIdParam | ||
7 | } from '../shared/index.js' | ||
8 | import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js' | ||
9 | |||
10 | export 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' |
14 | import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js' | 14 | import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js' |
15 | import { | 15 | import { |
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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { MVideo, MVideoChapter } from '@server/types/models/index.js' | ||
3 | import { VideoChapter, VideoChapterObject } from '@peertube/peertube-models' | ||
4 | import { AttributesOnly } from '@peertube/peertube-typescript-utils' | ||
5 | import { VideoModel } from './video.js' | ||
6 | import { Transaction } from 'sequelize' | ||
7 | import { getSort } from '../shared/sort.js' | ||
8 | |||
9 | @Table({ | ||
10 | tableName: 'videoChapter', | ||
11 | indexes: [ | ||
12 | { | ||
13 | fields: [ 'videoId', 'timecode' ], | ||
14 | unique: true | ||
15 | } | ||
16 | ] | ||
17 | }) | ||
18 | export 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' |
17 | import { MChannelDefault } from '../video/video-channels.js' | 17 | import { MChannelDefault } from '../video/video-channel.js' |
18 | import { MAccountBlocklistId } from './account-blocklist.js' | 18 | import { MAccountBlocklistId } from './account-blocklist.js' |
19 | 19 | ||
20 | type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M> | 20 | type 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' |
14 | import { MChannelFormattable } from '../video/video-channels.js' | 14 | import { MChannelFormattable } from '../video/video-channel.js' |
15 | import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js' | 15 | import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js' |
16 | 16 | ||
17 | type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M> | 17 | type 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' | |||
10 | export * from './video-caption.js' | 10 | export * from './video-caption.js' |
11 | export * from './video-change-ownership.js' | 11 | export * from './video-change-ownership.js' |
12 | export * from './video-channel-sync.js' | 12 | export * from './video-channel-sync.js' |
13 | export * from './video-channels.js' | 13 | export * from './video-channel.js' |
14 | export * from './video-chapter.js' | ||
14 | export * from './video-comment.js' | 15 | export * from './video-comment.js' |
15 | export * from './video-file.js' | 16 | export * from './video-file.js' |
16 | export * from './video-import.js' | 17 | export * 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 @@ | |||
1 | import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js' | 1 | import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js' |
2 | import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils' | 2 | import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils' |
3 | import { MChannelAccountDefault, MChannelFormattable } from './video-channels.js' | 3 | import { MChannelAccountDefault, MChannelFormattable } from './video-channel.js' |
4 | 4 | ||
5 | type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M> | 5 | type 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 @@ | |||
1 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
2 | |||
3 | export 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' | |||
3 | import { VideoPlaylistModel } from '../../../models/video/video-playlist.js' | 3 | import { VideoPlaylistModel } from '../../../models/video/video-playlist.js' |
4 | import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js' | 4 | import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js' |
5 | import { MThumbnail } from './thumbnail.js' | 5 | import { MThumbnail } from './thumbnail.js' |
6 | import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channels.js' | 6 | import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channel.js' |
7 | 7 | ||
8 | type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K, M> | 8 | type 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' |
20 | import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js' | 20 | import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js' |
21 | import { MVideoLive } from './video-live.js' | 21 | import { MVideoLive } from './video-live.js' |
22 | import { | 22 | import { |