aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models/video/video.ts
diff options
context:
space:
mode:
Diffstat (limited to 'server/models/video/video.ts')
-rw-r--r--server/models/video/video.ts419
1 files changed, 22 insertions, 397 deletions
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index b7d3f184f..ce856aed2 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1,8 +1,8 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { map, maxBy } from 'lodash' 2import { maxBy } from 'lodash'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import * as parseTorrent from 'parse-torrent' 4import * as parseTorrent from 'parse-torrent'
5import { extname, join } from 'path' 5import { join } from 'path'
6import * as Sequelize from 'sequelize' 6import * as Sequelize from 'sequelize'
7import { 7import {
8 AllowNull, 8 AllowNull,
@@ -27,7 +27,7 @@ import {
27 Table, 27 Table,
28 UpdatedAt 28 UpdatedAt
29} from 'sequelize-typescript' 29} from 'sequelize-typescript'
30import { ActivityUrlObject, VideoPrivacy, VideoResolution, VideoState } from '../../../shared' 30import { VideoPrivacy, VideoState } from '../../../shared'
31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
33import { VideoFilter } from '../../../shared/models/videos/video-query.type' 33import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@@ -45,7 +45,7 @@ import {
45 isVideoStateValid, 45 isVideoStateValid,
46 isVideoSupportValid 46 isVideoSupportValid
47} from '../../helpers/custom-validators/videos' 47} from '../../helpers/custom-validators/videos'
48import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' 48import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils'
49import { logger } from '../../helpers/logger' 49import { logger } from '../../helpers/logger'
50import { getServerActor } from '../../helpers/utils' 50import { getServerActor } from '../../helpers/utils'
51import { 51import {
@@ -59,18 +59,11 @@ import {
59 STATIC_PATHS, 59 STATIC_PATHS,
60 THUMBNAILS_SIZE, 60 THUMBNAILS_SIZE,
61 VIDEO_CATEGORIES, 61 VIDEO_CATEGORIES,
62 VIDEO_EXT_MIMETYPE,
63 VIDEO_LANGUAGES, 62 VIDEO_LANGUAGES,
64 VIDEO_LICENCES, 63 VIDEO_LICENCES,
65 VIDEO_PRIVACIES, 64 VIDEO_PRIVACIES,
66 VIDEO_STATES 65 VIDEO_STATES
67} from '../../initializers' 66} from '../../initializers'
68import {
69 getVideoCommentsActivityPubUrl,
70 getVideoDislikesActivityPubUrl,
71 getVideoLikesActivityPubUrl,
72 getVideoSharesActivityPubUrl
73} from '../../lib/activitypub'
74import { sendDeleteVideo } from '../../lib/activitypub/send' 67import { sendDeleteVideo } from '../../lib/activitypub/send'
75import { AccountModel } from '../account/account' 68import { AccountModel } from '../account/account'
76import { AccountVideoRateModel } from '../account/account-video-rate' 69import { AccountVideoRateModel } from '../account/account-video-rate'
@@ -88,9 +81,16 @@ import { VideoTagModel } from './video-tag'
88import { ScheduleVideoUpdateModel } from './schedule-video-update' 81import { ScheduleVideoUpdateModel } from './schedule-video-update'
89import { VideoCaptionModel } from './video-caption' 82import { VideoCaptionModel } from './video-caption'
90import { VideoBlacklistModel } from './video-blacklist' 83import { VideoBlacklistModel } from './video-blacklist'
91import { copy, remove, rename, stat, writeFile } from 'fs-extra' 84import { remove, writeFile } from 'fs-extra'
92import { VideoViewModel } from './video-views' 85import { VideoViewModel } from './video-views'
93import { VideoRedundancyModel } from '../redundancy/video-redundancy' 86import { VideoRedundancyModel } from '../redundancy/video-redundancy'
87import {
88 videoFilesModelToFormattedJSON,
89 VideoFormattingJSONOptions,
90 videoModelToActivityPubObject,
91 videoModelToFormattedDetailsJSON,
92 videoModelToFormattedJSON
93} from './video-format-utils'
94 94
95// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 95// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
96const indexes: Sequelize.DefineIndexesOptions[] = [ 96const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -1257,23 +1257,23 @@ export class VideoModel extends Model<VideoModel> {
1257 } 1257 }
1258 } 1258 }
1259 1259
1260 private static getCategoryLabel (id: number) { 1260 static getCategoryLabel (id: number) {
1261 return VIDEO_CATEGORIES[ id ] || 'Misc' 1261 return VIDEO_CATEGORIES[ id ] || 'Misc'
1262 } 1262 }
1263 1263
1264 private static getLicenceLabel (id: number) { 1264 static getLicenceLabel (id: number) {
1265 return VIDEO_LICENCES[ id ] || 'Unknown' 1265 return VIDEO_LICENCES[ id ] || 'Unknown'
1266 } 1266 }
1267 1267
1268 private static getLanguageLabel (id: string) { 1268 static getLanguageLabel (id: string) {
1269 return VIDEO_LANGUAGES[ id ] || 'Unknown' 1269 return VIDEO_LANGUAGES[ id ] || 'Unknown'
1270 } 1270 }
1271 1271
1272 private static getPrivacyLabel (id: number) { 1272 static getPrivacyLabel (id: number) {
1273 return VIDEO_PRIVACIES[ id ] || 'Unknown' 1273 return VIDEO_PRIVACIES[ id ] || 'Unknown'
1274 } 1274 }
1275 1275
1276 private static getStateLabel (id: number) { 1276 static getStateLabel (id: number) {
1277 return VIDEO_STATES[ id ] || 'Unknown' 1277 return VIDEO_STATES[ id ] || 'Unknown'
1278 } 1278 }
1279 1279
@@ -1369,273 +1369,20 @@ export class VideoModel extends Model<VideoModel> {
1369 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) 1369 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
1370 } 1370 }
1371 1371
1372 toFormattedJSON (options?: { 1372 toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
1373 additionalAttributes: { 1373 return videoModelToFormattedJSON(this, options)
1374 state?: boolean,
1375 waitTranscoding?: boolean,
1376 scheduledUpdate?: boolean,
1377 blacklistInfo?: boolean
1378 }
1379 }): Video {
1380 const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
1381 const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
1382
1383 const videoObject: Video = {
1384 id: this.id,
1385 uuid: this.uuid,
1386 name: this.name,
1387 category: {
1388 id: this.category,
1389 label: VideoModel.getCategoryLabel(this.category)
1390 },
1391 licence: {
1392 id: this.licence,
1393 label: VideoModel.getLicenceLabel(this.licence)
1394 },
1395 language: {
1396 id: this.language,
1397 label: VideoModel.getLanguageLabel(this.language)
1398 },
1399 privacy: {
1400 id: this.privacy,
1401 label: VideoModel.getPrivacyLabel(this.privacy)
1402 },
1403 nsfw: this.nsfw,
1404 description: this.getTruncatedDescription(),
1405 isLocal: this.isOwned(),
1406 duration: this.duration,
1407 views: this.views,
1408 likes: this.likes,
1409 dislikes: this.dislikes,
1410 thumbnailPath: this.getThumbnailStaticPath(),
1411 previewPath: this.getPreviewStaticPath(),
1412 embedPath: this.getEmbedStaticPath(),
1413 createdAt: this.createdAt,
1414 updatedAt: this.updatedAt,
1415 publishedAt: this.publishedAt,
1416 account: {
1417 id: formattedAccount.id,
1418 uuid: formattedAccount.uuid,
1419 name: formattedAccount.name,
1420 displayName: formattedAccount.displayName,
1421 url: formattedAccount.url,
1422 host: formattedAccount.host,
1423 avatar: formattedAccount.avatar
1424 },
1425 channel: {
1426 id: formattedVideoChannel.id,
1427 uuid: formattedVideoChannel.uuid,
1428 name: formattedVideoChannel.name,
1429 displayName: formattedVideoChannel.displayName,
1430 url: formattedVideoChannel.url,
1431 host: formattedVideoChannel.host,
1432 avatar: formattedVideoChannel.avatar
1433 }
1434 }
1435
1436 if (options) {
1437 if (options.additionalAttributes.state === true) {
1438 videoObject.state = {
1439 id: this.state,
1440 label: VideoModel.getStateLabel(this.state)
1441 }
1442 }
1443
1444 if (options.additionalAttributes.waitTranscoding === true) {
1445 videoObject.waitTranscoding = this.waitTranscoding
1446 }
1447
1448 if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) {
1449 videoObject.scheduledUpdate = {
1450 updateAt: this.ScheduleVideoUpdate.updateAt,
1451 privacy: this.ScheduleVideoUpdate.privacy || undefined
1452 }
1453 }
1454
1455 if (options.additionalAttributes.blacklistInfo === true) {
1456 videoObject.blacklisted = !!this.VideoBlacklist
1457 videoObject.blacklistedReason = this.VideoBlacklist ? this.VideoBlacklist.reason : null
1458 }
1459 }
1460
1461 return videoObject
1462 } 1374 }
1463 1375
1464 toFormattedDetailsJSON (): VideoDetails { 1376 toFormattedDetailsJSON (): VideoDetails {
1465 const formattedJson = this.toFormattedJSON({ 1377 return videoModelToFormattedDetailsJSON(this)
1466 additionalAttributes: {
1467 scheduledUpdate: true,
1468 blacklistInfo: true
1469 }
1470 })
1471
1472 const detailsJson = {
1473 support: this.support,
1474 descriptionPath: this.getDescriptionPath(),
1475 channel: this.VideoChannel.toFormattedJSON(),
1476 account: this.VideoChannel.Account.toFormattedJSON(),
1477 tags: map(this.Tags, 'name'),
1478 commentsEnabled: this.commentsEnabled,
1479 waitTranscoding: this.waitTranscoding,
1480 state: {
1481 id: this.state,
1482 label: VideoModel.getStateLabel(this.state)
1483 },
1484 files: []
1485 }
1486
1487 // Format and sort video files
1488 detailsJson.files = this.getFormattedVideoFilesJSON()
1489
1490 return Object.assign(formattedJson, detailsJson)
1491 } 1378 }
1492 1379
1493 getFormattedVideoFilesJSON (): VideoFile[] { 1380 getFormattedVideoFilesJSON (): VideoFile[] {
1494 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() 1381 return videoFilesModelToFormattedJSON(this, this.VideoFiles)
1495
1496 return this.VideoFiles
1497 .map(videoFile => {
1498 let resolutionLabel = videoFile.resolution + 'p'
1499
1500 return {
1501 resolution: {
1502 id: videoFile.resolution,
1503 label: resolutionLabel
1504 },
1505 magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1506 size: videoFile.size,
1507 fps: videoFile.fps,
1508 torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1509 torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
1510 fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
1511 fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
1512 } as VideoFile
1513 })
1514 .sort((a, b) => {
1515 if (a.resolution.id < b.resolution.id) return 1
1516 if (a.resolution.id === b.resolution.id) return 0
1517 return -1
1518 })
1519 } 1382 }
1520 1383
1521 toActivityPubObject (): VideoTorrentObject { 1384 toActivityPubObject (): VideoTorrentObject {
1522 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() 1385 return videoModelToActivityPubObject(this)
1523 if (!this.Tags) this.Tags = []
1524
1525 const tag = this.Tags.map(t => ({
1526 type: 'Hashtag' as 'Hashtag',
1527 name: t.name
1528 }))
1529
1530 let language
1531 if (this.language) {
1532 language = {
1533 identifier: this.language,
1534 name: VideoModel.getLanguageLabel(this.language)
1535 }
1536 }
1537
1538 let category
1539 if (this.category) {
1540 category = {
1541 identifier: this.category + '',
1542 name: VideoModel.getCategoryLabel(this.category)
1543 }
1544 }
1545
1546 let licence
1547 if (this.licence) {
1548 licence = {
1549 identifier: this.licence + '',
1550 name: VideoModel.getLicenceLabel(this.licence)
1551 }
1552 }
1553
1554 const url: ActivityUrlObject[] = []
1555 for (const file of this.VideoFiles) {
1556 url.push({
1557 type: 'Link',
1558 mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
1559 href: this.getVideoFileUrl(file, baseUrlHttp),
1560 height: file.resolution,
1561 size: file.size,
1562 fps: file.fps
1563 })
1564
1565 url.push({
1566 type: 'Link',
1567 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
1568 href: this.getTorrentUrl(file, baseUrlHttp),
1569 height: file.resolution
1570 })
1571
1572 url.push({
1573 type: 'Link',
1574 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
1575 href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1576 height: file.resolution
1577 })
1578 }
1579
1580 // Add video url too
1581 url.push({
1582 type: 'Link',
1583 mimeType: 'text/html',
1584 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1585 })
1586
1587 const subtitleLanguage = []
1588 for (const caption of this.VideoCaptions) {
1589 subtitleLanguage.push({
1590 identifier: caption.language,
1591 name: VideoCaptionModel.getLanguageLabel(caption.language)
1592 })
1593 }
1594
1595 return {
1596 type: 'Video' as 'Video',
1597 id: this.url,
1598 name: this.name,
1599 duration: this.getActivityStreamDuration(),
1600 uuid: this.uuid,
1601 tag,
1602 category,
1603 licence,
1604 language,
1605 views: this.views,
1606 sensitive: this.nsfw,
1607 waitTranscoding: this.waitTranscoding,
1608 state: this.state,
1609 commentsEnabled: this.commentsEnabled,
1610 published: this.publishedAt.toISOString(),
1611 updated: this.updatedAt.toISOString(),
1612 mediaType: 'text/markdown',
1613 content: this.getTruncatedDescription(),
1614 support: this.support,
1615 subtitleLanguage,
1616 icon: {
1617 type: 'Image',
1618 url: this.getThumbnailUrl(baseUrlHttp),
1619 mediaType: 'image/jpeg',
1620 width: THUMBNAILS_SIZE.width,
1621 height: THUMBNAILS_SIZE.height
1622 },
1623 url,
1624 likes: getVideoLikesActivityPubUrl(this),
1625 dislikes: getVideoDislikesActivityPubUrl(this),
1626 shares: getVideoSharesActivityPubUrl(this),
1627 comments: getVideoCommentsActivityPubUrl(this),
1628 attributedTo: [
1629 {
1630 type: 'Person',
1631 id: this.VideoChannel.Account.Actor.url
1632 },
1633 {
1634 type: 'Group',
1635 id: this.VideoChannel.Actor.url
1636 }
1637 ]
1638 }
1639 } 1386 }
1640 1387
1641 getTruncatedDescription () { 1388 getTruncatedDescription () {
@@ -1645,123 +1392,6 @@ export class VideoModel extends Model<VideoModel> {
1645 return peertubeTruncate(this.description, maxLength) 1392 return peertubeTruncate(this.description, maxLength)
1646 } 1393 }
1647 1394
1648 async optimizeOriginalVideofile () {
1649 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1650 const newExtname = '.mp4'
1651 const inputVideoFile = this.getOriginalFile()
1652 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1653 const videoTranscodedPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1654
1655 const transcodeOptions = {
1656 inputPath: videoInputPath,
1657 outputPath: videoTranscodedPath
1658 }
1659
1660 // Could be very long!
1661 await transcode(transcodeOptions)
1662
1663 try {
1664 await remove(videoInputPath)
1665
1666 // Important to do this before getVideoFilename() to take in account the new file extension
1667 inputVideoFile.set('extname', newExtname)
1668
1669 const videoOutputPath = this.getVideoFilePath(inputVideoFile)
1670 await rename(videoTranscodedPath, videoOutputPath)
1671 const stats = await stat(videoOutputPath)
1672 const fps = await getVideoFileFPS(videoOutputPath)
1673
1674 inputVideoFile.set('size', stats.size)
1675 inputVideoFile.set('fps', fps)
1676
1677 await this.createTorrentAndSetInfoHash(inputVideoFile)
1678 await inputVideoFile.save()
1679
1680 } catch (err) {
1681 // Auto destruction...
1682 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
1683
1684 throw err
1685 }
1686 }
1687
1688 async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
1689 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1690 const extname = '.mp4'
1691
1692 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1693 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1694
1695 const newVideoFile = new VideoFileModel({
1696 resolution,
1697 extname,
1698 size: 0,
1699 videoId: this.id
1700 })
1701 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1702
1703 const transcodeOptions = {
1704 inputPath: videoInputPath,
1705 outputPath: videoOutputPath,
1706 resolution,
1707 isPortraitMode
1708 }
1709
1710 await transcode(transcodeOptions)
1711
1712 const stats = await stat(videoOutputPath)
1713 const fps = await getVideoFileFPS(videoOutputPath)
1714
1715 newVideoFile.set('size', stats.size)
1716 newVideoFile.set('fps', fps)
1717
1718 await this.createTorrentAndSetInfoHash(newVideoFile)
1719
1720 await newVideoFile.save()
1721
1722 this.VideoFiles.push(newVideoFile)
1723 }
1724
1725 async importVideoFile (inputFilePath: string) {
1726 const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
1727 const { size } = await stat(inputFilePath)
1728 const fps = await getVideoFileFPS(inputFilePath)
1729
1730 let updatedVideoFile = new VideoFileModel({
1731 resolution: videoFileResolution,
1732 extname: extname(inputFilePath),
1733 size,
1734 fps,
1735 videoId: this.id
1736 })
1737
1738 const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
1739
1740 if (currentVideoFile) {
1741 // Remove old file and old torrent
1742 await this.removeFile(currentVideoFile)
1743 await this.removeTorrent(currentVideoFile)
1744 // Remove the old video file from the array
1745 this.VideoFiles = this.VideoFiles.filter(f => f !== currentVideoFile)
1746
1747 // Update the database
1748 currentVideoFile.set('extname', updatedVideoFile.extname)
1749 currentVideoFile.set('size', updatedVideoFile.size)
1750 currentVideoFile.set('fps', updatedVideoFile.fps)
1751
1752 updatedVideoFile = currentVideoFile
1753 }
1754
1755 const outputPath = this.getVideoFilePath(updatedVideoFile)
1756 await copy(inputFilePath, outputPath)
1757
1758 await this.createTorrentAndSetInfoHash(updatedVideoFile)
1759
1760 await updatedVideoFile.save()
1761
1762 this.VideoFiles.push(updatedVideoFile)
1763 }
1764
1765 getOriginalFileResolution () { 1395 getOriginalFileResolution () {
1766 const originalFilePath = this.getVideoFilePath(this.getOriginalFile()) 1396 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1767 1397
@@ -1796,11 +1426,6 @@ export class VideoModel extends Model<VideoModel> {
1796 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1426 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1797 } 1427 }
1798 1428
1799 getActivityStreamDuration () {
1800 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1801 return 'PT' + this.duration + 'S'
1802 }
1803
1804 isOutdated () { 1429 isOutdated () {
1805 if (this.isOwned()) return false 1430 if (this.isOwned()) return false
1806 1431