From ce33919c24e7402d92d81f3cd8e545df52d98240 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 6 Aug 2018 17:13:39 +0200 Subject: Import magnets with webtorrent --- server/controllers/api/videos/import.ts | 142 +++++++++++++++------ server/helpers/custom-validators/videos.ts | 9 ++ server/helpers/utils.ts | 11 +- server/helpers/webtorrent.ts | 31 +++++ server/helpers/youtube-dl.ts | 24 ++-- server/initializers/constants.ts | 5 +- .../initializers/migrations/0245-import-magnet.ts | 42 ++++++ server/lib/job-queue/handlers/video-import.ts | 99 +++++++++++--- server/lib/job-queue/job-queue.ts | 7 - server/middlewares/validators/video-imports.ts | 18 ++- server/models/video/video-import.ts | 15 ++- 11 files changed, 324 insertions(+), 79 deletions(-) create mode 100644 server/helpers/webtorrent.ts create mode 100644 server/initializers/migrations/0245-import-magnet.ts (limited to 'server') diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts index 30a7d816c..c16a254d2 100644 --- a/server/controllers/api/videos/import.ts +++ b/server/controllers/api/videos/import.ts @@ -1,3 +1,4 @@ +import * as magnetUtil from 'magnet-uri' import * as express from 'express' import { auditLoggerFactory, VideoImportAuditView } from '../../../helpers/audit-logger' import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares' @@ -13,6 +14,10 @@ import { VideoImportModel } from '../../../models/video/video-import' import { JobQueue } from '../../../lib/job-queue/job-queue' import { processImage } from '../../../helpers/image-utils' import { join } from 'path' +import { isArray } from '../../../helpers/custom-validators/misc' +import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' +import { VideoChannelModel } from '../../../models/video/video-channel' +import * as Bluebird from 'bluebird' const auditLogger = auditLoggerFactory('video-imports') const videoImportsRouter = express.Router() @@ -41,7 +46,45 @@ export { // --------------------------------------------------------------------------- -async function addVideoImport (req: express.Request, res: express.Response) { +function addVideoImport (req: express.Request, res: express.Response) { + if (req.body.targetUrl) return addYoutubeDLImport(req, res) + + if (req.body.magnetUri) return addTorrentImport(req, res) +} + +async function addTorrentImport (req: express.Request, res: express.Response) { + const body: VideoImportCreate = req.body + const magnetUri = body.magnetUri + + const parsed = magnetUtil.decode(magnetUri) + const magnetName = isArray(parsed.name) ? parsed.name[0] : parsed.name as string + + const video = buildVideo(res.locals.videoChannel.id, body, { name: magnetName }) + + await processThumbnail(req, video) + await processPreview(req, video) + + const tags = null + const videoImportAttributes = { + magnetUri, + state: VideoImportState.PENDING + } + const videoImport: VideoImportModel = await insertIntoDB(video, res.locals.videoChannel, tags, videoImportAttributes) + + // Create job to import the video + const payload = { + type: 'magnet-uri' as 'magnet-uri', + videoImportId: videoImport.id, + magnetUri + } + await JobQueue.Instance.createJob({ type: 'video-import', payload }) + + auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoImportAuditView(videoImport.toFormattedJSON())) + + return res.json(videoImport.toFormattedJSON()).end() +} + +async function addYoutubeDLImport (req: express.Request, res: express.Response) { const body: VideoImportCreate = req.body const targetUrl = body.targetUrl @@ -56,53 +99,94 @@ async function addVideoImport (req: express.Request, res: express.Response) { }).end() } - // Create video DB object + const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo) + + const downloadThumbnail = !await processThumbnail(req, video) + const downloadPreview = !await processPreview(req, video) + + const tags = body.tags || youtubeDLInfo.tags + const videoImportAttributes = { + targetUrl, + state: VideoImportState.PENDING + } + const videoImport: VideoImportModel = await insertIntoDB(video, res.locals.videoChannel, tags, videoImportAttributes) + + // Create job to import the video + const payload = { + type: 'youtube-dl' as 'youtube-dl', + videoImportId: videoImport.id, + thumbnailUrl: youtubeDLInfo.thumbnailUrl, + downloadThumbnail, + downloadPreview + } + await JobQueue.Instance.createJob({ type: 'video-import', payload }) + + auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoImportAuditView(videoImport.toFormattedJSON())) + + return res.json(videoImport.toFormattedJSON()).end() +} + +function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo) { const videoData = { - name: body.name || youtubeDLInfo.name, + name: body.name || importData.name || 'Unknown name', remote: false, - category: body.category || youtubeDLInfo.category, - licence: body.licence || youtubeDLInfo.licence, + category: body.category || importData.category, + licence: body.licence || importData.licence, language: body.language || undefined, commentsEnabled: body.commentsEnabled || true, waitTranscoding: body.waitTranscoding || false, state: VideoState.TO_IMPORT, - nsfw: body.nsfw || youtubeDLInfo.nsfw || false, - description: body.description || youtubeDLInfo.description, + nsfw: body.nsfw || importData.nsfw || false, + description: body.description || importData.description, support: body.support || null, privacy: body.privacy || VideoPrivacy.PRIVATE, duration: 0, // duration will be set by the import job - channelId: res.locals.videoChannel.id + channelId: channelId } const video = new VideoModel(videoData) video.url = getVideoActivityPubUrl(video) - // Process thumbnail file? + return video +} + +async function processThumbnail (req: express.Request, video: VideoModel) { const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined - let downloadThumbnail = true if (thumbnailField) { const thumbnailPhysicalFile = thumbnailField[ 0 ] await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()), THUMBNAILS_SIZE) - downloadThumbnail = false + + return true } - // Process preview file? + return false +} + +async function processPreview (req: express.Request, video: VideoModel) { const previewField = req.files ? req.files['previewfile'] : undefined - let downloadPreview = true if (previewField) { const previewPhysicalFile = previewField[0] await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()), PREVIEWS_SIZE) - downloadPreview = false + + return true } - const videoImport: VideoImportModel = await sequelizeTypescript.transaction(async t => { + return false +} + +function insertIntoDB ( + video: VideoModel, + videoChannel: VideoChannelModel, + tags: string[], + videoImportAttributes: FilteredModelAttributes +): Bluebird { + return sequelizeTypescript.transaction(async t => { const sequelizeOptions = { transaction: t } // Save video object in database const videoCreated = await video.save(sequelizeOptions) - videoCreated.VideoChannel = res.locals.videoChannel + videoCreated.VideoChannel = videoChannel // Set tags to the video - const tags = body.tags ? body.tags : youtubeDLInfo.tags if (tags !== undefined) { const tagInstances = await TagModel.findOrCreateTags(tags, t) @@ -111,28 +195,12 @@ async function addVideoImport (req: express.Request, res: express.Response) { } // Create video import object in database - const videoImport = await VideoImportModel.create({ - targetUrl, - state: VideoImportState.PENDING, - videoId: videoCreated.id - }, sequelizeOptions) - + const videoImport = await VideoImportModel.create( + Object.assign({ videoId: videoCreated.id }, videoImportAttributes), + sequelizeOptions + ) videoImport.Video = videoCreated return videoImport }) - - // Create job to import the video - const payload = { - type: 'youtube-dl' as 'youtube-dl', - videoImportId: videoImport.id, - thumbnailUrl: youtubeDLInfo.thumbnailUrl, - downloadThumbnail, - downloadPreview - } - await JobQueue.Instance.createJob({ type: 'video-import', payload }) - - auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoImportAuditView(videoImport.toFormattedJSON())) - - return res.json(videoImport.toFormattedJSON()).end() } diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 338c96582..f4c1c8b07 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -17,6 +17,7 @@ import { VideoModel } from '../../models/video/video' import { exists, isArray, isFileValid } from './misc' import { VideoChannelModel } from '../../models/video/video-channel' import { UserModel } from '../../models/account/user' +import * as magnetUtil from 'magnet-uri' const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES @@ -126,6 +127,13 @@ function isVideoFileSizeValid (value: string) { return exists(value) && validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.FILE_SIZE) } +function isVideoMagnetUriValid (value: string) { + if (!exists(value)) return false + + const parsed = magnetUtil.decode(value) + return parsed && isVideoFileInfoHashValid(parsed.infoHash) +} + function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: Response) { // Retrieve the user who did the request if (video.isOwned() === false) { @@ -214,6 +222,7 @@ export { isScheduleVideoUpdatePrivacyValid, isVideoAbuseReasonValid, isVideoFile, + isVideoMagnetUriValid, isVideoStateValid, isVideoViewsValid, isVideoRatingTypeValid, diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts index 7abcec5d7..f4cc5547d 100644 --- a/server/helpers/utils.ts +++ b/server/helpers/utils.ts @@ -9,6 +9,8 @@ import { ApplicationModel } from '../models/application/application' import { pseudoRandomBytesPromise, unlinkPromise } from './core-utils' import { logger } from './logger' import { isArray } from './custom-validators/misc' +import * as crypto from "crypto" +import { join } from "path" const isCidr = require('is-cidr') @@ -181,8 +183,14 @@ async function getServerActor () { return Promise.resolve(serverActor) } +function generateVideoTmpPath (id: string) { + const hash = crypto.createHash('sha256').update(id).digest('hex') + return join(CONFIG.STORAGE.VIDEOS_DIR, hash + '-import.mp4') +} + type SortType = { sortModel: any, sortValue: string } + // --------------------------------------------------------------------------- export { @@ -195,5 +203,6 @@ export { computeResolutionsToTranscode, resetSequelizeInstance, getServerActor, - SortType + SortType, + generateVideoTmpPath } diff --git a/server/helpers/webtorrent.ts b/server/helpers/webtorrent.ts new file mode 100644 index 000000000..fce88a1f6 --- /dev/null +++ b/server/helpers/webtorrent.ts @@ -0,0 +1,31 @@ +import { logger } from './logger' +import { generateVideoTmpPath } from './utils' +import * as WebTorrent from 'webtorrent' +import { createWriteStream } from 'fs' + +function downloadWebTorrentVideo (target: string) { + const path = generateVideoTmpPath(target) + + logger.info('Importing torrent video %s', target) + + return new Promise((res, rej) => { + const webtorrent = new WebTorrent() + + const torrent = webtorrent.add(target, torrent => { + if (torrent.files.length !== 1) throw new Error('The number of files is not equal to 1 for ' + target) + + const file = torrent.files[ 0 ] + file.createReadStream().pipe(createWriteStream(path)) + }) + + torrent.on('done', () => res(path)) + + torrent.on('error', err => rej(err)) + }) +} + +// --------------------------------------------------------------------------- + +export { + downloadWebTorrentVideo +} diff --git a/server/helpers/youtube-dl.ts b/server/helpers/youtube-dl.ts index c59ab9de0..77986f407 100644 --- a/server/helpers/youtube-dl.ts +++ b/server/helpers/youtube-dl.ts @@ -1,18 +1,17 @@ import * as youtubeDL from 'youtube-dl' import { truncate } from 'lodash' -import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers' -import { join } from 'path' -import * as crypto from 'crypto' +import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers' import { logger } from './logger' +import { generateVideoTmpPath } from './utils' export type YoutubeDLInfo = { - name: string - description: string - category: number - licence: number - nsfw: boolean - tags: string[] - thumbnailUrl: string + name?: string + description?: string + category?: number + licence?: number + nsfw?: boolean + tags?: string[] + thumbnailUrl?: string } function getYoutubeDLInfo (url: string): Promise { @@ -30,10 +29,9 @@ function getYoutubeDLInfo (url: string): Promise { } function downloadYoutubeDLVideo (url: string) { - const hash = crypto.createHash('sha256').update(url).digest('hex') - const path = join(CONFIG.STORAGE.VIDEOS_DIR, hash + '-import.mp4') + const path = generateVideoTmpPath(url) - logger.info('Importing video %s', url) + logger.info('Importing youtubeDL video %s', url) const options = [ '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best', '-o', path ] diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 74fe7965d..243d544ea 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -15,7 +15,7 @@ let config: IConfig = require('config') // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 240 +const LAST_MIGRATION_VERSION = 245 // --------------------------------------------------------------------------- @@ -271,7 +271,8 @@ const CONSTRAINTS_FIELDS = { } }, VIDEO_IMPORTS: { - URL: { min: 3, max: 2000 } // Length + URL: { min: 3, max: 2000 }, // Length + TORRENT_NAME: { min: 3, max: 255 }, // Length }, VIDEOS: { NAME: { min: 3, max: 120 }, // Length diff --git a/server/initializers/migrations/0245-import-magnet.ts b/server/initializers/migrations/0245-import-magnet.ts new file mode 100644 index 000000000..87603b006 --- /dev/null +++ b/server/initializers/migrations/0245-import-magnet.ts @@ -0,0 +1,42 @@ +import * as Sequelize from 'sequelize' +import { Migration } from '../../models/migrations' +import { CONSTRAINTS_FIELDS } from '../index' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + { + const data = { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null + } as Migration.String + await utils.queryInterface.changeColumn('videoImport', 'targetUrl', data) + } + + { + const data = { + type: Sequelize.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max), + allowNull: true, + defaultValue: null + } + await utils.queryInterface.addColumn('videoImport', 'magnetUri', data) + } + + { + const data = { + type: Sequelize.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_NAME.max), + allowNull: true, + defaultValue: null + } + await utils.queryInterface.addColumn('videoImport', 'torrentName', data) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { up, down } diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index cdfe412cc..c457b71fc 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -13,30 +13,99 @@ import { VideoState } from '../../../../shared' import { JobQueue } from '../index' import { federateVideoIfNeeded } from '../../activitypub' import { VideoModel } from '../../../models/video/video' +import { downloadWebTorrentVideo } from '../../../helpers/webtorrent' -export type VideoImportPayload = { +type VideoImportYoutubeDLPayload = { type: 'youtube-dl' videoImportId: number + thumbnailUrl: string downloadThumbnail: boolean downloadPreview: boolean } +type VideoImportTorrentPayload = { + type: 'magnet-uri' + videoImportId: number +} + +export type VideoImportPayload = VideoImportYoutubeDLPayload | VideoImportTorrentPayload + async function processVideoImport (job: Bull.Job) { const payload = job.data as VideoImportPayload - logger.info('Processing video import in job %d.', job.id) - const videoImport = await VideoImportModel.loadAndPopulateVideo(payload.videoImportId) + if (payload.type === 'youtube-dl') return processYoutubeDLImport(job, payload) + if (payload.type === 'magnet-uri') return processTorrentImport(job, payload) +} + +// --------------------------------------------------------------------------- + +export { + processVideoImport +} + +// --------------------------------------------------------------------------- + +async function processTorrentImport (job: Bull.Job, payload: VideoImportTorrentPayload) { + logger.info('Processing torrent video import in job %d.', job.id) + + const videoImport = await getVideoImportOrDie(payload.videoImportId) + const options = { + videoImportId: payload.videoImportId, + + downloadThumbnail: false, + downloadPreview: false, + + generateThumbnail: true, + generatePreview: true + } + return processFile(() => downloadWebTorrentVideo(videoImport.magnetUri), videoImport, options) +} + +async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutubeDLPayload) { + logger.info('Processing youtubeDL video import in job %d.', job.id) + + const videoImport = await getVideoImportOrDie(payload.videoImportId) + const options = { + videoImportId: videoImport.id, + + downloadThumbnail: payload.downloadThumbnail, + downloadPreview: payload.downloadPreview, + thumbnailUrl: payload.thumbnailUrl, + + generateThumbnail: false, + generatePreview: false + } + + return processFile(() => downloadYoutubeDLVideo(videoImport.targetUrl), videoImport, options) +} + +async function getVideoImportOrDie (videoImportId: number) { + const videoImport = await VideoImportModel.loadAndPopulateVideo(videoImportId) if (!videoImport || !videoImport.Video) { throw new Error('Cannot import video %s: the video import or video linked to this import does not exist anymore.') } + return videoImport +} + +type ProcessFileOptions = { + videoImportId: number + + downloadThumbnail: boolean + downloadPreview: boolean + thumbnailUrl?: string + + generateThumbnail: boolean + generatePreview: boolean +} +async function processFile (downloader: () => Promise, videoImport: VideoImportModel, options: ProcessFileOptions) { let tempVideoPath: string let videoDestFile: string let videoFile: VideoFileModel try { // Download video from youtubeDL - tempVideoPath = await downloadYoutubeDLVideo(videoImport.targetUrl) + tempVideoPath = await downloader() // Get information about this video const { videoFileResolution } = await getVideoFileResolution(tempVideoPath) @@ -62,23 +131,27 @@ async function processVideoImport (job: Bull.Job) { tempVideoPath = null // This path is not used anymore // Process thumbnail - if (payload.downloadThumbnail) { - if (payload.thumbnailUrl) { + if (options.downloadThumbnail) { + if (options.thumbnailUrl) { const destThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoImport.Video.getThumbnailName()) - await doRequestAndSaveToFile({ method: 'GET', uri: payload.thumbnailUrl }, destThumbnailPath) + await doRequestAndSaveToFile({ method: 'GET', uri: options.thumbnailUrl }, destThumbnailPath) } else { await videoImport.Video.createThumbnail(videoFile) } + } else if (options.generateThumbnail) { + await videoImport.Video.createThumbnail(videoFile) } // Process preview - if (payload.downloadPreview) { - if (payload.thumbnailUrl) { + if (options.downloadPreview) { + if (options.thumbnailUrl) { const destPreviewPath = join(CONFIG.STORAGE.PREVIEWS_DIR, videoImport.Video.getPreviewName()) - await doRequestAndSaveToFile({ method: 'GET', uri: payload.thumbnailUrl }, destPreviewPath) + await doRequestAndSaveToFile({ method: 'GET', uri: options.thumbnailUrl }, destPreviewPath) } else { await videoImport.Video.createPreview(videoFile) } + } else if (options.generatePreview) { + await videoImport.Video.createPreview(videoFile) } // Create torrent @@ -137,9 +210,3 @@ async function processVideoImport (job: Bull.Job) { throw err } } - -// --------------------------------------------------------------------------- - -export { - processVideoImport -} diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 8a24604e1..ddb357db5 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts @@ -32,13 +32,6 @@ const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise} = { 'video-import': processVideoImport } -const jobsWithRequestTimeout: { [ id in JobType ]?: boolean } = { - 'activitypub-http-broadcast': true, - 'activitypub-http-unicast': true, - 'activitypub-http-fetcher': true, - 'activitypub-follow': true -} - const jobTypes: JobType[] = [ 'activitypub-follow', 'activitypub-http-broadcast', diff --git a/server/middlewares/validators/video-imports.ts b/server/middlewares/validators/video-imports.ts index d806edfa3..8ec9373fb 100644 --- a/server/middlewares/validators/video-imports.ts +++ b/server/middlewares/validators/video-imports.ts @@ -6,14 +6,19 @@ import { areValidationErrors } from './utils' import { getCommonVideoAttributes } from './videos' import { isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports' import { cleanUpReqFiles } from '../../helpers/utils' -import { isVideoChannelOfAccountExist, isVideoNameValid } from '../../helpers/custom-validators/videos' +import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../helpers/custom-validators/videos' import { CONFIG } from '../../initializers/constants' const videoImportAddValidator = getCommonVideoAttributes().concat([ - body('targetUrl').custom(isVideoImportTargetUrlValid).withMessage('Should have a valid video import target URL'), body('channelId') .toInt() .custom(isIdValid).withMessage('Should have correct video channel id'), + body('targetUrl') + .optional() + .custom(isVideoImportTargetUrlValid).withMessage('Should have a valid video import target URL'), + body('magnetUri') + .optional() + .custom(isVideoMagnetUriValid).withMessage('Should have a valid video magnet URI'), body('name') .optional() .custom(isVideoNameValid).withMessage('Should have a valid name'), @@ -34,6 +39,15 @@ const videoImportAddValidator = getCommonVideoAttributes().concat([ if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) + // Check we have at least 1 required param + if (!req.body.targetUrl && !req.body.magnetUri) { + cleanUpReqFiles(req) + + return res.status(400) + .json({ error: 'Should have a magnetUri or a targetUrl.' }) + .end() + } + return next() } ]) diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts index eca87163d..55fca28b8 100644 --- a/server/models/video/video-import.ts +++ b/server/models/video/video-import.ts @@ -21,6 +21,7 @@ import { VideoImport, VideoImportState } from '../../../shared' import { VideoChannelModel } from './video-channel' import { AccountModel } from '../account/account' import { TagModel } from './tag' +import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' @DefaultScope({ include: [ @@ -62,11 +63,23 @@ export class VideoImportModel extends Model { @UpdatedAt updatedAt: Date - @AllowNull(false) + @AllowNull(true) + @Default(null) @Is('VideoImportTargetUrl', value => throwIfNotValid(value, isVideoImportTargetUrlValid, 'targetUrl')) @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) targetUrl: string + @AllowNull(true) + @Default(null) + @Is('VideoImportMagnetUri', value => throwIfNotValid(value, isVideoMagnetUriValid, 'magnetUri')) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) // Use the same constraints than URLs + magnetUri: string + + @AllowNull(true) + @Default(null) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_NAME.max)) + torrentName: string + @AllowNull(false) @Default(null) @Is('VideoImportState', value => throwIfNotValid(value, isVideoImportStateValid, 'state')) -- cgit v1.2.3