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)
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'
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()
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'))
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 {
// ---------------------------------------------------------------------------
-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
return activityPubResponse(activityPubContextify(object), res)
}
+async function videoPlaylistController (req: express.Request, res: express.Response) {
+ const playlist: VideoPlaylistModel = res.locals.videoPlaylist
+
+ const json = await playlist.toActivityPubObject()
+ const audience = getAudience(playlist.OwnerAccount.Actor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
+ const object = audiencify(json, audience)
+
+ return activityPubResponse(activityPubContextify(object), res)
+}
+
+async function videoPlaylistElementController (req: express.Request, res: express.Response) {
+ const videoPlaylistElement: VideoPlaylistElementModel = res.locals.videoPlaylistElement
+
+ const json = videoPlaylistElement.toActivityPubObject()
+ return activityPubResponse(activityPubContextify(json), res)
+}
+
// ---------------------------------------------------------------------------
async function actorFollowing (req: express.Request, actor: ActorModel) {
async function 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)
// ---------------------------------------------------------------------------
-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'
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()
)
accountsRouter.get('/:accountName',
- asyncMiddleware(accountsNameWithHostGetValidator),
+ asyncMiddleware(accountNameWithHostGetValidator),
getAccount
)
accountsRouter.get('/:accountName/videos',
- asyncMiddleware(accountsNameWithHostGetValidator),
+ asyncMiddleware(accountNameWithHostGetValidator),
paginationValidator,
videosSortValidator,
setDefaultSort,
)
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)
)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
-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()) {
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
import * as cors from 'cors'
import { searchRouter } from './search'
import { overviewsRouter } from './overviews'
+import { videoPlaylistRouter } from './video-playlist'
const apiRouter = express.Router()
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)
videoChannelsAddValidator,
videoChannelsRemoveValidator,
videoChannelsSortValidator,
- videoChannelsUpdateValidator
+ videoChannelsUpdateValidator,
+ videoPlaylistsSortValidator
} from '../../middlewares'
import { VideoChannelModel } from '../../models/video/video-channel'
import { videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
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 })
asyncMiddleware(getVideoChannel)
)
+videoChannelRouter.get('/:nameWithHost/video-playlists',
+ asyncMiddleware(videoChannelsNameWithHostValidator),
+ paginationValidator,
+ videoPlaylistsSortValidator,
+ setDefaultSort,
+ setDefaultPagination,
+ asyncMiddleware(listVideoChannelPlaylists)
+)
+
videoChannelRouter.get('/:nameWithHost/videos',
asyncMiddleware(videoChannelsNameWithHostValidator),
paginationValidator,
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
--- /dev/null
+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))
+}
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 {
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()
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()
generateOEmbed
)
servicesRouter.use('/redirect/accounts/:accountName',
- asyncMiddleware(accountsNameWithHostGetValidator),
+ asyncMiddleware(accountNameWithHostGetValidator),
redirectToAccountUrl
)
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',
'@id': 'as:dislikes',
'@type': '@id'
},
+ playlists: {
+ '@id': 'pt:playlists',
+ '@type': '@id'
+ },
shares: {
'@id': 'as:shares',
'@type': '@id'
return {
id: baseUrl,
- type: 'OrderedCollection',
+ type: 'OrderedCollectionPage',
totalItems: result.total,
first: baseUrl + '?page=1'
}
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']) && (
isViewActivityValid(activity.object) ||
isDislikeActivityValid(activity.object) ||
isFlagActivityValid(activity.object) ||
+ isPlaylistObjectValid(activity.object) ||
isCacheFileObjectValid(activity.object) ||
sanitizeAndCheckVideoCommentObject(activity.object) ||
return isBaseActivityValid(activity, 'Update') &&
(
isCacheFileObjectValid(activity.object) ||
+ isPlaylistObjectValid(activity.object) ||
sanitizeAndCheckVideoTorrentObject(activity.object) ||
sanitizeAndCheckActorObject(activity.object)
)
--- /dev/null
+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
+}
--- /dev/null
+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
+}
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)
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')
ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
SERVERS_BLOCKLIST: [ 'createdAt' ],
- USER_NOTIFICATIONS: [ 'createdAt' ]
+ USER_NOTIFICATIONS: [ 'createdAt' ],
+
+ VIDEO_PLAYLISTS: [ 'createdAt' ]
}
const OAUTH_LIFETIME = {
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
[VideoAbuseState.ACCEPTED]: 'Accepted'
}
+const VIDEO_PLAYLIST_PRIVACIES = {
+ [VideoPlaylistPrivacy.PUBLIC]: 'Public',
+ [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
+ [VideoPlaylistPrivacy.PRIVATE]: 'Private'
+}
+
const MIMETYPES = {
VIDEO: {
MIMETYPE_EXT: buildVideoMimetypeExt(),
VIDEO_IMPORT_STATES,
VIDEO_VIEW_LIFETIME,
CONTACT_FORM_LIFETIME,
+ VIDEO_PLAYLIST_PRIVACIES,
buildLanguages
}
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
ServerBlocklistModel,
UserNotificationModel,
UserNotificationSettingModel,
- VideoStreamingPlaylistModel
+ VideoStreamingPlaylistModel,
+ VideoPlaylistModel,
+ VideoPlaylistElementModel
])
// Check extensions exist in the database
) {
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
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)
actor = await retryTransactionWrapper(saveActorAndServerAndModelIfNotExist, result, ownerActor)
created = true
+ accountPlaylistsUrl = result.playlists
}
if (actor.Account) actor.Account.Actor = actor
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
}
name: string
summary: string
support?: string
+ playlists?: string
avatarName?: string
attributedTo: ActivityPubAttributedTo[]
}
avatarName,
summary: actorJSON.summary,
support: actorJSON.support,
+ playlists: actorJSON.playlists,
attributedTo: actorJSON.attributedTo
}
}
-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'
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 = {
--- /dev/null
+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)
+}
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
}
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 })
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 })
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)
+}
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
return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity)
}
+ if (objectType === 'Playlist') {
+ return retryTransactionWrapper(processUpdatePlaylist, byActor, activity)
+ }
+
return undefined
}
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)
+}
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
})
}
+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)
sendCreateVideo,
buildCreateActivity,
sendCreateVideoComment,
+ sendCreateVideoPlaylist,
sendCreateCacheFile
}
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)
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
}
// ---------------------------------------------------------------------------
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
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
}
// ---------------------------------------------------------------------------
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 : ''
export {
getVideoActivityPubUrl,
+ getVideoPlaylistElementActivityPubUrl,
+ getVideoPlaylistActivityPubUrl,
getVideoCacheStreamingPlaylistActivityPubUrl,
getVideoChannelActivityPubUrl,
getAccountActivityPubUrl,
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) {
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])
}
]
-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) => {
export {
localAccountValidator,
- accountsNameWithHostGetValidator
+ accountNameWithHostGetValidator
}
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)
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)
// ---------------------------------------------------------------------------
videoChannelsSearchSortValidator,
accountsBlocklistSortValidator,
serversBlocklistSortValidator,
- userNotificationsSortValidator
+ userNotificationsSortValidator,
+ videoPlaylistsSortValidator
}
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'),
// ---------------------------------------------------------------------------
export {
- listVideoAccountChannelsValidator,
videoChannelsAddValidator,
videoChannelsUpdateValidator,
videoChannelsRemoveValidator,
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'),
--- /dev/null
+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
+}
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: '
}
])
-const videosUpdateValidator = getCommonVideoAttributes().concat([
+const videosUpdateValidator = getCommonVideoEditAttributes().concat([
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
body('name')
.optional()
}
]
-function getCommonVideoAttributes () {
+function getCommonVideoEditAttributes () {
return [
body('thumbnailfile')
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
videosTerminateChangeOwnershipValidator,
videosAcceptChangeOwnershipValidator,
- getCommonVideoAttributes,
+ getCommonVideoEditAttributes,
commonVideosFiltersValidator
}
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'
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: [
}
]
})
+@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: [
})
VideoChannels: VideoChannelModel[]
+ @HasMany(() => VideoPlaylistModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'cascade',
+ hooks: true
+ })
+ VideoPlaylists: VideoPlaylistModel[]
+
@HasMany(() => VideoCommentModel, {
foreignKey: {
allowNull: false
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')
})
}
- 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)
}
id: this.url,
following: this.getFollowingUrl(),
followers: this.getFollowersUrl(),
+ playlists: this.getPlaylistsUrl(),
inbox: this.inboxUrl,
outbox: this.outboxUrl,
preferredUsername: this.preferredUsername,
return this.url + '/followers'
}
+ getPlaylistsUrl () {
+ return this.url + '/playlists'
+ }
+
getPublicKeyUrl () {
return this.url + '#main-key'
}
import { Sequelize } from 'sequelize-typescript'
+import * as validator from 'validator'
type SortType = { sortModel: any, sortValue: string }
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 + ')'
+}
+
+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 +
+ ')'
+}
- return query
+function buildWhereIdOrUUID (id: number | string) {
+ return validator.isInt('' + id) ? { id } : { uuid: id }
}
// ---------------------------------------------------------------------------
getSortOnModel,
createSimilarityAttribute,
throwIfNotValid,
- buildTrigramSearchIndex
+ buildServerIdsFollowedBy,
+ buildTrigramSearchIndex,
+ buildWhereIdOrUUID
}
// ---------------------------------------------------------------------------
Default,
DefaultScope,
ForeignKey,
- HasMany,
+ HasMany, IFindOptions,
Is,
Model,
Scopes,
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[] = [
}
]
-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 = {
]
})
@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: [
})
Videos: VideoModel[]
+ @HasMany(() => VideoPlaylistModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'cascade',
+ hooks: true
+ })
+ VideoPlaylists: VideoPlaylistModel[]
+
@BeforeDestroy
static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
if (!instance.Actor) {
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')
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 = {
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
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
--- /dev/null
+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
+ }
+}
--- /dev/null
+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
+ }
+ })
+ })
+ }
+}
isVideoDurationValid,
isVideoLanguageValid,
isVideoLicenceValid,
- isVideoNameValid, isVideoOriginallyPublishedAtValid,
+ isVideoNameValid,
isVideoPrivacyValid,
isVideoStateValid,
isVideoSupportValid
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,
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'
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[] = [
type ForAPIOptions = {
ids: number[]
+
+ videoPlaylistId?: number
+
withFiles?: boolean
}
serverAccountId: number
followerActorId: number
includeLocalVideos: boolean
+
filter?: VideoFilter
categoryOneOf?: number[]
nsfw?: boolean
languageOneOf?: string[]
tagsOneOf?: string[]
tagsAllOf?: string[]
+
withFiles?: boolean
+
accountId?: number
videoChannelId?: number
+
+ videoPlaylistId?: number
+
trendingDays?: number
user?: UserModel,
historyOfUser?: UserModel
@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) {
})
}
+ if (options.videoPlaylistId) {
+ query.include.push({
+ model: VideoPlaylistElementModel.unscoped(),
+ required: true
+ })
+ }
+
return query
},
[ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: 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: [],
})
Tags: TagModel[]
+ @HasMany(() => VideoPlaylistElementModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ VideoPlaylistElements: VideoPlaylistElementModel[]
+
@HasMany(() => VideoAbuseModel, {
foreignKey: {
name: 'videoId',
accountId?: number,
videoChannelId?: number,
followerActorId?: number
+ videoPlaylistId?: number,
trendingDays?: number,
user?: UserModel,
historyOfUser?: UserModel
withFiles: options.withFiles,
accountId: options.accountId,
videoChannelId: options.videoChannelId,
+ videoPlaylistId: options.videoPlaylistId,
includeLocalVideos: options.includeLocalVideos,
user: options.user,
historyOfUser: options.historyOfUser,
}
static load (id: number | string, t?: Sequelize.Transaction) {
- const where = VideoModel.buildWhereIdOrUUID(id)
+ const where = buildWhereIdOrUUID(id)
const options = {
where,
transaction: t
}
static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
- const where = VideoModel.buildWhereIdOrUUID(id)
+ const where = buildWhereIdOrUUID(id)
const options = {
where,
transaction: t
}
static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
- const where = VideoModel.buildWhereIdOrUUID(id)
+ const where = buildWhereIdOrUUID(id)
const options = {
attributes: [ 'id' ],
}
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' ] ],
}
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' ] ],
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
}
getThumbnailName () {
- // We always have a copy of the thumbnail
const extension = '.jpg'
return this.uuid + extension
}
--- /dev/null
+/* 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()
+ }
+ })
+})
--- /dev/null
+/* 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()
+ }
+ })
+})
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 |
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 {
id: string
following: string
followers: string
+ playlists?: string
inbox: string
outbox: string
preferredUsername: string
--- /dev/null
+export interface PlaylistElementObject {
+ id: string
+ type: 'PlaylistElement'
+
+ url: string
+ position: number
+
+ startTimestamp?: number
+ stopTimestamp?: number
+}
--- /dev/null
+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[]
+}
import { Actor } from './actor.model'
+import { Avatar } from '../avatars'
export interface Account extends Actor {
displayName: string
userId?: number
}
+
+export interface AccountSummary {
+ id: number
+ uuid: string
+ name: string
+ displayName: string
+ url: string
+ host: string
+ avatar?: Avatar
+}
-import { Video, VideoChannelAttribute, VideoConstant } from '../videos'
+import { Video, VideoChannelSummary, VideoConstant } from '../videos'
export interface VideosOverview {
channels: {
- channel: VideoChannelAttribute
+ channel: VideoChannelSummary
videos: Video[]
}[]
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
}
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,
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
isLocal: boolean
ownerAccount?: Account
}
+
+export interface VideoChannelSummary {
+ id: number
+ uuid: string
+ name: string
+ displayName: string
+ url: string
+ host: string
+ avatar?: Avatar
+}
--- /dev/null
+import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
+
+export interface VideoPlaylistCreate {
+ displayName: string
+ description: string
+ privacy: VideoPlaylistPrivacy
+
+ videoChannelId?: number
+
+ thumbnailfile?: Blob
+}
--- /dev/null
+export interface VideoPlaylistElementCreate {
+ startTimestamp?: number
+ stopTimestamp?: number
+}
--- /dev/null
+export interface VideoPlaylistElementUpdate {
+ startTimestamp?: number
+ stopTimestamp?: number
+}
--- /dev/null
+export enum VideoPlaylistPrivacy {
+ PUBLIC = 1,
+ UNLISTED = 2,
+ PRIVATE = 3
+}
--- /dev/null
+import { VideoPlaylistPrivacy } from './video-playlist-privacy.model'
+
+export interface VideoPlaylistUpdate {
+ displayName: string
+ description: string
+ privacy: VideoPlaylistPrivacy
+
+ videoChannelId?: number
+ thumbnailfile?: Blob
+}
--- /dev/null
+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
+}
-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'
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
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 {
-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 fields = omit(options.playlistAttrs, 'thumbnailfile')
- const videoName = `${videoUUID}-${resolution}-fragmented.mp4`
+ const attaches = options.playlistAttrs.thumbnailfile
+ ? { thumbnailfile: options.playlistAttrs.thumbnailfile }
+ : {}
- const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist)
+ return makeUploadRequest({
+ method: 'POST',
+ url: options.url,
+ path,
+ token: options.token,
+ fields,
+ attaches,
+ statusCodeExpected: options.expectedStatus
+ })
+}
- const length = parseInt(matches[1], 10)
- const offset = parseInt(matches[2], 10)
- const range = `${offset}-${offset + length - 1}`
+function updateVideoPlaylist (options: {
+ url: string,
+ token: string,
+ playlistAttrs: VideoPlaylistUpdate,
+ expectedStatus: number
+}) {
+ const path = '/api/v1/video-playlists/'
- const res2 = await getSegment(`${baseUrlSegment}/${videoUUID}/${videoName}`, 206, `bytes=${range}`)
+ const fields = omit(options.playlistAttrs, 'thumbnailfile')
- const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url)
+ const attaches = options.playlistAttrs.thumbnailfile
+ ? { thumbnailfile: options.playlistAttrs.thumbnailfile }
+ : {}
- const sha256Server = resSha.body[ videoName ][range]
- expect(sha256(res2.body)).to.equal(sha256Server)
+ 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
}
--- /dev/null
+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
+}
})
}
+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'
parseTorrentVideo,
getLocalVideos,
completeVideoCheck,
- checkVideoFilesWereRemoved
+ checkVideoFilesWereRemoved,
+ getPlaylistVideos
}