mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2024-11-10 12:26:35 +03:00
Playlist server API
This commit is contained in:
parent
b427febb4d
commit
418d092afa
@ -53,7 +53,7 @@ if (errorMessage !== null) {
|
||||
app.set('trust proxy', CONFIG.TRUST_PROXY)
|
||||
|
||||
// Security middleware
|
||||
import { baseCSP } from './server/middlewares'
|
||||
import { baseCSP } from './server/middlewares/csp'
|
||||
|
||||
if (CONFIG.CSP.ENABLED) {
|
||||
app.use(baseCSP)
|
||||
|
@ -14,7 +14,7 @@ import {
|
||||
videosCustomGetValidator,
|
||||
videosShareValidator
|
||||
} from '../../middlewares'
|
||||
import { getAccountVideoRateValidator, videoCommentGetValidator, videosGetValidator } from '../../middlewares/validators'
|
||||
import { getAccountVideoRateValidator, videoCommentGetValidator } from '../../middlewares/validators'
|
||||
import { AccountModel } from '../../models/account/account'
|
||||
import { ActorModel } from '../../models/activitypub/actor'
|
||||
import { ActorFollowModel } from '../../models/activitypub/actor-follow'
|
||||
@ -37,6 +37,10 @@ import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator }
|
||||
import { getServerActor } from '../../helpers/utils'
|
||||
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
|
||||
import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike'
|
||||
import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists'
|
||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
|
||||
import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
|
||||
const activityPubClientRouter = express.Router()
|
||||
|
||||
@ -52,6 +56,10 @@ activityPubClientRouter.get('/accounts?/:name/following',
|
||||
executeIfActivityPub(asyncMiddleware(localAccountValidator)),
|
||||
executeIfActivityPub(asyncMiddleware(accountFollowingController))
|
||||
)
|
||||
activityPubClientRouter.get('/accounts?/:name/playlists',
|
||||
executeIfActivityPub(asyncMiddleware(localAccountValidator)),
|
||||
executeIfActivityPub(asyncMiddleware(accountPlaylistsController))
|
||||
)
|
||||
activityPubClientRouter.get('/accounts?/:name/likes/:videoId',
|
||||
executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))),
|
||||
executeIfActivityPub(getAccountVideoRate('like'))
|
||||
@ -121,6 +129,15 @@ activityPubClientRouter.get('/redundancy/video-playlists/:streamingPlaylistType/
|
||||
executeIfActivityPub(asyncMiddleware(videoRedundancyController))
|
||||
)
|
||||
|
||||
activityPubClientRouter.get('/video-playlists/:playlistId',
|
||||
executeIfActivityPub(asyncMiddleware(videoPlaylistsGetValidator)),
|
||||
executeIfActivityPub(asyncMiddleware(videoPlaylistController))
|
||||
)
|
||||
activityPubClientRouter.get('/video-playlists/:playlistId/:videoId',
|
||||
executeIfActivityPub(asyncMiddleware(videoPlaylistElementAPGetValidator)),
|
||||
executeIfActivityPub(asyncMiddleware(videoPlaylistElementController))
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
@ -129,26 +146,33 @@ export {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function accountController (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
function accountController (req: express.Request, res: express.Response) {
|
||||
const account: AccountModel = res.locals.account
|
||||
|
||||
return activityPubResponse(activityPubContextify(account.toActivityPubObject()), res)
|
||||
}
|
||||
|
||||
async function accountFollowersController (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
async function accountFollowersController (req: express.Request, res: express.Response) {
|
||||
const account: AccountModel = res.locals.account
|
||||
const activityPubResult = await actorFollowers(req, account.Actor)
|
||||
|
||||
return activityPubResponse(activityPubContextify(activityPubResult), res)
|
||||
}
|
||||
|
||||
async function accountFollowingController (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
async function accountFollowingController (req: express.Request, res: express.Response) {
|
||||
const account: AccountModel = res.locals.account
|
||||
const activityPubResult = await actorFollowing(req, account.Actor)
|
||||
|
||||
return activityPubResponse(activityPubContextify(activityPubResult), res)
|
||||
}
|
||||
|
||||
async function accountPlaylistsController (req: express.Request, res: express.Response) {
|
||||
const account: AccountModel = res.locals.account
|
||||
const activityPubResult = await actorPlaylists(req, account)
|
||||
|
||||
return activityPubResponse(activityPubContextify(activityPubResult), res)
|
||||
}
|
||||
|
||||
function getAccountVideoRate (rateType: VideoRateType) {
|
||||
return (req: express.Request, res: express.Response) => {
|
||||
const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate
|
||||
@ -293,6 +317,23 @@ async function videoRedundancyController (req: express.Request, res: express.Res
|
||||
return activityPubResponse(activityPubContextify(object), res)
|
||||
}
|
||||
|
||||
async function videoPlaylistController (req: express.Request, res: express.Response) {
|
||||
const playlist: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
|
||||
const json = await playlist.toActivityPubObject()
|
||||
const audience = getAudience(playlist.OwnerAccount.Actor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
|
||||
const object = audiencify(json, audience)
|
||||
|
||||
return activityPubResponse(activityPubContextify(object), res)
|
||||
}
|
||||
|
||||
async function videoPlaylistElementController (req: express.Request, res: express.Response) {
|
||||
const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement
|
||||
|
||||
const json = videoPlaylistElement.toActivityPubObject()
|
||||
return activityPubResponse(activityPubContextify(json), res)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function actorFollowing (req: express.Request, actor: ActorModel) {
|
||||
@ -305,7 +346,15 @@ async function actorFollowing (req: express.Request, actor: ActorModel) {
|
||||
|
||||
async function actorFollowers (req: express.Request, actor: ActorModel) {
|
||||
const handler = (start: number, count: number) => {
|
||||
return ActorFollowModel.listAcceptedFollowerUrlsForApi([ actor.id ], undefined, start, count)
|
||||
return ActorFollowModel.listAcceptedFollowerUrlsForAP([ actor.id ], undefined, start, count)
|
||||
}
|
||||
|
||||
return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page)
|
||||
}
|
||||
|
||||
async function actorPlaylists (req: express.Request, account: AccountModel) {
|
||||
const handler = (start: number, count: number) => {
|
||||
return VideoPlaylistModel.listUrlsOfForAP(account.id, start, count)
|
||||
}
|
||||
|
||||
return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page)
|
||||
|
@ -32,7 +32,7 @@ export {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function outboxController (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
async function outboxController (req: express.Request, res: express.Response) {
|
||||
const accountOrVideoChannel: AccountModel | VideoChannelModel = res.locals.account || res.locals.videoChannel
|
||||
const actor = accountOrVideoChannel.Actor
|
||||
const actorOutboxUrl = actor.url + '/outbox'
|
||||
|
@ -1,21 +1,23 @@
|
||||
import * as express from 'express'
|
||||
import { getFormattedObjects } from '../../helpers/utils'
|
||||
import { getFormattedObjects, getServerActor } from '../../helpers/utils'
|
||||
import {
|
||||
asyncMiddleware,
|
||||
commonVideosFiltersValidator,
|
||||
listVideoAccountChannelsValidator,
|
||||
optionalAuthenticate,
|
||||
paginationValidator,
|
||||
setDefaultPagination,
|
||||
setDefaultSort
|
||||
setDefaultSort,
|
||||
videoPlaylistsSortValidator
|
||||
} from '../../middlewares'
|
||||
import { accountsNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators'
|
||||
import { accountNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators'
|
||||
import { AccountModel } from '../../models/account/account'
|
||||
import { VideoModel } from '../../models/video/video'
|
||||
import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
|
||||
import { VideoChannelModel } from '../../models/video/video-channel'
|
||||
import { JobQueue } from '../../lib/job-queue'
|
||||
import { logger } from '../../helpers/logger'
|
||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||
import { UserModel } from '../../models/account/user'
|
||||
|
||||
const accountsRouter = express.Router()
|
||||
|
||||
@ -28,12 +30,12 @@ accountsRouter.get('/',
|
||||
)
|
||||
|
||||
accountsRouter.get('/:accountName',
|
||||
asyncMiddleware(accountsNameWithHostGetValidator),
|
||||
asyncMiddleware(accountNameWithHostGetValidator),
|
||||
getAccount
|
||||
)
|
||||
|
||||
accountsRouter.get('/:accountName/videos',
|
||||
asyncMiddleware(accountsNameWithHostGetValidator),
|
||||
asyncMiddleware(accountNameWithHostGetValidator),
|
||||
paginationValidator,
|
||||
videosSortValidator,
|
||||
setDefaultSort,
|
||||
@ -44,8 +46,18 @@ accountsRouter.get('/:accountName/videos',
|
||||
)
|
||||
|
||||
accountsRouter.get('/:accountName/video-channels',
|
||||
asyncMiddleware(listVideoAccountChannelsValidator),
|
||||
asyncMiddleware(listVideoAccountChannels)
|
||||
asyncMiddleware(accountNameWithHostGetValidator),
|
||||
asyncMiddleware(listAccountChannels)
|
||||
)
|
||||
|
||||
accountsRouter.get('/:accountName/video-playlists',
|
||||
optionalAuthenticate,
|
||||
asyncMiddleware(accountNameWithHostGetValidator),
|
||||
paginationValidator,
|
||||
videoPlaylistsSortValidator,
|
||||
setDefaultSort,
|
||||
setDefaultPagination,
|
||||
asyncMiddleware(listAccountPlaylists)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -56,7 +68,7 @@ export {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
function getAccount (req: express.Request, res: express.Response) {
|
||||
const account: AccountModel = res.locals.account
|
||||
|
||||
if (account.isOutdated()) {
|
||||
@ -67,19 +79,40 @@ function getAccount (req: express.Request, res: express.Response, next: express.
|
||||
return res.json(account.toFormattedJSON())
|
||||
}
|
||||
|
||||
async function listAccounts (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
async function listAccounts (req: express.Request, res: express.Response) {
|
||||
const resultList = await AccountModel.listForApi(req.query.start, req.query.count, req.query.sort)
|
||||
|
||||
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
||||
}
|
||||
|
||||
async function listVideoAccountChannels (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
async function listAccountChannels (req: express.Request, res: express.Response) {
|
||||
const resultList = await VideoChannelModel.listByAccount(res.locals.account.id)
|
||||
|
||||
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
||||
}
|
||||
|
||||
async function listAccountVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
async function listAccountPlaylists (req: express.Request, res: express.Response) {
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
// Allow users to see their private/unlisted video playlists
|
||||
let privateAndUnlisted = false
|
||||
if (res.locals.oauth && (res.locals.oauth.token.User as UserModel).Account.id === res.locals.account.id) {
|
||||
privateAndUnlisted = true
|
||||
}
|
||||
|
||||
const resultList = await VideoPlaylistModel.listForApi({
|
||||
followerActorId: serverActor.id,
|
||||
start: req.query.start,
|
||||
count: req.query.count,
|
||||
sort: req.query.sort,
|
||||
accountId: res.locals.account.id,
|
||||
privateAndUnlisted
|
||||
})
|
||||
|
||||
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
||||
}
|
||||
|
||||
async function listAccountVideos (req: express.Request, res: express.Response) {
|
||||
const account: AccountModel = res.locals.account
|
||||
const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { videoChannelRouter } from './video-channel'
|
||||
import * as cors from 'cors'
|
||||
import { searchRouter } from './search'
|
||||
import { overviewsRouter } from './overviews'
|
||||
import { videoPlaylistRouter } from './video-playlist'
|
||||
|
||||
const apiRouter = express.Router()
|
||||
|
||||
@ -26,6 +27,7 @@ apiRouter.use('/config', configRouter)
|
||||
apiRouter.use('/users', usersRouter)
|
||||
apiRouter.use('/accounts', accountsRouter)
|
||||
apiRouter.use('/video-channels', videoChannelRouter)
|
||||
apiRouter.use('/video-playlists', videoPlaylistRouter)
|
||||
apiRouter.use('/videos', videosRouter)
|
||||
apiRouter.use('/jobs', jobsRouter)
|
||||
apiRouter.use('/search', searchRouter)
|
||||
|
@ -12,7 +12,8 @@ import {
|
||||
videoChannelsAddValidator,
|
||||
videoChannelsRemoveValidator,
|
||||
videoChannelsSortValidator,
|
||||
videoChannelsUpdateValidator
|
||||
videoChannelsUpdateValidator,
|
||||
videoPlaylistsSortValidator
|
||||
} from '../../middlewares'
|
||||
import { VideoChannelModel } from '../../models/video/video-channel'
|
||||
import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
|
||||
@ -31,6 +32,7 @@ import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '..
|
||||
import { resetSequelizeInstance } from '../../helpers/database-utils'
|
||||
import { UserModel } from '../../models/account/user'
|
||||
import { JobQueue } from '../../lib/job-queue'
|
||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||
|
||||
const auditLogger = auditLoggerFactory('channels')
|
||||
const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
|
||||
@ -77,6 +79,15 @@ videoChannelRouter.get('/:nameWithHost',
|
||||
asyncMiddleware(getVideoChannel)
|
||||
)
|
||||
|
||||
videoChannelRouter.get('/:nameWithHost/video-playlists',
|
||||
asyncMiddleware(videoChannelsNameWithHostValidator),
|
||||
paginationValidator,
|
||||
videoPlaylistsSortValidator,
|
||||
setDefaultSort,
|
||||
setDefaultPagination,
|
||||
asyncMiddleware(listVideoChannelPlaylists)
|
||||
)
|
||||
|
||||
videoChannelRouter.get('/:nameWithHost/videos',
|
||||
asyncMiddleware(videoChannelsNameWithHostValidator),
|
||||
paginationValidator,
|
||||
@ -206,6 +217,20 @@ async function getVideoChannel (req: express.Request, res: express.Response, nex
|
||||
return res.json(videoChannelWithVideos.toFormattedJSON())
|
||||
}
|
||||
|
||||
async function listVideoChannelPlaylists (req: express.Request, res: express.Response) {
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
const resultList = await VideoPlaylistModel.listForApi({
|
||||
followerActorId: serverActor.id,
|
||||
start: req.query.start,
|
||||
count: req.query.count,
|
||||
sort: req.query.sort,
|
||||
videoChannelId: res.locals.videoChannel.id
|
||||
})
|
||||
|
||||
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
||||
}
|
||||
|
||||
async function listVideoChannelVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
const videoChannelInstance: VideoChannelModel = res.locals.videoChannel
|
||||
const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined
|
||||
|
415
server/controllers/api/video-playlist.ts
Normal file
415
server/controllers/api/video-playlist.ts
Normal file
@ -0,0 +1,415 @@
|
||||
import * as express from 'express'
|
||||
import { getFormattedObjects, getServerActor } from '../../helpers/utils'
|
||||
import {
|
||||
asyncMiddleware,
|
||||
asyncRetryTransactionMiddleware,
|
||||
authenticate,
|
||||
commonVideosFiltersValidator,
|
||||
paginationValidator,
|
||||
setDefaultPagination,
|
||||
setDefaultSort
|
||||
} from '../../middlewares'
|
||||
import { VideoChannelModel } from '../../models/video/video-channel'
|
||||
import { videoPlaylistsSortValidator } from '../../middlewares/validators'
|
||||
import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
|
||||
import { CONFIG, MIMETYPES, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
|
||||
import { logger } from '../../helpers/logger'
|
||||
import { resetSequelizeInstance } from '../../helpers/database-utils'
|
||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||
import {
|
||||
videoPlaylistsAddValidator,
|
||||
videoPlaylistsAddVideoValidator,
|
||||
videoPlaylistsDeleteValidator,
|
||||
videoPlaylistsGetValidator,
|
||||
videoPlaylistsReorderVideosValidator,
|
||||
videoPlaylistsUpdateOrRemoveVideoValidator,
|
||||
videoPlaylistsUpdateValidator
|
||||
} from '../../middlewares/validators/videos/video-playlists'
|
||||
import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
|
||||
import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
import { processImage } from '../../helpers/image-utils'
|
||||
import { join } from 'path'
|
||||
import { UserModel } from '../../models/account/user'
|
||||
import {
|
||||
getVideoPlaylistActivityPubUrl,
|
||||
getVideoPlaylistElementActivityPubUrl,
|
||||
sendCreateVideoPlaylist,
|
||||
sendDeleteVideoPlaylist,
|
||||
sendUpdateVideoPlaylist
|
||||
} from '../../lib/activitypub'
|
||||
import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
|
||||
import { VideoModel } from '../../models/video/video'
|
||||
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
|
||||
import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
|
||||
import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
|
||||
import { copy, pathExists } from 'fs-extra'
|
||||
|
||||
const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
|
||||
|
||||
const videoPlaylistRouter = express.Router()
|
||||
|
||||
videoPlaylistRouter.get('/',
|
||||
paginationValidator,
|
||||
videoPlaylistsSortValidator,
|
||||
setDefaultSort,
|
||||
setDefaultPagination,
|
||||
asyncMiddleware(listVideoPlaylists)
|
||||
)
|
||||
|
||||
videoPlaylistRouter.get('/:playlistId',
|
||||
asyncMiddleware(videoPlaylistsGetValidator),
|
||||
getVideoPlaylist
|
||||
)
|
||||
|
||||
videoPlaylistRouter.post('/',
|
||||
authenticate,
|
||||
reqThumbnailFile,
|
||||
asyncMiddleware(videoPlaylistsAddValidator),
|
||||
asyncRetryTransactionMiddleware(addVideoPlaylist)
|
||||
)
|
||||
|
||||
videoPlaylistRouter.put('/:playlistId',
|
||||
authenticate,
|
||||
reqThumbnailFile,
|
||||
asyncMiddleware(videoPlaylistsUpdateValidator),
|
||||
asyncRetryTransactionMiddleware(updateVideoPlaylist)
|
||||
)
|
||||
|
||||
videoPlaylistRouter.delete('/:playlistId',
|
||||
authenticate,
|
||||
asyncMiddleware(videoPlaylistsDeleteValidator),
|
||||
asyncRetryTransactionMiddleware(removeVideoPlaylist)
|
||||
)
|
||||
|
||||
videoPlaylistRouter.get('/:playlistId/videos',
|
||||
asyncMiddleware(videoPlaylistsGetValidator),
|
||||
paginationValidator,
|
||||
setDefaultPagination,
|
||||
commonVideosFiltersValidator,
|
||||
asyncMiddleware(getVideoPlaylistVideos)
|
||||
)
|
||||
|
||||
videoPlaylistRouter.post('/:playlistId/videos',
|
||||
authenticate,
|
||||
asyncMiddleware(videoPlaylistsAddVideoValidator),
|
||||
asyncRetryTransactionMiddleware(addVideoInPlaylist)
|
||||
)
|
||||
|
||||
videoPlaylistRouter.put('/:playlistId/videos',
|
||||
authenticate,
|
||||
asyncMiddleware(videoPlaylistsReorderVideosValidator),
|
||||
asyncRetryTransactionMiddleware(reorderVideosPlaylist)
|
||||
)
|
||||
|
||||
videoPlaylistRouter.put('/:playlistId/videos/:videoId',
|
||||
authenticate,
|
||||
asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
|
||||
asyncRetryTransactionMiddleware(updateVideoPlaylistElement)
|
||||
)
|
||||
|
||||
videoPlaylistRouter.delete('/:playlistId/videos/:videoId',
|
||||
authenticate,
|
||||
asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
|
||||
asyncRetryTransactionMiddleware(removeVideoFromPlaylist)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
videoPlaylistRouter
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listVideoPlaylists (req: express.Request, res: express.Response) {
|
||||
const serverActor = await getServerActor()
|
||||
const resultList = await VideoPlaylistModel.listForApi({
|
||||
followerActorId: serverActor.id,
|
||||
start: req.query.start,
|
||||
count: req.query.count,
|
||||
sort: req.query.sort
|
||||
})
|
||||
|
||||
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
||||
}
|
||||
|
||||
function getVideoPlaylist (req: express.Request, res: express.Response) {
|
||||
const videoPlaylist = res.locals.videoPlaylist as VideoPlaylistModel
|
||||
|
||||
return res.json(videoPlaylist.toFormattedJSON())
|
||||
}
|
||||
|
||||
async function addVideoPlaylist (req: express.Request, res: express.Response) {
|
||||
const videoPlaylistInfo: VideoPlaylistCreate = req.body
|
||||
const user: UserModel = res.locals.oauth.token.User
|
||||
|
||||
const videoPlaylist = new VideoPlaylistModel({
|
||||
name: videoPlaylistInfo.displayName,
|
||||
description: videoPlaylistInfo.description,
|
||||
privacy: videoPlaylistInfo.privacy || VideoPlaylistPrivacy.PRIVATE,
|
||||
ownerAccountId: user.Account.id
|
||||
})
|
||||
|
||||
videoPlaylist.url = getVideoPlaylistActivityPubUrl(videoPlaylist) // We use the UUID, so set the URL after building the object
|
||||
|
||||
if (videoPlaylistInfo.videoChannelId !== undefined) {
|
||||
const videoChannel = res.locals.videoChannel as VideoChannelModel
|
||||
|
||||
videoPlaylist.videoChannelId = videoChannel.id
|
||||
videoPlaylist.VideoChannel = videoChannel
|
||||
}
|
||||
|
||||
const thumbnailField = req.files['thumbnailfile']
|
||||
if (thumbnailField) {
|
||||
const thumbnailPhysicalFile = thumbnailField[ 0 ]
|
||||
await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()), THUMBNAILS_SIZE)
|
||||
}
|
||||
|
||||
const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => {
|
||||
const videoPlaylistCreated = await videoPlaylist.save({ transaction: t })
|
||||
|
||||
await sendCreateVideoPlaylist(videoPlaylistCreated, t)
|
||||
|
||||
return videoPlaylistCreated
|
||||
})
|
||||
|
||||
logger.info('Video playlist with uuid %s created.', videoPlaylist.uuid)
|
||||
|
||||
return res.json({
|
||||
videoPlaylist: {
|
||||
id: videoPlaylistCreated.id,
|
||||
uuid: videoPlaylistCreated.uuid
|
||||
}
|
||||
}).end()
|
||||
}
|
||||
|
||||
async function updateVideoPlaylist (req: express.Request, res: express.Response) {
|
||||
const videoPlaylistInstance = res.locals.videoPlaylist as VideoPlaylistModel
|
||||
const videoPlaylistFieldsSave = videoPlaylistInstance.toJSON()
|
||||
const videoPlaylistInfoToUpdate = req.body as VideoPlaylistUpdate
|
||||
const wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE
|
||||
|
||||
const thumbnailField = req.files['thumbnailfile']
|
||||
if (thumbnailField) {
|
||||
const thumbnailPhysicalFile = thumbnailField[ 0 ]
|
||||
await processImage(
|
||||
thumbnailPhysicalFile,
|
||||
join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylistInstance.getThumbnailName()),
|
||||
THUMBNAILS_SIZE
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
const sequelizeOptions = {
|
||||
transaction: t
|
||||
}
|
||||
|
||||
if (videoPlaylistInfoToUpdate.videoChannelId !== undefined) {
|
||||
if (videoPlaylistInfoToUpdate.videoChannelId === null) {
|
||||
videoPlaylistInstance.videoChannelId = null
|
||||
} else {
|
||||
const videoChannel = res.locals.videoChannel as VideoChannelModel
|
||||
|
||||
videoPlaylistInstance.videoChannelId = videoChannel.id
|
||||
}
|
||||
}
|
||||
|
||||
if (videoPlaylistInfoToUpdate.displayName !== undefined) videoPlaylistInstance.name = videoPlaylistInfoToUpdate.displayName
|
||||
if (videoPlaylistInfoToUpdate.description !== undefined) videoPlaylistInstance.description = videoPlaylistInfoToUpdate.description
|
||||
|
||||
if (videoPlaylistInfoToUpdate.privacy !== undefined) {
|
||||
videoPlaylistInstance.privacy = parseInt(videoPlaylistInfoToUpdate.privacy.toString(), 10)
|
||||
}
|
||||
|
||||
const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions)
|
||||
|
||||
const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
|
||||
|
||||
if (isNewPlaylist) {
|
||||
await sendCreateVideoPlaylist(playlistUpdated, t)
|
||||
} else {
|
||||
await sendUpdateVideoPlaylist(playlistUpdated, t)
|
||||
}
|
||||
|
||||
logger.info('Video playlist %s updated.', videoPlaylistInstance.uuid)
|
||||
|
||||
return playlistUpdated
|
||||
})
|
||||
} catch (err) {
|
||||
logger.debug('Cannot update the video playlist.', { err })
|
||||
|
||||
// Force fields we want to update
|
||||
// If the transaction is retried, sequelize will think the object has not changed
|
||||
// So it will skip the SQL request, even if the last one was ROLLBACKed!
|
||||
resetSequelizeInstance(videoPlaylistInstance, videoPlaylistFieldsSave)
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
return res.type('json').status(204).end()
|
||||
}
|
||||
|
||||
async function removeVideoPlaylist (req: express.Request, res: express.Response) {
|
||||
const videoPlaylistInstance: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
await videoPlaylistInstance.destroy({ transaction: t })
|
||||
|
||||
await sendDeleteVideoPlaylist(videoPlaylistInstance, t)
|
||||
|
||||
logger.info('Video playlist %s deleted.', videoPlaylistInstance.uuid)
|
||||
})
|
||||
|
||||
return res.type('json').status(204).end()
|
||||
}
|
||||
|
||||
async function addVideoInPlaylist (req: express.Request, res: express.Response) {
|
||||
const body: VideoPlaylistElementCreate = req.body
|
||||
const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
const video: VideoModel = res.locals.video
|
||||
|
||||
const playlistElement: VideoPlaylistElementModel = await sequelizeTypescript.transaction(async t => {
|
||||
const position = await VideoPlaylistElementModel.getNextPositionOf(videoPlaylist.id, t)
|
||||
|
||||
const playlistElement = await VideoPlaylistElementModel.create({
|
||||
url: getVideoPlaylistElementActivityPubUrl(videoPlaylist, video),
|
||||
position,
|
||||
startTimestamp: body.startTimestamp || null,
|
||||
stopTimestamp: body.stopTimestamp || null,
|
||||
videoPlaylistId: videoPlaylist.id,
|
||||
videoId: video.id
|
||||
}, { transaction: t })
|
||||
|
||||
// If the user did not set a thumbnail, automatically take the video thumbnail
|
||||
if (playlistElement.position === 1) {
|
||||
const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName())
|
||||
|
||||
if (await pathExists(playlistThumbnailPath) === false) {
|
||||
const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
|
||||
await copy(videoThumbnailPath, playlistThumbnailPath)
|
||||
}
|
||||
}
|
||||
|
||||
await sendUpdateVideoPlaylist(videoPlaylist, t)
|
||||
|
||||
return playlistElement
|
||||
})
|
||||
|
||||
logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
|
||||
|
||||
return res.json({
|
||||
videoPlaylistElement: {
|
||||
id: playlistElement.id
|
||||
}
|
||||
}).end()
|
||||
}
|
||||
|
||||
async function updateVideoPlaylistElement (req: express.Request, res: express.Response) {
|
||||
const body: VideoPlaylistElementUpdate = req.body
|
||||
const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement
|
||||
|
||||
const playlistElement: VideoPlaylistElementModel = await sequelizeTypescript.transaction(async t => {
|
||||
if (body.startTimestamp !== undefined) videoPlaylistElement.startTimestamp = body.startTimestamp
|
||||
if (body.stopTimestamp !== undefined) videoPlaylistElement.stopTimestamp = body.stopTimestamp
|
||||
|
||||
const element = await videoPlaylistElement.save({ transaction: t })
|
||||
|
||||
await sendUpdateVideoPlaylist(videoPlaylist, t)
|
||||
|
||||
return element
|
||||
})
|
||||
|
||||
logger.info('Element of position %d of playlist %s updated.', playlistElement.position, videoPlaylist.uuid)
|
||||
|
||||
return res.type('json').status(204).end()
|
||||
}
|
||||
|
||||
async function removeVideoFromPlaylist (req: express.Request, res: express.Response) {
|
||||
const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement
|
||||
const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
const positionToDelete = videoPlaylistElement.position
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
await videoPlaylistElement.destroy({ transaction: t })
|
||||
|
||||
// Decrease position of the next elements
|
||||
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t)
|
||||
|
||||
await sendUpdateVideoPlaylist(videoPlaylist, t)
|
||||
|
||||
logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid)
|
||||
})
|
||||
|
||||
return res.type('json').status(204).end()
|
||||
}
|
||||
|
||||
async function reorderVideosPlaylist (req: express.Request, res: express.Response) {
|
||||
const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
|
||||
const start: number = req.body.startPosition
|
||||
const insertAfter: number = req.body.insertAfter
|
||||
const reorderLength: number = req.body.reorderLength || 1
|
||||
|
||||
if (start === insertAfter) {
|
||||
return res.status(204).end()
|
||||
}
|
||||
|
||||
// Example: if we reorder position 2 and insert after position 5 (so at position 6): # 1 2 3 4 5 6 7 8 9
|
||||
// * increase position when position > 5 # 1 2 3 4 5 7 8 9 10
|
||||
// * update position 2 -> position 6 # 1 3 4 5 6 7 8 9 10
|
||||
// * decrease position when position position > 2 # 1 2 3 4 5 6 7 8 9
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
const newPosition = insertAfter + 1
|
||||
|
||||
// Add space after the position when we want to insert our reordered elements (increase)
|
||||
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, newPosition, null, reorderLength, t)
|
||||
|
||||
let oldPosition = start
|
||||
|
||||
// We incremented the position of the elements we want to reorder
|
||||
if (start >= newPosition) oldPosition += reorderLength
|
||||
|
||||
const endOldPosition = oldPosition + reorderLength - 1
|
||||
// Insert our reordered elements in their place (update)
|
||||
await VideoPlaylistElementModel.reassignPositionOf(videoPlaylist.id, oldPosition, endOldPosition, newPosition, t)
|
||||
|
||||
// Decrease positions of elements after the old position of our ordered elements (decrease)
|
||||
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t)
|
||||
|
||||
await sendUpdateVideoPlaylist(videoPlaylist, t)
|
||||
})
|
||||
|
||||
logger.info(
|
||||
'Reordered playlist %s (inserted after %d elements %d - %d).',
|
||||
videoPlaylist.uuid, insertAfter, start, start + reorderLength - 1
|
||||
)
|
||||
|
||||
return res.type('json').status(204).end()
|
||||
}
|
||||
|
||||
async function getVideoPlaylistVideos (req: express.Request, res: express.Response) {
|
||||
const videoPlaylistInstance: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined
|
||||
|
||||
const resultList = await VideoModel.listForApi({
|
||||
followerActorId,
|
||||
start: req.query.start,
|
||||
count: req.query.count,
|
||||
sort: 'VideoPlaylistElements.position',
|
||||
includeLocalVideos: true,
|
||||
categoryOneOf: req.query.categoryOneOf,
|
||||
licenceOneOf: req.query.licenceOneOf,
|
||||
languageOneOf: req.query.languageOneOf,
|
||||
tagsOneOf: req.query.tagsOneOf,
|
||||
tagsAllOf: req.query.tagsAllOf,
|
||||
filter: req.query.filter,
|
||||
nsfw: buildNSFWFilter(res, req.query.nsfw),
|
||||
withFiles: false,
|
||||
videoPlaylistId: videoPlaylistInstance.id,
|
||||
user: res.locals.oauth ? res.locals.oauth.token.User : undefined
|
||||
})
|
||||
|
||||
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import * as express from 'express'
|
||||
import { VideoBlacklist, UserRight, VideoBlacklistCreate } from '../../../../shared'
|
||||
import { UserRight, VideoBlacklist, VideoBlacklistCreate } from '../../../../shared'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { getFormattedObjects } from '../../../helpers/utils'
|
||||
import {
|
||||
@ -18,7 +18,7 @@ import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
|
||||
import { sequelizeTypescript } from '../../../initializers'
|
||||
import { Notifier } from '../../../lib/notifier'
|
||||
import { VideoModel } from '../../../models/video/video'
|
||||
import { sendCreateVideo, sendDeleteVideo, sendUpdateVideo } from '../../../lib/activitypub/send'
|
||||
import { sendDeleteVideo } from '../../../lib/activitypub/send'
|
||||
import { federateVideoIfNeeded } from '../../../lib/activitypub'
|
||||
|
||||
const blacklistRouter = express.Router()
|
||||
|
@ -1,7 +1,7 @@
|
||||
import * as express from 'express'
|
||||
import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers'
|
||||
import { asyncMiddleware, oembedValidator } from '../middlewares'
|
||||
import { accountsNameWithHostGetValidator } from '../middlewares/validators'
|
||||
import { accountNameWithHostGetValidator } from '../middlewares/validators'
|
||||
import { VideoModel } from '../models/video/video'
|
||||
|
||||
const servicesRouter = express.Router()
|
||||
@ -11,7 +11,7 @@ servicesRouter.use('/oembed',
|
||||
generateOEmbed
|
||||
)
|
||||
servicesRouter.use('/redirect/accounts/:accountName',
|
||||
asyncMiddleware(accountsNameWithHostGetValidator),
|
||||
asyncMiddleware(accountNameWithHostGetValidator),
|
||||
redirectToAccountUrl
|
||||
)
|
||||
|
||||
|
@ -28,6 +28,9 @@ function activityPubContextify <T> (data: T) {
|
||||
state: 'sc:Number',
|
||||
size: 'sc:Number',
|
||||
fps: 'sc:Number',
|
||||
startTimestamp: 'sc:Number',
|
||||
stopTimestamp: 'sc:Number',
|
||||
position: 'sc:Number',
|
||||
commentsEnabled: 'sc:Boolean',
|
||||
downloadEnabled: 'sc:Boolean',
|
||||
waitTranscoding: 'sc:Boolean',
|
||||
@ -46,6 +49,10 @@ function activityPubContextify <T> (data: T) {
|
||||
'@id': 'as:dislikes',
|
||||
'@type': '@id'
|
||||
},
|
||||
playlists: {
|
||||
'@id': 'pt:playlists',
|
||||
'@type': '@id'
|
||||
},
|
||||
shares: {
|
||||
'@id': 'as:shares',
|
||||
'@type': '@id'
|
||||
@ -67,7 +74,7 @@ async function activityPubCollectionPagination (baseUrl: string, handler: Activi
|
||||
|
||||
return {
|
||||
id: baseUrl,
|
||||
type: 'OrderedCollection',
|
||||
type: 'OrderedCollectionPage',
|
||||
totalItems: result.total,
|
||||
first: baseUrl + '?page=1'
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import { isViewActivityValid } from './view'
|
||||
import { exists } from '../misc'
|
||||
import { isCacheFileObjectValid } from './cache-file'
|
||||
import { isFlagActivityValid } from './flag'
|
||||
import { isPlaylistObjectValid } from './playlist'
|
||||
|
||||
function isRootActivityValid (activity: any) {
|
||||
return Array.isArray(activity['@context']) && (
|
||||
@ -78,6 +79,7 @@ function checkCreateActivity (activity: any) {
|
||||
isViewActivityValid(activity.object) ||
|
||||
isDislikeActivityValid(activity.object) ||
|
||||
isFlagActivityValid(activity.object) ||
|
||||
isPlaylistObjectValid(activity.object) ||
|
||||
|
||||
isCacheFileObjectValid(activity.object) ||
|
||||
sanitizeAndCheckVideoCommentObject(activity.object) ||
|
||||
@ -89,6 +91,7 @@ function checkUpdateActivity (activity: any) {
|
||||
return isBaseActivityValid(activity, 'Update') &&
|
||||
(
|
||||
isCacheFileObjectValid(activity.object) ||
|
||||
isPlaylistObjectValid(activity.object) ||
|
||||
sanitizeAndCheckVideoTorrentObject(activity.object) ||
|
||||
sanitizeAndCheckActorObject(activity.object)
|
||||
)
|
||||
|
25
server/helpers/custom-validators/activitypub/playlist.ts
Normal file
25
server/helpers/custom-validators/activitypub/playlist.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { exists } from '../misc'
|
||||
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
|
||||
import * as validator from 'validator'
|
||||
import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object'
|
||||
import { isActivityPubUrlValid } from './misc'
|
||||
|
||||
function isPlaylistObjectValid (object: PlaylistObject) {
|
||||
return exists(object) &&
|
||||
object.type === 'Playlist' &&
|
||||
validator.isInt(object.totalItems + '')
|
||||
}
|
||||
|
||||
function isPlaylistElementObjectValid (object: PlaylistElementObject) {
|
||||
return exists(object) &&
|
||||
object.type === 'PlaylistElement' &&
|
||||
validator.isInt(object.position + '') &&
|
||||
isActivityPubUrlValid(object.url)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
isPlaylistObjectValid,
|
||||
isPlaylistElementObjectValid
|
||||
}
|
44
server/helpers/custom-validators/video-playlists.ts
Normal file
44
server/helpers/custom-validators/video-playlists.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { exists } from './misc'
|
||||
import * as validator from 'validator'
|
||||
import { CONSTRAINTS_FIELDS, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers'
|
||||
import * as express from 'express'
|
||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
|
||||
|
||||
const PLAYLISTS_CONSTRAINT_FIELDS = CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS
|
||||
|
||||
function isVideoPlaylistNameValid (value: any) {
|
||||
return exists(value) && validator.isLength(value, PLAYLISTS_CONSTRAINT_FIELDS.NAME)
|
||||
}
|
||||
|
||||
function isVideoPlaylistDescriptionValid (value: any) {
|
||||
return value === null || (exists(value) && validator.isLength(value, PLAYLISTS_CONSTRAINT_FIELDS.DESCRIPTION))
|
||||
}
|
||||
|
||||
function isVideoPlaylistPrivacyValid (value: number) {
|
||||
return validator.isInt(value + '') && VIDEO_PLAYLIST_PRIVACIES[ value ] !== undefined
|
||||
}
|
||||
|
||||
async function isVideoPlaylistExist (id: number | string, res: express.Response) {
|
||||
const videoPlaylist = await VideoPlaylistModel.load(id, undefined)
|
||||
|
||||
if (!videoPlaylist) {
|
||||
res.status(404)
|
||||
.json({ error: 'Video playlist not found' })
|
||||
.end()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
res.locals.videoPlaylist = videoPlaylist
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
isVideoPlaylistExist,
|
||||
isVideoPlaylistNameValid,
|
||||
isVideoPlaylistDescriptionValid,
|
||||
isVideoPlaylistPrivacyValid
|
||||
}
|
@ -165,7 +165,7 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use
|
||||
return true
|
||||
}
|
||||
|
||||
async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') {
|
||||
async function isVideoExist (id: number | string, res: Response, fetchType: VideoFetchType = 'all') {
|
||||
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
|
||||
|
||||
const video = await fetchVideo(id, fetchType, userId)
|
||||
|
@ -10,6 +10,7 @@ import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
|
||||
import { invert } from 'lodash'
|
||||
import { CronRepeatOptions, EveryRepeatOptions } from 'bull'
|
||||
import * as bytes from 'bytes'
|
||||
import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
|
||||
// Use a variable to reload the configuration if we need
|
||||
let config: IConfig = require('config')
|
||||
@ -52,7 +53,9 @@ const SORTABLE_COLUMNS = {
|
||||
ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
|
||||
SERVERS_BLOCKLIST: [ 'createdAt' ],
|
||||
|
||||
USER_NOTIFICATIONS: [ 'createdAt' ]
|
||||
USER_NOTIFICATIONS: [ 'createdAt' ],
|
||||
|
||||
VIDEO_PLAYLISTS: [ 'createdAt' ]
|
||||
}
|
||||
|
||||
const OAUTH_LIFETIME = {
|
||||
@ -386,6 +389,17 @@ let CONSTRAINTS_FIELDS = {
|
||||
FILE_SIZE: { min: 10 },
|
||||
URL: { min: 3, max: 2000 } // Length
|
||||
},
|
||||
VIDEO_PLAYLISTS: {
|
||||
NAME: { min: 1, max: 120 }, // Length
|
||||
DESCRIPTION: { min: 3, max: 1000 }, // Length
|
||||
URL: { min: 3, max: 2000 }, // Length
|
||||
IMAGE: {
|
||||
EXTNAME: [ '.jpg', '.jpeg' ],
|
||||
FILE_SIZE: {
|
||||
max: 2 * 1024 * 1024 // 2MB
|
||||
}
|
||||
}
|
||||
},
|
||||
ACTORS: {
|
||||
PUBLIC_KEY: { min: 10, max: 5000 }, // Length
|
||||
PRIVATE_KEY: { min: 10, max: 5000 }, // Length
|
||||
@ -502,6 +516,12 @@ const VIDEO_ABUSE_STATES = {
|
||||
[VideoAbuseState.ACCEPTED]: 'Accepted'
|
||||
}
|
||||
|
||||
const VIDEO_PLAYLIST_PRIVACIES = {
|
||||
[VideoPlaylistPrivacy.PUBLIC]: 'Public',
|
||||
[VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
|
||||
[VideoPlaylistPrivacy.PRIVATE]: 'Private'
|
||||
}
|
||||
|
||||
const MIMETYPES = {
|
||||
VIDEO: {
|
||||
MIMETYPE_EXT: buildVideoMimetypeExt(),
|
||||
@ -786,6 +806,7 @@ export {
|
||||
VIDEO_IMPORT_STATES,
|
||||
VIDEO_VIEW_LIFETIME,
|
||||
CONTACT_FORM_LIFETIME,
|
||||
VIDEO_PLAYLIST_PRIVACIES,
|
||||
buildLanguages
|
||||
}
|
||||
|
||||
|
@ -34,6 +34,8 @@ import { ServerBlocklistModel } from '../models/server/server-blocklist'
|
||||
import { UserNotificationModel } from '../models/account/user-notification'
|
||||
import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
|
||||
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
|
||||
import { VideoPlaylistModel } from '../models/video/video-playlist'
|
||||
import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
|
||||
|
||||
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
|
||||
|
||||
@ -101,7 +103,9 @@ async function initDatabaseModels (silent: boolean) {
|
||||
ServerBlocklistModel,
|
||||
UserNotificationModel,
|
||||
UserNotificationSettingModel,
|
||||
VideoStreamingPlaylistModel
|
||||
VideoStreamingPlaylistModel,
|
||||
VideoPlaylistModel,
|
||||
VideoPlaylistElementModel
|
||||
])
|
||||
|
||||
// Check extensions exist in the database
|
||||
|
@ -44,6 +44,7 @@ async function getOrCreateActorAndServerAndModel (
|
||||
) {
|
||||
const actorUrl = getAPId(activityActor)
|
||||
let created = false
|
||||
let accountPlaylistsUrl: string
|
||||
|
||||
let actor = await fetchActorByUrl(actorUrl, fetchType)
|
||||
// Orphan actor (not associated to an account of channel) so recreate it
|
||||
@ -70,7 +71,8 @@ async function getOrCreateActorAndServerAndModel (
|
||||
|
||||
try {
|
||||
// Don't recurse another time
|
||||
ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false)
|
||||
const recurseIfNeeded = false
|
||||
ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', recurseIfNeeded)
|
||||
} catch (err) {
|
||||
logger.error('Cannot get or create account attributed to video channel ' + actor.url)
|
||||
throw new Error(err)
|
||||
@ -79,6 +81,7 @@ async function getOrCreateActorAndServerAndModel (
|
||||
|
||||
actor = await retryTransactionWrapper(saveActorAndServerAndModelIfNotExist, result, ownerActor)
|
||||
created = true
|
||||
accountPlaylistsUrl = result.playlists
|
||||
}
|
||||
|
||||
if (actor.Account) actor.Account.Actor = actor
|
||||
@ -92,6 +95,12 @@ async function getOrCreateActorAndServerAndModel (
|
||||
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
|
||||
// We created a new account: fetch the playlists
|
||||
if (created === true && actor.Account && accountPlaylistsUrl) {
|
||||
const payload = { uri: accountPlaylistsUrl, accountId: actor.Account.id, type: 'account-playlists' as 'account-playlists' }
|
||||
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
|
||||
return actorRefreshed
|
||||
}
|
||||
|
||||
@ -342,6 +351,7 @@ type FetchRemoteActorResult = {
|
||||
name: string
|
||||
summary: string
|
||||
support?: string
|
||||
playlists?: string
|
||||
avatarName?: string
|
||||
attributedTo: ActivityPubAttributedTo[]
|
||||
}
|
||||
@ -398,6 +408,7 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
|
||||
avatarName,
|
||||
summary: actorJSON.summary,
|
||||
support: actorJSON.support,
|
||||
playlists: actorJSON.playlists,
|
||||
attributedTo: actorJSON.attributedTo
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ActivityPlaylistUrlObject, ActivityVideoUrlObject, CacheFileObject } from '../../../shared/index'
|
||||
import { CacheFileObject } from '../../../shared/index'
|
||||
import { VideoModel } from '../../models/video/video'
|
||||
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
|
||||
import { Transaction } from 'sequelize'
|
||||
|
@ -4,7 +4,7 @@ import { logger } from '../../helpers/logger'
|
||||
import * as Bluebird from 'bluebird'
|
||||
import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
|
||||
|
||||
async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) {
|
||||
async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => (Promise<any> | Bluebird<any>)) {
|
||||
logger.info('Crawling ActivityPub data on %s.', uri)
|
||||
|
||||
const options = {
|
||||
|
162
server/lib/activitypub/playlist.ts
Normal file
162
server/lib/activitypub/playlist.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
|
||||
import { crawlCollectionPage } from './crawl'
|
||||
import { ACTIVITY_PUB, CONFIG, CRAWL_REQUEST_CONCURRENCY, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
|
||||
import { AccountModel } from '../../models/account/account'
|
||||
import { isArray } from '../../helpers/custom-validators/misc'
|
||||
import { getOrCreateActorAndServerAndModel } from './actor'
|
||||
import { logger } from '../../helpers/logger'
|
||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||
import { doRequest, downloadImage } from '../../helpers/requests'
|
||||
import { checkUrlsSameHost } from '../../helpers/activitypub'
|
||||
import * as Bluebird from 'bluebird'
|
||||
import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
|
||||
import { getOrCreateVideoAndAccountAndChannel } from './videos'
|
||||
import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist'
|
||||
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
|
||||
import { VideoModel } from '../../models/video/video'
|
||||
import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
|
||||
import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
|
||||
|
||||
function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) {
|
||||
const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED
|
||||
|
||||
return {
|
||||
name: playlistObject.name,
|
||||
description: playlistObject.content,
|
||||
privacy,
|
||||
url: playlistObject.id,
|
||||
uuid: playlistObject.uuid,
|
||||
ownerAccountId: byAccount.id,
|
||||
videoChannelId: null
|
||||
}
|
||||
}
|
||||
|
||||
function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: VideoPlaylistModel, video: VideoModel) {
|
||||
return {
|
||||
position: elementObject.position,
|
||||
url: elementObject.id,
|
||||
startTimestamp: elementObject.startTimestamp || null,
|
||||
stopTimestamp: elementObject.stopTimestamp || null,
|
||||
videoPlaylistId: videoPlaylist.id,
|
||||
videoId: video.id
|
||||
}
|
||||
}
|
||||
|
||||
async function createAccountPlaylists (playlistUrls: string[], account: AccountModel) {
|
||||
await Bluebird.map(playlistUrls, async playlistUrl => {
|
||||
try {
|
||||
const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
|
||||
if (exists === true) return
|
||||
|
||||
// Fetch url
|
||||
const { body } = await doRequest<PlaylistObject>({
|
||||
uri: playlistUrl,
|
||||
json: true,
|
||||
activityPub: true
|
||||
})
|
||||
|
||||
if (!isPlaylistObjectValid(body)) {
|
||||
throw new Error(`Invalid playlist object when fetch account playlists: ${JSON.stringify(body)}`)
|
||||
}
|
||||
|
||||
if (!isArray(body.to)) {
|
||||
throw new Error('Playlist does not have an audience.')
|
||||
}
|
||||
|
||||
return createOrUpdateVideoPlaylist(body, account, body.to)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot add playlist element %s.', playlistUrl, { err })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
}
|
||||
|
||||
async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) {
|
||||
const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to)
|
||||
|
||||
if (isArray(playlistObject.attributedTo) && playlistObject.attributedTo.length === 1) {
|
||||
const actor = await getOrCreateActorAndServerAndModel(playlistObject.attributedTo[0])
|
||||
|
||||
if (actor.VideoChannel) {
|
||||
playlistAttributes.videoChannelId = actor.VideoChannel.id
|
||||
} else {
|
||||
logger.warn('Attributed to of video playlist %s is not a video channel.', playlistObject.id, { playlistObject })
|
||||
}
|
||||
}
|
||||
|
||||
const [ playlist ] = await VideoPlaylistModel.upsert<VideoPlaylistModel>(playlistAttributes, { returning: true })
|
||||
|
||||
let accItems: string[] = []
|
||||
await crawlCollectionPage<string>(playlistObject.id, items => {
|
||||
accItems = accItems.concat(items)
|
||||
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
// Empty playlists generally do not have a miniature, so skip it
|
||||
if (accItems.length !== 0) {
|
||||
try {
|
||||
await generateThumbnailFromUrl(playlist, playlistObject.icon)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err })
|
||||
}
|
||||
}
|
||||
|
||||
return resetVideoPlaylistElements(accItems, playlist)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
createAccountPlaylists,
|
||||
playlistObjectToDBAttributes,
|
||||
playlistElementObjectToDBAttributes,
|
||||
createOrUpdateVideoPlaylist
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resetVideoPlaylistElements (elementUrls: string[], playlist: VideoPlaylistModel) {
|
||||
const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
|
||||
|
||||
await Bluebird.map(elementUrls, async elementUrl => {
|
||||
try {
|
||||
// Fetch url
|
||||
const { body } = await doRequest<PlaylistElementObject>({
|
||||
uri: elementUrl,
|
||||
json: true,
|
||||
activityPub: true
|
||||
})
|
||||
|
||||
if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in video get playlist element ${elementUrl}`)
|
||||
|
||||
if (checkUrlsSameHost(body.id, elementUrl) !== true) {
|
||||
throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
|
||||
}
|
||||
|
||||
const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: { id: body.url }, fetchType: 'only-video' })
|
||||
|
||||
elementsToCreate.push(playlistElementObjectToDBAttributes(body, playlist, video))
|
||||
} catch (err) {
|
||||
logger.warn('Cannot add playlist element %s.', elementUrl, { err })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
|
||||
|
||||
for (const element of elementsToCreate) {
|
||||
await VideoPlaylistElementModel.create(element, { transaction: t })
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Reset playlist %s with %s elements.', playlist.url, elementsToCreate.length)
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function generateThumbnailFromUrl (playlist: VideoPlaylistModel, icon: ActivityIconObject) {
|
||||
const thumbnailName = playlist.getThumbnailName()
|
||||
|
||||
return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
|
||||
}
|
@ -12,6 +12,8 @@ import { Notifier } from '../../notifier'
|
||||
import { processViewActivity } from './process-view'
|
||||
import { processDislikeActivity } from './process-dislike'
|
||||
import { processFlagActivity } from './process-flag'
|
||||
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
|
||||
import { createOrUpdateVideoPlaylist } from '../playlist'
|
||||
|
||||
async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
|
||||
const activityObject = activity.object
|
||||
@ -38,7 +40,11 @@ async function processCreateActivity (activity: ActivityCreate, byActor: ActorMo
|
||||
}
|
||||
|
||||
if (activityType === 'CacheFile') {
|
||||
return retryTransactionWrapper(processCacheFile, activity, byActor)
|
||||
return retryTransactionWrapper(processCreateCacheFile, activity, byActor)
|
||||
}
|
||||
|
||||
if (activityType === 'Playlist') {
|
||||
return retryTransactionWrapper(processCreatePlaylist, activity, byActor)
|
||||
}
|
||||
|
||||
logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
|
||||
@ -63,7 +69,7 @@ async function processCreateVideo (activity: ActivityCreate) {
|
||||
return video
|
||||
}
|
||||
|
||||
async function processCacheFile (activity: ActivityCreate, byActor: ActorModel) {
|
||||
async function processCreateCacheFile (activity: ActivityCreate, byActor: ActorModel) {
|
||||
const cacheFile = activity.object as CacheFileObject
|
||||
|
||||
const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object })
|
||||
@ -98,3 +104,12 @@ async function processCreateVideoComment (activity: ActivityCreate, byActor: Act
|
||||
|
||||
if (created === true) Notifier.Instance.notifyOnNewComment(comment)
|
||||
}
|
||||
|
||||
async function processCreatePlaylist (activity: ActivityCreate, byActor: ActorModel) {
|
||||
const playlistObject = activity.object as PlaylistObject
|
||||
const byAccount = byActor.Account
|
||||
|
||||
if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
|
||||
|
||||
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
|
||||
}
|
||||
|
@ -12,6 +12,8 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-vali
|
||||
import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
|
||||
import { createOrUpdateCacheFile } from '../cache-file'
|
||||
import { forwardVideoRelatedActivity } from '../send/utils'
|
||||
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
|
||||
import { createOrUpdateVideoPlaylist } from '../playlist'
|
||||
|
||||
async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorModel) {
|
||||
const objectType = activity.object.type
|
||||
@ -32,6 +34,10 @@ async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorMo
|
||||
return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity)
|
||||
}
|
||||
|
||||
if (objectType === 'Playlist') {
|
||||
return retryTransactionWrapper(processUpdatePlaylist, byActor, activity)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@ -135,3 +141,12 @@ async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function processUpdatePlaylist (byActor: ActorModel, activity: ActivityUpdate) {
|
||||
const playlistObject = activity.object as PlaylistObject
|
||||
const byAccount = byActor.Account
|
||||
|
||||
if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
|
||||
|
||||
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
|
||||
}
|
||||
|
@ -8,6 +8,9 @@ import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unic
|
||||
import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
|
||||
import { VideoPlaylistModel } from '../../../models/video/video-playlist'
|
||||
import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
import { getServerActor } from '../../../helpers/utils'
|
||||
|
||||
async function sendCreateVideo (video: VideoModel, t: Transaction) {
|
||||
if (video.privacy === VideoPrivacy.PRIVATE) return undefined
|
||||
@ -34,6 +37,25 @@ async function sendCreateCacheFile (byActor: ActorModel, video: VideoModel, file
|
||||
})
|
||||
}
|
||||
|
||||
async function sendCreateVideoPlaylist (playlist: VideoPlaylistModel, t: Transaction) {
|
||||
if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
|
||||
|
||||
logger.info('Creating job to send create video playlist of %s.', playlist.url)
|
||||
|
||||
const byActor = playlist.OwnerAccount.Actor
|
||||
const audience = getAudience(byActor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
|
||||
|
||||
const object = await playlist.toActivityPubObject()
|
||||
const createActivity = buildCreateActivity(playlist.url, byActor, object, audience)
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
const toFollowersOf = [ byActor, serverActor ]
|
||||
|
||||
if (playlist.VideoChannel) toFollowersOf.push(playlist.VideoChannel.Actor)
|
||||
|
||||
return broadcastToFollowers(createActivity, byActor, toFollowersOf, t)
|
||||
}
|
||||
|
||||
async function sendCreateVideoComment (comment: VideoCommentModel, t: Transaction) {
|
||||
logger.info('Creating job to send comment %s.', comment.url)
|
||||
|
||||
@ -92,6 +114,7 @@ export {
|
||||
sendCreateVideo,
|
||||
buildCreateActivity,
|
||||
sendCreateVideoComment,
|
||||
sendCreateVideoPlaylist,
|
||||
sendCreateCacheFile
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,8 @@ import { getDeleteActivityPubUrl } from '../url'
|
||||
import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
|
||||
import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { VideoPlaylistModel } from '../../../models/video/video-playlist'
|
||||
import { getServerActor } from '../../../helpers/utils'
|
||||
|
||||
async function sendDeleteVideo (video: VideoModel, transaction: Transaction) {
|
||||
logger.info('Creating job to broadcast delete of video %s.', video.url)
|
||||
@ -64,12 +66,29 @@ async function sendDeleteVideoComment (videoComment: VideoCommentModel, t: Trans
|
||||
return unicastTo(activity, byActor, videoComment.Video.VideoChannel.Account.Actor.sharedInboxUrl)
|
||||
}
|
||||
|
||||
async function sendDeleteVideoPlaylist (videoPlaylist: VideoPlaylistModel, t: Transaction) {
|
||||
logger.info('Creating job to send delete of playlist %s.', videoPlaylist.url)
|
||||
|
||||
const byActor = videoPlaylist.OwnerAccount.Actor
|
||||
|
||||
const url = getDeleteActivityPubUrl(videoPlaylist.url)
|
||||
const activity = buildDeleteActivity(url, videoPlaylist.url, byActor)
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
const toFollowersOf = [ byActor, serverActor ]
|
||||
|
||||
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
|
||||
|
||||
return broadcastToFollowers(activity, byActor, toFollowersOf, t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendDeleteVideo,
|
||||
sendDeleteActor,
|
||||
sendDeleteVideoComment
|
||||
sendDeleteVideoComment,
|
||||
sendDeleteVideoPlaylist
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -12,8 +12,13 @@ import { audiencify, getActorsInvolvedInVideo, getAudience } from '../audience'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { VideoCaptionModel } from '../../../models/video/video-caption'
|
||||
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
|
||||
import { VideoPlaylistModel } from '../../../models/video/video-playlist'
|
||||
import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
import { getServerActor } from '../../../helpers/utils'
|
||||
|
||||
async function sendUpdateVideo (video: VideoModel, t: Transaction, overrodeByActor?: ActorModel) {
|
||||
if (video.privacy === VideoPrivacy.PRIVATE) return undefined
|
||||
|
||||
logger.info('Creating job to update video %s.', video.url)
|
||||
|
||||
const byActor = overrodeByActor ? overrodeByActor : video.VideoChannel.Account.Actor
|
||||
@ -73,12 +78,35 @@ async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoR
|
||||
return sendVideoRelatedActivity(activityBuilder, { byActor, video })
|
||||
}
|
||||
|
||||
async function sendUpdateVideoPlaylist (videoPlaylist: VideoPlaylistModel, t: Transaction) {
|
||||
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
|
||||
|
||||
const byActor = videoPlaylist.OwnerAccount.Actor
|
||||
|
||||
logger.info('Creating job to update video playlist %s.', videoPlaylist.url)
|
||||
|
||||
const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString())
|
||||
|
||||
const object = await videoPlaylist.toActivityPubObject()
|
||||
const audience = getAudience(byActor, videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC)
|
||||
|
||||
const updateActivity = buildUpdateActivity(url, byActor, object, audience)
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
const toFollowersOf = [ byActor, serverActor ]
|
||||
|
||||
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
|
||||
|
||||
return broadcastToFollowers(updateActivity, byActor, toFollowersOf, t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendUpdateActor,
|
||||
sendUpdateVideo,
|
||||
sendUpdateCacheFile
|
||||
sendUpdateCacheFile,
|
||||
sendUpdateVideoPlaylist
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -7,11 +7,21 @@ import { VideoCommentModel } from '../../models/video/video-comment'
|
||||
import { VideoFileModel } from '../../models/video/video-file'
|
||||
import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
|
||||
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
|
||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
|
||||
|
||||
function getVideoActivityPubUrl (video: VideoModel) {
|
||||
return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
|
||||
}
|
||||
|
||||
function getVideoPlaylistActivityPubUrl (videoPlaylist: VideoPlaylistModel) {
|
||||
return CONFIG.WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid
|
||||
}
|
||||
|
||||
function getVideoPlaylistElementActivityPubUrl (videoPlaylist: VideoPlaylistModel, video: VideoModel) {
|
||||
return CONFIG.WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid + '/' + video.uuid
|
||||
}
|
||||
|
||||
function getVideoCacheFileActivityPubUrl (videoFile: VideoFileModel) {
|
||||
const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : ''
|
||||
|
||||
@ -98,6 +108,8 @@ function getUndoActivityPubUrl (originalUrl: string) {
|
||||
|
||||
export {
|
||||
getVideoActivityPubUrl,
|
||||
getVideoPlaylistElementActivityPubUrl,
|
||||
getVideoPlaylistActivityPubUrl,
|
||||
getVideoCacheStreamingPlaylistActivityPubUrl,
|
||||
getVideoChannelActivityPubUrl,
|
||||
getAccountActivityPubUrl,
|
||||
|
@ -5,13 +5,16 @@ import { addVideoComments } from '../../activitypub/video-comments'
|
||||
import { crawlCollectionPage } from '../../activitypub/crawl'
|
||||
import { VideoModel } from '../../../models/video/video'
|
||||
import { addVideoShares, createRates } from '../../activitypub'
|
||||
import { createAccountPlaylists } from '../../activitypub/playlist'
|
||||
import { AccountModel } from '../../../models/account/account'
|
||||
|
||||
type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments'
|
||||
type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments' | 'account-playlists'
|
||||
|
||||
export type ActivitypubHttpFetcherPayload = {
|
||||
uri: string
|
||||
type: FetchType
|
||||
videoId?: number
|
||||
accountId?: number
|
||||
}
|
||||
|
||||
async function processActivityPubHttpFetcher (job: Bull.Job) {
|
||||
@ -22,12 +25,16 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
|
||||
let video: VideoModel
|
||||
if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
|
||||
|
||||
let account: AccountModel
|
||||
if (payload.accountId) account = await AccountModel.load(payload.accountId)
|
||||
|
||||
const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
|
||||
'activity': items => processActivities(items, { outboxUrl: payload.uri }),
|
||||
'video-likes': items => createRates(items, video, 'like'),
|
||||
'video-dislikes': items => createRates(items, video, 'dislike'),
|
||||
'video-shares': items => addVideoShares(items, video),
|
||||
'video-comments': items => addVideoComments(items, video)
|
||||
'video-comments': items => addVideoComments(items, video),
|
||||
'account-playlists': items => createAccountPlaylists(items, account)
|
||||
}
|
||||
|
||||
return crawlCollectionPage(payload.uri, fetcherType[payload.type])
|
||||
|
@ -17,7 +17,7 @@ const localAccountValidator = [
|
||||
}
|
||||
]
|
||||
|
||||
const accountsNameWithHostGetValidator = [
|
||||
const accountNameWithHostGetValidator = [
|
||||
param('accountName').exists().withMessage('Should have an account name with host'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
@ -34,5 +34,5 @@ const accountsNameWithHostGetValidator = [
|
||||
|
||||
export {
|
||||
localAccountValidator,
|
||||
accountsNameWithHostGetValidator
|
||||
accountNameWithHostGetValidator
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUM
|
||||
const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
|
||||
const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
|
||||
const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
|
||||
const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
|
||||
|
||||
const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
|
||||
const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
|
||||
@ -37,6 +38,7 @@ const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COL
|
||||
const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
|
||||
const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
|
||||
const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
|
||||
const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -57,5 +59,6 @@ export {
|
||||
videoChannelsSearchSortValidator,
|
||||
accountsBlocklistSortValidator,
|
||||
serversBlocklistSortValidator,
|
||||
userNotificationsSortValidator
|
||||
userNotificationsSortValidator,
|
||||
videoPlaylistsSortValidator
|
||||
}
|
||||
|
@ -16,19 +16,6 @@ import { areValidationErrors } from '../utils'
|
||||
import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor'
|
||||
import { ActorModel } from '../../../models/activitypub/actor'
|
||||
|
||||
const listVideoAccountChannelsValidator = [
|
||||
param('accountName').exists().withMessage('Should have a valid account name'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking listVideoAccountChannelsValidator parameters', { parameters: req.body })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
if (!await isAccountNameWithHostExist(req.params.accountName, res)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
const videoChannelsAddValidator = [
|
||||
body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'),
|
||||
body('displayName').custom(isVideoChannelNameValid).withMessage('Should have a valid display name'),
|
||||
@ -127,7 +114,6 @@ const localVideoChannelValidator = [
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
listVideoAccountChannelsValidator,
|
||||
videoChannelsAddValidator,
|
||||
videoChannelsUpdateValidator,
|
||||
videoChannelsRemoveValidator,
|
||||
|
@ -3,14 +3,14 @@ import { body } from 'express-validator/check'
|
||||
import { isIdValid } from '../../../helpers/custom-validators/misc'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { areValidationErrors } from '../utils'
|
||||
import { getCommonVideoAttributes } from './videos'
|
||||
import { getCommonVideoEditAttributes } from './videos'
|
||||
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
|
||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||
import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos'
|
||||
import { CONFIG } from '../../../initializers/constants'
|
||||
import { CONSTRAINTS_FIELDS } from '../../../initializers'
|
||||
|
||||
const videoImportAddValidator = getCommonVideoAttributes().concat([
|
||||
const videoImportAddValidator = getCommonVideoEditAttributes().concat([
|
||||
body('channelId')
|
||||
.toInt()
|
||||
.custom(isIdValid).withMessage('Should have correct video channel id'),
|
||||
|
302
server/middlewares/validators/videos/video-playlists.ts
Normal file
302
server/middlewares/validators/videos/video-playlists.ts
Normal file
@ -0,0 +1,302 @@
|
||||
import * as express from 'express'
|
||||
import { body, param, ValidationChain } from 'express-validator/check'
|
||||
import { UserRight, VideoPrivacy } from '../../../../shared'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { UserModel } from '../../../models/account/user'
|
||||
import { areValidationErrors } from '../utils'
|
||||
import { isVideoExist, isVideoImage } from '../../../helpers/custom-validators/videos'
|
||||
import { CONSTRAINTS_FIELDS } from '../../../initializers'
|
||||
import { isIdOrUUIDValid, toValueOrNull } from '../../../helpers/custom-validators/misc'
|
||||
import {
|
||||
isVideoPlaylistDescriptionValid,
|
||||
isVideoPlaylistExist,
|
||||
isVideoPlaylistNameValid,
|
||||
isVideoPlaylistPrivacyValid
|
||||
} from '../../../helpers/custom-validators/video-playlists'
|
||||
import { VideoPlaylistModel } from '../../../models/video/video-playlist'
|
||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||
import { isVideoChannelIdExist } from '../../../helpers/custom-validators/video-channels'
|
||||
import { VideoPlaylistElementModel } from '../../../models/video/video-playlist-element'
|
||||
import { VideoModel } from '../../../models/video/video'
|
||||
import { authenticatePromiseIfNeeded } from '../../oauth'
|
||||
import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
|
||||
const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoPlaylistsAddValidator parameters', { parameters: req.body })
|
||||
|
||||
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||
|
||||
if (req.body.videoChannelId && !await isVideoChannelIdExist(req.body.videoChannelId, res)) return cleanUpReqFiles(req)
|
||||
|
||||
return next()
|
||||
}
|
||||
])
|
||||
|
||||
const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
|
||||
param('playlistId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoPlaylistsUpdateValidator parameters', { parameters: req.body })
|
||||
|
||||
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||
|
||||
if (!await isVideoPlaylistExist(req.params.playlistId, res)) return cleanUpReqFiles(req)
|
||||
if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) {
|
||||
return cleanUpReqFiles(req)
|
||||
}
|
||||
|
||||
if (req.body.videoChannelId && !await isVideoChannelIdExist(req.body.videoChannelId, res)) return cleanUpReqFiles(req)
|
||||
|
||||
return next()
|
||||
}
|
||||
])
|
||||
|
||||
const videoPlaylistsDeleteValidator = [
|
||||
param('playlistId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoPlaylistsDeleteValidator parameters', { parameters: req.params })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
|
||||
if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
|
||||
if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) {
|
||||
return
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
const videoPlaylistsGetValidator = [
|
||||
param('playlistId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoPlaylistsGetValidator parameters', { parameters: req.params })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
|
||||
if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
|
||||
|
||||
const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
|
||||
await authenticatePromiseIfNeeded(req, res)
|
||||
|
||||
const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : null
|
||||
|
||||
if (
|
||||
!user ||
|
||||
(videoPlaylist.OwnerAccount.userId !== user.id && !user.hasRight(UserRight.UPDATE_ANY_VIDEO_PLAYLIST))
|
||||
) {
|
||||
return res.status(403)
|
||||
.json({ error: 'Cannot get this private video playlist.' })
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
const videoPlaylistsAddVideoValidator = [
|
||||
param('playlistId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
|
||||
body('videoId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'),
|
||||
body('startTimestamp')
|
||||
.optional()
|
||||
.isInt({ min: 0 }).withMessage('Should have a valid start timestamp'),
|
||||
body('stopTimestamp')
|
||||
.optional()
|
||||
.isInt({ min: 0 }).withMessage('Should have a valid stop timestamp'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoPlaylistsAddVideoValidator parameters', { parameters: req.params })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
|
||||
if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
|
||||
if (!await isVideoExist(req.body.videoId, res, 'id')) return
|
||||
|
||||
const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
const video: VideoModel = res.locals.video
|
||||
|
||||
const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideo(videoPlaylist.id, video.id)
|
||||
if (videoPlaylistElement) {
|
||||
res.status(409)
|
||||
.json({ error: 'This video in this playlist already exists' })
|
||||
.end()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, res.locals.videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) {
|
||||
return
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
const videoPlaylistsUpdateOrRemoveVideoValidator = [
|
||||
param('playlistId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
|
||||
param('videoId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have an video id/uuid'),
|
||||
body('startTimestamp')
|
||||
.optional()
|
||||
.isInt({ min: 0 }).withMessage('Should have a valid start timestamp'),
|
||||
body('stopTimestamp')
|
||||
.optional()
|
||||
.isInt({ min: 0 }).withMessage('Should have a valid stop timestamp'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoPlaylistsRemoveVideoValidator parameters', { parameters: req.params })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
|
||||
if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
|
||||
if (!await isVideoExist(req.params.playlistId, res, 'id')) return
|
||||
|
||||
const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
const video: VideoModel = res.locals.video
|
||||
|
||||
const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideo(videoPlaylist.id, video.id)
|
||||
if (!videoPlaylistElement) {
|
||||
res.status(404)
|
||||
.json({ error: 'Video playlist element not found' })
|
||||
.end()
|
||||
|
||||
return
|
||||
}
|
||||
res.locals.videoPlaylistElement = videoPlaylistElement
|
||||
|
||||
if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
const videoPlaylistElementAPGetValidator = [
|
||||
param('playlistId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
|
||||
param('videoId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have an video id/uuid'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoPlaylistElementAPGetValidator parameters', { parameters: req.params })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
|
||||
const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideoForAP(req.params.playlistId, req.params.videoId)
|
||||
if (!videoPlaylistElement) {
|
||||
res.status(404)
|
||||
.json({ error: 'Video playlist element not found' })
|
||||
.end()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (videoPlaylistElement.VideoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
res.locals.videoPlaylistElement = videoPlaylistElement
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
const videoPlaylistsReorderVideosValidator = [
|
||||
param('playlistId')
|
||||
.custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
|
||||
body('startPosition')
|
||||
.isInt({ min: 1 }).withMessage('Should have a valid start position'),
|
||||
body('insertAfterPosition')
|
||||
.isInt({ min: 0 }).withMessage('Should have a valid insert after position'),
|
||||
body('reorderLength')
|
||||
.optional()
|
||||
.isInt({ min: 1 }).withMessage('Should have a valid range length'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoPlaylistsReorderVideosValidator parameters', { parameters: req.params })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
|
||||
if (!await isVideoPlaylistExist(req.params.playlistId, res)) return
|
||||
|
||||
const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist
|
||||
if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
videoPlaylistsAddValidator,
|
||||
videoPlaylistsUpdateValidator,
|
||||
videoPlaylistsDeleteValidator,
|
||||
videoPlaylistsGetValidator,
|
||||
|
||||
videoPlaylistsAddVideoValidator,
|
||||
videoPlaylistsUpdateOrRemoveVideoValidator,
|
||||
videoPlaylistsReorderVideosValidator,
|
||||
|
||||
videoPlaylistElementAPGetValidator
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getCommonPlaylistEditAttributes () {
|
||||
return [
|
||||
body('thumbnailfile')
|
||||
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
|
||||
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
|
||||
+ CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ')
|
||||
),
|
||||
|
||||
body('displayName')
|
||||
.custom(isVideoPlaylistNameValid).withMessage('Should have a valid display name'),
|
||||
body('description')
|
||||
.optional()
|
||||
.customSanitizer(toValueOrNull)
|
||||
.custom(isVideoPlaylistDescriptionValid).withMessage('Should have a valid description'),
|
||||
body('privacy')
|
||||
.optional()
|
||||
.toInt()
|
||||
.custom(isVideoPlaylistPrivacyValid).withMessage('Should have correct playlist privacy'),
|
||||
body('videoChannelId')
|
||||
.optional()
|
||||
.toInt()
|
||||
] as (ValidationChain | express.Handler)[]
|
||||
}
|
||||
|
||||
function checkUserCanManageVideoPlaylist (user: UserModel, videoPlaylist: VideoPlaylistModel, right: UserRight, res: express.Response) {
|
||||
if (videoPlaylist.isOwned() === false) {
|
||||
res.status(403)
|
||||
.json({ error: 'Cannot manage video playlist of another server.' })
|
||||
.end()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the user can manage the video playlist
|
||||
// The user can delete it if s/he is an admin
|
||||
// Or if s/he is the video playlist's owner
|
||||
if (user.hasRight(right) === false && videoPlaylist.ownerAccountId !== user.Account.id) {
|
||||
res.status(403)
|
||||
.json({ error: 'Cannot manage video playlist of another user' })
|
||||
.end()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
@ -46,7 +46,7 @@ import { VideoFetchType } from '../../../helpers/video'
|
||||
import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
|
||||
import { getServerActor } from '../../../helpers/utils'
|
||||
|
||||
const videosAddValidator = getCommonVideoAttributes().concat([
|
||||
const videosAddValidator = getCommonVideoEditAttributes().concat([
|
||||
body('videofile')
|
||||
.custom((value, { req }) => isVideoFile(req.files)).withMessage(
|
||||
'This file is not supported or too large. Please, make sure it is of the following type: '
|
||||
@ -94,7 +94,7 @@ const videosAddValidator = getCommonVideoAttributes().concat([
|
||||
}
|
||||
])
|
||||
|
||||
const videosUpdateValidator = getCommonVideoAttributes().concat([
|
||||
const videosUpdateValidator = getCommonVideoEditAttributes().concat([
|
||||
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
|
||||
body('name')
|
||||
.optional()
|
||||
@ -288,7 +288,7 @@ const videosAcceptChangeOwnershipValidator = [
|
||||
}
|
||||
]
|
||||
|
||||
function getCommonVideoAttributes () {
|
||||
function getCommonVideoEditAttributes () {
|
||||
return [
|
||||
body('thumbnailfile')
|
||||
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
|
||||
@ -421,7 +421,7 @@ export {
|
||||
videosTerminateChangeOwnershipValidator,
|
||||
videosAcceptChangeOwnershipValidator,
|
||||
|
||||
getCommonVideoAttributes,
|
||||
getCommonVideoEditAttributes,
|
||||
|
||||
commonVideosFiltersValidator
|
||||
}
|
||||
|
@ -10,11 +10,11 @@ import {
|
||||
ForeignKey,
|
||||
HasMany,
|
||||
Is,
|
||||
Model,
|
||||
Model, Scopes,
|
||||
Table,
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
import { Account } from '../../../shared/models/actors'
|
||||
import { Account, AccountSummary } from '../../../shared/models/actors'
|
||||
import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
|
||||
import { sendDeleteActor } from '../../lib/activitypub/send'
|
||||
import { ActorModel } from '../activitypub/actor'
|
||||
@ -25,6 +25,13 @@ import { VideoChannelModel } from '../video/video-channel'
|
||||
import { VideoCommentModel } from '../video/video-comment'
|
||||
import { UserModel } from './user'
|
||||
import { CONFIG } from '../../initializers'
|
||||
import { AvatarModel } from '../avatar/avatar'
|
||||
import { WhereOptions } from 'sequelize'
|
||||
import { VideoPlaylistModel } from '../video/video-playlist'
|
||||
|
||||
export enum ScopeNames {
|
||||
SUMMARY = 'SUMMARY'
|
||||
}
|
||||
|
||||
@DefaultScope({
|
||||
include: [
|
||||
@ -34,6 +41,32 @@ import { CONFIG } from '../../initializers'
|
||||
}
|
||||
]
|
||||
})
|
||||
@Scopes({
|
||||
[ ScopeNames.SUMMARY ]: (whereActor?: WhereOptions<ActorModel>) => {
|
||||
return {
|
||||
attributes: [ 'id', 'name' ],
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
|
||||
model: ActorModel.unscoped(),
|
||||
required: true,
|
||||
where: whereActor,
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'host' ],
|
||||
model: ServerModel.unscoped(),
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: AvatarModel.unscoped(),
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
@Table({
|
||||
tableName: 'account',
|
||||
indexes: [
|
||||
@ -112,6 +145,15 @@ export class AccountModel extends Model<AccountModel> {
|
||||
})
|
||||
VideoChannels: VideoChannelModel[]
|
||||
|
||||
@HasMany(() => VideoPlaylistModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade',
|
||||
hooks: true
|
||||
})
|
||||
VideoPlaylists: VideoPlaylistModel[]
|
||||
|
||||
@HasMany(() => VideoCommentModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
@ -285,6 +327,20 @@ export class AccountModel extends Model<AccountModel> {
|
||||
return Object.assign(actor, account)
|
||||
}
|
||||
|
||||
toFormattedSummaryJSON (): AccountSummary {
|
||||
const actor = this.Actor.toFormattedJSON()
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
uuid: actor.uuid,
|
||||
name: actor.name,
|
||||
displayName: this.getDisplayName(),
|
||||
url: actor.url,
|
||||
host: actor.host,
|
||||
avatar: actor.avatar
|
||||
}
|
||||
}
|
||||
|
||||
toActivityPubObject () {
|
||||
const obj = this.Actor.toActivityPubObject(this.name, 'Account')
|
||||
|
||||
|
@ -407,7 +407,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
|
||||
})
|
||||
}
|
||||
|
||||
static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
|
||||
static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
|
||||
return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
|
||||
}
|
||||
|
||||
|
@ -444,6 +444,7 @@ export class ActorModel extends Model<ActorModel> {
|
||||
id: this.url,
|
||||
following: this.getFollowingUrl(),
|
||||
followers: this.getFollowersUrl(),
|
||||
playlists: this.getPlaylistsUrl(),
|
||||
inbox: this.inboxUrl,
|
||||
outbox: this.outboxUrl,
|
||||
preferredUsername: this.preferredUsername,
|
||||
@ -494,6 +495,10 @@ export class ActorModel extends Model<ActorModel> {
|
||||
return this.url + '/followers'
|
||||
}
|
||||
|
||||
getPlaylistsUrl () {
|
||||
return this.url + '/playlists'
|
||||
}
|
||||
|
||||
getPublicKeyUrl () {
|
||||
return this.url + '#main-key'
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Sequelize } from 'sequelize-typescript'
|
||||
import * as validator from 'validator'
|
||||
|
||||
type SortType = { sortModel: any, sortValue: string }
|
||||
|
||||
@ -74,13 +75,25 @@ function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number
|
||||
|
||||
const blockerIdsString = blockerIds.join(', ')
|
||||
|
||||
const query = 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
|
||||
return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
|
||||
' UNION ALL ' +
|
||||
'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
|
||||
'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
|
||||
'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
|
||||
}
|
||||
|
||||
return query
|
||||
function buildServerIdsFollowedBy (actorId: any) {
|
||||
const actorIdNumber = parseInt(actorId + '', 10)
|
||||
|
||||
return '(' +
|
||||
'SELECT "actor"."serverId" FROM "actorFollow" ' +
|
||||
'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
|
||||
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
|
||||
')'
|
||||
}
|
||||
|
||||
function buildWhereIdOrUUID (id: number | string) {
|
||||
return validator.isInt('' + id) ? { id } : { uuid: id }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -93,7 +106,9 @@ export {
|
||||
getSortOnModel,
|
||||
createSimilarityAttribute,
|
||||
throwIfNotValid,
|
||||
buildTrigramSearchIndex
|
||||
buildServerIdsFollowedBy,
|
||||
buildTrigramSearchIndex,
|
||||
buildWhereIdOrUUID
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
Default,
|
||||
DefaultScope,
|
||||
ForeignKey,
|
||||
HasMany,
|
||||
HasMany, IFindOptions,
|
||||
Is,
|
||||
Model,
|
||||
Scopes,
|
||||
@ -17,20 +17,22 @@ import {
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
import { ActivityPubActor } from '../../../shared/models/activitypub'
|
||||
import { VideoChannel } from '../../../shared/models/videos'
|
||||
import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
|
||||
import {
|
||||
isVideoChannelDescriptionValid,
|
||||
isVideoChannelNameValid,
|
||||
isVideoChannelSupportValid
|
||||
} from '../../helpers/custom-validators/video-channels'
|
||||
import { sendDeleteActor } from '../../lib/activitypub/send'
|
||||
import { AccountModel } from '../account/account'
|
||||
import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
|
||||
import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
|
||||
import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
|
||||
import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
|
||||
import { VideoModel } from './video'
|
||||
import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
|
||||
import { ServerModel } from '../server/server'
|
||||
import { DefineIndexesOptions } from 'sequelize'
|
||||
import { AvatarModel } from '../avatar/avatar'
|
||||
import { VideoPlaylistModel } from './video-playlist'
|
||||
|
||||
// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
|
||||
const indexes: DefineIndexesOptions[] = [
|
||||
@ -44,11 +46,12 @@ const indexes: DefineIndexesOptions[] = [
|
||||
}
|
||||
]
|
||||
|
||||
enum ScopeNames {
|
||||
export enum ScopeNames {
|
||||
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
|
||||
WITH_ACCOUNT = 'WITH_ACCOUNT',
|
||||
WITH_ACTOR = 'WITH_ACTOR',
|
||||
WITH_VIDEOS = 'WITH_VIDEOS'
|
||||
WITH_VIDEOS = 'WITH_VIDEOS',
|
||||
SUMMARY = 'SUMMARY'
|
||||
}
|
||||
|
||||
type AvailableForListOptions = {
|
||||
@ -64,15 +67,41 @@ type AvailableForListOptions = {
|
||||
]
|
||||
})
|
||||
@Scopes({
|
||||
[ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
|
||||
const actorIdNumber = parseInt(options.actorId + '', 10)
|
||||
[ScopeNames.SUMMARY]: (required: boolean, withAccount: boolean) => {
|
||||
const base: IFindOptions<VideoChannelModel> = {
|
||||
attributes: [ 'name', 'description', 'id' ],
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
|
||||
model: ActorModel.unscoped(),
|
||||
required: true,
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'host' ],
|
||||
model: ServerModel.unscoped(),
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: AvatarModel.unscoped(),
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (withAccount === true) {
|
||||
base.include.push({
|
||||
model: AccountModel.scope(AccountModelScopeNames.SUMMARY),
|
||||
required: true
|
||||
})
|
||||
}
|
||||
|
||||
return base
|
||||
},
|
||||
[ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
|
||||
// Only list local channels OR channels that are on an instance followed by actorId
|
||||
const inQueryInstanceFollow = '(' +
|
||||
'SELECT "actor"."serverId" FROM "actorFollow" ' +
|
||||
'INNER JOIN "actor" ON actor.id= "actorFollow"."targetActorId" ' +
|
||||
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
|
||||
')'
|
||||
const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
|
||||
|
||||
return {
|
||||
include: [
|
||||
@ -192,6 +221,15 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
|
||||
})
|
||||
Videos: VideoModel[]
|
||||
|
||||
@HasMany(() => VideoPlaylistModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade',
|
||||
hooks: true
|
||||
})
|
||||
VideoPlaylists: VideoPlaylistModel[]
|
||||
|
||||
@BeforeDestroy
|
||||
static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
|
||||
if (!instance.Actor) {
|
||||
@ -460,6 +498,20 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
|
||||
return Object.assign(actor, videoChannel)
|
||||
}
|
||||
|
||||
toFormattedSummaryJSON (): VideoChannelSummary {
|
||||
const actor = this.Actor.toFormattedJSON()
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
uuid: actor.uuid,
|
||||
name: actor.name,
|
||||
displayName: this.getDisplayName(),
|
||||
url: actor.url,
|
||||
host: actor.host,
|
||||
avatar: actor.avatar
|
||||
}
|
||||
}
|
||||
|
||||
toActivityPubObject (): ActivityPubActor {
|
||||
const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
|
||||
|
||||
|
@ -26,12 +26,10 @@ export type VideoFormattingJSONOptions = {
|
||||
waitTranscoding?: boolean,
|
||||
scheduledUpdate?: boolean,
|
||||
blacklistInfo?: boolean
|
||||
playlistInfo?: boolean
|
||||
}
|
||||
}
|
||||
function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
|
||||
const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
|
||||
const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
|
||||
|
||||
const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
|
||||
|
||||
const videoObject: Video = {
|
||||
@ -68,24 +66,9 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
|
||||
updatedAt: video.updatedAt,
|
||||
publishedAt: video.publishedAt,
|
||||
originallyPublishedAt: video.originallyPublishedAt,
|
||||
account: {
|
||||
id: formattedAccount.id,
|
||||
uuid: formattedAccount.uuid,
|
||||
name: formattedAccount.name,
|
||||
displayName: formattedAccount.displayName,
|
||||
url: formattedAccount.url,
|
||||
host: formattedAccount.host,
|
||||
avatar: formattedAccount.avatar
|
||||
},
|
||||
channel: {
|
||||
id: formattedVideoChannel.id,
|
||||
uuid: formattedVideoChannel.uuid,
|
||||
name: formattedVideoChannel.name,
|
||||
displayName: formattedVideoChannel.displayName,
|
||||
url: formattedVideoChannel.url,
|
||||
host: formattedVideoChannel.host,
|
||||
avatar: formattedVideoChannel.avatar
|
||||
},
|
||||
|
||||
account: video.VideoChannel.Account.toFormattedSummaryJSON(),
|
||||
channel: video.VideoChannel.toFormattedSummaryJSON(),
|
||||
|
||||
userHistory: userHistory ? {
|
||||
currentTime: userHistory.currentTime
|
||||
@ -115,6 +98,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
|
||||
videoObject.blacklisted = !!video.VideoBlacklist
|
||||
videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
|
||||
}
|
||||
|
||||
if (options.additionalAttributes.playlistInfo === true) {
|
||||
// We filtered on a specific videoId/videoPlaylistId, that is unique
|
||||
const playlistElement = video.VideoPlaylistElements[0]
|
||||
|
||||
videoObject.playlistElement = {
|
||||
position: playlistElement.position,
|
||||
startTimestamp: playlistElement.startTimestamp,
|
||||
stopTimestamp: playlistElement.stopTimestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return videoObject
|
||||
|
231
server/models/video/video-playlist-element.ts
Normal file
231
server/models/video/video-playlist-element.ts
Normal file
@ -0,0 +1,231 @@
|
||||
import {
|
||||
AllowNull,
|
||||
BelongsTo,
|
||||
Column,
|
||||
CreatedAt,
|
||||
DataType,
|
||||
Default,
|
||||
ForeignKey,
|
||||
Is,
|
||||
IsInt,
|
||||
Min,
|
||||
Model,
|
||||
Table,
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
import { VideoModel } from './video'
|
||||
import { VideoPlaylistModel } from './video-playlist'
|
||||
import * as Sequelize from 'sequelize'
|
||||
import { getSort, throwIfNotValid } from '../utils'
|
||||
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
|
||||
import { CONSTRAINTS_FIELDS } from '../../initializers'
|
||||
import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
|
||||
|
||||
@Table({
|
||||
tableName: 'videoPlaylistElement',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'videoPlaylistId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'videoId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'videoPlaylistId', 'videoId' ],
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: [ 'videoPlaylistId', 'position' ],
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: [ 'url' ],
|
||||
unique: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> {
|
||||
@CreatedAt
|
||||
createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
updatedAt: Date
|
||||
|
||||
@AllowNull(false)
|
||||
@Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
|
||||
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
|
||||
url: string
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(1)
|
||||
@IsInt
|
||||
@Min(1)
|
||||
@Column
|
||||
position: number
|
||||
|
||||
@AllowNull(true)
|
||||
@IsInt
|
||||
@Min(0)
|
||||
@Column
|
||||
startTimestamp: number
|
||||
|
||||
@AllowNull(true)
|
||||
@IsInt
|
||||
@Min(0)
|
||||
@Column
|
||||
stopTimestamp: number
|
||||
|
||||
@ForeignKey(() => VideoPlaylistModel)
|
||||
@Column
|
||||
videoPlaylistId: number
|
||||
|
||||
@BelongsTo(() => VideoPlaylistModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
VideoPlaylist: VideoPlaylistModel
|
||||
|
||||
@ForeignKey(() => VideoModel)
|
||||
@Column
|
||||
videoId: number
|
||||
|
||||
@BelongsTo(() => VideoModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Video: VideoModel
|
||||
|
||||
static deleteAllOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) {
|
||||
const query = {
|
||||
where: {
|
||||
videoPlaylistId
|
||||
},
|
||||
transaction
|
||||
}
|
||||
|
||||
return VideoPlaylistElementModel.destroy(query)
|
||||
}
|
||||
|
||||
static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) {
|
||||
const query = {
|
||||
where: {
|
||||
videoPlaylistId,
|
||||
videoId
|
||||
}
|
||||
}
|
||||
|
||||
return VideoPlaylistElementModel.findOne(query)
|
||||
}
|
||||
|
||||
static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) {
|
||||
const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId }
|
||||
const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId }
|
||||
|
||||
const query = {
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'privacy' ],
|
||||
model: VideoPlaylistModel.unscoped(),
|
||||
where: playlistWhere
|
||||
},
|
||||
{
|
||||
attributes: [ 'url' ],
|
||||
model: VideoModel.unscoped(),
|
||||
where: videoWhere
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return VideoPlaylistElementModel.findOne(query)
|
||||
}
|
||||
|
||||
static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number) {
|
||||
const query = {
|
||||
attributes: [ 'url' ],
|
||||
offset: start,
|
||||
limit: count,
|
||||
order: getSort('position'),
|
||||
where: {
|
||||
videoPlaylistId
|
||||
}
|
||||
}
|
||||
|
||||
return VideoPlaylistElementModel
|
||||
.findAndCountAll(query)
|
||||
.then(({ rows, count }) => {
|
||||
return { total: count, data: rows.map(e => e.url) }
|
||||
})
|
||||
}
|
||||
|
||||
static getNextPositionOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) {
|
||||
const query = {
|
||||
where: {
|
||||
videoPlaylistId
|
||||
},
|
||||
transaction
|
||||
}
|
||||
|
||||
return VideoPlaylistElementModel.max('position', query)
|
||||
.then(position => position ? position + 1 : 1)
|
||||
}
|
||||
|
||||
static reassignPositionOf (
|
||||
videoPlaylistId: number,
|
||||
firstPosition: number,
|
||||
endPosition: number,
|
||||
newPosition: number,
|
||||
transaction?: Sequelize.Transaction
|
||||
) {
|
||||
const query = {
|
||||
where: {
|
||||
videoPlaylistId,
|
||||
position: {
|
||||
[Sequelize.Op.gte]: firstPosition,
|
||||
[Sequelize.Op.lte]: endPosition
|
||||
}
|
||||
},
|
||||
transaction
|
||||
}
|
||||
|
||||
return VideoPlaylistElementModel.update({ position: Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) }, query)
|
||||
}
|
||||
|
||||
static increasePositionOf (
|
||||
videoPlaylistId: number,
|
||||
fromPosition: number,
|
||||
toPosition?: number,
|
||||
by = 1,
|
||||
transaction?: Sequelize.Transaction
|
||||
) {
|
||||
const query = {
|
||||
where: {
|
||||
videoPlaylistId,
|
||||
position: {
|
||||
[Sequelize.Op.gte]: fromPosition
|
||||
}
|
||||
},
|
||||
transaction
|
||||
}
|
||||
|
||||
return VideoPlaylistElementModel.increment({ position: by }, query)
|
||||
}
|
||||
|
||||
toActivityPubObject (): PlaylistElementObject {
|
||||
const base: PlaylistElementObject = {
|
||||
id: this.url,
|
||||
type: 'PlaylistElement',
|
||||
|
||||
url: this.Video.url,
|
||||
position: this.position
|
||||
}
|
||||
|
||||
if (this.startTimestamp) base.startTimestamp = this.startTimestamp
|
||||
if (this.stopTimestamp) base.stopTimestamp = this.stopTimestamp
|
||||
|
||||
return base
|
||||
}
|
||||
}
|
381
server/models/video/video-playlist.ts
Normal file
381
server/models/video/video-playlist.ts
Normal file
@ -0,0 +1,381 @@
|
||||
import {
|
||||
AllowNull,
|
||||
BeforeDestroy,
|
||||
BelongsTo,
|
||||
Column,
|
||||
CreatedAt,
|
||||
DataType,
|
||||
Default,
|
||||
ForeignKey,
|
||||
HasMany,
|
||||
Is,
|
||||
IsUUID,
|
||||
Model,
|
||||
Scopes,
|
||||
Table,
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
import * as Sequelize from 'sequelize'
|
||||
import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, throwIfNotValid } from '../utils'
|
||||
import {
|
||||
isVideoPlaylistDescriptionValid,
|
||||
isVideoPlaylistNameValid,
|
||||
isVideoPlaylistPrivacyValid
|
||||
} from '../../helpers/custom-validators/video-playlists'
|
||||
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
|
||||
import { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers'
|
||||
import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
|
||||
import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
|
||||
import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
|
||||
import { join } from 'path'
|
||||
import { VideoPlaylistElementModel } from './video-playlist-element'
|
||||
import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
|
||||
import { activityPubCollectionPagination } from '../../helpers/activitypub'
|
||||
import { remove } from 'fs-extra'
|
||||
import { logger } from '../../helpers/logger'
|
||||
|
||||
enum ScopeNames {
|
||||
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
|
||||
WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
|
||||
WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
|
||||
}
|
||||
|
||||
type AvailableForListOptions = {
|
||||
followerActorId: number
|
||||
accountId?: number,
|
||||
videoChannelId?: number
|
||||
privateAndUnlisted?: boolean
|
||||
}
|
||||
|
||||
@Scopes({
|
||||
[ScopeNames.WITH_VIDEOS_LENGTH]: {
|
||||
attributes: {
|
||||
include: [
|
||||
[
|
||||
Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
|
||||
'videosLength'
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
[ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: {
|
||||
include: [
|
||||
{
|
||||
model: () => AccountModel.scope(AccountScopeNames.SUMMARY),
|
||||
required: true
|
||||
},
|
||||
{
|
||||
model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
[ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
|
||||
// Only list local playlists OR playlists that are on an instance followed by actorId
|
||||
const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
|
||||
const actorWhere = {
|
||||
[ Sequelize.Op.or ]: [
|
||||
{
|
||||
serverId: null
|
||||
},
|
||||
{
|
||||
serverId: {
|
||||
[ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const whereAnd: any[] = []
|
||||
|
||||
if (options.privateAndUnlisted !== true) {
|
||||
whereAnd.push({
|
||||
privacy: VideoPlaylistPrivacy.PUBLIC
|
||||
})
|
||||
}
|
||||
|
||||
if (options.accountId) {
|
||||
whereAnd.push({
|
||||
ownerAccountId: options.accountId
|
||||
})
|
||||
}
|
||||
|
||||
if (options.videoChannelId) {
|
||||
whereAnd.push({
|
||||
videoChannelId: options.videoChannelId
|
||||
})
|
||||
}
|
||||
|
||||
const where = {
|
||||
[Sequelize.Op.and]: whereAnd
|
||||
}
|
||||
|
||||
const accountScope = {
|
||||
method: [ AccountScopeNames.SUMMARY, actorWhere ]
|
||||
}
|
||||
|
||||
return {
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: AccountModel.scope(accountScope),
|
||||
required: true
|
||||
},
|
||||
{
|
||||
model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@Table({
|
||||
tableName: 'videoPlaylist',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'ownerAccountId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'videoChannelId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'url' ],
|
||||
unique: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
|
||||
@CreatedAt
|
||||
createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
updatedAt: Date
|
||||
|
||||
@AllowNull(false)
|
||||
@Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
|
||||
@Column
|
||||
name: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description'))
|
||||
@Column
|
||||
description: string
|
||||
|
||||
@AllowNull(false)
|
||||
@Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
|
||||
@Column
|
||||
privacy: VideoPlaylistPrivacy
|
||||
|
||||
@AllowNull(false)
|
||||
@Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
|
||||
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
|
||||
url: string
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(DataType.UUIDV4)
|
||||
@IsUUID(4)
|
||||
@Column(DataType.UUID)
|
||||
uuid: string
|
||||
|
||||
@ForeignKey(() => AccountModel)
|
||||
@Column
|
||||
ownerAccountId: number
|
||||
|
||||
@BelongsTo(() => AccountModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
OwnerAccount: AccountModel
|
||||
|
||||
@ForeignKey(() => VideoChannelModel)
|
||||
@Column
|
||||
videoChannelId: number
|
||||
|
||||
@BelongsTo(() => VideoChannelModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
VideoChannel: VideoChannelModel
|
||||
|
||||
@HasMany(() => VideoPlaylistElementModel, {
|
||||
foreignKey: {
|
||||
name: 'videoPlaylistId',
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
VideoPlaylistElements: VideoPlaylistElementModel[]
|
||||
|
||||
// Calculated field
|
||||
videosLength?: number
|
||||
|
||||
@BeforeDestroy
|
||||
static async removeFiles (instance: VideoPlaylistModel) {
|
||||
logger.info('Removing files of video playlist %s.', instance.url)
|
||||
|
||||
return instance.removeThumbnail()
|
||||
}
|
||||
|
||||
static listForApi (options: {
|
||||
followerActorId: number
|
||||
start: number,
|
||||
count: number,
|
||||
sort: string,
|
||||
accountId?: number,
|
||||
videoChannelId?: number,
|
||||
privateAndUnlisted?: boolean
|
||||
}) {
|
||||
const query = {
|
||||
offset: options.start,
|
||||
limit: options.count,
|
||||
order: getSort(options.sort)
|
||||
}
|
||||
|
||||
const scopes = [
|
||||
{
|
||||
method: [
|
||||
ScopeNames.AVAILABLE_FOR_LIST,
|
||||
{
|
||||
followerActorId: options.followerActorId,
|
||||
accountId: options.accountId,
|
||||
videoChannelId: options.videoChannelId,
|
||||
privateAndUnlisted: options.privateAndUnlisted
|
||||
} as AvailableForListOptions
|
||||
]
|
||||
} as any, // FIXME: typings
|
||||
ScopeNames.WITH_VIDEOS_LENGTH
|
||||
]
|
||||
|
||||
return VideoPlaylistModel
|
||||
.scope(scopes)
|
||||
.findAndCountAll(query)
|
||||
.then(({ rows, count }) => {
|
||||
return { total: count, data: rows }
|
||||
})
|
||||
}
|
||||
|
||||
static listUrlsOfForAP (accountId: number, start: number, count: number) {
|
||||
const query = {
|
||||
attributes: [ 'url' ],
|
||||
offset: start,
|
||||
limit: count,
|
||||
where: {
|
||||
ownerAccountId: accountId
|
||||
}
|
||||
}
|
||||
|
||||
return VideoPlaylistModel.findAndCountAll(query)
|
||||
.then(({ rows, count }) => {
|
||||
return { total: count, data: rows.map(p => p.url) }
|
||||
})
|
||||
}
|
||||
|
||||
static doesPlaylistExist (url: string) {
|
||||
const query = {
|
||||
attributes: [],
|
||||
where: {
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
return VideoPlaylistModel
|
||||
.findOne(query)
|
||||
.then(e => !!e)
|
||||
}
|
||||
|
||||
static load (id: number | string, transaction: Sequelize.Transaction) {
|
||||
const where = buildWhereIdOrUUID(id)
|
||||
|
||||
const query = {
|
||||
where,
|
||||
transaction
|
||||
}
|
||||
|
||||
return VideoPlaylistModel
|
||||
.scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ])
|
||||
.findOne(query)
|
||||
}
|
||||
|
||||
static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
|
||||
return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
|
||||
}
|
||||
|
||||
getThumbnailName () {
|
||||
const extension = '.jpg'
|
||||
|
||||
return 'playlist-' + this.uuid + extension
|
||||
}
|
||||
|
||||
getThumbnailUrl () {
|
||||
return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
|
||||
}
|
||||
|
||||
getThumbnailStaticPath () {
|
||||
return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
|
||||
}
|
||||
|
||||
removeThumbnail () {
|
||||
const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
|
||||
return remove(thumbnailPath)
|
||||
.catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
|
||||
}
|
||||
|
||||
isOwned () {
|
||||
return this.OwnerAccount.isOwned()
|
||||
}
|
||||
|
||||
toFormattedJSON (): VideoPlaylist {
|
||||
return {
|
||||
id: this.id,
|
||||
uuid: this.uuid,
|
||||
isLocal: this.isOwned(),
|
||||
|
||||
displayName: this.name,
|
||||
description: this.description,
|
||||
privacy: {
|
||||
id: this.privacy,
|
||||
label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
|
||||
},
|
||||
|
||||
thumbnailPath: this.getThumbnailStaticPath(),
|
||||
|
||||
videosLength: this.videosLength,
|
||||
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
|
||||
ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
|
||||
videoChannel: this.VideoChannel.toFormattedSummaryJSON()
|
||||
}
|
||||
}
|
||||
|
||||
toActivityPubObject (): Promise<PlaylistObject> {
|
||||
const handler = (start: number, count: number) => {
|
||||
return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count)
|
||||
}
|
||||
|
||||
return activityPubCollectionPagination(this.url, handler, null)
|
||||
.then(o => {
|
||||
return Object.assign(o, {
|
||||
type: 'Playlist' as 'Playlist',
|
||||
name: this.name,
|
||||
content: this.description,
|
||||
uuid: this.uuid,
|
||||
attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
|
||||
icon: {
|
||||
type: 'Image' as 'Image',
|
||||
url: this.getThumbnailUrl(),
|
||||
mediaType: 'image/jpeg' as 'image/jpeg',
|
||||
width: THUMBNAILS_SIZE.width,
|
||||
height: THUMBNAILS_SIZE.height
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -40,7 +40,7 @@ import {
|
||||
isVideoDurationValid,
|
||||
isVideoLanguageValid,
|
||||
isVideoLicenceValid,
|
||||
isVideoNameValid, isVideoOriginallyPublishedAtValid,
|
||||
isVideoNameValid,
|
||||
isVideoPrivacyValid,
|
||||
isVideoStateValid,
|
||||
isVideoSupportValid
|
||||
@ -52,7 +52,9 @@ import {
|
||||
ACTIVITY_PUB,
|
||||
API_VERSION,
|
||||
CONFIG,
|
||||
CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY,
|
||||
CONSTRAINTS_FIELDS,
|
||||
HLS_PLAYLIST_DIRECTORY,
|
||||
HLS_REDUNDANCY_DIRECTORY,
|
||||
PREVIEWS_SIZE,
|
||||
REMOTE_SCHEME,
|
||||
STATIC_DOWNLOAD_PATHS,
|
||||
@ -70,10 +72,17 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
|
||||
import { ActorModel } from '../activitypub/actor'
|
||||
import { AvatarModel } from '../avatar/avatar'
|
||||
import { ServerModel } from '../server/server'
|
||||
import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
|
||||
import {
|
||||
buildBlockedAccountSQL,
|
||||
buildTrigramSearchIndex,
|
||||
buildWhereIdOrUUID,
|
||||
createSimilarityAttribute,
|
||||
getVideoSort,
|
||||
throwIfNotValid
|
||||
} from '../utils'
|
||||
import { TagModel } from './tag'
|
||||
import { VideoAbuseModel } from './video-abuse'
|
||||
import { VideoChannelModel } from './video-channel'
|
||||
import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel'
|
||||
import { VideoCommentModel } from './video-comment'
|
||||
import { VideoFileModel } from './video-file'
|
||||
import { VideoShareModel } from './video-share'
|
||||
@ -91,11 +100,11 @@ import {
|
||||
videoModelToFormattedDetailsJSON,
|
||||
videoModelToFormattedJSON
|
||||
} from './video-format-utils'
|
||||
import * as validator from 'validator'
|
||||
import { UserVideoHistoryModel } from '../account/user-video-history'
|
||||
import { UserModel } from '../account/user'
|
||||
import { VideoImportModel } from './video-import'
|
||||
import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
|
||||
import { VideoPlaylistElementModel } from './video-playlist-element'
|
||||
|
||||
// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
|
||||
const indexes: Sequelize.DefineIndexesOptions[] = [
|
||||
@ -175,6 +184,9 @@ export enum ScopeNames {
|
||||
|
||||
type ForAPIOptions = {
|
||||
ids: number[]
|
||||
|
||||
videoPlaylistId?: number
|
||||
|
||||
withFiles?: boolean
|
||||
}
|
||||
|
||||
@ -182,6 +194,7 @@ type AvailableForListIDsOptions = {
|
||||
serverAccountId: number
|
||||
followerActorId: number
|
||||
includeLocalVideos: boolean
|
||||
|
||||
filter?: VideoFilter
|
||||
categoryOneOf?: number[]
|
||||
nsfw?: boolean
|
||||
@ -189,9 +202,14 @@ type AvailableForListIDsOptions = {
|
||||
languageOneOf?: string[]
|
||||
tagsOneOf?: string[]
|
||||
tagsAllOf?: string[]
|
||||
|
||||
withFiles?: boolean
|
||||
|
||||
accountId?: number
|
||||
videoChannelId?: number
|
||||
|
||||
videoPlaylistId?: number
|
||||
|
||||
trendingDays?: number
|
||||
user?: UserModel,
|
||||
historyOfUser?: UserModel
|
||||
@ -199,62 +217,17 @@ type AvailableForListIDsOptions = {
|
||||
|
||||
@Scopes({
|
||||
[ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
|
||||
const accountInclude = {
|
||||
attributes: [ 'id', 'name' ],
|
||||
model: AccountModel.unscoped(),
|
||||
required: true,
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
|
||||
model: ActorModel.unscoped(),
|
||||
required: true,
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'host' ],
|
||||
model: ServerModel.unscoped(),
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: AvatarModel.unscoped(),
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const videoChannelInclude = {
|
||||
attributes: [ 'name', 'description', 'id' ],
|
||||
model: VideoChannelModel.unscoped(),
|
||||
required: true,
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
|
||||
model: ActorModel.unscoped(),
|
||||
required: true,
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'host' ],
|
||||
model: ServerModel.unscoped(),
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: AvatarModel.unscoped(),
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
accountInclude
|
||||
]
|
||||
}
|
||||
|
||||
const query: IFindOptions<VideoModel> = {
|
||||
where: {
|
||||
id: {
|
||||
[ Sequelize.Op.any ]: options.ids
|
||||
}
|
||||
},
|
||||
include: [ videoChannelInclude ]
|
||||
include: [
|
||||
{
|
||||
model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (options.withFiles === true) {
|
||||
@ -264,6 +237,13 @@ type AvailableForListIDsOptions = {
|
||||
})
|
||||
}
|
||||
|
||||
if (options.videoPlaylistId) {
|
||||
query.include.push({
|
||||
model: VideoPlaylistElementModel.unscoped(),
|
||||
required: true
|
||||
})
|
||||
}
|
||||
|
||||
return query
|
||||
},
|
||||
[ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
|
||||
@ -315,6 +295,17 @@ type AvailableForListIDsOptions = {
|
||||
Object.assign(query.where, privacyWhere)
|
||||
}
|
||||
|
||||
if (options.videoPlaylistId) {
|
||||
query.include.push({
|
||||
attributes: [],
|
||||
model: VideoPlaylistElementModel.unscoped(),
|
||||
required: true,
|
||||
where: {
|
||||
videoPlaylistId: options.videoPlaylistId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (options.filter || options.accountId || options.videoChannelId) {
|
||||
const videoChannelInclude: IIncludeOptions = {
|
||||
attributes: [],
|
||||
@ -772,6 +763,15 @@ export class VideoModel extends Model<VideoModel> {
|
||||
})
|
||||
Tags: TagModel[]
|
||||
|
||||
@HasMany(() => VideoPlaylistElementModel, {
|
||||
foreignKey: {
|
||||
name: 'videoId',
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
VideoPlaylistElements: VideoPlaylistElementModel[]
|
||||
|
||||
@HasMany(() => VideoAbuseModel, {
|
||||
foreignKey: {
|
||||
name: 'videoId',
|
||||
@ -1118,6 +1118,7 @@ export class VideoModel extends Model<VideoModel> {
|
||||
accountId?: number,
|
||||
videoChannelId?: number,
|
||||
followerActorId?: number
|
||||
videoPlaylistId?: number,
|
||||
trendingDays?: number,
|
||||
user?: UserModel,
|
||||
historyOfUser?: UserModel
|
||||
@ -1157,6 +1158,7 @@ export class VideoModel extends Model<VideoModel> {
|
||||
withFiles: options.withFiles,
|
||||
accountId: options.accountId,
|
||||
videoChannelId: options.videoChannelId,
|
||||
videoPlaylistId: options.videoPlaylistId,
|
||||
includeLocalVideos: options.includeLocalVideos,
|
||||
user: options.user,
|
||||
historyOfUser: options.historyOfUser,
|
||||
@ -1280,7 +1282,7 @@ export class VideoModel extends Model<VideoModel> {
|
||||
}
|
||||
|
||||
static load (id: number | string, t?: Sequelize.Transaction) {
|
||||
const where = VideoModel.buildWhereIdOrUUID(id)
|
||||
const where = buildWhereIdOrUUID(id)
|
||||
const options = {
|
||||
where,
|
||||
transaction: t
|
||||
@ -1290,7 +1292,7 @@ export class VideoModel extends Model<VideoModel> {
|
||||
}
|
||||
|
||||
static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
|
||||
const where = VideoModel.buildWhereIdOrUUID(id)
|
||||
const where = buildWhereIdOrUUID(id)
|
||||
const options = {
|
||||
where,
|
||||
transaction: t
|
||||
@ -1300,7 +1302,7 @@ export class VideoModel extends Model<VideoModel> {
|
||||
}
|
||||
|
||||
static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
|
||||
const where = VideoModel.buildWhereIdOrUUID(id)
|
||||
const where = buildWhereIdOrUUID(id)
|
||||
|
||||
const options = {
|
||||
attributes: [ 'id' ],
|
||||
@ -1353,7 +1355,7 @@ export class VideoModel extends Model<VideoModel> {
|
||||
}
|
||||
|
||||
static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
|
||||
const where = VideoModel.buildWhereIdOrUUID(id)
|
||||
const where = buildWhereIdOrUUID(id)
|
||||
|
||||
const options = {
|
||||
order: [ [ 'Tags', 'name', 'ASC' ] ],
|
||||
@ -1380,7 +1382,7 @@ export class VideoModel extends Model<VideoModel> {
|
||||
}
|
||||
|
||||
static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
|
||||
const where = VideoModel.buildWhereIdOrUUID(id)
|
||||
const where = buildWhereIdOrUUID(id)
|
||||
|
||||
const options = {
|
||||
order: [ [ 'Tags', 'name', 'ASC' ] ],
|
||||
@ -1582,10 +1584,6 @@ export class VideoModel extends Model<VideoModel> {
|
||||
return VIDEO_STATES[ id ] || 'Unknown'
|
||||
}
|
||||
|
||||
static buildWhereIdOrUUID (id: number | string) {
|
||||
return validator.isInt('' + id) ? { id } : { uuid: id }
|
||||
}
|
||||
|
||||
getOriginalFile () {
|
||||
if (Array.isArray(this.VideoFiles) === false) return undefined
|
||||
|
||||
@ -1598,7 +1596,6 @@ export class VideoModel extends Model<VideoModel> {
|
||||
}
|
||||
|
||||
getThumbnailName () {
|
||||
// We always have a copy of the thumbnail
|
||||
const extension = '.jpg'
|
||||
return this.uuid + extension
|
||||
}
|
||||
|
117
server/tests/api/check-params/video-playlists.ts
Normal file
117
server/tests/api/check-params/video-playlists.ts
Normal file
@ -0,0 +1,117 @@
|
||||
/* tslint:disable:no-unused-expression */
|
||||
|
||||
import { omit } from 'lodash'
|
||||
import 'mocha'
|
||||
import { join } from 'path'
|
||||
import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum'
|
||||
import {
|
||||
createUser,
|
||||
flushTests,
|
||||
getMyUserInformation,
|
||||
immutableAssign,
|
||||
killallServers,
|
||||
makeGetRequest,
|
||||
makePostBodyRequest,
|
||||
makeUploadRequest,
|
||||
runServer,
|
||||
ServerInfo,
|
||||
setAccessTokensToServers,
|
||||
updateCustomSubConfig,
|
||||
userLogin
|
||||
} from '../../../../shared/utils'
|
||||
import {
|
||||
checkBadCountPagination,
|
||||
checkBadSortPagination,
|
||||
checkBadStartPagination
|
||||
} from '../../../../shared/utils/requests/check-api-params'
|
||||
import { getMagnetURI, getYoutubeVideoUrl } from '../../../../shared/utils/videos/video-imports'
|
||||
|
||||
describe('Test video playlists API validator', function () {
|
||||
const path = '/api/v1/videos/video-playlists'
|
||||
let server: ServerInfo
|
||||
let userAccessToken = ''
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
before(async function () {
|
||||
this.timeout(30000)
|
||||
|
||||
await flushTests()
|
||||
|
||||
server = await runServer(1)
|
||||
|
||||
await setAccessTokensToServers([ server ])
|
||||
|
||||
const username = 'user1'
|
||||
const password = 'my super password'
|
||||
await createUser(server.url, server.accessToken, username, password)
|
||||
userAccessToken = await userLogin(server, { username, password })
|
||||
})
|
||||
|
||||
describe('When listing video playlists', function () {
|
||||
const globalPath = '/api/v1/video-playlists'
|
||||
const accountPath = '/api/v1/accounts/root/video-playlists'
|
||||
const videoChannelPath = '/api/v1/video-channels/root_channel/video-playlists'
|
||||
|
||||
it('Should fail with a bad start pagination', async function () {
|
||||
await checkBadStartPagination(server.url, globalPath, server.accessToken)
|
||||
await checkBadStartPagination(server.url, accountPath, server.accessToken)
|
||||
await checkBadStartPagination(server.url, videoChannelPath, server.accessToken)
|
||||
})
|
||||
|
||||
it('Should fail with a bad count pagination', async function () {
|
||||
await checkBadCountPagination(server.url, globalPath, server.accessToken)
|
||||
await checkBadCountPagination(server.url, accountPath, server.accessToken)
|
||||
await checkBadCountPagination(server.url, videoChannelPath, server.accessToken)
|
||||
})
|
||||
|
||||
it('Should fail with an incorrect sort', async function () {
|
||||
await checkBadSortPagination(server.url, globalPath, server.accessToken)
|
||||
await checkBadSortPagination(server.url, accountPath, server.accessToken)
|
||||
await checkBadSortPagination(server.url, videoChannelPath, server.accessToken)
|
||||
})
|
||||
|
||||
it('Should fail with a bad account parameter', async function () {
|
||||
const accountPath = '/api/v1/accounts/root2/video-playlists'
|
||||
|
||||
await makeGetRequest({ url: server.url, path: accountPath, statusCodeExpected: 404, token: server.accessToken })
|
||||
})
|
||||
|
||||
it('Should fail with a bad video channel parameter', async function () {
|
||||
const accountPath = '/api/v1/video-channels/bad_channel/video-playlists'
|
||||
|
||||
await makeGetRequest({ url: server.url, path: accountPath, statusCodeExpected: 404, token: server.accessToken })
|
||||
})
|
||||
|
||||
it('Should success with the correct parameters', async function () {
|
||||
await makeGetRequest({ url: server.url, path: globalPath, statusCodeExpected: 200, token: server.accessToken })
|
||||
await makeGetRequest({ url: server.url, path: accountPath, statusCodeExpected: 200, token: server.accessToken })
|
||||
await makeGetRequest({ url: server.url, path: videoChannelPath, statusCodeExpected: 200, token: server.accessToken })
|
||||
})
|
||||
})
|
||||
|
||||
describe('When listing videos of a playlist', async function () {
|
||||
const path = '/api/v1/video-playlists'
|
||||
|
||||
it('Should fail with a bad start pagination', async function () {
|
||||
await checkBadStartPagination(server.url, path, server.accessToken)
|
||||
})
|
||||
|
||||
it('Should fail with a bad count pagination', async function () {
|
||||
await checkBadCountPagination(server.url, path, server.accessToken)
|
||||
})
|
||||
|
||||
it('Should fail with an incorrect sort', async function () {
|
||||
await checkBadSortPagination(server.url, path, server.accessToken)
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
killallServers([ server ])
|
||||
|
||||
// Keep the logs if the test failed
|
||||
if (this['ok']) {
|
||||
await flushTests()
|
||||
}
|
||||
})
|
||||
})
|
161
server/tests/api/videos/video-playlists.ts
Normal file
161
server/tests/api/videos/video-playlists.ts
Normal file
@ -0,0 +1,161 @@
|
||||
/* tslint:disable:no-unused-expression */
|
||||
|
||||
import * as chai from 'chai'
|
||||
import 'mocha'
|
||||
import { join } from 'path'
|
||||
import * as request from 'supertest'
|
||||
import { VideoPrivacy } from '../../../../shared/models/videos'
|
||||
import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
|
||||
import {
|
||||
addVideoChannel,
|
||||
checkTmpIsEmpty,
|
||||
checkVideoFilesWereRemoved,
|
||||
completeVideoCheck,
|
||||
createUser,
|
||||
dateIsValid,
|
||||
doubleFollow,
|
||||
flushAndRunMultipleServers,
|
||||
flushTests,
|
||||
getLocalVideos,
|
||||
getVideo,
|
||||
getVideoChannelsList,
|
||||
getVideosList,
|
||||
killallServers,
|
||||
rateVideo,
|
||||
removeVideo,
|
||||
ServerInfo,
|
||||
setAccessTokensToServers,
|
||||
testImage,
|
||||
updateVideo,
|
||||
uploadVideo,
|
||||
userLogin,
|
||||
viewVideo,
|
||||
wait,
|
||||
webtorrentAdd
|
||||
} from '../../../../shared/utils'
|
||||
import {
|
||||
addVideoCommentReply,
|
||||
addVideoCommentThread,
|
||||
deleteVideoComment,
|
||||
getVideoCommentThreads,
|
||||
getVideoThreadComments
|
||||
} from '../../../../shared/utils/videos/video-comments'
|
||||
import { waitJobs } from '../../../../shared/utils/server/jobs'
|
||||
|
||||
const expect = chai.expect
|
||||
|
||||
describe('Test video playlists', function () {
|
||||
let servers: ServerInfo[] = []
|
||||
|
||||
before(async function () {
|
||||
this.timeout(120000)
|
||||
|
||||
servers = await flushAndRunMultipleServers(3)
|
||||
|
||||
// Get the access tokens
|
||||
await setAccessTokensToServers(servers)
|
||||
|
||||
// Server 1 and server 2 follow each other
|
||||
await doubleFollow(servers[0], servers[1])
|
||||
// Server 1 and server 3 follow each other
|
||||
await doubleFollow(servers[0], servers[2])
|
||||
})
|
||||
|
||||
it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () {
|
||||
|
||||
})
|
||||
|
||||
it('Should create a playlist on server 2 and have the playlist on server 1 but not on server 3', async function () {
|
||||
// create 2 playlists (with videos and no videos)
|
||||
// With thumbnail and no thumbnail
|
||||
})
|
||||
|
||||
it('Should have the playlist on server 3 after a new follow', async function () {
|
||||
// Server 2 and server 3 follow each other
|
||||
await doubleFollow(servers[1], servers[2])
|
||||
})
|
||||
|
||||
it('Should create some playlists and list them correctly', async function () {
|
||||
// create 3 playlists with some videos in it
|
||||
// check pagination
|
||||
// check sort
|
||||
// check empty
|
||||
})
|
||||
|
||||
it('Should list video channel playlists', async function () {
|
||||
// check pagination
|
||||
// check sort
|
||||
// check empty
|
||||
})
|
||||
|
||||
it('Should list account playlists', async function () {
|
||||
// check pagination
|
||||
// check sort
|
||||
// check empty
|
||||
})
|
||||
|
||||
it('Should get a playlist', async function () {
|
||||
// get empty playlist
|
||||
// get non empty playlist
|
||||
})
|
||||
|
||||
it('Should update a playlist', async function () {
|
||||
// update thumbnail
|
||||
|
||||
// update other details
|
||||
})
|
||||
|
||||
it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () {
|
||||
|
||||
})
|
||||
|
||||
it('Should correctly list playlist videos', async function () {
|
||||
// empty
|
||||
// some filters?
|
||||
})
|
||||
|
||||
it('Should reorder the playlist', async function () {
|
||||
// reorder 1 element
|
||||
// reorder 3 elements
|
||||
// reorder at the beginning
|
||||
// reorder at the end
|
||||
// reorder before/after
|
||||
})
|
||||
|
||||
it('Should update startTimestamp/endTimestamp of some elements', async function () {
|
||||
|
||||
})
|
||||
|
||||
it('Should delete some elements', async function () {
|
||||
|
||||
})
|
||||
|
||||
it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () {
|
||||
|
||||
})
|
||||
|
||||
it('Should have deleted the thumbnail on server 1, 2 and 3', async function () {
|
||||
|
||||
})
|
||||
|
||||
it('Should unfollow servers 1 and 2 and hide their playlists', async function () {
|
||||
|
||||
})
|
||||
|
||||
it('Should delete a channel and remove the associated playlist', async function () {
|
||||
|
||||
})
|
||||
|
||||
it('Should delete an account and delete its playlists', async function () {
|
||||
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
killallServers(servers)
|
||||
|
||||
// Keep the logs if the test failed
|
||||
if (this['ok']) {
|
||||
await flushTests()
|
||||
}
|
||||
})
|
||||
})
|
@ -6,6 +6,7 @@ import { VideoAbuseObject } from './objects/video-abuse-object'
|
||||
import { VideoCommentObject } from './objects/video-comment-object'
|
||||
import { ViewObject } from './objects/view-object'
|
||||
import { APObject } from './objects/object.model'
|
||||
import { PlaylistObject } from './objects/playlist-object'
|
||||
|
||||
export type Activity = ActivityCreate | ActivityUpdate |
|
||||
ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce |
|
||||
@ -31,12 +32,12 @@ export interface BaseActivity {
|
||||
|
||||
export interface ActivityCreate extends BaseActivity {
|
||||
type: 'Create'
|
||||
object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject
|
||||
object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject
|
||||
}
|
||||
|
||||
export interface ActivityUpdate extends BaseActivity {
|
||||
type: 'Update'
|
||||
object: VideoTorrentObject | ActivityPubActor | CacheFileObject
|
||||
object: VideoTorrentObject | ActivityPubActor | CacheFileObject | PlaylistObject
|
||||
}
|
||||
|
||||
export interface ActivityDelete extends BaseActivity {
|
||||
|
@ -8,6 +8,7 @@ export interface ActivityPubActor {
|
||||
id: string
|
||||
following: string
|
||||
followers: string
|
||||
playlists?: string
|
||||
inbox: string
|
||||
outbox: string
|
||||
preferredUsername: string
|
||||
|
10
shared/models/activitypub/objects/playlist-element-object.ts
Normal file
10
shared/models/activitypub/objects/playlist-element-object.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface PlaylistElementObject {
|
||||
id: string
|
||||
type: 'PlaylistElement'
|
||||
|
||||
url: string
|
||||
position: number
|
||||
|
||||
startTimestamp?: number
|
||||
stopTimestamp?: number
|
||||
}
|
23
shared/models/activitypub/objects/playlist-object.ts
Normal file
23
shared/models/activitypub/objects/playlist-object.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ActivityIconObject } from './common-objects'
|
||||
|
||||
export interface PlaylistObject {
|
||||
id: string
|
||||
type: 'Playlist'
|
||||
|
||||
name: string
|
||||
content: string
|
||||
uuid: string
|
||||
|
||||
totalItems: number
|
||||
attributedTo: string[]
|
||||
|
||||
icon: ActivityIconObject
|
||||
|
||||
orderedItems?: string[]
|
||||
|
||||
partOf?: string
|
||||
next?: string
|
||||
first?: string
|
||||
|
||||
to?: string[]
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Actor } from './actor.model'
|
||||
import { Avatar } from '../avatars'
|
||||
|
||||
export interface Account extends Actor {
|
||||
displayName: string
|
||||
@ -6,3 +7,13 @@ export interface Account extends Actor {
|
||||
|
||||
userId?: number
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
displayName: string
|
||||
url: string
|
||||
host: string
|
||||
avatar?: Avatar
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Video, VideoChannelAttribute, VideoConstant } from '../videos'
|
||||
import { Video, VideoChannelSummary, VideoConstant } from '../videos'
|
||||
|
||||
export interface VideosOverview {
|
||||
channels: {
|
||||
channel: VideoChannelAttribute
|
||||
channel: VideoChannelSummary
|
||||
videos: Video[]
|
||||
}[]
|
||||
|
||||
|
@ -20,8 +20,12 @@ export enum UserRight {
|
||||
|
||||
REMOVE_ANY_VIDEO,
|
||||
REMOVE_ANY_VIDEO_CHANNEL,
|
||||
REMOVE_ANY_VIDEO_PLAYLIST,
|
||||
REMOVE_ANY_VIDEO_COMMENT,
|
||||
|
||||
UPDATE_ANY_VIDEO,
|
||||
UPDATE_ANY_VIDEO_PLAYLIST,
|
||||
|
||||
SEE_ALL_VIDEOS,
|
||||
CHANGE_VIDEO_OWNERSHIP
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ const userRoleRights: { [ id: number ]: UserRight[] } = {
|
||||
UserRight.MANAGE_VIDEO_ABUSES,
|
||||
UserRight.REMOVE_ANY_VIDEO,
|
||||
UserRight.REMOVE_ANY_VIDEO_CHANNEL,
|
||||
UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
|
||||
UserRight.REMOVE_ANY_VIDEO_COMMENT,
|
||||
UserRight.UPDATE_ANY_VIDEO,
|
||||
UserRight.SEE_ALL_VIDEOS,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Actor } from '../../actors/actor.model'
|
||||
import { Video } from '../video.model'
|
||||
import { Account } from '../../actors/index'
|
||||
import { Avatar } from '../../avatars'
|
||||
|
||||
export interface VideoChannel extends Actor {
|
||||
displayName: string
|
||||
@ -9,3 +9,13 @@ export interface VideoChannel extends Actor {
|
||||
isLocal: boolean
|
||||
ownerAccount?: Account
|
||||
}
|
||||
|
||||
export interface VideoChannelSummary {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
displayName: string
|
||||
url: string
|
||||
host: string
|
||||
avatar?: Avatar
|
||||
}
|
||||
|
11
shared/models/videos/playlist/video-playlist-create.model.ts
Normal file
11
shared/models/videos/playlist/video-playlist-create.model.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
|
||||
|
||||
export interface VideoPlaylistCreate {
|
||||
displayName: string
|
||||
description: string
|
||||
privacy: VideoPlaylistPrivacy
|
||||
|
||||
videoChannelId?: number
|
||||
|
||||
thumbnailfile?: Blob
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
export interface VideoPlaylistElementCreate {
|
||||
startTimestamp?: number
|
||||
stopTimestamp?: number
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
export interface VideoPlaylistElementUpdate {
|
||||
startTimestamp?: number
|
||||
stopTimestamp?: number
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export enum VideoPlaylistPrivacy {
|
||||
PUBLIC = 1,
|
||||
UNLISTED = 2,
|
||||
PRIVATE = 3
|
||||
}
|
10
shared/models/videos/playlist/video-playlist-update.model.ts
Normal file
10
shared/models/videos/playlist/video-playlist-update.model.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
|
||||
|
||||
export interface VideoPlaylistUpdate {
|
||||
displayName: string
|
||||
description: string
|
||||
privacy: VideoPlaylistPrivacy
|
||||
|
||||
videoChannelId?: number
|
||||
thumbnailfile?: Blob
|
||||
}
|
23
shared/models/videos/playlist/video-playlist.model.ts
Normal file
23
shared/models/videos/playlist/video-playlist.model.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { AccountSummary } from '../../actors/index'
|
||||
import { VideoChannelSummary, VideoConstant } from '..'
|
||||
import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
|
||||
|
||||
export interface VideoPlaylist {
|
||||
id: number
|
||||
uuid: string
|
||||
isLocal: boolean
|
||||
|
||||
displayName: string
|
||||
description: string
|
||||
privacy: VideoConstant<VideoPlaylistPrivacy>
|
||||
|
||||
thumbnailPath: string
|
||||
|
||||
videosLength: number
|
||||
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
|
||||
ownerAccount?: AccountSummary
|
||||
videoChannel?: VideoChannelSummary
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { VideoResolution, VideoState } from '../../index'
|
||||
import { AccountSummary, VideoChannelSummary, VideoResolution, VideoState } from '../../index'
|
||||
import { Account } from '../actors'
|
||||
import { Avatar } from '../avatars/avatar.model'
|
||||
import { VideoChannel } from './channel/video-channel.model'
|
||||
@ -18,26 +18,6 @@ export interface VideoFile {
|
||||
fps: number
|
||||
}
|
||||
|
||||
export interface VideoChannelAttribute {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
displayName: string
|
||||
url: string
|
||||
host: string
|
||||
avatar?: Avatar
|
||||
}
|
||||
|
||||
export interface AccountAttribute {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
displayName: string
|
||||
url: string
|
||||
host: string
|
||||
avatar?: Avatar
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
id: number
|
||||
uuid: string
|
||||
@ -68,12 +48,18 @@ export interface Video {
|
||||
blacklisted?: boolean
|
||||
blacklistedReason?: string
|
||||
|
||||
account: AccountAttribute
|
||||
channel: VideoChannelAttribute
|
||||
account: AccountSummary
|
||||
channel: VideoChannelSummary
|
||||
|
||||
userHistory?: {
|
||||
currentTime: number
|
||||
}
|
||||
|
||||
playlistElement?: {
|
||||
position: number
|
||||
startTimestamp: number
|
||||
stopTimestamp: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface VideoDetails extends Video {
|
||||
|
@ -1,51 +1,185 @@
|
||||
import { makeRawRequest } from '../requests/requests'
|
||||
import { sha256 } from '../../../server/helpers/core-utils'
|
||||
import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model'
|
||||
import { expect } from 'chai'
|
||||
import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests'
|
||||
import { VideoPlaylistCreate } from '../../models/videos/playlist/video-playlist-create.model'
|
||||
import { omit } from 'lodash'
|
||||
import { VideoPlaylistUpdate } from '../../models/videos/playlist/video-playlist-update.model'
|
||||
import { VideoPlaylistElementCreate } from '../../models/videos/playlist/video-playlist-element-create.model'
|
||||
import { VideoPlaylistElementUpdate } from '../../models/videos/playlist/video-playlist-element-update.model'
|
||||
|
||||
function getPlaylist (url: string, statusCodeExpected = 200) {
|
||||
return makeRawRequest(url, statusCodeExpected)
|
||||
function getVideoPlaylistsList (url: string, start: number, count: number, sort?: string) {
|
||||
const path = '/api/v1/video-playlists'
|
||||
|
||||
const query = {
|
||||
start,
|
||||
count,
|
||||
sort
|
||||
}
|
||||
|
||||
return makeGetRequest({
|
||||
url,
|
||||
path,
|
||||
query
|
||||
})
|
||||
}
|
||||
|
||||
function getSegment (url: string, statusCodeExpected = 200, range?: string) {
|
||||
return makeRawRequest(url, statusCodeExpected, range)
|
||||
function getVideoPlaylist (url: string, playlistId: number | string, statusCodeExpected = 200) {
|
||||
const path = '/api/v1/video-playlists/' + playlistId
|
||||
|
||||
return makeGetRequest({
|
||||
url,
|
||||
path,
|
||||
statusCodeExpected
|
||||
})
|
||||
}
|
||||
|
||||
function getSegmentSha256 (url: string, statusCodeExpected = 200) {
|
||||
return makeRawRequest(url, statusCodeExpected)
|
||||
function deleteVideoPlaylist (url: string, token: string, playlistId: number | string, statusCodeExpected = 200) {
|
||||
const path = '/api/v1/video-playlists/' + playlistId
|
||||
|
||||
return makeDeleteRequest({
|
||||
url,
|
||||
path,
|
||||
token,
|
||||
statusCodeExpected
|
||||
})
|
||||
}
|
||||
|
||||
async function checkSegmentHash (
|
||||
baseUrlPlaylist: string,
|
||||
baseUrlSegment: string,
|
||||
videoUUID: string,
|
||||
resolution: number,
|
||||
hlsPlaylist: VideoStreamingPlaylist
|
||||
) {
|
||||
const res = await getPlaylist(`${baseUrlPlaylist}/${videoUUID}/${resolution}.m3u8`)
|
||||
const playlist = res.text
|
||||
function createVideoPlaylist (options: {
|
||||
url: string,
|
||||
token: string,
|
||||
playlistAttrs: VideoPlaylistCreate,
|
||||
expectedStatus: number
|
||||
}) {
|
||||
const path = '/api/v1/video-playlists/'
|
||||
|
||||
const videoName = `${videoUUID}-${resolution}-fragmented.mp4`
|
||||
const fields = omit(options.playlistAttrs, 'thumbnailfile')
|
||||
|
||||
const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist)
|
||||
const attaches = options.playlistAttrs.thumbnailfile
|
||||
? { thumbnailfile: options.playlistAttrs.thumbnailfile }
|
||||
: {}
|
||||
|
||||
const length = parseInt(matches[1], 10)
|
||||
const offset = parseInt(matches[2], 10)
|
||||
const range = `${offset}-${offset + length - 1}`
|
||||
return makeUploadRequest({
|
||||
method: 'POST',
|
||||
url: options.url,
|
||||
path,
|
||||
token: options.token,
|
||||
fields,
|
||||
attaches,
|
||||
statusCodeExpected: options.expectedStatus
|
||||
})
|
||||
}
|
||||
|
||||
const res2 = await getSegment(`${baseUrlSegment}/${videoUUID}/${videoName}`, 206, `bytes=${range}`)
|
||||
function updateVideoPlaylist (options: {
|
||||
url: string,
|
||||
token: string,
|
||||
playlistAttrs: VideoPlaylistUpdate,
|
||||
expectedStatus: number
|
||||
}) {
|
||||
const path = '/api/v1/video-playlists/'
|
||||
|
||||
const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url)
|
||||
const fields = omit(options.playlistAttrs, 'thumbnailfile')
|
||||
|
||||
const sha256Server = resSha.body[ videoName ][range]
|
||||
expect(sha256(res2.body)).to.equal(sha256Server)
|
||||
const attaches = options.playlistAttrs.thumbnailfile
|
||||
? { thumbnailfile: options.playlistAttrs.thumbnailfile }
|
||||
: {}
|
||||
|
||||
return makeUploadRequest({
|
||||
method: 'PUT',
|
||||
url: options.url,
|
||||
path,
|
||||
token: options.token,
|
||||
fields,
|
||||
attaches,
|
||||
statusCodeExpected: options.expectedStatus
|
||||
})
|
||||
}
|
||||
|
||||
function addVideoInPlaylist (options: {
|
||||
url: string,
|
||||
token: string,
|
||||
playlistId: number | string,
|
||||
elementAttrs: VideoPlaylistElementCreate
|
||||
expectedStatus: number
|
||||
}) {
|
||||
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
|
||||
|
||||
return makePostBodyRequest({
|
||||
url: options.url,
|
||||
path,
|
||||
token: options.token,
|
||||
fields: options.elementAttrs,
|
||||
statusCodeExpected: options.expectedStatus
|
||||
})
|
||||
}
|
||||
|
||||
function updateVideoPlaylistElement (options: {
|
||||
url: string,
|
||||
token: string,
|
||||
playlistId: number | string,
|
||||
videoId: number | string,
|
||||
elementAttrs: VideoPlaylistElementUpdate,
|
||||
expectedStatus: number
|
||||
}) {
|
||||
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId
|
||||
|
||||
return makePutBodyRequest({
|
||||
url: options.url,
|
||||
path,
|
||||
token: options.token,
|
||||
fields: options.elementAttrs,
|
||||
statusCodeExpected: options.expectedStatus
|
||||
})
|
||||
}
|
||||
|
||||
function removeVideoFromPlaylist (options: {
|
||||
url: string,
|
||||
token: string,
|
||||
playlistId: number | string,
|
||||
videoId: number | string,
|
||||
expectedStatus: number
|
||||
}) {
|
||||
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId
|
||||
|
||||
return makeDeleteRequest({
|
||||
url: options.url,
|
||||
path,
|
||||
token: options.token,
|
||||
statusCodeExpected: options.expectedStatus
|
||||
})
|
||||
}
|
||||
|
||||
function reorderVideosPlaylist (options: {
|
||||
url: string,
|
||||
token: string,
|
||||
playlistId: number | string,
|
||||
elementAttrs: {
|
||||
startPosition: number,
|
||||
insertAfter: number,
|
||||
reorderLength?: number
|
||||
},
|
||||
expectedStatus: number
|
||||
}) {
|
||||
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
|
||||
|
||||
return makePutBodyRequest({
|
||||
url: options.url,
|
||||
path,
|
||||
token: options.token,
|
||||
fields: options.elementAttrs,
|
||||
statusCodeExpected: options.expectedStatus
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
getPlaylist,
|
||||
getSegment,
|
||||
getSegmentSha256,
|
||||
checkSegmentHash
|
||||
getVideoPlaylistsList,
|
||||
getVideoPlaylist,
|
||||
|
||||
createVideoPlaylist,
|
||||
updateVideoPlaylist,
|
||||
deleteVideoPlaylist,
|
||||
|
||||
addVideoInPlaylist,
|
||||
removeVideoFromPlaylist,
|
||||
|
||||
reorderVideosPlaylist
|
||||
}
|
||||
|
51
shared/utils/videos/video-streaming-playlists.ts
Normal file
51
shared/utils/videos/video-streaming-playlists.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { makeRawRequest } from '../requests/requests'
|
||||
import { sha256 } from '../../../server/helpers/core-utils'
|
||||
import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model'
|
||||
import { expect } from 'chai'
|
||||
|
||||
function getPlaylist (url: string, statusCodeExpected = 200) {
|
||||
return makeRawRequest(url, statusCodeExpected)
|
||||
}
|
||||
|
||||
function getSegment (url: string, statusCodeExpected = 200, range?: string) {
|
||||
return makeRawRequest(url, statusCodeExpected, range)
|
||||
}
|
||||
|
||||
function getSegmentSha256 (url: string, statusCodeExpected = 200) {
|
||||
return makeRawRequest(url, statusCodeExpected)
|
||||
}
|
||||
|
||||
async function checkSegmentHash (
|
||||
baseUrlPlaylist: string,
|
||||
baseUrlSegment: string,
|
||||
videoUUID: string,
|
||||
resolution: number,
|
||||
hlsPlaylist: VideoStreamingPlaylist
|
||||
) {
|
||||
const res = await getPlaylist(`${baseUrlPlaylist}/${videoUUID}/${resolution}.m3u8`)
|
||||
const playlist = res.text
|
||||
|
||||
const videoName = `${videoUUID}-${resolution}-fragmented.mp4`
|
||||
|
||||
const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist)
|
||||
|
||||
const length = parseInt(matches[1], 10)
|
||||
const offset = parseInt(matches[2], 10)
|
||||
const range = `${offset}-${offset + length - 1}`
|
||||
|
||||
const res2 = await getSegment(`${baseUrlSegment}/${videoUUID}/${videoName}`, 206, `bytes=${range}`)
|
||||
|
||||
const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url)
|
||||
|
||||
const sha256Server = resSha.body[ videoName ][range]
|
||||
expect(sha256(res2.body)).to.equal(sha256Server)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
getPlaylist,
|
||||
getSegment,
|
||||
getSegmentSha256,
|
||||
checkSegmentHash
|
||||
}
|
@ -223,6 +223,28 @@ function getVideoChannelVideos (
|
||||
})
|
||||
}
|
||||
|
||||
function getPlaylistVideos (
|
||||
url: string,
|
||||
accessToken: string,
|
||||
playlistId: number | string,
|
||||
start: number,
|
||||
count: number,
|
||||
query: { nsfw?: boolean } = {}
|
||||
) {
|
||||
const path = '/api/v1/video-playlists/' + playlistId + '/videos'
|
||||
|
||||
return makeGetRequest({
|
||||
url,
|
||||
path,
|
||||
query: immutableAssign(query, {
|
||||
start,
|
||||
count
|
||||
}),
|
||||
token: accessToken,
|
||||
statusCodeExpected: 200
|
||||
})
|
||||
}
|
||||
|
||||
function getVideosListPagination (url: string, start: number, count: number, sort?: string) {
|
||||
const path = '/api/v1/videos'
|
||||
|
||||
@ -601,5 +623,6 @@ export {
|
||||
parseTorrentVideo,
|
||||
getLocalVideos,
|
||||
completeVideoCheck,
|
||||
checkVideoFilesWereRemoved
|
||||
checkVideoFilesWereRemoved,
|
||||
getPlaylistVideos
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user