Add ability to list imports of a channel sync

This commit is contained in:
Chocobozzz 2022-08-10 11:51:13 +02:00
parent 0567049a98
commit a3b472a12e
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
37 changed files with 565 additions and 179 deletions

View File

@ -30,12 +30,13 @@
<ng-template pTemplate="header"> <ng-template pTemplate="header">
<tr> <tr>
<th style="width: 10%"><my-global-icon iconName="columns"></my-global-icon></th> <th style="width: 10%"></th>
<th style="width: 25%" i18n pSortableColumn="externalChannelUrl">External Channel <p-sortIcon field="externalChannelUrl"></p-sortIcon></th> <th style="width: 25%" i18n pSortableColumn="externalChannelUrl">External Channel <p-sortIcon field="externalChannelUrl"></p-sortIcon></th>
<th style="width: 25%" i18n pSortableColumn="videoChannel">Channel <p-sortIcon field="videoChannel"></p-sortIcon></th> <th style="width: 25%" i18n pSortableColumn="videoChannel">Channel <p-sortIcon field="videoChannel"></p-sortIcon></th>
<th style="width: 10%" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> <th style="width: 10%" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
<th style="width: 10%" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 10%" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 10%" i18n pSortableColumn="lastSyncAt">Last synchronization at <p-sortIcon field="lastSyncAt"></p-sortIcon></th> <th style="width: 10%" i18n pSortableColumn="lastSyncAt">Last synchronization at <p-sortIcon field="lastSyncAt"></p-sortIcon></th>
<th></th>
</tr> </tr>
</ng-template> </ng-template>
@ -78,6 +79,12 @@
<td>{{ videoChannelSync.createdAt | date: 'short' }}</td> <td>{{ videoChannelSync.createdAt | date: 'short' }}</td>
<td>{{ videoChannelSync.lastSyncAt | date: 'short' }}</td> <td>{{ videoChannelSync.lastSyncAt | date: 'short' }}</td>
<td>
<a i18n routerLink="/my-library/video-imports" [queryParams]="{ search: 'videoChannelSyncId:' + videoChannelSync.id }" class="peertube-button-link grey-button">
List imports
</a>
</td>
</tr> </tr>
</ng-template> </ng-template>
</p-table> </p-table>

View File

@ -100,7 +100,7 @@ export class MyVideoChannelSyncsComponent extends RestTable implements OnInit {
} }
fullySynchronize (videoChannelSync: VideoChannelSync) { fullySynchronize (videoChannelSync: VideoChannelSync) {
this.videoChannelService.importVideos(videoChannelSync.channel.name, videoChannelSync.externalChannelUrl) this.videoChannelService.importVideos(videoChannelSync.channel.name, videoChannelSync.externalChannelUrl, videoChannelSync.id)
.subscribe({ .subscribe({
next: () => { next: () => {
this.notifier.success($localize`Full synchronization requested successfully for ${videoChannelSync.channel.displayName}.`) this.notifier.success($localize`Full synchronization requested successfully for ${videoChannelSync.channel.displayName}.`)

View File

@ -59,7 +59,7 @@ export class VideoChannelSyncEditComponent extends FormReactive implements OnIni
this.videoChannelSyncService.createSync(videoChannelSyncCreate) this.videoChannelSyncService.createSync(videoChannelSyncCreate)
.pipe(mergeMap(({ videoChannelSync }) => { .pipe(mergeMap(({ videoChannelSync }) => {
return importExistingVideos return importExistingVideos
? this.videoChannelService.importVideos(videoChannelSync.channel.name, videoChannelSync.externalChannelUrl) ? this.videoChannelService.importVideos(videoChannelSync.channel.name, videoChannelSync.externalChannelUrl, videoChannelSync.id)
: Promise.resolve(null) : Promise.resolve(null)
})) }))
.subscribe({ .subscribe({

View File

@ -3,9 +3,18 @@
<ng-container i18n>My imports</ng-container> <ng-container i18n>My imports</ng-container>
</h1> </h1>
<div class="mb-4 d-flex justify-content-between">
<my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
<a routerLink="/my-library/video-channel-syncs" class="button-link">
<my-global-icon iconName="repeat" aria-hidden="true"></my-global-icon>
<ng-container i18n>My synchronizations</ng-container>
</a>
</div>
<p-table <p-table
[value]="videoImports" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" [value]="videoImports" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" dataKey="id"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate [showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} imports" currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} imports"
[expandedRowKeys]="expandedRows" [expandedRowKeys]="expandedRows"

View File

@ -8,3 +8,9 @@ pre {
.video-import-error { .video-import-error {
color: #ff0000; color: #ff0000;
} }
.button-link {
@include peertube-button-link;
@include grey-button;
@include button-with-icon(18px, 3px, -1px);
}

View File

@ -33,12 +33,16 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
switch (state) { switch (state) {
case VideoImportState.FAILED: case VideoImportState.FAILED:
return 'badge-red' return 'badge-red'
case VideoImportState.REJECTED: case VideoImportState.REJECTED:
return 'badge-banned' return 'badge-banned'
case VideoImportState.PENDING: case VideoImportState.PENDING:
return 'badge-yellow' return 'badge-yellow'
case VideoImportState.PROCESSING: case VideoImportState.PROCESSING:
return 'badge-blue' return 'badge-blue'
default: default:
return 'badge-green' return 'badge-green'
} }
@ -87,7 +91,7 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
} }
protected reloadData () { protected reloadData () {
this.videoImportService.getMyVideoImports(this.pagination, this.sort) this.videoImportService.getMyVideoImports(this.pagination, this.sort, this.search)
.subscribe({ .subscribe({
next: resultList => { next: resultList => {
this.videoImports = resultList.data this.videoImports = resultList.data

View File

@ -3,7 +3,14 @@ import { catchError, map, tap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
import { ActorImage, ResultList, VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '@shared/models' import {
ActorImage,
ResultList,
VideoChannel as VideoChannelServer,
VideoChannelCreate,
VideoChannelUpdate,
VideosImportInChannelCreate
} from '@shared/models'
import { environment } from '../../../../environments/environment' import { environment } from '../../../../environments/environment'
import { Account } from '../account' import { Account } from '../account'
import { AccountService } from '../account/account.service' import { AccountService } from '../account/account.service'
@ -96,9 +103,15 @@ export class VideoChannelService {
.pipe(catchError(err => this.restExtractor.handleError(err))) .pipe(catchError(err => this.restExtractor.handleError(err)))
} }
importVideos (videoChannelName: string, externalChannelUrl: string) { importVideos (videoChannelName: string, externalChannelUrl: string, syncId?: number) {
const path = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/import-videos' const path = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/import-videos'
return this.authHttp.post(path, { externalChannelUrl })
const body: VideosImportInChannelCreate = {
externalChannelUrl,
videoChannelSyncId: syncId
}
return this.authHttp.post(path, body)
.pipe(catchError(err => this.restExtractor.handleError(err))) .pipe(catchError(err => this.restExtractor.handleError(err)))
} }
} }

View File

@ -43,10 +43,23 @@ export class VideoImportService {
.pipe(catchError(res => this.restExtractor.handleError(res))) .pipe(catchError(res => this.restExtractor.handleError(res)))
} }
getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoImport>> { getMyVideoImports (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<VideoImport>> {
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)
if (search) {
const filters = this.restService.parseQueryStringFilter(search, {
videoChannelSyncId: {
prefix: 'videoChannelSyncId:'
},
targetUrl: {
prefix: 'targetUrl:'
}
})
params = this.restService.addObjectParams(params, filters)
}
return this.authHttp return this.authHttp
.get<ResultList<VideoImport>>(UserService.BASE_USERS_URL + '/me/videos/imports', { params }) .get<ResultList<VideoImport>>(UserService.BASE_USERS_URL + '/me/videos/imports', { params })
.pipe( .pipe(

View File

@ -25,7 +25,13 @@ import {
usersUpdateMeValidator, usersUpdateMeValidator,
usersVideoRatingValidator usersVideoRatingValidator
} from '../../../middlewares' } from '../../../middlewares'
import { deleteMeValidator, usersVideosValidator, videoImportsSortValidator, videosSortValidator } from '../../../middlewares/validators' import {
deleteMeValidator,
getMyVideoImportsValidator,
usersVideosValidator,
videoImportsSortValidator,
videosSortValidator
} from '../../../middlewares/validators'
import { updateAvatarValidator } from '../../../middlewares/validators/actor-image' import { updateAvatarValidator } from '../../../middlewares/validators/actor-image'
import { AccountModel } from '../../../models/account/account' import { AccountModel } from '../../../models/account/account'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate' import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
@ -60,6 +66,7 @@ meRouter.get('/me/videos/imports',
videoImportsSortValidator, videoImportsSortValidator,
setDefaultSort, setDefaultSort,
setDefaultPagination, setDefaultPagination,
getMyVideoImportsValidator,
asyncMiddleware(getUserVideoImports) asyncMiddleware(getUserVideoImports)
) )
@ -138,7 +145,7 @@ async function getUserVideoImports (req: express.Request, res: express.Response)
const resultList = await VideoImportModel.listUserVideoImportsForApi({ const resultList = await VideoImportModel.listUserVideoImportsForApi({
userId: user.id, userId: user.id,
...pick(req.query, [ 'targetUrl', 'start', 'count', 'sort' ]) ...pick(req.query, [ 'targetUrl', 'start', 'count', 'sort', 'search', 'videoChannelSyncId' ])
}) })
return res.json(getFormattedObjects(resultList.data, resultList.total)) return res.json(getFormattedObjects(resultList.data, resultList.total))

View File

@ -6,7 +6,7 @@ import { ActorFollowModel } from '@server/models/actor/actor-follow'
import { getServerActor } from '@server/models/application/application' import { getServerActor } from '@server/models/application/application'
import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils'
import { MChannelBannerAccountDefault } from '@server/types/models' import { MChannelBannerAccountDefault } from '@server/types/models'
import { ActorImageType, HttpStatusCode, VideoChannelCreate, VideoChannelUpdate } from '@shared/models' import { ActorImageType, HttpStatusCode, VideoChannelCreate, VideoChannelUpdate, VideosImportInChannelCreate } from '@shared/models'
import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger' import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger'
import { resetSequelizeInstance } from '../../helpers/database-utils' import { resetSequelizeInstance } from '../../helpers/database-utils'
import { buildNSFWFilter, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' import { buildNSFWFilter, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
@ -166,7 +166,7 @@ videoChannelRouter.get('/:nameWithHost/followers',
videoChannelRouter.post('/:nameWithHost/import-videos', videoChannelRouter.post('/:nameWithHost/import-videos',
authenticate, authenticate,
asyncMiddleware(videoChannelsNameWithHostValidator), asyncMiddleware(videoChannelsNameWithHostValidator),
videoChannelImportVideosValidator, asyncMiddleware(videoChannelImportVideosValidator),
ensureIsLocalChannel, ensureIsLocalChannel,
ensureCanManageChannel, ensureCanManageChannel,
asyncMiddleware(ensureChannelOwnerCanUpload), asyncMiddleware(ensureChannelOwnerCanUpload),
@ -418,13 +418,14 @@ async function listVideoChannelFollowers (req: express.Request, res: express.Res
} }
async function importVideosInChannel (req: express.Request, res: express.Response) { async function importVideosInChannel (req: express.Request, res: express.Response) {
const { externalChannelUrl } = req.body const { externalChannelUrl } = req.body as VideosImportInChannelCreate
await JobQueue.Instance.createJob({ await JobQueue.Instance.createJob({
type: 'video-channel-import', type: 'video-channel-import',
payload: { payload: {
externalChannelUrl, externalChannelUrl,
videoChannelId: res.locals.videoChannel.id videoChannelId: res.locals.videoChannel.id,
partOfChannelSyncId: res.locals.videoChannelSync?.id
} }
}) })

View File

@ -25,7 +25,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 730 const LAST_MIGRATION_VERSION = 735
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -0,0 +1,32 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
db: any
}): Promise<void> {
await utils.queryInterface.addColumn('videoImport', 'videoChannelSyncId', {
type: Sequelize.INTEGER,
defaultValue: null,
allowNull: true,
references: {
model: 'videoChannelSync',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
}, { transaction: utils.transaction })
}
async function down (utils: {
queryInterface: Sequelize.QueryInterface
transaction: Sequelize.Transaction
}) {
await utils.queryInterface.dropTable('videoChannelSync', { transaction: utils.transaction })
}
export {
up,
down
}

View File

@ -3,6 +3,8 @@ import { logger } from '@server/helpers/logger'
import { CONFIG } from '@server/initializers/config' import { CONFIG } from '@server/initializers/config'
import { synchronizeChannel } from '@server/lib/sync-channel' import { synchronizeChannel } from '@server/lib/sync-channel'
import { VideoChannelModel } from '@server/models/video/video-channel' import { VideoChannelModel } from '@server/models/video/video-channel'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
import { MChannelSync } from '@server/types/models'
import { VideoChannelImportPayload } from '@shared/models' import { VideoChannelImportPayload } from '@shared/models'
export async function processVideoChannelImport (job: Job) { export async function processVideoChannelImport (job: Job) {
@ -12,13 +14,20 @@ export async function processVideoChannelImport (job: Job) {
// Channel import requires only http upload to be allowed // Channel import requires only http upload to be allowed
if (!CONFIG.IMPORT.VIDEOS.HTTP.ENABLED) { if (!CONFIG.IMPORT.VIDEOS.HTTP.ENABLED) {
logger.error('Cannot import channel as the HTTP upload is disabled') throw new Error('Cannot import channel as the HTTP upload is disabled')
return
} }
if (!CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED) { if (!CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED) {
logger.error('Cannot import channel as the synchronization is disabled') throw new Error('Cannot import channel as the synchronization is disabled')
return }
let channelSync: MChannelSync
if (payload.partOfChannelSyncId) {
channelSync = await VideoChannelSyncModel.loadWithChannel(payload.partOfChannelSyncId)
if (!channelSync) {
throw new Error('Unlnown channel sync specified in videos channel import')
}
} }
const videoChannel = await VideoChannelModel.loadAndPopulateAccount(payload.videoChannelId) const videoChannel = await VideoChannelModel.loadAndPopulateAccount(payload.videoChannelId)
@ -28,7 +37,8 @@ export async function processVideoChannelImport (job: Job) {
await synchronizeChannel({ await synchronizeChannel({
channel: videoChannel, channel: videoChannel,
externalChannelUrl: payload.externalChannelUrl externalChannelUrl: payload.externalChannelUrl,
channelSync
}) })
} catch (err) { } catch (err) {
logger.error(`Failed to import channel ${videoChannel.name}`, { err }) logger.error(`Failed to import channel ${videoChannel.name}`, { err })

View File

@ -36,10 +36,6 @@ export class VideoChannelSyncLatestScheduler extends AbstractScheduler {
const onlyAfter = sync.lastSyncAt || sync.createdAt const onlyAfter = sync.lastSyncAt || sync.createdAt
sync.state = VideoChannelSyncState.PROCESSING
sync.lastSyncAt = new Date()
await sync.save()
await synchronizeChannel({ await synchronizeChannel({
channel, channel,
externalChannelUrl: sync.externalChannelUrl, externalChannelUrl: sync.externalChannelUrl,

View File

@ -18,6 +18,12 @@ export async function synchronizeChannel (options: {
}) { }) {
const { channel, externalChannelUrl, videosCountLimit, onlyAfter, channelSync } = options const { channel, externalChannelUrl, videosCountLimit, onlyAfter, channelSync } = options
if (channelSync) {
channelSync.state = VideoChannelSyncState.PROCESSING
channelSync.lastSyncAt = new Date()
await channelSync.save()
}
const user = await UserModel.loadByChannelActorId(channel.actorId) const user = await UserModel.loadByChannelActorId(channel.actorId)
const youtubeDL = new YoutubeDLWrapper( const youtubeDL = new YoutubeDLWrapper(
externalChannelUrl, externalChannelUrl,
@ -70,6 +76,7 @@ export async function synchronizeChannel (options: {
children.push(job) children.push(job)
} }
// Will update the channel sync status
const parent: CreateJobArgument = { const parent: CreateJobArgument = {
type: 'after-video-channel-import', type: 'after-video-channel-import',
payload: { payload: {

View File

@ -206,7 +206,8 @@ async function buildYoutubeDLImport (options: {
videoImportAttributes: { videoImportAttributes: {
targetUrl, targetUrl,
state: VideoImportState.PENDING, state: VideoImportState.PENDING,
userId: user.id userId: user.id,
videoChannelSyncId: channelSync?.id
} }
}) })

View File

@ -4,6 +4,7 @@ export * from './utils'
export * from './video-blacklists' export * from './video-blacklists'
export * from './video-captions' export * from './video-captions'
export * from './video-channels' export * from './video-channels'
export * from './video-channel-syncs'
export * from './video-comments' export * from './video-comments'
export * from './video-imports' export * from './video-imports'
export * from './video-ownerships' export * from './video-ownerships'

View File

@ -0,0 +1,24 @@
import express from 'express'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
import { HttpStatusCode } from '@shared/models'
async function doesVideoChannelSyncIdExist (id: number, res: express.Response) {
const sync = await VideoChannelSyncModel.loadWithChannel(+id)
if (!sync) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video channel sync not found'
})
return false
}
res.locals.videoChannelSync = sync
return true
}
// ---------------------------------------------------------------------------
export {
doesVideoChannelSyncIdExist
}

View File

@ -3,10 +3,10 @@ import { body, param } from 'express-validator'
import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc' import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
import { logger } from '@server/helpers/logger' import { logger } from '@server/helpers/logger'
import { CONFIG } from '@server/initializers/config' import { CONFIG } from '@server/initializers/config'
import { VideoChannelModel } from '@server/models/video/video-channel'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
import { HttpStatusCode, VideoChannelSyncCreate } from '@shared/models' import { HttpStatusCode, VideoChannelSyncCreate } from '@shared/models'
import { areValidationErrors, doesVideoChannelIdExist } from '../shared' import { areValidationErrors, doesVideoChannelIdExist } from '../shared'
import { doesVideoChannelSyncIdExist } from '../shared/video-channel-syncs'
export const ensureSyncIsEnabled = (req: express.Request, res: express.Response, next: express.NextFunction) => { export const ensureSyncIsEnabled = (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED) { if (!CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED) {
@ -48,18 +48,8 @@ export const ensureSyncExists = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
const syncId = parseInt(req.params.id, 10) if (!await doesVideoChannelSyncIdExist(+req.params.id, res)) return
const sync = await VideoChannelSyncModel.loadWithChannel(syncId) if (!await doesVideoChannelIdExist(res.locals.videoChannelSync.videoChannelId, res)) return
if (!sync) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Synchronization not found'
})
}
res.locals.videoChannelSync = sync
res.locals.videoChannel = await VideoChannelModel.loadAndPopulateAccount(sync.videoChannelId)
return next() return next()
} }

View File

@ -3,8 +3,9 @@ import { body, param, query } from 'express-validator'
import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc' import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
import { CONFIG } from '@server/initializers/config' import { CONFIG } from '@server/initializers/config'
import { MChannelAccountDefault } from '@server/types/models' import { MChannelAccountDefault } from '@server/types/models'
import { VideosImportInChannelCreate } from '@shared/models'
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
import { isBooleanValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc' import { isBooleanValid, isIdValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc'
import { import {
isVideoChannelDescriptionValid, isVideoChannelDescriptionValid,
isVideoChannelDisplayNameValid, isVideoChannelDisplayNameValid,
@ -15,6 +16,7 @@ import { logger } from '../../../helpers/logger'
import { ActorModel } from '../../../models/actor/actor' import { ActorModel } from '../../../models/actor/actor'
import { VideoChannelModel } from '../../../models/video/video-channel' import { VideoChannelModel } from '../../../models/video/video-channel'
import { areValidationErrors, checkUserQuota, doesVideoChannelNameWithHostExist } from '../shared' import { areValidationErrors, checkUserQuota, doesVideoChannelNameWithHostExist } from '../shared'
import { doesVideoChannelSyncIdExist } from '../shared/video-channel-syncs'
export const videoChannelsAddValidator = [ export const videoChannelsAddValidator = [
body('name').custom(isVideoChannelUsernameValid).withMessage('Should have a valid channel name'), body('name').custom(isVideoChannelUsernameValid).withMessage('Should have a valid channel name'),
@ -145,11 +147,17 @@ export const videoChannelsListValidator = [
export const videoChannelImportVideosValidator = [ export const videoChannelImportVideosValidator = [
body('externalChannelUrl').custom(isUrlValid).withMessage('Should have a valid channel url'), body('externalChannelUrl').custom(isUrlValid).withMessage('Should have a valid channel url'),
(req: express.Request, res: express.Response, next: express.NextFunction) => { body('videoChannelSyncId')
.optional()
.custom(isIdValid).withMessage('Should have a valid channel sync id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoChannelImport parameters', { parameters: req.body }) logger.debug('Checking videoChannelImport parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
const body: VideosImportInChannelCreate = req.body
if (!CONFIG.IMPORT.VIDEOS.HTTP.ENABLED) { if (!CONFIG.IMPORT.VIDEOS.HTTP.ENABLED) {
return res.fail({ return res.fail({
status: HttpStatusCode.FORBIDDEN_403, status: HttpStatusCode.FORBIDDEN_403,
@ -157,6 +165,8 @@ export const videoChannelImportVideosValidator = [
}) })
} }
if (body.videoChannelSyncId && !await doesVideoChannelSyncIdExist(body.videoChannelSyncId, res)) return
return next() return next()
} }
] ]

View File

@ -1,5 +1,5 @@
import express from 'express' import express from 'express'
import { body, param } from 'express-validator' import { body, param, query } from 'express-validator'
import { isResolvingToUnicastOnly } from '@server/helpers/dns' import { isResolvingToUnicastOnly } from '@server/helpers/dns'
import { isPreImportVideoAccepted } from '@server/lib/moderation' import { isPreImportVideoAccepted } from '@server/lib/moderation'
import { Hooks } from '@server/lib/plugins/hooks' import { Hooks } from '@server/lib/plugins/hooks'
@ -92,6 +92,20 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
} }
]) ])
const getMyVideoImportsValidator = [
query('videoChannelSyncId')
.optional()
.custom(isIdValid).withMessage('Should have correct videoChannelSync id'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking getMyVideoImportsValidator parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
return next()
}
]
const videoImportDeleteValidator = [ const videoImportDeleteValidator = [
param('id') param('id')
.custom(isIdValid).withMessage('Should have correct import id'), .custom(isIdValid).withMessage('Should have correct import id'),
@ -143,7 +157,8 @@ const videoImportCancelValidator = [
export { export {
videoImportAddValidator, videoImportAddValidator,
videoImportCancelValidator, videoImportCancelValidator,
videoImportDeleteValidator videoImportDeleteValidator,
getMyVideoImportsValidator
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,4 +1,4 @@
import { Op, WhereOptions } from 'sequelize' import { IncludeOptions, Op, WhereOptions } from 'sequelize'
import { import {
AfterUpdate, AfterUpdate,
AllowNull, AllowNull,
@ -22,8 +22,17 @@ import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../help
import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants' import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
import { UserModel } from '../user/user' import { UserModel } from '../user/user'
import { getSort, throwIfNotValid } from '../utils' import { getSort, searchAttribute, throwIfNotValid } from '../utils'
import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
import { VideoChannelSyncModel } from './video-channel-sync'
const defaultVideoScope = () => {
return VideoModel.scope([
VideoModelScopeNames.WITH_ACCOUNT_DETAILS,
VideoModelScopeNames.WITH_TAGS,
VideoModelScopeNames.WITH_THUMBNAILS
])
}
@DefaultScope(() => ({ @DefaultScope(() => ({
include: [ include: [
@ -32,11 +41,11 @@ import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
required: true required: true
}, },
{ {
model: VideoModel.scope([ model: defaultVideoScope(),
VideoModelScopeNames.WITH_ACCOUNT_DETAILS, required: false
VideoModelScopeNames.WITH_TAGS, },
VideoModelScopeNames.WITH_THUMBNAILS {
]), model: VideoChannelSyncModel.unscoped(),
required: false required: false
} }
] ]
@ -113,6 +122,18 @@ export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportMo
}) })
Video: VideoModel Video: VideoModel
@ForeignKey(() => VideoChannelSyncModel)
@Column
videoChannelSyncId: number
@BelongsTo(() => VideoChannelSyncModel, {
foreignKey: {
allowNull: true
},
onDelete: 'set null'
})
VideoChannelSync: VideoChannelSyncModel
@AfterUpdate @AfterUpdate
static deleteVideoIfFailed (instance: VideoImportModel, options) { static deleteVideoIfFailed (instance: VideoImportModel, options) {
if (instance.state === VideoImportState.FAILED) { if (instance.state === VideoImportState.FAILED) {
@ -132,23 +153,44 @@ export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportMo
count: number count: number
sort: string sort: string
search?: string
targetUrl?: string targetUrl?: string
videoChannelSyncId?: number
}) { }) {
const { userId, start, count, sort, targetUrl } = options const { userId, start, count, sort, targetUrl, videoChannelSyncId, search } = options
const where: WhereOptions = { userId } const where: WhereOptions = { userId }
const include: IncludeOptions[] = [
if (targetUrl) where['targetUrl'] = targetUrl
const query = {
distinct: true,
include: [
{ {
attributes: [ 'id' ], attributes: [ 'id' ],
model: UserModel.unscoped(), // FIXME: Without this, sequelize try to COUNT(DISTINCT(*)) which is an invalid SQL query model: UserModel.unscoped(), // FIXME: Without this, sequelize try to COUNT(DISTINCT(*)) which is an invalid SQL query
required: true required: true
},
{
model: VideoChannelSyncModel.unscoped(),
required: false
} }
], ]
if (targetUrl) where['targetUrl'] = targetUrl
if (videoChannelSyncId) where['videoChannelSyncId'] = videoChannelSyncId
if (search) {
include.push({
model: defaultVideoScope(),
required: true,
where: searchAttribute(search, 'name')
})
} else {
include.push({
model: defaultVideoScope(),
required: false
})
}
const query = {
distinct: true,
include,
offset: start, offset: start,
limit: count, limit: count,
order: getSort(sort), order: getSort(sort),
@ -196,6 +238,10 @@ export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportMo
? Object.assign(this.Video.toFormattedJSON(videoFormatOptions), { tags: this.Video.Tags.map(t => t.name) }) ? Object.assign(this.Video.toFormattedJSON(videoFormatOptions), { tags: this.Video.Tags.map(t => t.name) })
: undefined : undefined
const videoChannelSync = this.VideoChannelSync
? { id: this.VideoChannelSync.id, externalChannelUrl: this.VideoChannelSync.externalChannelUrl }
: undefined
return { return {
id: this.id, id: this.id,
@ -210,7 +256,8 @@ export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportMo
error: this.error, error: this.error,
updatedAt: this.updatedAt.toISOString(), updatedAt: this.updatedAt.toISOString(),
createdAt: this.createdAt.toISOString(), createdAt: this.createdAt.toISOString(),
video video,
videoChannelSync
} }
} }

View File

@ -0,0 +1,172 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import { FIXTURE_URLS } from '@server/tests/shared'
import { areHttpImportTestsDisabled } from '@shared/core-utils'
import { HttpStatusCode } from '@shared/models'
import { ChannelsCommand, cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
describe('Test videos import in a channel API validator', function () {
let server: PeerTubeServer
const userInfo = {
accessToken: '',
channelName: 'fake_channel',
id: -1,
videoQuota: -1,
videoQuotaDaily: -1
}
let command: ChannelsCommand
// ---------------------------------------------------------------
before(async function () {
this.timeout(30000)
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
const userCreds = {
username: 'fake',
password: 'fake_password'
}
{
const user = await server.users.create({ username: userCreds.username, password: userCreds.password })
userInfo.id = user.id
userInfo.accessToken = await server.login.getAccessToken(userCreds)
}
command = server.channels
})
it('Should fail when HTTP upload is disabled', async function () {
await server.config.disableImports()
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: server.accessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
await server.config.enableImports()
})
it('Should fail when externalChannelUrl is not provided', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: null,
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail when externalChannelUrl is malformed', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: 'not-a-url',
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with a bad sync id', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
videoChannelSyncId: 'toto' as any,
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with a unknown sync id', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
videoChannelSyncId: 42,
token: server.accessToken,
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
})
it('Should fail with no authentication', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: null,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail when sync is not owned by the user', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: userInfo.accessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail when the user has no quota', async function () {
await server.users.update({
userId: userInfo.id,
videoQuota: 0
})
await command.importVideos({
channelName: 'fake_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: userInfo.accessToken,
expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413
})
await server.users.update({
userId: userInfo.id,
videoQuota: userInfo.videoQuota
})
})
it('Should fail when the user has no daily quota', async function () {
await server.users.update({
userId: userInfo.id,
videoQuotaDaily: 0
})
await command.importVideos({
channelName: 'fake_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: userInfo.accessToken,
expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413
})
await server.users.update({
userId: userInfo.id,
videoQuotaDaily: userInfo.videoQuotaDaily
})
})
it('Should succeed when sync is run by its owner', async function () {
if (!areHttpImportTestsDisabled()) return
await command.importVideos({
channelName: 'fake_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: userInfo.accessToken
})
})
it('Should succeed when sync is run with root and for another user\'s channel', async function () {
if (!areHttpImportTestsDisabled()) return
await command.importVideos({
channelName: 'fake_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel
})
})
after(async function () {
await cleanupTests([ server ])
})
})

View File

@ -28,6 +28,7 @@ import './video-comments'
import './video-files' import './video-files'
import './video-imports' import './video-imports'
import './video-channel-syncs' import './video-channel-syncs'
import './channel-import-videos'
import './video-playlists' import './video-playlists'
import './video-source' import './video-source'
import './video-studio' import './video-studio'

View File

@ -3,8 +3,8 @@
import 'mocha' import 'mocha'
import * as chai from 'chai' import * as chai from 'chai'
import { omit } from 'lodash' import { omit } from 'lodash'
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination, FIXTURE_URLS } from '@server/tests/shared' import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
import { areHttpImportTestsDisabled, buildAbsoluteFixturePath } from '@shared/core-utils' import { buildAbsoluteFixturePath } from '@shared/core-utils'
import { HttpStatusCode, VideoChannelUpdate } from '@shared/models' import { HttpStatusCode, VideoChannelUpdate } from '@shared/models'
import { import {
ChannelsCommand, ChannelsCommand,
@ -354,115 +354,6 @@ describe('Test video channels API validator', function () {
}) })
}) })
describe('When triggering full synchronization', function () {
it('Should fail when HTTP upload is disabled', async function () {
await server.config.disableImports()
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: server.accessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
await server.config.enableImports()
})
it('Should fail when externalChannelUrl is not provided', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: null,
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail when externalChannelUrl is malformed', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: 'not-a-url',
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with no authentication', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: null,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail when sync is not owned by the user', async function () {
await command.importVideos({
channelName: 'super_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: userInfo.accessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail when the user has no quota', async function () {
await server.users.update({
userId: userInfo.id,
videoQuota: 0
})
await command.importVideos({
channelName: 'fake_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: userInfo.accessToken,
expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413
})
await server.users.update({
userId: userInfo.id,
videoQuota: userInfo.videoQuota
})
})
it('Should fail when the user has no daily quota', async function () {
await server.users.update({
userId: userInfo.id,
videoQuotaDaily: 0
})
await command.importVideos({
channelName: 'fake_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: userInfo.accessToken,
expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413
})
await server.users.update({
userId: userInfo.id,
videoQuotaDaily: userInfo.videoQuotaDaily
})
})
it('Should succeed when sync is run by its owner', async function () {
if (!areHttpImportTestsDisabled()) return
await command.importVideos({
channelName: 'fake_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
token: userInfo.accessToken
})
})
it('Should succeed when sync is run with root and for another user\'s channel', async function () {
if (!areHttpImportTestsDisabled()) return
await command.importVideos({
channelName: 'fake_channel',
externalChannelUrl: FIXTURE_URLS.youtubeChannel
})
})
})
describe('When deleting a video channel', function () { describe('When deleting a video channel', function () {
it('Should fail with a non authenticated user', async function () { it('Should fail with a non authenticated user', async function () {
await command.delete({ token: 'coucou', channelName: 'super_channel', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) await command.delete({ token: 'coucou', channelName: 'super_channel', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })

View File

@ -59,6 +59,15 @@ describe('Test video imports API validator', function () {
await checkBadSortPagination(server.url, myPath, server.accessToken) await checkBadSortPagination(server.url, myPath, server.accessToken)
}) })
it('Should fail with a bad videoChannelSyncId param', async function () {
await makeGetRequest({
url: server.url,
path: myPath,
query: { videoChannelSyncId: 'toto' },
token: server.accessToken
})
})
it('Should success with the correct parameters', async function () { it('Should success with the correct parameters', async function () {
await makeGetRequest({ url: server.url, path: myPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken }) await makeGetRequest({ url: server.url, path: myPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken })
}) })

View File

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai' import { expect } from 'chai'
import { FIXTURE_URLS } from '@server/tests/shared' import { FIXTURE_URLS } from '@server/tests/shared'
import { areHttpImportTestsDisabled } from '@shared/core-utils' import { areHttpImportTestsDisabled } from '@shared/core-utils'
@ -29,7 +31,7 @@ describe('Test videos import in a channel', function () {
await server.config.enableChannelSync() await server.config.enableChannelSync()
}) })
it('Should import a whole channel', async function () { it('Should import a whole channel without specifying the sync id', async function () {
this.timeout(240_000) this.timeout(240_000)
await server.channels.importVideos({ channelName: server.store.channel.name, externalChannelUrl: FIXTURE_URLS.youtubeChannel }) await server.channels.importVideos({ channelName: server.store.channel.name, externalChannelUrl: FIXTURE_URLS.youtubeChannel })
@ -39,6 +41,74 @@ describe('Test videos import in a channel', function () {
expect(videos.total).to.equal(2) expect(videos.total).to.equal(2)
}) })
it('These imports should not have a sync id', async function () {
const { total, data } = await server.imports.getMyVideoImports()
expect(total).to.equal(2)
expect(data).to.have.lengthOf(2)
for (const videoImport of data) {
expect(videoImport.videoChannelSync).to.not.exist
}
})
it('Should import a whole channel and specifying the sync id', async function () {
this.timeout(240_000)
{
server.store.channel.name = 'channel2'
const { id } = await server.channels.create({ attributes: { name: server.store.channel.name } })
server.store.channel.id = id
}
{
const attributes = {
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
videoChannelId: server.store.channel.id
}
const { videoChannelSync } = await server.channelSyncs.create({ attributes })
server.store.videoChannelSync = videoChannelSync
await waitJobs(server)
}
await server.channels.importVideos({
channelName: server.store.channel.name,
externalChannelUrl: FIXTURE_URLS.youtubeChannel,
videoChannelSyncId: server.store.videoChannelSync.id
})
await waitJobs(server)
})
it('These imports should have a sync id', async function () {
const { total, data } = await server.imports.getMyVideoImports()
expect(total).to.equal(4)
expect(data).to.have.lengthOf(4)
const importsWithSyncId = data.filter(i => !!i.videoChannelSync)
expect(importsWithSyncId).to.have.lengthOf(2)
for (const videoImport of importsWithSyncId) {
expect(videoImport.videoChannelSync).to.exist
expect(videoImport.videoChannelSync.id).to.equal(server.store.videoChannelSync.id)
}
})
it('Should be able to filter imports by this sync id', async function () {
const { total, data } = await server.imports.getMyVideoImports({ videoChannelSyncId: server.store.videoChannelSync.id })
expect(total).to.equal(2)
expect(data).to.have.lengthOf(2)
for (const videoImport of data) {
expect(videoImport.videoChannelSync).to.exist
expect(videoImport.videoChannelSync.id).to.equal(server.store.videoChannelSync.id)
}
})
after(async function () { after(async function () {
await server?.kill() await server?.kill()
}) })

View File

@ -23,7 +23,10 @@ describe('Test channel synchronizations', function () {
describe('Sync using ' + mode, function () { describe('Sync using ' + mode, function () {
let server: PeerTubeServer let server: PeerTubeServer
let command: ChannelSyncsCommand let command: ChannelSyncsCommand
let startTestDate: Date let startTestDate: Date
let rootChannelSyncId: number
const userInfo = { const userInfo = {
accessToken: '', accessToken: '',
username: 'user1', username: 'user1',
@ -90,6 +93,7 @@ describe('Test channel synchronizations', function () {
token: server.accessToken, token: server.accessToken,
expectedStatus: HttpStatusCode.OK_200 expectedStatus: HttpStatusCode.OK_200
}) })
rootChannelSyncId = videoChannelSync.id
// Ensure any missing video not already fetched will be considered as new // Ensure any missing video not already fetched will be considered as new
await changeDateForSync(videoChannelSync.id, '1970-01-01') await changeDateForSync(videoChannelSync.id, '1970-01-01')
@ -208,6 +212,14 @@ describe('Test channel synchronizations', function () {
} }
}) })
it('Should list imports of a channel synchronization', async function () {
const { total, data } = await server.imports.getMyVideoImports({ videoChannelSyncId: rootChannelSyncId })
expect(total).to.equal(1)
expect(data).to.have.lengthOf(1)
expect(data[0].video.name).to.equal('test')
})
it('Should remove user\'s channel synchronizations', async function () { it('Should remove user\'s channel synchronizations', async function () {
await command.delete({ channelSyncId: userInfo.syncId }) await command.delete({ channelSyncId: userInfo.syncId })

View File

@ -228,6 +228,15 @@ describe('Test video imports', function () {
expect(videoImports[0].targetUrl).to.equal(FIXTURE_URLS.youtube) expect(videoImports[0].targetUrl).to.equal(FIXTURE_URLS.youtube)
}) })
it('Should search in my imports', async function () {
const { total, data: videoImports } = await servers[0].imports.getMyVideoImports({ search: 'peertube2' })
expect(total).to.equal(1)
expect(videoImports).to.have.lengthOf(1)
expect(videoImports[0].magnetUri).to.equal(FIXTURE_URLS.magnet)
expect(videoImports[0].video.name).to.equal('super peertube2 video')
})
it('Should have the video listed on the two instances', async function () { it('Should have the video listed on the two instances', async function () {
this.timeout(120_000) this.timeout(120_000)

View File

@ -236,6 +236,8 @@ export interface VideoStudioEditionPayload {
export interface VideoChannelImportPayload { export interface VideoChannelImportPayload {
externalChannelUrl: string externalChannelUrl: string
videoChannelId: number videoChannelId: number
partOfChannelSyncId?: number
} }
export interface AfterVideoChannelImportPayload { export interface AfterVideoChannelImportPayload {

View File

@ -1,3 +1,4 @@
export * from './video-import-create.model' export * from './video-import-create.model'
export * from './video-import-state.enum' export * from './video-import-state.enum'
export * from './video-import.model' export * from './video-import.model'
export * from './videos-import-in-channel-create.model'

View File

@ -16,4 +16,9 @@ export interface VideoImport {
error?: string error?: string
video?: Video & { tags: string[] } video?: Video & { tags: string[] }
videoChannelSync?: {
id: number
externalChannelUrl: string
}
} }

View File

@ -0,0 +1,4 @@
export interface VideosImportInChannelCreate {
externalChannelUrl: string
videoChannelSyncId?: number
}

View File

@ -2,7 +2,7 @@ import { ChildProcess, fork } from 'child_process'
import { copy } from 'fs-extra' import { copy } from 'fs-extra'
import { join } from 'path' import { join } from 'path'
import { parallelTests, randomInt, root } from '@shared/core-utils' import { parallelTests, randomInt, root } from '@shared/core-utils'
import { Video, VideoChannel, VideoCreateResult, VideoDetails } from '@shared/models' import { Video, VideoChannel, VideoChannelSync, VideoCreateResult, VideoDetails } from '@shared/models'
import { BulkCommand } from '../bulk' import { BulkCommand } from '../bulk'
import { CLICommand } from '../cli' import { CLICommand } from '../cli'
import { CustomPagesCommand } from '../custom-pages' import { CustomPagesCommand } from '../custom-pages'
@ -80,6 +80,7 @@ export class PeerTubeServer {
} }
channel?: VideoChannel channel?: VideoChannel
videoChannelSync?: Partial<VideoChannelSync>
video?: Video video?: Video
videoCreated?: VideoCreateResult videoCreated?: VideoCreateResult

View File

@ -6,7 +6,8 @@ import {
VideoChannel, VideoChannel,
VideoChannelCreate, VideoChannelCreate,
VideoChannelCreateResult, VideoChannelCreateResult,
VideoChannelUpdate VideoChannelUpdate,
VideosImportInChannelCreate
} from '@shared/models' } from '@shared/models'
import { unwrapBody } from '../requests' import { unwrapBody } from '../requests'
import { AbstractCommand, OverrideCommandOptions } from '../shared' import { AbstractCommand, OverrideCommandOptions } from '../shared'
@ -182,11 +183,10 @@ export class ChannelsCommand extends AbstractCommand {
}) })
} }
importVideos (options: OverrideCommandOptions & { importVideos (options: OverrideCommandOptions & VideosImportInChannelCreate & {
channelName: string channelName: string
externalChannelUrl: string
}) { }) {
const { channelName, externalChannelUrl } = options const { channelName, externalChannelUrl, videoChannelSyncId } = options
const path = `/api/v1/video-channels/${channelName}/import-videos` const path = `/api/v1/video-channels/${channelName}/import-videos`
@ -194,7 +194,7 @@ export class ChannelsCommand extends AbstractCommand {
...options, ...options,
path, path,
fields: { externalChannelUrl }, fields: { externalChannelUrl, videoChannelSyncId },
implicitToken: true, implicitToken: true,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
}) })

View File

@ -57,15 +57,17 @@ export class ImportsCommand extends AbstractCommand {
getMyVideoImports (options: OverrideCommandOptions & { getMyVideoImports (options: OverrideCommandOptions & {
sort?: string sort?: string
targetUrl?: string targetUrl?: string
videoChannelSyncId?: number
search?: string
} = {}) { } = {}) {
const { sort, targetUrl } = options const { sort, targetUrl, videoChannelSyncId, search } = options
const path = '/api/v1/users/me/videos/imports' const path = '/api/v1/users/me/videos/imports'
return this.getRequestBody<ResultList<VideoImport>>({ return this.getRequestBody<ResultList<VideoImport>>({
...options, ...options,
path, path,
query: { sort, targetUrl }, query: { sort, targetUrl, videoChannelSyncId, search },
implicitToken: true, implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200 defaultExpectedStatus: HttpStatusCode.OK_200
}) })

View File

@ -1187,6 +1187,20 @@ paths:
- $ref: '#/components/parameters/start' - $ref: '#/components/parameters/start'
- $ref: '#/components/parameters/count' - $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/sort' - $ref: '#/components/parameters/sort'
-
name: targetUrl
in: query
required: false
description: Filter on import target URL
schema:
type: string
-
name: videoChannelSyncId
in: query
required: false
description: Filter on imports created by a specific channel synchronization
schema:
type: number
responses: responses:
'200': '200':
description: successful operation description: successful operation