Playlist server API

pull/1745/head
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)
// Security middleware
import { baseCSP } from './server/middlewares'
import { baseCSP } from './server/middlewares/csp'
if (CONFIG.CSP.ENABLED) {
app.use(baseCSP)

View File

@ -14,7 +14,7 @@ import {
videosCustomGetValidator,
videosShareValidator
} from '../../middlewares'
import { getAccountVideoRateValidator, videoCommentGetValidator, videosGetValidator } from '../../middlewares/validators'
import { getAccountVideoRateValidator, videoCommentGetValidator } from '../../middlewares/validators'
import { AccountModel } from '../../models/account/account'
import { ActorModel } from '../../models/activitypub/actor'
import { ActorFollowModel } from '../../models/activitypub/actor-follow'
@ -37,6 +37,10 @@ import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator }
import { getServerActor } from '../../helpers/utils'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike'
import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists'
import { VideoPlaylistModel } from '../../models/video/video-playlist'
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
const activityPubClientRouter = express.Router()
@ -52,6 +56,10 @@ activityPubClientRouter.get('/accounts?/:name/following',
executeIfActivityPub(asyncMiddleware(localAccountValidator)),
executeIfActivityPub(asyncMiddleware(accountFollowingController))
)
activityPubClientRouter.get('/accounts?/:name/playlists',
executeIfActivityPub(asyncMiddleware(localAccountValidator)),
executeIfActivityPub(asyncMiddleware(accountPlaylistsController))
)
activityPubClientRouter.get('/accounts?/:name/likes/:videoId',
executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))),
executeIfActivityPub(getAccountVideoRate('like'))
@ -121,6 +129,15 @@ activityPubClientRouter.get('/redundancy/video-playlists/:streamingPlaylistType/
executeIfActivityPub(asyncMiddleware(videoRedundancyController))
)
activityPubClientRouter.get('/video-playlists/:playlistId',
executeIfActivityPub(asyncMiddleware(videoPlaylistsGetValidator)),
executeIfActivityPub(asyncMiddleware(videoPlaylistController))
)
activityPubClientRouter.get('/video-playlists/:playlistId/:videoId',
executeIfActivityPub(asyncMiddleware(videoPlaylistElementAPGetValidator)),
executeIfActivityPub(asyncMiddleware(videoPlaylistElementController))
)
// ---------------------------------------------------------------------------
export {
@ -129,26 +146,33 @@ export {
// ---------------------------------------------------------------------------
function accountController (req: express.Request, res: express.Response, next: express.NextFunction) {
function accountController (req: express.Request, res: express.Response) {
const account: AccountModel = res.locals.account
return activityPubResponse(activityPubContextify(account.toActivityPubObject()), res)
}
async function accountFollowersController (req: express.Request, res: express.Response, next: express.NextFunction) {
async function accountFollowersController (req: express.Request, res: express.Response) {
const account: AccountModel = res.locals.account
const activityPubResult = await actorFollowers(req, account.Actor)
return activityPubResponse(activityPubContextify(activityPubResult), res)
}
async function accountFollowingController (req: express.Request, res: express.Response, next: express.NextFunction) {
async function accountFollowingController (req: express.Request, res: express.Response) {
const account: AccountModel = res.locals.account
const activityPubResult = await actorFollowing(req, account.Actor)
return activityPubResponse(activityPubContextify(activityPubResult), res)
}
async function accountPlaylistsController (req: express.Request, res: express.Response) {
const account: AccountModel = res.locals.account
const activityPubResult = await actorPlaylists(req, account)
return activityPubResponse(activityPubContextify(activityPubResult), res)
}
function getAccountVideoRate (rateType: VideoRateType) {
return (req: express.Request, res: express.Response) => {
const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate
@ -293,6 +317,23 @@ async function videoRedundancyController (req: express.Request, res: express.Res
return activityPubResponse(activityPubContextify(object), res)
}
async function videoPlaylistController (req: express.Request, res: express.Response) {
const playlist: VideoPlaylistModel = res.locals.videoPlaylist
const json = await playlist.toActivityPubObject()
const audience = getAudience(playlist.OwnerAccount.Actor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
const object = audiencify(json, audience)
return activityPubResponse(activityPubContextify(object), res)
}
async function videoPlaylistElementController (req: express.Request, res: express.Response) {
const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement
const json = videoPlaylistElement.toActivityPubObject()
return activityPubResponse(activityPubContextify(json), res)
}
// ---------------------------------------------------------------------------
async function actorFollowing (req: express.Request, actor: ActorModel) {
@ -305,7 +346,15 @@ async function actorFollowing (req: express.Request, actor: ActorModel) {
async function actorFollowers (req: express.Request, actor: ActorModel) {
const handler = (start: number, count: number) => {
return ActorFollowModel.listAcceptedFollowerUrlsForApi([ actor.id ], undefined, start, count)
return ActorFollowModel.listAcceptedFollowerUrlsForAP([ actor.id ], undefined, start, count)
}
return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page)
}
async function actorPlaylists (req: express.Request, account: AccountModel) {
const handler = (start: number, count: number) => {
return VideoPlaylistModel.listUrlsOfForAP(account.id, start, count)
}
return activityPubCollectionPagination(CONFIG.WEBSERVER.URL + req.path, handler, req.query.page)

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 actor = accountOrVideoChannel.Actor
const actorOutboxUrl = actor.url + '/outbox'

View File

@ -1,21 +1,23 @@
import * as express from 'express'
import { getFormattedObjects } from '../../helpers/utils'
import { getFormattedObjects, getServerActor } from '../../helpers/utils'
import {
asyncMiddleware,
commonVideosFiltersValidator,
listVideoAccountChannelsValidator,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSort
setDefaultSort,
videoPlaylistsSortValidator
} from '../../middlewares'
import { accountsNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators'
import { accountNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators'
import { AccountModel } from '../../models/account/account'
import { VideoModel } from '../../models/video/video'
import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
import { VideoChannelModel } from '../../models/video/video-channel'
import { JobQueue } from '../../lib/job-queue'
import { logger } from '../../helpers/logger'
import { VideoPlaylistModel } from '../../models/video/video-playlist'
import { UserModel } from '../../models/account/user'
const accountsRouter = express.Router()
@ -28,12 +30,12 @@ accountsRouter.get('/',
)
accountsRouter.get('/:accountName',
asyncMiddleware(accountsNameWithHostGetValidator),
asyncMiddleware(accountNameWithHostGetValidator),
getAccount
)
accountsRouter.get('/:accountName/videos',
asyncMiddleware(accountsNameWithHostGetValidator),
asyncMiddleware(accountNameWithHostGetValidator),
paginationValidator,
videosSortValidator,
setDefaultSort,
@ -44,8 +46,18 @@ accountsRouter.get('/:accountName/videos',
)
accountsRouter.get('/:accountName/video-channels',
asyncMiddleware(listVideoAccountChannelsValidator),
asyncMiddleware(listVideoAccountChannels)
asyncMiddleware(accountNameWithHostGetValidator),
asyncMiddleware(listAccountChannels)
)
accountsRouter.get('/:accountName/video-playlists',
optionalAuthenticate,
asyncMiddleware(accountNameWithHostGetValidator),
paginationValidator,
videoPlaylistsSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listAccountPlaylists)
)
// ---------------------------------------------------------------------------
@ -56,7 +68,7 @@ export {
// ---------------------------------------------------------------------------
function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) {
function getAccount (req: express.Request, res: express.Response) {
const account: AccountModel = res.locals.account
if (account.isOutdated()) {
@ -67,19 +79,40 @@ function getAccount (req: express.Request, res: express.Response, next: express.
return res.json(account.toFormattedJSON())
}
async function listAccounts (req: express.Request, res: express.Response, next: express.NextFunction) {
async function listAccounts (req: express.Request, res: express.Response) {
const resultList = await AccountModel.listForApi(req.query.start, req.query.count, req.query.sort)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listVideoAccountChannels (req: express.Request, res: express.Response, next: express.NextFunction) {
async function listAccountChannels (req: express.Request, res: express.Response) {
const resultList = await VideoChannelModel.listByAccount(res.locals.account.id)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listAccountVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
async function listAccountPlaylists (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
// Allow users to see their private/unlisted video playlists
let privateAndUnlisted = false
if (res.locals.oauth && (res.locals.oauth.token.User as UserModel).Account.id === res.locals.account.id) {
privateAndUnlisted = true
}
const resultList = await VideoPlaylistModel.listForApi({
followerActorId: serverActor.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
accountId: res.locals.account.id,
privateAndUnlisted
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listAccountVideos (req: express.Request, res: express.Response) {
const account: AccountModel = res.locals.account
const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined

View File

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

View File

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

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

View File

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

View File

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

View File

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

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
}
async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') {
async function isVideoExist (id: number | string, res: Response, fetchType: VideoFetchType = 'all') {
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
const video = await fetchVideo(id, fetchType, userId)

View File

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

View File

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

View File

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

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 { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
import { Transaction } from 'sequelize'

View File

@ -4,7 +4,7 @@ import { logger } from '../../helpers/logger'
import * as Bluebird from 'bluebird'
import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) {
async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => (Promise<any> | Bluebird<any>)) {
logger.info('Crawling ActivityPub data on %s.', uri)
const options = {

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 { processDislikeActivity } from './process-dislike'
import { processFlagActivity } from './process-flag'
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
import { createOrUpdateVideoPlaylist } from '../playlist'
async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
const activityObject = activity.object
@ -38,7 +40,11 @@ async function processCreateActivity (activity: ActivityCreate, byActor: ActorMo
}
if (activityType === 'CacheFile') {
return retryTransactionWrapper(processCacheFile, activity, byActor)
return retryTransactionWrapper(processCreateCacheFile, activity, byActor)
}
if (activityType === 'Playlist') {
return retryTransactionWrapper(processCreatePlaylist, activity, byActor)
}
logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
@ -63,7 +69,7 @@ async function processCreateVideo (activity: ActivityCreate) {
return video
}
async function processCacheFile (activity: ActivityCreate, byActor: ActorModel) {
async function processCreateCacheFile (activity: ActivityCreate, byActor: ActorModel) {
const cacheFile = activity.object as CacheFileObject
const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object })
@ -98,3 +104,12 @@ async function processCreateVideoComment (activity: ActivityCreate, byActor: Act
if (created === true) Notifier.Instance.notifyOnNewComment(comment)
}
async function processCreatePlaylist (activity: ActivityCreate, byActor: ActorModel) {
const playlistObject = activity.object as PlaylistObject
const byAccount = byActor.Account
if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
}

View File

@ -12,6 +12,8 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-vali
import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
import { createOrUpdateCacheFile } from '../cache-file'
import { forwardVideoRelatedActivity } from '../send/utils'
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
import { createOrUpdateVideoPlaylist } from '../playlist'
async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorModel) {
const objectType = activity.object.type
@ -32,6 +34,10 @@ async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorMo
return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity)
}
if (objectType === 'Playlist') {
return retryTransactionWrapper(processUpdatePlaylist, byActor, activity)
}
return undefined
}
@ -135,3 +141,12 @@ async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate)
throw err
}
}
async function processUpdatePlaylist (byActor: ActorModel, activity: ActivityUpdate) {
const playlistObject = activity.object as PlaylistObject
const byAccount = byActor.Account
if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
}

View File

@ -8,6 +8,9 @@ import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unic
import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience'
import { logger } from '../../../helpers/logger'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
import { VideoPlaylistModel } from '../../../models/video/video-playlist'
import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
import { getServerActor } from '../../../helpers/utils'
async function sendCreateVideo (video: VideoModel, t: Transaction) {
if (video.privacy === VideoPrivacy.PRIVATE) return undefined
@ -34,6 +37,25 @@ async function sendCreateCacheFile (byActor: ActorModel, video: VideoModel, file
})
}
async function sendCreateVideoPlaylist (playlist: VideoPlaylistModel, t: Transaction) {
if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
logger.info('Creating job to send create video playlist of %s.', playlist.url)
const byActor = playlist.OwnerAccount.Actor
const audience = getAudience(byActor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
const object = await playlist.toActivityPubObject()
const createActivity = buildCreateActivity(playlist.url, byActor, object, audience)
const serverActor = await getServerActor()
const toFollowersOf = [ byActor, serverActor ]
if (playlist.VideoChannel) toFollowersOf.push(playlist.VideoChannel.Actor)
return broadcastToFollowers(createActivity, byActor, toFollowersOf, t)
}
async function sendCreateVideoComment (comment: VideoCommentModel, t: Transaction) {
logger.info('Creating job to send comment %s.', comment.url)
@ -92,6 +114,7 @@ export {
sendCreateVideo,
buildCreateActivity,
sendCreateVideoComment,
sendCreateVideoPlaylist,
sendCreateCacheFile
}

View File

@ -8,6 +8,8 @@ import { getDeleteActivityPubUrl } from '../url'
import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
import { logger } from '../../../helpers/logger'
import { VideoPlaylistModel } from '../../../models/video/video-playlist'
import { getServerActor } from '../../../helpers/utils'
async function sendDeleteVideo (video: VideoModel, transaction: Transaction) {
logger.info('Creating job to broadcast delete of video %s.', video.url)
@ -64,12 +66,29 @@ async function sendDeleteVideoComment (videoComment: VideoCommentModel, t: Trans
return unicastTo(activity, byActor, videoComment.Video.VideoChannel.Account.Actor.sharedInboxUrl)
}
async function sendDeleteVideoPlaylist (videoPlaylist: VideoPlaylistModel, t: Transaction) {
logger.info('Creating job to send delete of playlist %s.', videoPlaylist.url)
const byActor = videoPlaylist.OwnerAccount.Actor
const url = getDeleteActivityPubUrl(videoPlaylist.url)
const activity = buildDeleteActivity(url, videoPlaylist.url, byActor)
const serverActor = await getServerActor()
const toFollowersOf = [ byActor, serverActor ]
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
return broadcastToFollowers(activity, byActor, toFollowersOf, t)
}
// ---------------------------------------------------------------------------
export {
sendDeleteVideo,
sendDeleteActor,
sendDeleteVideoComment
sendDeleteVideoComment,
sendDeleteVideoPlaylist
}
// ---------------------------------------------------------------------------

View File

@ -12,8 +12,13 @@ import { audiencify, getActorsInvolvedInVideo, getAudience } from '../audience'
import { logger } from '../../../helpers/logger'
import { VideoCaptionModel } from '../../../models/video/video-caption'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
import { VideoPlaylistModel } from '../../../models/video/video-playlist'
import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
import { getServerActor } from '../../../helpers/utils'
async function sendUpdateVideo (video: VideoModel, t: Transaction, overrodeByActor?: ActorModel) {
if (video.privacy === VideoPrivacy.PRIVATE) return undefined
logger.info('Creating job to update video %s.', video.url)
const byActor = overrodeByActor ? overrodeByActor : video.VideoChannel.Account.Actor
@ -73,12 +78,35 @@ async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoR
return sendVideoRelatedActivity(activityBuilder, { byActor, video })
}
async function sendUpdateVideoPlaylist (videoPlaylist: VideoPlaylistModel, t: Transaction) {
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
const byActor = videoPlaylist.OwnerAccount.Actor
logger.info('Creating job to update video playlist %s.', videoPlaylist.url)
const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString())
const object = await videoPlaylist.toActivityPubObject()
const audience = getAudience(byActor, videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC)
const updateActivity = buildUpdateActivity(url, byActor, object, audience)
const serverActor = await getServerActor()
const toFollowersOf = [ byActor, serverActor ]
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
return broadcastToFollowers(updateActivity, byActor, toFollowersOf, t)
}
// ---------------------------------------------------------------------------
export {
sendUpdateActor,
sendUpdateVideo,
sendUpdateCacheFile
sendUpdateCacheFile,
sendUpdateVideoPlaylist
}
// ---------------------------------------------------------------------------

View File

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

View File

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

View File

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

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_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@ -37,6 +38,7 @@ const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COL
const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS)
// ---------------------------------------------------------------------------
@ -57,5 +59,6 @@ export {
videoChannelsSearchSortValidator,
accountsBlocklistSortValidator,
serversBlocklistSortValidator,
userNotificationsSortValidator
userNotificationsSortValidator,
videoPlaylistsSortValidator
}

View File

@ -16,19 +16,6 @@ import { areValidationErrors } from '../utils'
import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor'
import { ActorModel } from '../../../models/activitypub/actor'
const listVideoAccountChannelsValidator = [
param('accountName').exists().withMessage('Should have a valid account name'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking listVideoAccountChannelsValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await isAccountNameWithHostExist(req.params.accountName, res)) return
return next()
}
]
const videoChannelsAddValidator = [
body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'),
body('displayName').custom(isVideoChannelNameValid).withMessage('Should have a valid display name'),
@ -127,7 +114,6 @@ const localVideoChannelValidator = [
// ---------------------------------------------------------------------------
export {
listVideoAccountChannelsValidator,
videoChannelsAddValidator,
videoChannelsUpdateValidator,
videoChannelsRemoveValidator,

View File

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

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

View File

@ -10,11 +10,11 @@ import {
ForeignKey,
HasMany,
Is,
Model,
Model, Scopes,
Table,
UpdatedAt
} from 'sequelize-typescript'
import { Account } from '../../../shared/models/actors'
import { Account, AccountSummary } from '../../../shared/models/actors'
import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
import { sendDeleteActor } from '../../lib/activitypub/send'
import { ActorModel } from '../activitypub/actor'
@ -25,6 +25,13 @@ import { VideoChannelModel } from '../video/video-channel'
import { VideoCommentModel } from '../video/video-comment'
import { UserModel } from './user'
import { CONFIG } from '../../initializers'
import { AvatarModel } from '../avatar/avatar'
import { WhereOptions } from 'sequelize'
import { VideoPlaylistModel } from '../video/video-playlist'
export enum ScopeNames {
SUMMARY = 'SUMMARY'
}
@DefaultScope({
include: [
@ -34,6 +41,32 @@ import { CONFIG } from '../../initializers'
}
]
})
@Scopes({
[ ScopeNames.SUMMARY ]: (whereActor?: WhereOptions<ActorModel>) => {
return {
attributes: [ 'id', 'name' ],
include: [
{
attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
model: ActorModel.unscoped(),
required: true,
where: whereActor,
include: [
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: false
},
{
model: AvatarModel.unscoped(),
required: false
}
]
}
]
}
}
})
@Table({
tableName: 'account',
indexes: [
@ -112,6 +145,15 @@ export class AccountModel extends Model<AccountModel> {
})
VideoChannels: VideoChannelModel[]
@HasMany(() => VideoPlaylistModel, {
foreignKey: {
allowNull: false
},
onDelete: 'cascade',
hooks: true
})
VideoPlaylists: VideoPlaylistModel[]
@HasMany(() => VideoCommentModel, {
foreignKey: {
allowNull: false
@ -285,6 +327,20 @@ export class AccountModel extends Model<AccountModel> {
return Object.assign(actor, account)
}
toFormattedSummaryJSON (): AccountSummary {
const actor = this.Actor.toFormattedJSON()
return {
id: this.id,
uuid: actor.uuid,
name: actor.name,
displayName: this.getDisplayName(),
url: actor.url,
host: actor.host,
avatar: actor.avatar
}
}
toActivityPubObject () {
const obj = this.Actor.toActivityPubObject(this.name, 'Account')

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

View File

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

View File

@ -1,4 +1,5 @@
import { Sequelize } from 'sequelize-typescript'
import * as validator from 'validator'
type SortType = { sortModel: any, sortValue: string }
@ -74,13 +75,25 @@ function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number
const blockerIdsString = blockerIds.join(', ')
const query = 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
' UNION ALL ' +
'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
}
return query
function buildServerIdsFollowedBy (actorId: any) {
const actorIdNumber = parseInt(actorId + '', 10)
return '(' +
'SELECT "actor"."serverId" FROM "actorFollow" ' +
'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
')'
}
function buildWhereIdOrUUID (id: number | string) {
return validator.isInt('' + id) ? { id } : { uuid: id }
}
// ---------------------------------------------------------------------------
@ -93,7 +106,9 @@ export {
getSortOnModel,
createSimilarityAttribute,
throwIfNotValid,
buildTrigramSearchIndex
buildServerIdsFollowedBy,
buildTrigramSearchIndex,
buildWhereIdOrUUID
}
// ---------------------------------------------------------------------------

View File

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

View File

@ -26,12 +26,10 @@ export type VideoFormattingJSONOptions = {
waitTranscoding?: boolean,
scheduledUpdate?: boolean,
blacklistInfo?: boolean
playlistInfo?: boolean
}
}
function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
const videoObject: Video = {
@ -68,24 +66,9 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
updatedAt: video.updatedAt,
publishedAt: video.publishedAt,
originallyPublishedAt: video.originallyPublishedAt,
account: {
id: formattedAccount.id,
uuid: formattedAccount.uuid,
name: formattedAccount.name,
displayName: formattedAccount.displayName,
url: formattedAccount.url,
host: formattedAccount.host,
avatar: formattedAccount.avatar
},
channel: {
id: formattedVideoChannel.id,
uuid: formattedVideoChannel.uuid,
name: formattedVideoChannel.name,
displayName: formattedVideoChannel.displayName,
url: formattedVideoChannel.url,
host: formattedVideoChannel.host,
avatar: formattedVideoChannel.avatar
},
account: video.VideoChannel.Account.toFormattedSummaryJSON(),
channel: video.VideoChannel.toFormattedSummaryJSON(),
userHistory: userHistory ? {
currentTime: userHistory.currentTime
@ -115,6 +98,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
videoObject.blacklisted = !!video.VideoBlacklist
videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
}
if (options.additionalAttributes.playlistInfo === true) {
// We filtered on a specific videoId/videoPlaylistId, that is unique
const playlistElement = video.VideoPlaylistElements[0]
videoObject.playlistElement = {
position: playlistElement.position,
startTimestamp: playlistElement.startTimestamp,
stopTimestamp: playlistElement.stopTimestamp
}
}
}
return videoObject

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

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

View File

@ -8,6 +8,7 @@ export interface ActivityPubActor {
id: string
following: string
followers: string
playlists?: string
inbox: string
outbox: 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 { Avatar } from '../avatars'
export interface Account extends Actor {
displayName: string
@ -6,3 +7,13 @@ export interface Account extends Actor {
userId?: number
}
export interface AccountSummary {
id: number
uuid: string
name: string
displayName: string
url: string
host: string
avatar?: Avatar
}

View File

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

View File

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

View File

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

View File

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

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

View File

@ -1,51 +1,185 @@
import { makeRawRequest } from '../requests/requests'
import { sha256 } from '../../../server/helpers/core-utils'
import { VideoStreamingPlaylist } from '../../models/videos/video-streaming-playlist.model'
import { expect } from 'chai'
import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests'
import { VideoPlaylistCreate } from '../../models/videos/playlist/video-playlist-create.model'
import { omit } from 'lodash'
import { VideoPlaylistUpdate } from '../../models/videos/playlist/video-playlist-update.model'
import { VideoPlaylistElementCreate } from '../../models/videos/playlist/video-playlist-element-create.model'
import { VideoPlaylistElementUpdate } from '../../models/videos/playlist/video-playlist-element-update.model'
function getPlaylist (url: string, statusCodeExpected = 200) {
return makeRawRequest(url, statusCodeExpected)
function getVideoPlaylistsList (url: string, start: number, count: number, sort?: string) {
const path = '/api/v1/video-playlists'
const query = {
start,
count,
sort
}
return makeGetRequest({
url,
path,
query
})
}
function getSegment (url: string, statusCodeExpected = 200, range?: string) {
return makeRawRequest(url, statusCodeExpected, range)
function getVideoPlaylist (url: string, playlistId: number | string, statusCodeExpected = 200) {
const path = '/api/v1/video-playlists/' + playlistId
return makeGetRequest({
url,
path,
statusCodeExpected
})
}
function getSegmentSha256 (url: string, statusCodeExpected = 200) {
return makeRawRequest(url, statusCodeExpected)
function deleteVideoPlaylist (url: string, token: string, playlistId: number | string, statusCodeExpected = 200) {
const path = '/api/v1/video-playlists/' + playlistId
return makeDeleteRequest({
url,
path,
token,
statusCodeExpected
})
}
async function checkSegmentHash (
baseUrlPlaylist: string,
baseUrlSegment: string,
videoUUID: string,
resolution: number,
hlsPlaylist: VideoStreamingPlaylist
) {
const res = await getPlaylist(`${baseUrlPlaylist}/${videoUUID}/${resolution}.m3u8`)
const playlist = res.text
function createVideoPlaylist (options: {
url: string,
token: string,
playlistAttrs: VideoPlaylistCreate,
expectedStatus: number
}) {
const path = '/api/v1/video-playlists/'
const videoName = `${videoUUID}-${resolution}-fragmented.mp4`
const fields = omit(options.playlistAttrs, 'thumbnailfile')
const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist)
const attaches = options.playlistAttrs.thumbnailfile
? { thumbnailfile: options.playlistAttrs.thumbnailfile }
: {}
const length = parseInt(matches[1], 10)
const offset = parseInt(matches[2], 10)
const range = `${offset}-${offset + length - 1}`
return makeUploadRequest({
method: 'POST',
url: options.url,
path,
token: options.token,
fields,
attaches,
statusCodeExpected: options.expectedStatus
})
}
const res2 = await getSegment(`${baseUrlSegment}/${videoUUID}/${videoName}`, 206, `bytes=${range}`)
function updateVideoPlaylist (options: {
url: string,
token: string,
playlistAttrs: VideoPlaylistUpdate,
expectedStatus: number
}) {
const path = '/api/v1/video-playlists/'
const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url)
const fields = omit(options.playlistAttrs, 'thumbnailfile')
const sha256Server = resSha.body[ videoName ][range]
expect(sha256(res2.body)).to.equal(sha256Server)
const attaches = options.playlistAttrs.thumbnailfile
? { thumbnailfile: options.playlistAttrs.thumbnailfile }
: {}
return makeUploadRequest({
method: 'PUT',
url: options.url,
path,
token: options.token,
fields,
attaches,
statusCodeExpected: options.expectedStatus
})
}
function addVideoInPlaylist (options: {
url: string,
token: string,
playlistId: number | string,
elementAttrs: VideoPlaylistElementCreate
expectedStatus: number
}) {
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
return makePostBodyRequest({
url: options.url,
path,
token: options.token,
fields: options.elementAttrs,
statusCodeExpected: options.expectedStatus
})
}
function updateVideoPlaylistElement (options: {
url: string,
token: string,
playlistId: number | string,
videoId: number | string,
elementAttrs: VideoPlaylistElementUpdate,
expectedStatus: number
}) {
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId
return makePutBodyRequest({
url: options.url,
path,
token: options.token,
fields: options.elementAttrs,
statusCodeExpected: options.expectedStatus
})
}
function removeVideoFromPlaylist (options: {
url: string,
token: string,
playlistId: number | string,
videoId: number | string,
expectedStatus: number
}) {
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId
return makeDeleteRequest({
url: options.url,
path,
token: options.token,
statusCodeExpected: options.expectedStatus
})
}
function reorderVideosPlaylist (options: {
url: string,
token: string,
playlistId: number | string,
elementAttrs: {
startPosition: number,
insertAfter: number,
reorderLength?: number
},
expectedStatus: number
}) {
const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
return makePutBodyRequest({
url: options.url,
path,
token: options.token,
fields: options.elementAttrs,
statusCodeExpected: options.expectedStatus
})
}
// ---------------------------------------------------------------------------
export {
getPlaylist,
getSegment,
getSegmentSha256,
checkSegmentHash
getVideoPlaylistsList,
getVideoPlaylist,
createVideoPlaylist,
updateVideoPlaylist,
deleteVideoPlaylist,
addVideoInPlaylist,
removeVideoFromPlaylist,
reorderVideosPlaylist
}

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) {
const path = '/api/v1/videos'
@ -601,5 +623,6 @@ export {
parseTorrentVideo,
getLocalVideos,
completeVideoCheck,
checkVideoFilesWereRemoved
checkVideoFilesWereRemoved,
getPlaylistVideos
}