Playlist server API

This commit is contained in:
Chocobozzz 2019-02-26 10:55:40 +01:00 committed by Chocobozzz
parent b427febb4d
commit 418d092afa
63 changed files with 2758 additions and 226 deletions

View File

@ -53,7 +53,7 @@ if (errorMessage !== null) {
app.set('trust proxy', CONFIG.TRUST_PROXY) app.set('trust proxy', CONFIG.TRUST_PROXY)
// Security middleware // Security middleware
import { baseCSP } from './server/middlewares' import { baseCSP } from './server/middlewares/csp'
if (CONFIG.CSP.ENABLED) { if (CONFIG.CSP.ENABLED) {
app.use(baseCSP) app.use(baseCSP)

View File

@ -14,7 +14,7 @@ import {
videosCustomGetValidator, videosCustomGetValidator,
videosShareValidator videosShareValidator
} from '../../middlewares' } from '../../middlewares'
import { getAccountVideoRateValidator, videoCommentGetValidator, videosGetValidator } from '../../middlewares/validators' import { getAccountVideoRateValidator, videoCommentGetValidator } from '../../middlewares/validators'
import { AccountModel } from '../../models/account/account' import { AccountModel } from '../../models/account/account'
import { ActorModel } from '../../models/activitypub/actor' import { ActorModel } from '../../models/activitypub/actor'
import { ActorFollowModel } from '../../models/activitypub/actor-follow' import { ActorFollowModel } from '../../models/activitypub/actor-follow'
@ -37,6 +37,10 @@ import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator }
import { getServerActor } from '../../helpers/utils' import { getServerActor } from '../../helpers/utils'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike' 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() const activityPubClientRouter = express.Router()
@ -52,6 +56,10 @@ activityPubClientRouter.get('/accounts?/:name/following',
executeIfActivityPub(asyncMiddleware(localAccountValidator)), executeIfActivityPub(asyncMiddleware(localAccountValidator)),
executeIfActivityPub(asyncMiddleware(accountFollowingController)) executeIfActivityPub(asyncMiddleware(accountFollowingController))
) )
activityPubClientRouter.get('/accounts?/:name/playlists',
executeIfActivityPub(asyncMiddleware(localAccountValidator)),
executeIfActivityPub(asyncMiddleware(accountPlaylistsController))
)
activityPubClientRouter.get('/accounts?/:name/likes/:videoId', activityPubClientRouter.get('/accounts?/:name/likes/:videoId',
executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))), executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))),
executeIfActivityPub(getAccountVideoRate('like')) executeIfActivityPub(getAccountVideoRate('like'))
@ -121,6 +129,15 @@ activityPubClientRouter.get('/redundancy/video-playlists/:streamingPlaylistType/
executeIfActivityPub(asyncMiddleware(videoRedundancyController)) 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 { 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 const account: AccountModel = res.locals.account
return activityPubResponse(activityPubContextify(account.toActivityPubObject()), res) 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 account: AccountModel = res.locals.account
const activityPubResult = await actorFollowers(req, account.Actor) const activityPubResult = await actorFollowers(req, account.Actor)
return activityPubResponse(activityPubContextify(activityPubResult), res) 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 account: AccountModel = res.locals.account
const activityPubResult = await actorFollowing(req, account.Actor) const activityPubResult = await actorFollowing(req, account.Actor)
return activityPubResponse(activityPubContextify(activityPubResult), res) 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) { function getAccountVideoRate (rateType: VideoRateType) {
return (req: express.Request, res: express.Response) => { return (req: express.Request, res: express.Response) => {
const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate
@ -293,6 +317,23 @@ async function videoRedundancyController (req: express.Request, res: express.Res
return activityPubResponse(activityPubContextify(object), 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) { 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) { async function actorFollowers (req: express.Request, actor: ActorModel) {
const handler = (start: number, count: number) => { 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) return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page)

View File

@ -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 accountOrVideoChannel: AccountModel | VideoChannelModel = res.locals.account || res.locals.videoChannel
const actor = accountOrVideoChannel.Actor const actor = accountOrVideoChannel.Actor
const actorOutboxUrl = actor.url + '/outbox' const actorOutboxUrl = actor.url + '/outbox'

View File

@ -1,21 +1,23 @@
import * as express from 'express' import * as express from 'express'
import { getFormattedObjects } from '../../helpers/utils' import { getFormattedObjects, getServerActor } from '../../helpers/utils'
import { import {
asyncMiddleware, asyncMiddleware,
commonVideosFiltersValidator, commonVideosFiltersValidator,
listVideoAccountChannelsValidator,
optionalAuthenticate, optionalAuthenticate,
paginationValidator, paginationValidator,
setDefaultPagination, setDefaultPagination,
setDefaultSort setDefaultSort,
videoPlaylistsSortValidator
} from '../../middlewares' } from '../../middlewares'
import { accountsNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators' import { accountNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators'
import { AccountModel } from '../../models/account/account' import { AccountModel } from '../../models/account/account'
import { VideoModel } from '../../models/video/video' import { VideoModel } from '../../models/video/video'
import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
import { VideoChannelModel } from '../../models/video/video-channel' import { VideoChannelModel } from '../../models/video/video-channel'
import { JobQueue } from '../../lib/job-queue' import { JobQueue } from '../../lib/job-queue'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { VideoPlaylistModel } from '../../models/video/video-playlist'
import { UserModel } from '../../models/account/user'
const accountsRouter = express.Router() const accountsRouter = express.Router()
@ -28,12 +30,12 @@ accountsRouter.get('/',
) )
accountsRouter.get('/:accountName', accountsRouter.get('/:accountName',
asyncMiddleware(accountsNameWithHostGetValidator), asyncMiddleware(accountNameWithHostGetValidator),
getAccount getAccount
) )
accountsRouter.get('/:accountName/videos', accountsRouter.get('/:accountName/videos',
asyncMiddleware(accountsNameWithHostGetValidator), asyncMiddleware(accountNameWithHostGetValidator),
paginationValidator, paginationValidator,
videosSortValidator, videosSortValidator,
setDefaultSort, setDefaultSort,
@ -44,8 +46,18 @@ accountsRouter.get('/:accountName/videos',
) )
accountsRouter.get('/:accountName/video-channels', accountsRouter.get('/:accountName/video-channels',
asyncMiddleware(listVideoAccountChannelsValidator), asyncMiddleware(accountNameWithHostGetValidator),
asyncMiddleware(listVideoAccountChannels) 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 const account: AccountModel = res.locals.account
if (account.isOutdated()) { if (account.isOutdated()) {
@ -67,19 +79,40 @@ function getAccount (req: express.Request, res: express.Response, next: express.
return res.json(account.toFormattedJSON()) 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) const resultList = await AccountModel.listForApi(req.query.start, req.query.count, req.query.sort)
return res.json(getFormattedObjects(resultList.data, resultList.total)) 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) const resultList = await VideoChannelModel.listByAccount(res.locals.account.id)
return res.json(getFormattedObjects(resultList.data, resultList.total)) 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 account: AccountModel = res.locals.account
const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined

View File

@ -11,6 +11,7 @@ import { videoChannelRouter } from './video-channel'
import * as cors from 'cors' import * as cors from 'cors'
import { searchRouter } from './search' import { searchRouter } from './search'
import { overviewsRouter } from './overviews' import { overviewsRouter } from './overviews'
import { videoPlaylistRouter } from './video-playlist'
const apiRouter = express.Router() const apiRouter = express.Router()
@ -26,6 +27,7 @@ apiRouter.use('/config', configRouter)
apiRouter.use('/users', usersRouter) apiRouter.use('/users', usersRouter)
apiRouter.use('/accounts', accountsRouter) apiRouter.use('/accounts', accountsRouter)
apiRouter.use('/video-channels', videoChannelRouter) apiRouter.use('/video-channels', videoChannelRouter)
apiRouter.use('/video-playlists', videoPlaylistRouter)
apiRouter.use('/videos', videosRouter) apiRouter.use('/videos', videosRouter)
apiRouter.use('/jobs', jobsRouter) apiRouter.use('/jobs', jobsRouter)
apiRouter.use('/search', searchRouter) apiRouter.use('/search', searchRouter)

View File

@ -12,7 +12,8 @@ import {
videoChannelsAddValidator, videoChannelsAddValidator,
videoChannelsRemoveValidator, videoChannelsRemoveValidator,
videoChannelsSortValidator, videoChannelsSortValidator,
videoChannelsUpdateValidator videoChannelsUpdateValidator,
videoPlaylistsSortValidator
} from '../../middlewares' } from '../../middlewares'
import { VideoChannelModel } from '../../models/video/video-channel' import { VideoChannelModel } from '../../models/video/video-channel'
import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators' import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
@ -31,6 +32,7 @@ import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '..
import { resetSequelizeInstance } from '../../helpers/database-utils' import { resetSequelizeInstance } from '../../helpers/database-utils'
import { UserModel } from '../../models/account/user' import { UserModel } from '../../models/account/user'
import { JobQueue } from '../../lib/job-queue' import { JobQueue } from '../../lib/job-queue'
import { VideoPlaylistModel } from '../../models/video/video-playlist'
const auditLogger = auditLoggerFactory('channels') const auditLogger = auditLoggerFactory('channels')
const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
@ -77,6 +79,15 @@ videoChannelRouter.get('/:nameWithHost',
asyncMiddleware(getVideoChannel) asyncMiddleware(getVideoChannel)
) )
videoChannelRouter.get('/:nameWithHost/video-playlists',
asyncMiddleware(videoChannelsNameWithHostValidator),
paginationValidator,
videoPlaylistsSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listVideoChannelPlaylists)
)
videoChannelRouter.get('/:nameWithHost/videos', videoChannelRouter.get('/:nameWithHost/videos',
asyncMiddleware(videoChannelsNameWithHostValidator), asyncMiddleware(videoChannelsNameWithHostValidator),
paginationValidator, paginationValidator,
@ -206,6 +217,20 @@ async function getVideoChannel (req: express.Request, res: express.Response, nex
return res.json(videoChannelWithVideos.toFormattedJSON()) 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) { async function listVideoChannelVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
const videoChannelInstance: VideoChannelModel = res.locals.videoChannel const videoChannelInstance: VideoChannelModel = res.locals.videoChannel
const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined

View 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))
}

View File

@ -1,5 +1,5 @@
import * as express from 'express' import * as express from 'express'
import { VideoBlacklist, UserRight, VideoBlacklistCreate } from '../../../../shared' import { UserRight, VideoBlacklist, VideoBlacklistCreate } from '../../../../shared'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { getFormattedObjects } from '../../../helpers/utils' import { getFormattedObjects } from '../../../helpers/utils'
import { import {
@ -18,7 +18,7 @@ import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
import { sequelizeTypescript } from '../../../initializers' import { sequelizeTypescript } from '../../../initializers'
import { Notifier } from '../../../lib/notifier' import { Notifier } from '../../../lib/notifier'
import { VideoModel } from '../../../models/video/video' 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' import { federateVideoIfNeeded } from '../../../lib/activitypub'
const blacklistRouter = express.Router() const blacklistRouter = express.Router()

View File

@ -1,7 +1,7 @@
import * as express from 'express' import * as express from 'express'
import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers' import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers'
import { asyncMiddleware, oembedValidator } from '../middlewares' import { asyncMiddleware, oembedValidator } from '../middlewares'
import { accountsNameWithHostGetValidator } from '../middlewares/validators' import { accountNameWithHostGetValidator } from '../middlewares/validators'
import { VideoModel } from '../models/video/video' import { VideoModel } from '../models/video/video'
const servicesRouter = express.Router() const servicesRouter = express.Router()
@ -11,7 +11,7 @@ servicesRouter.use('/oembed',
generateOEmbed generateOEmbed
) )
servicesRouter.use('/redirect/accounts/:accountName', servicesRouter.use('/redirect/accounts/:accountName',
asyncMiddleware(accountsNameWithHostGetValidator), asyncMiddleware(accountNameWithHostGetValidator),
redirectToAccountUrl redirectToAccountUrl
) )

View File

@ -28,6 +28,9 @@ function activityPubContextify <T> (data: T) {
state: 'sc:Number', state: 'sc:Number',
size: 'sc:Number', size: 'sc:Number',
fps: 'sc:Number', fps: 'sc:Number',
startTimestamp: 'sc:Number',
stopTimestamp: 'sc:Number',
position: 'sc:Number',
commentsEnabled: 'sc:Boolean', commentsEnabled: 'sc:Boolean',
downloadEnabled: 'sc:Boolean', downloadEnabled: 'sc:Boolean',
waitTranscoding: 'sc:Boolean', waitTranscoding: 'sc:Boolean',
@ -46,6 +49,10 @@ function activityPubContextify <T> (data: T) {
'@id': 'as:dislikes', '@id': 'as:dislikes',
'@type': '@id' '@type': '@id'
}, },
playlists: {
'@id': 'pt:playlists',
'@type': '@id'
},
shares: { shares: {
'@id': 'as:shares', '@id': 'as:shares',
'@type': '@id' '@type': '@id'
@ -67,7 +74,7 @@ async function activityPubCollectionPagination (baseUrl: string, handler: Activi
return { return {
id: baseUrl, id: baseUrl,
type: 'OrderedCollection', type: 'OrderedCollectionPage',
totalItems: result.total, totalItems: result.total,
first: baseUrl + '?page=1' first: baseUrl + '?page=1'
} }

View File

@ -9,6 +9,7 @@ import { isViewActivityValid } from './view'
import { exists } from '../misc' import { exists } from '../misc'
import { isCacheFileObjectValid } from './cache-file' import { isCacheFileObjectValid } from './cache-file'
import { isFlagActivityValid } from './flag' import { isFlagActivityValid } from './flag'
import { isPlaylistObjectValid } from './playlist'
function isRootActivityValid (activity: any) { function isRootActivityValid (activity: any) {
return Array.isArray(activity['@context']) && ( return Array.isArray(activity['@context']) && (
@ -78,6 +79,7 @@ function checkCreateActivity (activity: any) {
isViewActivityValid(activity.object) || isViewActivityValid(activity.object) ||
isDislikeActivityValid(activity.object) || isDislikeActivityValid(activity.object) ||
isFlagActivityValid(activity.object) || isFlagActivityValid(activity.object) ||
isPlaylistObjectValid(activity.object) ||
isCacheFileObjectValid(activity.object) || isCacheFileObjectValid(activity.object) ||
sanitizeAndCheckVideoCommentObject(activity.object) || sanitizeAndCheckVideoCommentObject(activity.object) ||
@ -89,6 +91,7 @@ function checkUpdateActivity (activity: any) {
return isBaseActivityValid(activity, 'Update') && return isBaseActivityValid(activity, 'Update') &&
( (
isCacheFileObjectValid(activity.object) || isCacheFileObjectValid(activity.object) ||
isPlaylistObjectValid(activity.object) ||
sanitizeAndCheckVideoTorrentObject(activity.object) || sanitizeAndCheckVideoTorrentObject(activity.object) ||
sanitizeAndCheckActorObject(activity.object) sanitizeAndCheckActorObject(activity.object)
) )

View 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
}

View 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
}

View File

@ -165,7 +165,7 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use
return true 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 userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
const video = await fetchVideo(id, fetchType, userId) const video = await fetchVideo(id, fetchType, userId)

View File

@ -10,6 +10,7 @@ import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
import { invert } from 'lodash' import { invert } from 'lodash'
import { CronRepeatOptions, EveryRepeatOptions } from 'bull' import { CronRepeatOptions, EveryRepeatOptions } from 'bull'
import * as bytes from 'bytes' 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 // Use a variable to reload the configuration if we need
let config: IConfig = require('config') let config: IConfig = require('config')
@ -52,7 +53,9 @@ const SORTABLE_COLUMNS = {
ACCOUNTS_BLOCKLIST: [ 'createdAt' ], ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
SERVERS_BLOCKLIST: [ 'createdAt' ], SERVERS_BLOCKLIST: [ 'createdAt' ],
USER_NOTIFICATIONS: [ 'createdAt' ] USER_NOTIFICATIONS: [ 'createdAt' ],
VIDEO_PLAYLISTS: [ 'createdAt' ]
} }
const OAUTH_LIFETIME = { const OAUTH_LIFETIME = {
@ -386,6 +389,17 @@ let CONSTRAINTS_FIELDS = {
FILE_SIZE: { min: 10 }, FILE_SIZE: { min: 10 },
URL: { min: 3, max: 2000 } // Length 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: { ACTORS: {
PUBLIC_KEY: { min: 10, max: 5000 }, // Length PUBLIC_KEY: { min: 10, max: 5000 }, // Length
PRIVATE_KEY: { min: 10, max: 5000 }, // Length PRIVATE_KEY: { min: 10, max: 5000 }, // Length
@ -502,6 +516,12 @@ const VIDEO_ABUSE_STATES = {
[VideoAbuseState.ACCEPTED]: 'Accepted' [VideoAbuseState.ACCEPTED]: 'Accepted'
} }
const VIDEO_PLAYLIST_PRIVACIES = {
[VideoPlaylistPrivacy.PUBLIC]: 'Public',
[VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
[VideoPlaylistPrivacy.PRIVATE]: 'Private'
}
const MIMETYPES = { const MIMETYPES = {
VIDEO: { VIDEO: {
MIMETYPE_EXT: buildVideoMimetypeExt(), MIMETYPE_EXT: buildVideoMimetypeExt(),
@ -786,6 +806,7 @@ export {
VIDEO_IMPORT_STATES, VIDEO_IMPORT_STATES,
VIDEO_VIEW_LIFETIME, VIDEO_VIEW_LIFETIME,
CONTACT_FORM_LIFETIME, CONTACT_FORM_LIFETIME,
VIDEO_PLAYLIST_PRIVACIES,
buildLanguages buildLanguages
} }

View File

@ -34,6 +34,8 @@ import { ServerBlocklistModel } from '../models/server/server-blocklist'
import { UserNotificationModel } from '../models/account/user-notification' import { UserNotificationModel } from '../models/account/user-notification'
import { UserNotificationSettingModel } from '../models/account/user-notification-setting' import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 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 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -101,7 +103,9 @@ async function initDatabaseModels (silent: boolean) {
ServerBlocklistModel, ServerBlocklistModel,
UserNotificationModel, UserNotificationModel,
UserNotificationSettingModel, UserNotificationSettingModel,
VideoStreamingPlaylistModel VideoStreamingPlaylistModel,
VideoPlaylistModel,
VideoPlaylistElementModel
]) ])
// Check extensions exist in the database // Check extensions exist in the database

View File

@ -44,6 +44,7 @@ async function getOrCreateActorAndServerAndModel (
) { ) {
const actorUrl = getAPId(activityActor) const actorUrl = getAPId(activityActor)
let created = false let created = false
let accountPlaylistsUrl: string
let actor = await fetchActorByUrl(actorUrl, fetchType) let actor = await fetchActorByUrl(actorUrl, fetchType)
// Orphan actor (not associated to an account of channel) so recreate it // Orphan actor (not associated to an account of channel) so recreate it
@ -70,7 +71,8 @@ async function getOrCreateActorAndServerAndModel (
try { try {
// Don't recurse another time // Don't recurse another time
ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false) const recurseIfNeeded = false
ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', recurseIfNeeded)
} catch (err) { } catch (err) {
logger.error('Cannot get or create account attributed to video channel ' + actor.url) logger.error('Cannot get or create account attributed to video channel ' + actor.url)
throw new Error(err) throw new Error(err)
@ -79,6 +81,7 @@ async function getOrCreateActorAndServerAndModel (
actor = await retryTransactionWrapper(saveActorAndServerAndModelIfNotExist, result, ownerActor) actor = await retryTransactionWrapper(saveActorAndServerAndModelIfNotExist, result, ownerActor)
created = true created = true
accountPlaylistsUrl = result.playlists
} }
if (actor.Account) actor.Account.Actor = actor if (actor.Account) actor.Account.Actor = actor
@ -92,6 +95,12 @@ async function getOrCreateActorAndServerAndModel (
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) 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 return actorRefreshed
} }
@ -342,6 +351,7 @@ type FetchRemoteActorResult = {
name: string name: string
summary: string summary: string
support?: string support?: string
playlists?: string
avatarName?: string avatarName?: string
attributedTo: ActivityPubAttributedTo[] attributedTo: ActivityPubAttributedTo[]
} }
@ -398,6 +408,7 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
avatarName, avatarName,
summary: actorJSON.summary, summary: actorJSON.summary,
support: actorJSON.support, support: actorJSON.support,
playlists: actorJSON.playlists,
attributedTo: actorJSON.attributedTo attributedTo: actorJSON.attributedTo
} }
} }

View File

@ -1,4 +1,4 @@
import { ActivityPlaylistUrlObject, ActivityVideoUrlObject, CacheFileObject } from '../../../shared/index' import { CacheFileObject } from '../../../shared/index'
import { VideoModel } from '../../models/video/video' import { VideoModel } from '../../models/video/video'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
import { Transaction } from 'sequelize' import { Transaction } from 'sequelize'

View File

@ -4,7 +4,7 @@ import { logger } from '../../helpers/logger'
import * as Bluebird from 'bluebird' import * as Bluebird from 'bluebird'
import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' 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) logger.info('Crawling ActivityPub data on %s.', uri)
const options = { const options = {

View 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)
}

View File

@ -12,6 +12,8 @@ import { Notifier } from '../../notifier'
import { processViewActivity } from './process-view' import { processViewActivity } from './process-view'
import { processDislikeActivity } from './process-dislike' import { processDislikeActivity } from './process-dislike'
import { processFlagActivity } from './process-flag' 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) { async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
const activityObject = activity.object const activityObject = activity.object
@ -38,7 +40,11 @@ async function processCreateActivity (activity: ActivityCreate, byActor: ActorMo
} }
if (activityType === 'CacheFile') { 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 }) 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 return video
} }
async function processCacheFile (activity: ActivityCreate, byActor: ActorModel) { async function processCreateCacheFile (activity: ActivityCreate, byActor: ActorModel) {
const cacheFile = activity.object as CacheFileObject const cacheFile = activity.object as CacheFileObject
const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object }) 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) 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)
}

View File

@ -12,6 +12,8 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-vali
import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file' import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
import { createOrUpdateCacheFile } from '../cache-file' import { createOrUpdateCacheFile } from '../cache-file'
import { forwardVideoRelatedActivity } from '../send/utils' 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) { async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorModel) {
const objectType = activity.object.type const objectType = activity.object.type
@ -32,6 +34,10 @@ async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorMo
return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity) return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity)
} }
if (objectType === 'Playlist') {
return retryTransactionWrapper(processUpdatePlaylist, byActor, activity)
}
return undefined return undefined
} }
@ -135,3 +141,12 @@ async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate)
throw err 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)
}

View File

@ -8,6 +8,9 @@ import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unic
import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience' import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' 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) { async function sendCreateVideo (video: VideoModel, t: Transaction) {
if (video.privacy === VideoPrivacy.PRIVATE) return undefined 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) { async function sendCreateVideoComment (comment: VideoCommentModel, t: Transaction) {
logger.info('Creating job to send comment %s.', comment.url) logger.info('Creating job to send comment %s.', comment.url)
@ -92,6 +114,7 @@ export {
sendCreateVideo, sendCreateVideo,
buildCreateActivity, buildCreateActivity,
sendCreateVideoComment, sendCreateVideoComment,
sendCreateVideoPlaylist,
sendCreateCacheFile sendCreateCacheFile
} }

View File

@ -8,6 +8,8 @@ import { getDeleteActivityPubUrl } from '../url'
import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience' import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { VideoPlaylistModel } from '../../../models/video/video-playlist'
import { getServerActor } from '../../../helpers/utils'
async function sendDeleteVideo (video: VideoModel, transaction: Transaction) { async function sendDeleteVideo (video: VideoModel, transaction: Transaction) {
logger.info('Creating job to broadcast delete of video %s.', video.url) 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) 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 { export {
sendDeleteVideo, sendDeleteVideo,
sendDeleteActor, sendDeleteActor,
sendDeleteVideoComment sendDeleteVideoComment,
sendDeleteVideoPlaylist
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -12,8 +12,13 @@ import { audiencify, getActorsInvolvedInVideo, getAudience } from '../audience'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { VideoCaptionModel } from '../../../models/video/video-caption' import { VideoCaptionModel } from '../../../models/video/video-caption'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' 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) { 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) logger.info('Creating job to update video %s.', video.url)
const byActor = overrodeByActor ? overrodeByActor : video.VideoChannel.Account.Actor const byActor = overrodeByActor ? overrodeByActor : video.VideoChannel.Account.Actor
@ -73,12 +78,35 @@ async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoR
return sendVideoRelatedActivity(activityBuilder, { byActor, video }) 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 { export {
sendUpdateActor, sendUpdateActor,
sendUpdateVideo, sendUpdateVideo,
sendUpdateCacheFile sendUpdateCacheFile,
sendUpdateVideoPlaylist
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -7,11 +7,21 @@ import { VideoCommentModel } from '../../models/video/video-comment'
import { VideoFileModel } from '../../models/video/video-file' import { VideoFileModel } from '../../models/video/video-file'
import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist' 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) { function getVideoActivityPubUrl (video: VideoModel) {
return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 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) { function getVideoCacheFileActivityPubUrl (videoFile: VideoFileModel) {
const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : '' const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : ''
@ -98,6 +108,8 @@ function getUndoActivityPubUrl (originalUrl: string) {
export { export {
getVideoActivityPubUrl, getVideoActivityPubUrl,
getVideoPlaylistElementActivityPubUrl,
getVideoPlaylistActivityPubUrl,
getVideoCacheStreamingPlaylistActivityPubUrl, getVideoCacheStreamingPlaylistActivityPubUrl,
getVideoChannelActivityPubUrl, getVideoChannelActivityPubUrl,
getAccountActivityPubUrl, getAccountActivityPubUrl,

View File

@ -5,13 +5,16 @@ import { addVideoComments } from '../../activitypub/video-comments'
import { crawlCollectionPage } from '../../activitypub/crawl' import { crawlCollectionPage } from '../../activitypub/crawl'
import { VideoModel } from '../../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { addVideoShares, createRates } from '../../activitypub' 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 = { export type ActivitypubHttpFetcherPayload = {
uri: string uri: string
type: FetchType type: FetchType
videoId?: number videoId?: number
accountId?: number
} }
async function processActivityPubHttpFetcher (job: Bull.Job) { async function processActivityPubHttpFetcher (job: Bull.Job) {
@ -22,12 +25,16 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
let video: VideoModel let video: VideoModel
if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) 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> } = { const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
'activity': items => processActivities(items, { outboxUrl: payload.uri }), 'activity': items => processActivities(items, { outboxUrl: payload.uri }),
'video-likes': items => createRates(items, video, 'like'), 'video-likes': items => createRates(items, video, 'like'),
'video-dislikes': items => createRates(items, video, 'dislike'), 'video-dislikes': items => createRates(items, video, 'dislike'),
'video-shares': items => addVideoShares(items, video), '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]) return crawlCollectionPage(payload.uri, fetcherType[payload.type])

View File

@ -17,7 +17,7 @@ const localAccountValidator = [
} }
] ]
const accountsNameWithHostGetValidator = [ const accountNameWithHostGetValidator = [
param('accountName').exists().withMessage('Should have an account name with host'), param('accountName').exists().withMessage('Should have an account name with host'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@ -34,5 +34,5 @@ const accountsNameWithHostGetValidator = [
export { export {
localAccountValidator, localAccountValidator,
accountsNameWithHostGetValidator accountNameWithHostGetValidator
} }

View File

@ -19,6 +19,7 @@ const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUM
const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST) const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST) const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS) 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 usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_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 accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS) const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS) const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -57,5 +59,6 @@ export {
videoChannelsSearchSortValidator, videoChannelsSearchSortValidator,
accountsBlocklistSortValidator, accountsBlocklistSortValidator,
serversBlocklistSortValidator, serversBlocklistSortValidator,
userNotificationsSortValidator userNotificationsSortValidator,
videoPlaylistsSortValidator
} }

View File

@ -16,19 +16,6 @@ import { areValidationErrors } from '../utils'
import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor' import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor'
import { ActorModel } from '../../../models/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 = [ const videoChannelsAddValidator = [
body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'), body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'),
body('displayName').custom(isVideoChannelNameValid).withMessage('Should have a valid display name'), body('displayName').custom(isVideoChannelNameValid).withMessage('Should have a valid display name'),
@ -127,7 +114,6 @@ const localVideoChannelValidator = [
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
listVideoAccountChannelsValidator,
videoChannelsAddValidator, videoChannelsAddValidator,
videoChannelsUpdateValidator, videoChannelsUpdateValidator,
videoChannelsRemoveValidator, videoChannelsRemoveValidator,

View File

@ -3,14 +3,14 @@ import { body } from 'express-validator/check'
import { isIdValid } from '../../../helpers/custom-validators/misc' import { isIdValid } from '../../../helpers/custom-validators/misc'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { areValidationErrors } from '../utils' import { areValidationErrors } from '../utils'
import { getCommonVideoAttributes } from './videos' import { getCommonVideoEditAttributes } from './videos'
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
import { cleanUpReqFiles } from '../../../helpers/express-utils' import { cleanUpReqFiles } from '../../../helpers/express-utils'
import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos'
import { CONFIG } from '../../../initializers/constants' import { CONFIG } from '../../../initializers/constants'
import { CONSTRAINTS_FIELDS } from '../../../initializers' import { CONSTRAINTS_FIELDS } from '../../../initializers'
const videoImportAddValidator = getCommonVideoAttributes().concat([ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
body('channelId') body('channelId')
.toInt() .toInt()
.custom(isIdValid).withMessage('Should have correct video channel id'), .custom(isIdValid).withMessage('Should have correct video channel id'),

View 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
}

View File

@ -46,7 +46,7 @@ import { VideoFetchType } from '../../../helpers/video'
import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
import { getServerActor } from '../../../helpers/utils' import { getServerActor } from '../../../helpers/utils'
const videosAddValidator = getCommonVideoAttributes().concat([ const videosAddValidator = getCommonVideoEditAttributes().concat([
body('videofile') body('videofile')
.custom((value, { req }) => isVideoFile(req.files)).withMessage( .custom((value, { req }) => isVideoFile(req.files)).withMessage(
'This file is not supported or too large. Please, make sure it is of the following type: ' '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'), param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
body('name') body('name')
.optional() .optional()
@ -288,7 +288,7 @@ const videosAcceptChangeOwnershipValidator = [
} }
] ]
function getCommonVideoAttributes () { function getCommonVideoEditAttributes () {
return [ return [
body('thumbnailfile') body('thumbnailfile')
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
@ -421,7 +421,7 @@ export {
videosTerminateChangeOwnershipValidator, videosTerminateChangeOwnershipValidator,
videosAcceptChangeOwnershipValidator, videosAcceptChangeOwnershipValidator,
getCommonVideoAttributes, getCommonVideoEditAttributes,
commonVideosFiltersValidator commonVideosFiltersValidator
} }

View File

@ -10,11 +10,11 @@ import {
ForeignKey, ForeignKey,
HasMany, HasMany,
Is, Is,
Model, Model, Scopes,
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } from 'sequelize-typescript'
import { Account } from '../../../shared/models/actors' import { Account, AccountSummary } from '../../../shared/models/actors'
import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
import { sendDeleteActor } from '../../lib/activitypub/send' import { sendDeleteActor } from '../../lib/activitypub/send'
import { ActorModel } from '../activitypub/actor' import { ActorModel } from '../activitypub/actor'
@ -25,6 +25,13 @@ import { VideoChannelModel } from '../video/video-channel'
import { VideoCommentModel } from '../video/video-comment' import { VideoCommentModel } from '../video/video-comment'
import { UserModel } from './user' import { UserModel } from './user'
import { CONFIG } from '../../initializers' 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({ @DefaultScope({
include: [ 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({ @Table({
tableName: 'account', tableName: 'account',
indexes: [ indexes: [
@ -112,6 +145,15 @@ export class AccountModel extends Model<AccountModel> {
}) })
VideoChannels: VideoChannelModel[] VideoChannels: VideoChannelModel[]
@HasMany(() => VideoPlaylistModel, {
foreignKey: {
allowNull: false
},
onDelete: 'cascade',
hooks: true
})
VideoPlaylists: VideoPlaylistModel[]
@HasMany(() => VideoCommentModel, { @HasMany(() => VideoCommentModel, {
foreignKey: { foreignKey: {
allowNull: false allowNull: false
@ -285,6 +327,20 @@ export class AccountModel extends Model<AccountModel> {
return Object.assign(actor, account) 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 () { toActivityPubObject () {
const obj = this.Actor.toActivityPubObject(this.name, 'Account') const obj = this.Actor.toActivityPubObject(this.name, 'Account')

View File

@ -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) return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
} }

View File

@ -444,6 +444,7 @@ export class ActorModel extends Model<ActorModel> {
id: this.url, id: this.url,
following: this.getFollowingUrl(), following: this.getFollowingUrl(),
followers: this.getFollowersUrl(), followers: this.getFollowersUrl(),
playlists: this.getPlaylistsUrl(),
inbox: this.inboxUrl, inbox: this.inboxUrl,
outbox: this.outboxUrl, outbox: this.outboxUrl,
preferredUsername: this.preferredUsername, preferredUsername: this.preferredUsername,
@ -494,6 +495,10 @@ export class ActorModel extends Model<ActorModel> {
return this.url + '/followers' return this.url + '/followers'
} }
getPlaylistsUrl () {
return this.url + '/playlists'
}
getPublicKeyUrl () { getPublicKeyUrl () {
return this.url + '#main-key' return this.url + '#main-key'
} }

View File

@ -1,4 +1,5 @@
import { Sequelize } from 'sequelize-typescript' import { Sequelize } from 'sequelize-typescript'
import * as validator from 'validator'
type SortType = { sortModel: any, sortValue: string } type SortType = { sortModel: any, sortValue: string }
@ -74,13 +75,25 @@ function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number
const blockerIdsString = blockerIds.join(', ') 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 ' + ' UNION ALL ' +
'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' + 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' + 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')' '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, getSortOnModel,
createSimilarityAttribute, createSimilarityAttribute,
throwIfNotValid, throwIfNotValid,
buildTrigramSearchIndex buildServerIdsFollowedBy,
buildTrigramSearchIndex,
buildWhereIdOrUUID
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -8,7 +8,7 @@ import {
Default, Default,
DefaultScope, DefaultScope,
ForeignKey, ForeignKey,
HasMany, HasMany, IFindOptions,
Is, Is,
Model, Model,
Scopes, Scopes,
@ -17,20 +17,22 @@ import {
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } from 'sequelize-typescript'
import { ActivityPubActor } from '../../../shared/models/activitypub' import { ActivityPubActor } from '../../../shared/models/activitypub'
import { VideoChannel } from '../../../shared/models/videos' import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
import { import {
isVideoChannelDescriptionValid, isVideoChannelDescriptionValid,
isVideoChannelNameValid, isVideoChannelNameValid,
isVideoChannelSupportValid isVideoChannelSupportValid
} from '../../helpers/custom-validators/video-channels' } from '../../helpers/custom-validators/video-channels'
import { sendDeleteActor } from '../../lib/activitypub/send' 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 { 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 { VideoModel } from './video'
import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
import { ServerModel } from '../server/server' import { ServerModel } from '../server/server'
import { DefineIndexesOptions } from 'sequelize' 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 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
const indexes: DefineIndexesOptions[] = [ const indexes: DefineIndexesOptions[] = [
@ -44,11 +46,12 @@ const indexes: DefineIndexesOptions[] = [
} }
] ]
enum ScopeNames { export enum ScopeNames {
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
WITH_ACCOUNT = 'WITH_ACCOUNT', WITH_ACCOUNT = 'WITH_ACCOUNT',
WITH_ACTOR = 'WITH_ACTOR', WITH_ACTOR = 'WITH_ACTOR',
WITH_VIDEOS = 'WITH_VIDEOS' WITH_VIDEOS = 'WITH_VIDEOS',
SUMMARY = 'SUMMARY'
} }
type AvailableForListOptions = { type AvailableForListOptions = {
@ -64,15 +67,41 @@ type AvailableForListOptions = {
] ]
}) })
@Scopes({ @Scopes({
[ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { [ScopeNames.SUMMARY]: (required: boolean, withAccount: boolean) => {
const actorIdNumber = parseInt(options.actorId + '', 10) 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 // Only list local channels OR channels that are on an instance followed by actorId
const inQueryInstanceFollow = '(' + const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
'SELECT "actor"."serverId" FROM "actorFollow" ' +
'INNER JOIN "actor" ON actor.id= "actorFollow"."targetActorId" ' +
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
')'
return { return {
include: [ include: [
@ -192,6 +221,15 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
}) })
Videos: VideoModel[] Videos: VideoModel[]
@HasMany(() => VideoPlaylistModel, {
foreignKey: {
allowNull: false
},
onDelete: 'cascade',
hooks: true
})
VideoPlaylists: VideoPlaylistModel[]
@BeforeDestroy @BeforeDestroy
static async sendDeleteIfOwned (instance: VideoChannelModel, options) { static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
if (!instance.Actor) { if (!instance.Actor) {
@ -460,6 +498,20 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
return Object.assign(actor, videoChannel) 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 { toActivityPubObject (): ActivityPubActor {
const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel') const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')

View File

@ -26,12 +26,10 @@ export type VideoFormattingJSONOptions = {
waitTranscoding?: boolean, waitTranscoding?: boolean,
scheduledUpdate?: boolean, scheduledUpdate?: boolean,
blacklistInfo?: boolean blacklistInfo?: boolean
playlistInfo?: boolean
} }
} }
function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video { 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 userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
const videoObject: Video = { const videoObject: Video = {
@ -68,24 +66,9 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
updatedAt: video.updatedAt, updatedAt: video.updatedAt,
publishedAt: video.publishedAt, publishedAt: video.publishedAt,
originallyPublishedAt: video.originallyPublishedAt, originallyPublishedAt: video.originallyPublishedAt,
account: {
id: formattedAccount.id, account: video.VideoChannel.Account.toFormattedSummaryJSON(),
uuid: formattedAccount.uuid, channel: video.VideoChannel.toFormattedSummaryJSON(),
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
},
userHistory: userHistory ? { userHistory: userHistory ? {
currentTime: userHistory.currentTime currentTime: userHistory.currentTime
@ -115,6 +98,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
videoObject.blacklisted = !!video.VideoBlacklist videoObject.blacklisted = !!video.VideoBlacklist
videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null 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 return videoObject

View 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
}
}

View 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
}
})
})
}
}

View File

@ -40,7 +40,7 @@ import {
isVideoDurationValid, isVideoDurationValid,
isVideoLanguageValid, isVideoLanguageValid,
isVideoLicenceValid, isVideoLicenceValid,
isVideoNameValid, isVideoOriginallyPublishedAtValid, isVideoNameValid,
isVideoPrivacyValid, isVideoPrivacyValid,
isVideoStateValid, isVideoStateValid,
isVideoSupportValid isVideoSupportValid
@ -52,7 +52,9 @@ import {
ACTIVITY_PUB, ACTIVITY_PUB,
API_VERSION, API_VERSION,
CONFIG, CONFIG,
CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY, CONSTRAINTS_FIELDS,
HLS_PLAYLIST_DIRECTORY,
HLS_REDUNDANCY_DIRECTORY,
PREVIEWS_SIZE, PREVIEWS_SIZE,
REMOTE_SCHEME, REMOTE_SCHEME,
STATIC_DOWNLOAD_PATHS, STATIC_DOWNLOAD_PATHS,
@ -70,10 +72,17 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
import { ActorModel } from '../activitypub/actor' import { ActorModel } from '../activitypub/actor'
import { AvatarModel } from '../avatar/avatar' import { AvatarModel } from '../avatar/avatar'
import { ServerModel } from '../server/server' 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 { TagModel } from './tag'
import { VideoAbuseModel } from './video-abuse' import { VideoAbuseModel } from './video-abuse'
import { VideoChannelModel } from './video-channel' import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel'
import { VideoCommentModel } from './video-comment' import { VideoCommentModel } from './video-comment'
import { VideoFileModel } from './video-file' import { VideoFileModel } from './video-file'
import { VideoShareModel } from './video-share' import { VideoShareModel } from './video-share'
@ -91,11 +100,11 @@ import {
videoModelToFormattedDetailsJSON, videoModelToFormattedDetailsJSON,
videoModelToFormattedJSON videoModelToFormattedJSON
} from './video-format-utils' } from './video-format-utils'
import * as validator from 'validator'
import { UserVideoHistoryModel } from '../account/user-video-history' import { UserVideoHistoryModel } from '../account/user-video-history'
import { UserModel } from '../account/user' import { UserModel } from '../account/user'
import { VideoImportModel } from './video-import' import { VideoImportModel } from './video-import'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 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 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
const indexes: Sequelize.DefineIndexesOptions[] = [ const indexes: Sequelize.DefineIndexesOptions[] = [
@ -175,6 +184,9 @@ export enum ScopeNames {
type ForAPIOptions = { type ForAPIOptions = {
ids: number[] ids: number[]
videoPlaylistId?: number
withFiles?: boolean withFiles?: boolean
} }
@ -182,6 +194,7 @@ type AvailableForListIDsOptions = {
serverAccountId: number serverAccountId: number
followerActorId: number followerActorId: number
includeLocalVideos: boolean includeLocalVideos: boolean
filter?: VideoFilter filter?: VideoFilter
categoryOneOf?: number[] categoryOneOf?: number[]
nsfw?: boolean nsfw?: boolean
@ -189,9 +202,14 @@ type AvailableForListIDsOptions = {
languageOneOf?: string[] languageOneOf?: string[]
tagsOneOf?: string[] tagsOneOf?: string[]
tagsAllOf?: string[] tagsAllOf?: string[]
withFiles?: boolean withFiles?: boolean
accountId?: number accountId?: number
videoChannelId?: number videoChannelId?: number
videoPlaylistId?: number
trendingDays?: number trendingDays?: number
user?: UserModel, user?: UserModel,
historyOfUser?: UserModel historyOfUser?: UserModel
@ -199,62 +217,17 @@ type AvailableForListIDsOptions = {
@Scopes({ @Scopes({
[ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { [ 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> = { const query: IFindOptions<VideoModel> = {
where: { where: {
id: { id: {
[ Sequelize.Op.any ]: options.ids [ Sequelize.Op.any ]: options.ids
} }
}, },
include: [ videoChannelInclude ] include: [
{
model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY)
}
]
} }
if (options.withFiles === true) { if (options.withFiles === true) {
@ -264,6 +237,13 @@ type AvailableForListIDsOptions = {
}) })
} }
if (options.videoPlaylistId) {
query.include.push({
model: VideoPlaylistElementModel.unscoped(),
required: true
})
}
return query return query
}, },
[ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
@ -315,6 +295,17 @@ type AvailableForListIDsOptions = {
Object.assign(query.where, privacyWhere) 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) { if (options.filter || options.accountId || options.videoChannelId) {
const videoChannelInclude: IIncludeOptions = { const videoChannelInclude: IIncludeOptions = {
attributes: [], attributes: [],
@ -772,6 +763,15 @@ export class VideoModel extends Model<VideoModel> {
}) })
Tags: TagModel[] Tags: TagModel[]
@HasMany(() => VideoPlaylistElementModel, {
foreignKey: {
name: 'videoId',
allowNull: false
},
onDelete: 'cascade'
})
VideoPlaylistElements: VideoPlaylistElementModel[]
@HasMany(() => VideoAbuseModel, { @HasMany(() => VideoAbuseModel, {
foreignKey: { foreignKey: {
name: 'videoId', name: 'videoId',
@ -1118,6 +1118,7 @@ export class VideoModel extends Model<VideoModel> {
accountId?: number, accountId?: number,
videoChannelId?: number, videoChannelId?: number,
followerActorId?: number followerActorId?: number
videoPlaylistId?: number,
trendingDays?: number, trendingDays?: number,
user?: UserModel, user?: UserModel,
historyOfUser?: UserModel historyOfUser?: UserModel
@ -1157,6 +1158,7 @@ export class VideoModel extends Model<VideoModel> {
withFiles: options.withFiles, withFiles: options.withFiles,
accountId: options.accountId, accountId: options.accountId,
videoChannelId: options.videoChannelId, videoChannelId: options.videoChannelId,
videoPlaylistId: options.videoPlaylistId,
includeLocalVideos: options.includeLocalVideos, includeLocalVideos: options.includeLocalVideos,
user: options.user, user: options.user,
historyOfUser: options.historyOfUser, historyOfUser: options.historyOfUser,
@ -1280,7 +1282,7 @@ export class VideoModel extends Model<VideoModel> {
} }
static load (id: number | string, t?: Sequelize.Transaction) { static load (id: number | string, t?: Sequelize.Transaction) {
const where = VideoModel.buildWhereIdOrUUID(id) const where = buildWhereIdOrUUID(id)
const options = { const options = {
where, where,
transaction: t transaction: t
@ -1290,7 +1292,7 @@ export class VideoModel extends Model<VideoModel> {
} }
static loadWithRights (id: number | string, t?: Sequelize.Transaction) { static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
const where = VideoModel.buildWhereIdOrUUID(id) const where = buildWhereIdOrUUID(id)
const options = { const options = {
where, where,
transaction: t transaction: t
@ -1300,7 +1302,7 @@ export class VideoModel extends Model<VideoModel> {
} }
static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
const where = VideoModel.buildWhereIdOrUUID(id) const where = buildWhereIdOrUUID(id)
const options = { const options = {
attributes: [ 'id' ], attributes: [ 'id' ],
@ -1353,7 +1355,7 @@ export class VideoModel extends Model<VideoModel> {
} }
static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
const where = VideoModel.buildWhereIdOrUUID(id) const where = buildWhereIdOrUUID(id)
const options = { const options = {
order: [ [ 'Tags', 'name', 'ASC' ] ], order: [ [ 'Tags', 'name', 'ASC' ] ],
@ -1380,7 +1382,7 @@ export class VideoModel extends Model<VideoModel> {
} }
static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) { static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
const where = VideoModel.buildWhereIdOrUUID(id) const where = buildWhereIdOrUUID(id)
const options = { const options = {
order: [ [ 'Tags', 'name', 'ASC' ] ], order: [ [ 'Tags', 'name', 'ASC' ] ],
@ -1582,10 +1584,6 @@ export class VideoModel extends Model<VideoModel> {
return VIDEO_STATES[ id ] || 'Unknown' return VIDEO_STATES[ id ] || 'Unknown'
} }
static buildWhereIdOrUUID (id: number | string) {
return validator.isInt('' + id) ? { id } : { uuid: id }
}
getOriginalFile () { getOriginalFile () {
if (Array.isArray(this.VideoFiles) === false) return undefined if (Array.isArray(this.VideoFiles) === false) return undefined
@ -1598,7 +1596,6 @@ export class VideoModel extends Model<VideoModel> {
} }
getThumbnailName () { getThumbnailName () {
// We always have a copy of the thumbnail
const extension = '.jpg' const extension = '.jpg'
return this.uuid + extension return this.uuid + extension
} }

View 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()
}
})
})

View 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()
}
})
})

View File

@ -6,6 +6,7 @@ import { VideoAbuseObject } from './objects/video-abuse-object'
import { VideoCommentObject } from './objects/video-comment-object' import { VideoCommentObject } from './objects/video-comment-object'
import { ViewObject } from './objects/view-object' import { ViewObject } from './objects/view-object'
import { APObject } from './objects/object.model' import { APObject } from './objects/object.model'
import { PlaylistObject } from './objects/playlist-object'
export type Activity = ActivityCreate | ActivityUpdate | export type Activity = ActivityCreate | ActivityUpdate |
ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce | ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce |
@ -31,12 +32,12 @@ export interface BaseActivity {
export interface ActivityCreate extends BaseActivity { export interface ActivityCreate extends BaseActivity {
type: 'Create' type: 'Create'
object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject
} }
export interface ActivityUpdate extends BaseActivity { export interface ActivityUpdate extends BaseActivity {
type: 'Update' type: 'Update'
object: VideoTorrentObject | ActivityPubActor | CacheFileObject object: VideoTorrentObject | ActivityPubActor | CacheFileObject | PlaylistObject
} }
export interface ActivityDelete extends BaseActivity { export interface ActivityDelete extends BaseActivity {

View File

@ -8,6 +8,7 @@ export interface ActivityPubActor {
id: string id: string
following: string following: string
followers: string followers: string
playlists?: string
inbox: string inbox: string
outbox: string outbox: string
preferredUsername: string preferredUsername: string

View File

@ -0,0 +1,10 @@
export interface PlaylistElementObject {
id: string
type: 'PlaylistElement'
url: string
position: number
startTimestamp?: number
stopTimestamp?: number
}

View 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[]
}

View File

@ -1,4 +1,5 @@
import { Actor } from './actor.model' import { Actor } from './actor.model'
import { Avatar } from '../avatars'
export interface Account extends Actor { export interface Account extends Actor {
displayName: string displayName: string
@ -6,3 +7,13 @@ export interface Account extends Actor {
userId?: number userId?: number
} }
export interface AccountSummary {
id: number
uuid: string
name: string
displayName: string
url: string
host: string
avatar?: Avatar
}

View File

@ -1,8 +1,8 @@
import { Video, VideoChannelAttribute, VideoConstant } from '../videos' import { Video, VideoChannelSummary, VideoConstant } from '../videos'
export interface VideosOverview { export interface VideosOverview {
channels: { channels: {
channel: VideoChannelAttribute channel: VideoChannelSummary
videos: Video[] videos: Video[]
}[] }[]

View File

@ -20,8 +20,12 @@ export enum UserRight {
REMOVE_ANY_VIDEO, REMOVE_ANY_VIDEO,
REMOVE_ANY_VIDEO_CHANNEL, REMOVE_ANY_VIDEO_CHANNEL,
REMOVE_ANY_VIDEO_PLAYLIST,
REMOVE_ANY_VIDEO_COMMENT, REMOVE_ANY_VIDEO_COMMENT,
UPDATE_ANY_VIDEO, UPDATE_ANY_VIDEO,
UPDATE_ANY_VIDEO_PLAYLIST,
SEE_ALL_VIDEOS, SEE_ALL_VIDEOS,
CHANGE_VIDEO_OWNERSHIP CHANGE_VIDEO_OWNERSHIP
} }

View File

@ -25,6 +25,7 @@ const userRoleRights: { [ id: number ]: UserRight[] } = {
UserRight.MANAGE_VIDEO_ABUSES, UserRight.MANAGE_VIDEO_ABUSES,
UserRight.REMOVE_ANY_VIDEO, UserRight.REMOVE_ANY_VIDEO,
UserRight.REMOVE_ANY_VIDEO_CHANNEL, UserRight.REMOVE_ANY_VIDEO_CHANNEL,
UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
UserRight.REMOVE_ANY_VIDEO_COMMENT, UserRight.REMOVE_ANY_VIDEO_COMMENT,
UserRight.UPDATE_ANY_VIDEO, UserRight.UPDATE_ANY_VIDEO,
UserRight.SEE_ALL_VIDEOS, UserRight.SEE_ALL_VIDEOS,

View File

@ -1,6 +1,6 @@
import { Actor } from '../../actors/actor.model' import { Actor } from '../../actors/actor.model'
import { Video } from '../video.model'
import { Account } from '../../actors/index' import { Account } from '../../actors/index'
import { Avatar } from '../../avatars'
export interface VideoChannel extends Actor { export interface VideoChannel extends Actor {
displayName: string displayName: string
@ -9,3 +9,13 @@ export interface VideoChannel extends Actor {
isLocal: boolean isLocal: boolean
ownerAccount?: Account ownerAccount?: Account
} }
export interface VideoChannelSummary {
id: number
uuid: string
name: string
displayName: string
url: string
host: string
avatar?: Avatar
}

View 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
}

View File

@ -0,0 +1,4 @@
export interface VideoPlaylistElementCreate {
startTimestamp?: number
stopTimestamp?: number
}

View File

@ -0,0 +1,4 @@
export interface VideoPlaylistElementUpdate {
startTimestamp?: number
stopTimestamp?: number
}

View File

@ -0,0 +1,5 @@
export enum VideoPlaylistPrivacy {
PUBLIC = 1,
UNLISTED = 2,
PRIVATE = 3
}

View 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
}

View 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
}

View File

@ -1,4 +1,4 @@
import { VideoResolution, VideoState } from '../../index' import { AccountSummary, VideoChannelSummary, VideoResolution, VideoState } from '../../index'
import { Account } from '../actors' import { Account } from '../actors'
import { Avatar } from '../avatars/avatar.model' import { Avatar } from '../avatars/avatar.model'
import { VideoChannel } from './channel/video-channel.model' import { VideoChannel } from './channel/video-channel.model'
@ -18,26 +18,6 @@ export interface VideoFile {
fps: number 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 { export interface Video {
id: number id: number
uuid: string uuid: string
@ -68,12 +48,18 @@ export interface Video {
blacklisted?: boolean blacklisted?: boolean
blacklistedReason?: string blacklistedReason?: string
account: AccountAttribute account: AccountSummary
channel: VideoChannelAttribute channel: VideoChannelSummary
userHistory?: { userHistory?: {
currentTime: number currentTime: number
} }
playlistElement?: {
position: number
startTimestamp: number
stopTimestamp: number
}
} }
export interface VideoDetails extends Video { export interface VideoDetails extends Video {

View File

@ -1,51 +1,185 @@
import { makeRawRequest } from '../requests/requests' import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests'
import { sha256 } from '../../../server/helpers/core-utils' import { VideoPlaylistCreate } from '../../models/videos/playlist/video-playlist-create.model'
import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model' import { omit } from 'lodash'
import { expect } from 'chai' 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) { function getVideoPlaylistsList (url: string, start: number, count: number, sort?: string) {
return makeRawRequest(url, statusCodeExpected) const path = '/api/v1/video-playlists'
const query = {
start,
count,
sort
}
return makeGetRequest({
url,
path,
query
})
} }
function getSegment (url: string, statusCodeExpected = 200, range?: string) { function getVideoPlaylist (url: string, playlistId: number | string, statusCodeExpected = 200) {
return makeRawRequest(url, statusCodeExpected, range) const path = '/api/v1/video-playlists/' + playlistId
return makeGetRequest({
url,
path,
statusCodeExpected
})
} }
function getSegmentSha256 (url: string, statusCodeExpected = 200) { function deleteVideoPlaylist (url: string, token: string, playlistId: number | string, statusCodeExpected = 200) {
return makeRawRequest(url, statusCodeExpected) const path = '/api/v1/video-playlists/' + playlistId
return makeDeleteRequest({
url,
path,
token,
statusCodeExpected
})
} }
async function checkSegmentHash ( function createVideoPlaylist (options: {
baseUrlPlaylist: string, url: string,
baseUrlSegment: string, token: string,
videoUUID: string, playlistAttrs: VideoPlaylistCreate,
resolution: number, expectedStatus: number
hlsPlaylist: VideoStreamingPlaylist }) {
) { const path = '/api/v1/video-playlists/'
const res = await getPlaylist(`${baseUrlPlaylist}/${videoUUID}/${resolution}.m3u8`)
const playlist = res.text
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) return makeUploadRequest({
const offset = parseInt(matches[2], 10) method: 'POST',
const range = `${offset}-${offset + length - 1}` 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] const attaches = options.playlistAttrs.thumbnailfile
expect(sha256(res2.body)).to.equal(sha256Server) ? { 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 { export {
getPlaylist, getVideoPlaylistsList,
getSegment, getVideoPlaylist,
getSegmentSha256,
checkSegmentHash createVideoPlaylist,
updateVideoPlaylist,
deleteVideoPlaylist,
addVideoInPlaylist,
removeVideoFromPlaylist,
reorderVideosPlaylist
} }

View 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
}

View File

@ -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) { function getVideosListPagination (url: string, start: number, count: number, sort?: string) {
const path = '/api/v1/videos' const path = '/api/v1/videos'
@ -601,5 +623,6 @@ export {
parseTorrentVideo, parseTorrentVideo,
getLocalVideos, getLocalVideos,
completeVideoCheck, completeVideoCheck,
checkVideoFilesWereRemoved checkVideoFilesWereRemoved,
getPlaylistVideos
} }