diff options
Diffstat (limited to 'server/lib')
-rw-r--r-- | server/lib/activitypub/process/process-update.ts | 2 | ||||
-rw-r--r-- | server/lib/activitypub/videos.ts | 3 | ||||
-rw-r--r-- | server/lib/job-queue/handlers/video-import.ts | 145 | ||||
-rw-r--r-- | server/lib/job-queue/job-queue.ts | 25 | ||||
-rw-r--r-- | server/lib/schedulers/youtube-dl-update-scheduler.ts | 72 | ||||
-rw-r--r-- | server/lib/user.ts | 1 |
6 files changed, 236 insertions, 12 deletions
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts index 62791ff1b..82b661a03 100644 --- a/server/lib/activitypub/process/process-update.ts +++ b/server/lib/activitypub/process/process-update.ts | |||
@@ -108,7 +108,7 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate) | |||
108 | await Promise.all(videoFileDestroyTasks) | 108 | await Promise.all(videoFileDestroyTasks) |
109 | 109 | ||
110 | const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoInstance, videoObject) | 110 | const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoInstance, videoObject) |
111 | const tasks = videoFileAttributes.map(f => VideoFileModel.create(f)) | 111 | const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, sequelizeOptions)) |
112 | await Promise.all(tasks) | 112 | await Promise.all(tasks) |
113 | 113 | ||
114 | // Update Tags | 114 | // Update Tags |
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index b3fbf88d0..e2f46bd02 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts | |||
@@ -162,7 +162,8 @@ function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObje | |||
162 | infoHash: parsed.infoHash, | 162 | infoHash: parsed.infoHash, |
163 | resolution: fileUrl.width, | 163 | resolution: fileUrl.width, |
164 | size: fileUrl.size, | 164 | size: fileUrl.size, |
165 | videoId: videoCreated.id | 165 | videoId: videoCreated.id, |
166 | fps: fileUrl.fps | ||
166 | } as VideoFileModel | 167 | } as VideoFileModel |
167 | attributes.push(attribute) | 168 | attributes.push(attribute) |
168 | } | 169 | } |
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts new file mode 100644 index 000000000..cdfe412cc --- /dev/null +++ b/server/lib/job-queue/handlers/video-import.ts | |||
@@ -0,0 +1,145 @@ | |||
1 | import * as Bull from 'bull' | ||
2 | import { logger } from '../../../helpers/logger' | ||
3 | import { downloadYoutubeDLVideo } from '../../../helpers/youtube-dl' | ||
4 | import { VideoImportModel } from '../../../models/video/video-import' | ||
5 | import { VideoImportState } from '../../../../shared/models/videos' | ||
6 | import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' | ||
7 | import { extname, join } from 'path' | ||
8 | import { VideoFileModel } from '../../../models/video/video-file' | ||
9 | import { renamePromise, statPromise, unlinkPromise } from '../../../helpers/core-utils' | ||
10 | import { CONFIG, sequelizeTypescript } from '../../../initializers' | ||
11 | import { doRequestAndSaveToFile } from '../../../helpers/requests' | ||
12 | import { VideoState } from '../../../../shared' | ||
13 | import { JobQueue } from '../index' | ||
14 | import { federateVideoIfNeeded } from '../../activitypub' | ||
15 | import { VideoModel } from '../../../models/video/video' | ||
16 | |||
17 | export type VideoImportPayload = { | ||
18 | type: 'youtube-dl' | ||
19 | videoImportId: number | ||
20 | thumbnailUrl: string | ||
21 | downloadThumbnail: boolean | ||
22 | downloadPreview: boolean | ||
23 | } | ||
24 | |||
25 | async function processVideoImport (job: Bull.Job) { | ||
26 | const payload = job.data as VideoImportPayload | ||
27 | logger.info('Processing video import in job %d.', job.id) | ||
28 | |||
29 | const videoImport = await VideoImportModel.loadAndPopulateVideo(payload.videoImportId) | ||
30 | if (!videoImport || !videoImport.Video) { | ||
31 | throw new Error('Cannot import video %s: the video import or video linked to this import does not exist anymore.') | ||
32 | } | ||
33 | |||
34 | let tempVideoPath: string | ||
35 | let videoDestFile: string | ||
36 | let videoFile: VideoFileModel | ||
37 | try { | ||
38 | // Download video from youtubeDL | ||
39 | tempVideoPath = await downloadYoutubeDLVideo(videoImport.targetUrl) | ||
40 | |||
41 | // Get information about this video | ||
42 | const { videoFileResolution } = await getVideoFileResolution(tempVideoPath) | ||
43 | const fps = await getVideoFileFPS(tempVideoPath) | ||
44 | const stats = await statPromise(tempVideoPath) | ||
45 | const duration = await getDurationFromVideoFile(tempVideoPath) | ||
46 | |||
47 | // Create video file object in database | ||
48 | const videoFileData = { | ||
49 | extname: extname(tempVideoPath), | ||
50 | resolution: videoFileResolution, | ||
51 | size: stats.size, | ||
52 | fps, | ||
53 | videoId: videoImport.videoId | ||
54 | } | ||
55 | videoFile = new VideoFileModel(videoFileData) | ||
56 | // Import if the import fails, to clean files | ||
57 | videoImport.Video.VideoFiles = [ videoFile ] | ||
58 | |||
59 | // Move file | ||
60 | videoDestFile = join(CONFIG.STORAGE.VIDEOS_DIR, videoImport.Video.getVideoFilename(videoFile)) | ||
61 | await renamePromise(tempVideoPath, videoDestFile) | ||
62 | tempVideoPath = null // This path is not used anymore | ||
63 | |||
64 | // Process thumbnail | ||
65 | if (payload.downloadThumbnail) { | ||
66 | if (payload.thumbnailUrl) { | ||
67 | const destThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoImport.Video.getThumbnailName()) | ||
68 | await doRequestAndSaveToFile({ method: 'GET', uri: payload.thumbnailUrl }, destThumbnailPath) | ||
69 | } else { | ||
70 | await videoImport.Video.createThumbnail(videoFile) | ||
71 | } | ||
72 | } | ||
73 | |||
74 | // Process preview | ||
75 | if (payload.downloadPreview) { | ||
76 | if (payload.thumbnailUrl) { | ||
77 | const destPreviewPath = join(CONFIG.STORAGE.PREVIEWS_DIR, videoImport.Video.getPreviewName()) | ||
78 | await doRequestAndSaveToFile({ method: 'GET', uri: payload.thumbnailUrl }, destPreviewPath) | ||
79 | } else { | ||
80 | await videoImport.Video.createPreview(videoFile) | ||
81 | } | ||
82 | } | ||
83 | |||
84 | // Create torrent | ||
85 | await videoImport.Video.createTorrentAndSetInfoHash(videoFile) | ||
86 | |||
87 | const videoImportUpdated: VideoImportModel = await sequelizeTypescript.transaction(async t => { | ||
88 | // Refresh video | ||
89 | const video = await VideoModel.load(videoImport.videoId, t) | ||
90 | if (!video) throw new Error('Video linked to import ' + videoImport.videoId + ' does not exist anymore.') | ||
91 | videoImport.Video = video | ||
92 | |||
93 | const videoFileCreated = await videoFile.save({ transaction: t }) | ||
94 | video.VideoFiles = [ videoFileCreated ] | ||
95 | |||
96 | // Update video DB object | ||
97 | video.duration = duration | ||
98 | video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED | ||
99 | const videoUpdated = await video.save({ transaction: t }) | ||
100 | |||
101 | // Now we can federate the video (reload from database, we need more attributes) | ||
102 | const videoForFederation = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid, t) | ||
103 | await federateVideoIfNeeded(videoForFederation, true, t) | ||
104 | |||
105 | // Update video import object | ||
106 | videoImport.state = VideoImportState.SUCCESS | ||
107 | const videoImportUpdated = await videoImport.save({ transaction: t }) | ||
108 | |||
109 | logger.info('Video %s imported.', videoImport.targetUrl) | ||
110 | |||
111 | videoImportUpdated.Video = videoUpdated | ||
112 | return videoImportUpdated | ||
113 | }) | ||
114 | |||
115 | // Create transcoding jobs? | ||
116 | if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) { | ||
117 | // Put uuid because we don't have id auto incremented for now | ||
118 | const dataInput = { | ||
119 | videoUUID: videoImportUpdated.Video.uuid, | ||
120 | isNewVideo: true | ||
121 | } | ||
122 | |||
123 | await JobQueue.Instance.createJob({ type: 'video-file', payload: dataInput }) | ||
124 | } | ||
125 | |||
126 | } catch (err) { | ||
127 | try { | ||
128 | if (tempVideoPath) await unlinkPromise(tempVideoPath) | ||
129 | } catch (errUnlink) { | ||
130 | logger.warn('Cannot cleanup files after a video import error.', { err: errUnlink }) | ||
131 | } | ||
132 | |||
133 | videoImport.error = err.message | ||
134 | videoImport.state = VideoImportState.FAILED | ||
135 | await videoImport.save() | ||
136 | |||
137 | throw err | ||
138 | } | ||
139 | } | ||
140 | |||
141 | // --------------------------------------------------------------------------- | ||
142 | |||
143 | export { | ||
144 | processVideoImport | ||
145 | } | ||
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 8ff0c169e..8a24604e1 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts | |||
@@ -2,13 +2,14 @@ import * as Bull from 'bull' | |||
2 | import { JobState, JobType } from '../../../shared/models' | 2 | import { JobState, JobType } from '../../../shared/models' |
3 | import { logger } from '../../helpers/logger' | 3 | import { logger } from '../../helpers/logger' |
4 | import { Redis } from '../redis' | 4 | import { Redis } from '../redis' |
5 | import { CONFIG, JOB_ATTEMPTS, JOB_COMPLETED_LIFETIME, JOB_CONCURRENCY, JOB_REQUEST_TTL } from '../../initializers' | 5 | import { CONFIG, JOB_ATTEMPTS, JOB_COMPLETED_LIFETIME, JOB_CONCURRENCY, JOB_TTL } from '../../initializers' |
6 | import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast' | 6 | import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast' |
7 | import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' | 7 | import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' |
8 | import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' | 8 | import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' |
9 | import { EmailPayload, processEmail } from './handlers/email' | 9 | import { EmailPayload, processEmail } from './handlers/email' |
10 | import { processVideoFile, processVideoFileImport, VideoFileImportPayload, VideoFilePayload } from './handlers/video-file' | 10 | import { processVideoFile, processVideoFileImport, VideoFileImportPayload, VideoFilePayload } from './handlers/video-file' |
11 | import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow' | 11 | import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow' |
12 | import { processVideoImport, VideoImportPayload } from './handlers/video-import' | ||
12 | 13 | ||
13 | type CreateJobArgument = | 14 | type CreateJobArgument = |
14 | { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | | 15 | { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | |
@@ -17,7 +18,8 @@ type CreateJobArgument = | |||
17 | { type: 'activitypub-follow', payload: ActivitypubFollowPayload } | | 18 | { type: 'activitypub-follow', payload: ActivitypubFollowPayload } | |
18 | { type: 'video-file-import', payload: VideoFileImportPayload } | | 19 | { type: 'video-file-import', payload: VideoFileImportPayload } | |
19 | { type: 'video-file', payload: VideoFilePayload } | | 20 | { type: 'video-file', payload: VideoFilePayload } | |
20 | { type: 'email', payload: EmailPayload } | 21 | { type: 'email', payload: EmailPayload } | |
22 | { type: 'video-import', payload: VideoImportPayload } | ||
21 | 23 | ||
22 | const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = { | 24 | const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = { |
23 | 'activitypub-http-broadcast': processActivityPubHttpBroadcast, | 25 | 'activitypub-http-broadcast': processActivityPubHttpBroadcast, |
@@ -26,7 +28,8 @@ const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = { | |||
26 | 'activitypub-follow': processActivityPubFollow, | 28 | 'activitypub-follow': processActivityPubFollow, |
27 | 'video-file-import': processVideoFileImport, | 29 | 'video-file-import': processVideoFileImport, |
28 | 'video-file': processVideoFile, | 30 | 'video-file': processVideoFile, |
29 | 'email': processEmail | 31 | 'email': processEmail, |
32 | 'video-import': processVideoImport | ||
30 | } | 33 | } |
31 | 34 | ||
32 | const jobsWithRequestTimeout: { [ id in JobType ]?: boolean } = { | 35 | const jobsWithRequestTimeout: { [ id in JobType ]?: boolean } = { |
@@ -43,7 +46,8 @@ const jobTypes: JobType[] = [ | |||
43 | 'activitypub-http-unicast', | 46 | 'activitypub-http-unicast', |
44 | 'email', | 47 | 'email', |
45 | 'video-file', | 48 | 'video-file', |
46 | 'video-file-import' | 49 | 'video-file-import', |
50 | 'video-import' | ||
47 | ] | 51 | ] |
48 | 52 | ||
49 | class JobQueue { | 53 | class JobQueue { |
@@ -75,7 +79,11 @@ class JobQueue { | |||
75 | const handler = handlers[handlerName] | 79 | const handler = handlers[handlerName] |
76 | 80 | ||
77 | queue.process(JOB_CONCURRENCY[handlerName], handler) | 81 | queue.process(JOB_CONCURRENCY[handlerName], handler) |
78 | .catch(err => logger.error('Cannot execute job queue %s.', handlerName, { err })) | 82 | .catch(err => logger.error('Error in job queue processor %s.', handlerName, { err })) |
83 | |||
84 | queue.on('failed', (job, err) => { | ||
85 | logger.error('Cannot execute job %d in queue %s.', job.id, handlerName, { payload: job.data, err }) | ||
86 | }) | ||
79 | 87 | ||
80 | queue.on('error', err => { | 88 | queue.on('error', err => { |
81 | logger.error('Error in job queue %s.', handlerName, { err }) | 89 | logger.error('Error in job queue %s.', handlerName, { err }) |
@@ -102,11 +110,8 @@ class JobQueue { | |||
102 | 110 | ||
103 | const jobArgs: Bull.JobOptions = { | 111 | const jobArgs: Bull.JobOptions = { |
104 | backoff: { delay: 60 * 1000, type: 'exponential' }, | 112 | backoff: { delay: 60 * 1000, type: 'exponential' }, |
105 | attempts: JOB_ATTEMPTS[obj.type] | 113 | attempts: JOB_ATTEMPTS[obj.type], |
106 | } | 114 | timeout: JOB_TTL[obj.type] |
107 | |||
108 | if (jobsWithRequestTimeout[obj.type] === true) { | ||
109 | jobArgs.timeout = JOB_REQUEST_TTL | ||
110 | } | 115 | } |
111 | 116 | ||
112 | return queue.add(obj.payload, jobArgs) | 117 | return queue.add(obj.payload, jobArgs) |
diff --git a/server/lib/schedulers/youtube-dl-update-scheduler.ts b/server/lib/schedulers/youtube-dl-update-scheduler.ts new file mode 100644 index 000000000..a2d919603 --- /dev/null +++ b/server/lib/schedulers/youtube-dl-update-scheduler.ts | |||
@@ -0,0 +1,72 @@ | |||
1 | // Thanks: https://github.com/przemyslawpluta/node-youtube-dl/blob/master/lib/downloader.js | ||
2 | // We rewrote it to avoid sync calls | ||
3 | |||
4 | import { AbstractScheduler } from './abstract-scheduler' | ||
5 | import { SCHEDULER_INTERVALS_MS } from '../../initializers' | ||
6 | import { logger } from '../../helpers/logger' | ||
7 | import * as request from 'request' | ||
8 | import { createWriteStream, writeFile } from 'fs' | ||
9 | import { join } from 'path' | ||
10 | import { root } from '../../helpers/core-utils' | ||
11 | |||
12 | export class YoutubeDlUpdateScheduler extends AbstractScheduler { | ||
13 | |||
14 | private static instance: AbstractScheduler | ||
15 | |||
16 | protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.youtubeDLUpdate | ||
17 | |||
18 | private constructor () { | ||
19 | super() | ||
20 | } | ||
21 | |||
22 | async execute () { | ||
23 | const binDirectory = join(root(), 'node_modules', 'youtube-dl', 'bin') | ||
24 | const bin = join(binDirectory, 'youtube-dl') | ||
25 | const detailsPath = join(binDirectory, 'details') | ||
26 | const url = 'https://yt-dl.org/downloads/latest/youtube-dl' | ||
27 | |||
28 | request.get(url, { followRedirect: false }, (err, res) => { | ||
29 | if (err) { | ||
30 | logger.error('Cannot update youtube-dl.', { err }) | ||
31 | return | ||
32 | } | ||
33 | |||
34 | if (res.statusCode !== 302) { | ||
35 | logger.error('youtube-dl update error: did not get redirect for the latest version link. Status %d', res.statusCode) | ||
36 | return | ||
37 | } | ||
38 | |||
39 | const url = res.headers.location | ||
40 | const downloadFile = request.get(url) | ||
41 | const newVersion = /yt-dl\.org\/downloads\/(\d{4}\.\d\d\.\d\d(\.\d)?)\/youtube-dl/.exec(url)[1] | ||
42 | |||
43 | downloadFile.on('response', res => { | ||
44 | if (res.statusCode !== 200) { | ||
45 | logger.error('Cannot update youtube-dl: new version response is not 200, it\'s %d.', res.statusCode) | ||
46 | return | ||
47 | } | ||
48 | |||
49 | downloadFile.pipe(createWriteStream(bin, { mode: 493 })) | ||
50 | }) | ||
51 | |||
52 | downloadFile.on('error', err => logger.error('youtube-dl update error.', { err })) | ||
53 | |||
54 | downloadFile.on('end', () => { | ||
55 | const details = JSON.stringify({ version: newVersion, path: bin, exec: 'youtube-dl' }) | ||
56 | writeFile(detailsPath, details, { encoding: 'utf8' }, err => { | ||
57 | if (err) { | ||
58 | logger.error('youtube-dl update error: cannot write details.', { err }) | ||
59 | return | ||
60 | } | ||
61 | |||
62 | logger.info('youtube-dl updated to version %s.', newVersion) | ||
63 | }) | ||
64 | }) | ||
65 | |||
66 | }) | ||
67 | } | ||
68 | |||
69 | static get Instance () { | ||
70 | return this.instance || (this.instance = new this()) | ||
71 | } | ||
72 | } | ||
diff --git a/server/lib/user.ts b/server/lib/user.ts index ac5f55260..e7a45f5aa 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts | |||
@@ -17,6 +17,7 @@ async function createUserAccountAndChannel (userToCreate: UserModel, validateUse | |||
17 | 17 | ||
18 | const userCreated = await userToCreate.save(userOptions) | 18 | const userCreated = await userToCreate.save(userOptions) |
19 | const accountCreated = await createLocalAccountWithoutKeys(userToCreate.username, userToCreate.id, null, t) | 19 | const accountCreated = await createLocalAccountWithoutKeys(userToCreate.username, userToCreate.id, null, t) |
20 | userCreated.Account = accountCreated | ||
20 | 21 | ||
21 | const videoChannelDisplayName = `Default ${userCreated.username} channel` | 22 | const videoChannelDisplayName = `Default ${userCreated.username} channel` |
22 | const videoChannelInfo = { | 23 | const videoChannelInfo = { |