Add ability to list ownership changes of a video

This commit is contained in:
Chocobozzz
2026-04-01 17:34:23 +02:00
parent 2db076f65a
commit 4912c3025b
13 changed files with 335 additions and 103 deletions
@@ -1,6 +1,6 @@
import { CommonModule, NgClass } from '@angular/common'
import { Component, inject, viewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { AuthService, Notifier } from '@app/core'
import { Account } from '@app/shared/shared-main/account/account.model'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { VideoOwnershipService } from '@app/shared/shared-main/video/video-ownership.service'
@@ -30,6 +30,7 @@ import { MyAcceptOwnershipComponent } from './my-accept-ownership/my-accept-owne
export class MyOwnershipComponent {
private notifier = inject(Notifier)
private videoOwnershipService = inject(VideoOwnershipService)
private authService = inject(AuthService)
readonly myAccountAcceptOwnershipComponent = viewChild<MyAcceptOwnershipComponent>('myAcceptOwnershipComponent')
readonly table = viewChild<TableComponent<VideoChangeOwnership>>('table')
@@ -74,6 +75,10 @@ export class MyOwnershipComponent {
})
}
isReceiver (videoChangeOwnership: VideoChangeOwnership) {
return videoChangeOwnership.nextOwnerAccount.id === this.authService.getUser().account.id
}
private _dataLoader (options: DataLoaderOptionsBase) {
return this.videoOwnershipService.getOwnershipChanges(options.pagination, options.sort)
.pipe(
@@ -2,11 +2,12 @@ import { HttpStatusCode, ResultList, VideoChangeOwnership } from '@peertube/peer
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class ChangeOwnershipCommand extends AbstractCommand {
create (options: OverrideCommandOptions & {
videoId: number | string
username: string
}) {
create (
options: OverrideCommandOptions & {
videoId: number | string
username: string
}
) {
const { videoId, username } = options
const path = '/api/v1/videos/' + videoId + '/give-ownership'
@@ -33,10 +34,31 @@ export class ChangeOwnershipCommand extends AbstractCommand {
})
}
accept (options: OverrideCommandOptions & {
ownershipId: number
channelId: number
}) {
listOfVideo (
options: OverrideCommandOptions & {
videoId: number | string
state?: string
}
) {
const { videoId, state } = options
const path = '/api/v1/videos/' + videoId + '/ownership'
return this.getRequestBody<ResultList<VideoChangeOwnership>>({
...options,
path,
query: { state, sort: '-createdAt' },
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
accept (
options: OverrideCommandOptions & {
ownershipId: number
channelId: number
}
) {
const { ownershipId, channelId } = options
const path = '/api/v1/videos/ownership/' + ownershipId + '/accept'
@@ -50,9 +72,11 @@ export class ChangeOwnershipCommand extends AbstractCommand {
})
}
refuse (options: OverrideCommandOptions & {
ownershipId: number
}) {
refuse (
options: OverrideCommandOptions & {
ownershipId: number
}
) {
const { ownershipId } = options
const path = '/api/v1/videos/ownership/' + ownershipId + '/refuse'
@@ -2,6 +2,7 @@
import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models'
import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands'
import { expect } from 'chai'
describe('Test video change ownership API validator', function () {
let server: PeerTubeServer
@@ -98,6 +99,57 @@ describe('Test video change ownership API validator', function () {
})
})
describe('List video ownership changes', function () {
it('Should fail if not authenticated', async function () {
await server.changeOwnership.listOfVideo({
videoId: rootVideo.id,
token: null,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail with a non existing video', async function () {
await server.changeOwnership.listOfVideo({
videoId: 42,
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
})
it('Should fail with a video of another user', async function () {
for (const token of [ userToken, userEditorToken ]) {
await server.changeOwnership.listOfVideo({
videoId: rootVideo.id,
token,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
}
})
it('Should fail with an invalid state parameter', async function () {
await server.changeOwnership.listOfVideo({
videoId: rootVideo.id,
state: 'INVALID_STATE' as any,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should succeed with valid params', async function () {
const { data, total } = await server.changeOwnership.listOfVideo({
videoId: rootVideo.id
})
expect(total).to.be.a('number')
expect(data).to.be.an('array')
})
it('Should succeed with a state filter', async function () {
const { data } = await server.changeOwnership.listOfVideo({
videoId: rootVideo.id,
state: 'WAITING'
})
expect(data).to.be.an('array')
})
})
describe('Reject ownership change request', function () {
it('Should fail if not authenticated', async function () {
await server.changeOwnership.refuse({
@@ -105,8 +105,6 @@ describe('Test video change ownership - nominal', function () {
})
it('Should send a request to change ownership of a video', async function () {
this.timeout(15000)
await command.create({ token: firstUserToken, videoId: servers[0].store.videoCreated.id, username: secondUser })
})
@@ -215,8 +213,6 @@ describe('Test video change ownership - nominal', function () {
})
it('Should accept a live ownership change', async function () {
this.timeout(20000)
await command.accept({ token: secondUserToken, ownershipId: lastRequestId, channelId: secondUserChannelId })
await waitJobs(servers)
@@ -230,6 +226,28 @@ describe('Test video change ownership - nominal', function () {
}
})
it('Should list ownership changes for a specific video', async function () {
const body = await command.listOfVideo({ videoId: servers[0].store.videoCreated.id })
expect(body.total).to.equal(2)
expect(body.data).to.be.an('array')
expect(body.data.length).to.equal(2)
expect(body.data[0].video.id).to.equal(servers[0].store.videoCreated.id)
expect(body.data.map(i => i.status)).to.have.members([ 'ACCEPTED', 'REFUSED' ])
})
it('Should list ownership changes with state filter', async function () {
const body = await command.listOfVideo({
videoId: servers[0].store.videoCreated.id,
state: 'ACCEPTED'
})
expect(body.total).to.equal(1)
expect(body.data[0].status).to.equal('ACCEPTED')
})
after(async function () {
await cleanupTests(servers)
})
@@ -1,4 +1,4 @@
import { HttpStatusCode, VideoChangeOwnershipStatus, VideoChannelActivityAction } from '@peertube/peertube-models'
import { HttpStatusCode, VideoChangeOwnershipStatus, VideoChangeOwnershipStatusType, VideoChannelActivityAction } from '@peertube/peertube-models'
import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { MVideoFull } from '@server/types/models/index.js'
@@ -16,6 +16,7 @@ import {
setDefaultPagination,
videosAcceptChangeOwnershipValidator,
videosChangeOwnershipValidator,
videosListVideoOwnershipChangesValidator,
videosTerminateChangeOwnershipValidator
} from '../../../middlewares/index.js'
import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership.js'
@@ -31,6 +32,15 @@ ownershipVideoRouter.post(
asyncRetryTransactionMiddleware(giveVideoOwnership)
)
ownershipVideoRouter.get(
'/:videoId/ownership',
authenticate,
asyncMiddleware(videosListVideoOwnershipChangesValidator),
paginationValidator,
setDefaultPagination,
asyncRetryTransactionMiddleware(listVideoOwnershipChanges)
)
ownershipVideoRouter.get(
'/ownership',
authenticate,
@@ -99,6 +109,21 @@ async function giveVideoOwnership (req: express.Request, res: express.Response)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function listVideoOwnershipChanges (req: express.Request, res: express.Response) {
const videoId = res.locals.videoWithRights.id
const state = req.query.state as VideoChangeOwnershipStatusType | undefined
const resultList = await VideoChangeOwnershipModel.listForApi({
videoId,
state,
start: req.query.start,
count: req.query.count,
sort: req.query.sort || 'createdAt'
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listVideoOwnership (req: express.Request, res: express.Response) {
const currentAccountId = res.locals.oauth.token.User.Account.id
+14 -5
View File
@@ -58,27 +58,36 @@ const lTags = loggerTagsFactory('notifier')
class Notifier {
private readonly notificationModels = {
newVideoOrLive: [ NewVideoOrLiveForSubscribers ],
publicationAfterTranscoding: [ OwnedPublicationAfterTranscoding ],
publicationAfterScheduleUpdate: [ OwnedPublicationAfterScheduleUpdate ],
publicationAfterAutoUnblacklist: [ OwnedPublicationAfterAutoUnblacklist ],
videoStudioEditionFinished: [ StudioEditionFinishedForOwner ],
videoTranscriptionGenerated: [ VideoTranscriptionGeneratedForOwner ],
newComment: [ CommentMention, NewCommentForVideoOwner ],
commentApproval: [ CommentMention ],
newAbuse: [ NewAbuseForModerators ],
abuseStateChange: [ AbuseStateChangeForReporter ],
newAbuseMessage: [ NewAbuseMessageForReporter, NewAbuseMessageForModerators ],
newBlacklist: [ NewBlacklistForOwner ],
newAutoBlacklist: [ NewAutoBlacklistForModerators ],
unblacklist: [ UnblacklistForOwner ],
importFinished: [ ImportFinishedForOwner ],
directRegistration: [ DirectRegistrationForModerators ],
registrationRequest: [ RegistrationRequestForModerators ],
userFollow: [ FollowForUser ],
instanceFollow: [ FollowForInstance ],
autoInstanceFollow: [ AutoFollowForInstance ],
newAutoBlacklist: [ NewAutoBlacklistForModerators ],
abuseStateChange: [ AbuseStateChangeForReporter ],
newAbuseMessage: [ NewAbuseMessageForReporter, NewAbuseMessageForModerators ],
newPeertubeVersion: [ NewPeerTubeVersionForAdmins ],
newPluginVersion: [ NewPluginVersionForAdmins ],
videoStudioEditionFinished: [ StudioEditionFinishedForOwner ],
videoTranscriptionGenerated: [ VideoTranscriptionGeneratedForOwner ],
channelCollaboratorInvitation: [ InvitedToCollaborateToChannel ],
channelCollaborationAccepted: [ AcceptedToCollaborateToChannel ],
channelCollaborationRefused: [ RefusedToCollaborateToChannel ]
@@ -4,7 +4,7 @@ import { CONFIG } from '@server/initializers/config.js'
import { AccountModel } from '@server/models/account/account.js'
import { MUserAccountId, MVideoChangeOwnershipFull, MVideoWithAllFiles } from '@server/types/models/index.js'
import express from 'express'
import { param } from 'express-validator'
import { param, query } from 'express-validator'
import {
areValidationErrors,
checkCanManageAccount,
@@ -16,6 +16,7 @@ import {
isValidVideoIdParam
} from '../shared/index.js'
import { VideoChangeOwnershipModel } from '@server/models/video/video-change-ownership.js'
import { VideoModel } from '@server/models/video/video.js'
export const videosChangeOwnershipValidator = [
isValidVideoIdParam('videoId'),
@@ -98,7 +99,7 @@ export const videosAcceptChangeOwnershipValidator = [
const videoChangeOwnership = res.locals.videoChangeOwnership
const video = videoChangeOwnership.Video
const video = await VideoModel.loadWithFiles(videoChangeOwnership.Video.id)
if (!await checkCanAccept(video, req, res)) return
@@ -106,6 +107,38 @@ export const videosAcceptChangeOwnershipValidator = [
}
]
export const videosListVideoOwnershipChangesValidator = [
isValidVideoIdParam('videoId'),
query('state')
.optional()
.custom(value => {
if (!Object.values(VideoChangeOwnershipStatus).includes(value)) return false
return true
}),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'with-rights')) return
// Check if the user who did the request is able to manage the video
if (
!await checkCanManageVideo({
user: res.locals.oauth.token.User,
video: res.locals.videoWithRights,
right: UserRight.CHANGE_VIDEO_OWNERSHIP,
checkIsOwner: false,
checkIsLocal: true,
req,
res
})
) return
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
@@ -1,11 +1,17 @@
import { VideoChannelCollaboratorState } from '@peertube/peertube-models'
import { VideoChangeOwnershipStatusType, VideoChannelCollaboratorState } from '@peertube/peertube-models'
import { AbstractListQuery, AbstractListQueryOptions } from '@server/models/shared/abstract-list-query.js'
import { getActorJoin, getAvatarsJoin, getChannelJoin } from '@server/models/shared/sql/actor-helpers.js'
import { Sequelize } from 'sequelize'
import { VideoChangeOwnershipTableAttributes } from './video-change-ownership-table-attributes.js'
export interface ListVideoChangeOwnershipOptions extends AbstractListQueryOptions {
accountId: number
id?: number
accountId?: number
state?: VideoChangeOwnershipStatusType
videoId?: number
}
export class VideoChangeOwnershipListQueryBuilder extends AbstractListQuery {
@@ -31,16 +37,42 @@ export class VideoChangeOwnershipListQueryBuilder extends AbstractListQuery {
// ---------------------------------------------------------------------------
protected buildSubQueryWhere () {
this.buildChannelCollaboratorsJoin()
this.subQueryWhere = `WHERE "VideoChangeOwnershipModel"."nextOwnerAccountId" = :nextOwnerAccountId ` +
`OR (` +
`"Video->VideoChannel->VideoChannelCollaborators"."accountId" = :collaborationAccountId OR ` +
`"Video->VideoChannel->Account"."id" = :videoAccountId` +
`)`
const where: string[] = []
this.replacements.collaborationAccountId = this.options.accountId
this.replacements.videoAccountId = this.options.accountId
this.replacements.nextOwnerAccountId = this.options.accountId
if (this.options.accountId) {
this.buildChannelCollaboratorsJoin()
where.push(
`"VideoChangeOwnershipModel"."nextOwnerAccountId" = :nextOwnerAccountId ` +
`OR (` +
`"Video->VideoChannel->VideoChannelCollaborators"."accountId" = :collaborationAccountId OR ` +
`"Video->VideoChannel->Account"."id" = :videoAccountId` +
`)`
)
this.replacements.collaborationAccountId = this.options.accountId
this.replacements.videoAccountId = this.options.accountId
this.replacements.nextOwnerAccountId = this.options.accountId
}
if (this.options.state) {
where.push(`"VideoChangeOwnershipModel"."status" = :status`)
this.replacements.status = this.options.state
}
if (this.options.videoId) {
where.push(`"VideoChangeOwnershipModel"."videoId" = :videoId`)
this.replacements.videoId = this.options.videoId
}
if (this.options.id) {
where.push(`"VideoChangeOwnershipModel"."id" = :id`)
this.replacements.id = this.options.id
}
this.subQueryWhere = where.length !== 0
? `WHERE ${where.join(' AND ')}`
: ''
}
// ---------------------------------------------------------------------------
@@ -1,22 +1,13 @@
import { VideoChangeOwnership, VideoChangeOwnershipStatus, type VideoChangeOwnershipStatusType } from '@peertube/peertube-models'
import {
MVideoChangeOwnership,
MVideoChangeOwnershipFormattable,
MVideoChangeOwnershipFull
} from '@server/types/models/video/video-change-ownership.js'
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
import { MVideoChangeOwnership, MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership.js'
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
import { AccountModel } from '../account/account.js'
import { SequelizeModel, buildSQLAttributes } from '../shared/index.js'
import {
ListVideoChangeOwnershipOptions,
VideoChangeOwnershipListQueryBuilder
} from './sql/change-ownership/video-change-ownership-list-query-builder.js'
import { VideoModel, ScopeNames as VideoScopeNames } from './video.js'
enum ScopeNames {
WITH_ACCOUNTS = 'WITH_ACCOUNTS',
WITH_VIDEO = 'WITH_VIDEO'
}
import { VideoModel } from './video.js'
@Table({
tableName: 'videoChangeOwnership',
@@ -32,35 +23,6 @@ enum ScopeNames {
}
]
})
@Scopes(() => ({
[ScopeNames.WITH_ACCOUNTS]: {
include: [
{
model: AccountModel,
as: 'Initiator',
required: true
},
{
model: AccountModel,
as: 'NextOwner',
required: true
}
]
},
[ScopeNames.WITH_VIDEO]: {
include: [
{
model: VideoModel.scope([
VideoScopeNames.WITH_THUMBNAILS,
VideoScopeNames.WITH_WEB_VIDEO_FILES,
VideoScopeNames.WITH_STREAMING_PLAYLISTS,
VideoScopeNames.WITH_ACCOUNT_DETAILS
]),
required: true
}
]
}
}))
export class VideoChangeOwnershipModel extends SequelizeModel<VideoChangeOwnershipModel> {
@CreatedAt
declare createdAt: Date
@@ -128,20 +90,19 @@ export class VideoChangeOwnershipModel extends SequelizeModel<VideoChangeOwnersh
}
static load (id: number): Promise<MVideoChangeOwnershipFull> {
return VideoChangeOwnershipModel.scope([ ScopeNames.WITH_ACCOUNTS, ScopeNames.WITH_VIDEO ])
.findByPk(id)
return new VideoChangeOwnershipListQueryBuilder(VideoChangeOwnershipModel.sequelize, { id }).get<MVideoChangeOwnershipFull>()
}
static loadPendingByVideo (videoId: number): Promise<MVideoChangeOwnership> {
return VideoChangeOwnershipModel.findOne({
where: {
videoId,
status: VideoChangeOwnershipStatus.WAITING
}
})
return new VideoChangeOwnershipListQueryBuilder(VideoChangeOwnershipModel.sequelize, {
videoId,
state: VideoChangeOwnershipStatus.WAITING
}).get<MVideoChangeOwnershipFull>()
}
toFormattedJSON (this: MVideoChangeOwnershipFormattable): VideoChangeOwnership {
// ---------------------------------------------------------------------------
toFormattedJSON (this: MVideoChangeOwnershipFull): VideoChangeOwnership {
return {
id: this.id,
status: this.status,
@@ -10,7 +10,8 @@ import {
import { uuidToShort } from '@peertube/peertube-node-utils'
import { VIDEO_CHANNEL_ACTIVITY_ACTIONS, VIDEO_CHANNEL_ACTIVITY_TARGETS } from '@server/initializers/constants.js'
import {
MAccountActor,
MAccountNames,
MAccountUrl,
MChannelId,
MChannelSync,
MUserAccountId,
@@ -309,7 +310,7 @@ export class VideoChannelActivityModel extends SequelizeModel<VideoChannelActivi
user: MUserAccountId
channel: MChannelId
video: MVideo
targetAccount: MAccountActor
targetAccount: MAccountNames & MAccountUrl
transaction: Transaction
}) {
const { action, user, channel, video, targetAccount, transaction } = options
+6 -2
View File
@@ -12,7 +12,8 @@ import {
MActorId,
MActorSummary,
MActorSummaryFormattable,
MActorUrl
MActorUrl,
MActorUsername
} from '../actor/index.js'
import { MChannelDefault } from '../video/video-channel.js'
import { MAccountBlocklistId } from './account-blocklist.js'
@@ -71,7 +72,6 @@ export type MAccountLight =
// ############################################################################
// Full actor
export type MAccountActor =
& MAccount
& Use<'Actor', MActor>
@@ -84,6 +84,10 @@ export type MAccountHost =
& MAccount
& Use<'Actor', MActorHost>
export type MAccountNames =
& Pick<MAccount, 'id' | 'name'>
& Use<'Actor', MActorUsername>
// ############################################################################
// For API
@@ -1,7 +1,7 @@
import { VideoChangeOwnershipModel } from '@server/models/video/video-change-ownership.js'
import { PickWith } from '@peertube/peertube-typescript-utils'
import { MAccountDefault, MAccountFormattable } from '../account/account.js'
import { MVideoFormattable, MVideoWithAllFiles } from './video.js'
import { VideoChangeOwnershipModel } from '@server/models/video/video-change-ownership.js'
import { MAccountFormattable } from '../account/account.js'
import { MVideoFormattable } from './video.js'
type Use<K extends keyof VideoChangeOwnershipModel, M> = PickWith<VideoChangeOwnershipModel, K, M>
@@ -11,16 +11,6 @@ export type MVideoChangeOwnership = Omit<VideoChangeOwnershipModel, 'Initiator'
export type MVideoChangeOwnershipFull =
& MVideoChangeOwnership
& Use<'Initiator', MAccountDefault>
& Use<'NextOwner', MAccountDefault>
& Use<'Video', MVideoWithAllFiles>
// ############################################################################
// Format for API or AP object
export type MVideoChangeOwnershipFormattable =
& Pick<MVideoChangeOwnership, 'id' | 'status' | 'createdAt'>
& Use<'Initiator', MAccountFormattable>
& Use<'NextOwner', MAccountFormattable>
& Use<'Video', MVideoFormattable>
+78
View File
@@ -2967,6 +2967,7 @@ paths:
/api/v1/videos/ownership:
get:
summary: List video ownership changes
description: List ownership change requests received by the authenticated user and requests sent by a channel managed by the authenticated user
tags:
- Video Ownership Change
security:
@@ -2974,6 +2975,18 @@ paths:
responses:
'200':
description: successful operation
content:
'application/json':
schema:
type: object
properties:
total:
type: integer
example: 1
data:
type: array
items:
$ref: '#/components/schemas/VideoChangeOwnership'
'/api/v1/videos/ownership/{id}/accept':
post:
@@ -3009,6 +3022,49 @@ paths:
'404':
description: video ownership change not found
'/api/v1/videos/{id}/ownership':
get:
summary: List ownership change requests for a video
description: List all ownership change requests for a specific video. The authenticated user must be able to manage the video.
tags:
- Video Ownership Change
security:
- OAuth2: []
parameters:
- $ref: '#/components/parameters/idOrUUID'
- name: state
in: query
required: false
description: Filter by ownership change state (WAITING, ACCEPTED, REFUSED)
schema:
type: string
enum:
- WAITING
- ACCEPTED
- REFUSED
- $ref: '#/components/parameters/start'
- $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/sort'
responses:
'200':
description: successful operation
content:
'application/json':
schema:
type: object
properties:
total:
type: integer
example: 1
data:
type: array
items:
$ref: '#/components/schemas/VideoChangeOwnership'
'403':
description: cannot manage the video
'404':
description: video not found
'/api/v1/videos/{id}/give-ownership':
post:
summary: Request ownership change
@@ -9977,6 +10033,28 @@ components:
type: integer
nsfw:
type: boolean
VideoChangeOwnership:
type: object
description: Representation of a video ownership change
properties:
id:
$ref: '#/components/schemas/id'
status:
type: string
description: Status of the ownership change request
enum:
- WAITING
- ACCEPTED
- REFUSED
initiatorAccount:
$ref: '#/components/schemas/Account'
nextOwnerAccount:
$ref: '#/components/schemas/Account'
video:
$ref: '#/components/schemas/Video'
createdAt:
type: string
format: date-time
VideoPlaylist:
properties:
id: