Add ability to download a video from direct link or torrent file
This commit is contained in:
parent
bda65bdc9f
commit
a96aed1518
|
@ -0,0 +1,30 @@
|
||||||
|
<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content modal-lg">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" aria-label="Close" (click)="hide()">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
<h4 class="modal-title">Download</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div *ngFor="let file of video.files" class="resolution-block">
|
||||||
|
<label>{{ file.resolutionLabel }}</label>
|
||||||
|
<a class="btn btn-default " target="_blank" [href]="file.torrentUrl">
|
||||||
|
<span class="glyphicon glyphicon-download"></span>
|
||||||
|
Torrent file
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-default" target="_blank" [href]="file.fileUrl">
|
||||||
|
<span class="glyphicon glyphicon-download"></span>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Don't display magnet URI for now, this is not compatible with most torrent clients -->
|
||||||
|
<!--<input #magnetUriInput (click)="magnetUriInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="file.magnetUri" />-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -5,10 +5,11 @@ import { ModalDirective } from 'ngx-bootstrap/modal'
|
||||||
import { Video } from '../shared'
|
import { Video } from '../shared'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-video-magnet',
|
selector: 'my-video-download',
|
||||||
templateUrl: './video-magnet.component.html'
|
templateUrl: './video-download.component.html',
|
||||||
|
styles: [ '.resolution-block { margin-top: 20px; }' ]
|
||||||
})
|
})
|
||||||
export class VideoMagnetComponent {
|
export class VideoDownloadComponent {
|
||||||
@Input() video: Video = null
|
@Input() video: Video = null
|
||||||
|
|
||||||
@ViewChild('modal') modal: ModalDirective
|
@ViewChild('modal') modal: ModalDirective
|
|
@ -1,20 +0,0 @@
|
||||||
<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content modal-lg">
|
|
||||||
|
|
||||||
<div class="modal-header">
|
|
||||||
<button type="button" class="close" aria-label="Close" (click)="hide()">
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
<h4 class="modal-title">Magnet Uri</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<div *ngFor="let file of video.files">
|
|
||||||
<label>{{ file.resolutionLabel }}</label>
|
|
||||||
<input #magnetUriInput (click)="magnetUriInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="file.magnetUri" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -71,8 +71,8 @@
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li role="menuitem">
|
<li role="menuitem">
|
||||||
<a class="dropdown-item" title="Get magnet URI" href="#" (click)="showMagnetUriModal($event)">
|
<a class="dropdown-item" title="Download the video" href="#" (click)="showDownloadModal($event)">
|
||||||
<span class="glyphicon glyphicon-magnet"></span> Magnet
|
<span class="glyphicon glyphicon-download-alt"></span> Download
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
@ -179,6 +179,6 @@
|
||||||
|
|
||||||
<ng-template [ngIf]="video !== null">
|
<ng-template [ngIf]="video !== null">
|
||||||
<my-video-share #videoShareModal [video]="video"></my-video-share>
|
<my-video-share #videoShareModal [video]="video"></my-video-share>
|
||||||
<my-video-magnet #videoMagnetModal [video]="video"></my-video-magnet>
|
<my-video-download #videoDownloadModal [video]="video"></my-video-download>
|
||||||
<my-video-report #videoReportModal [video]="video"></my-video-report>
|
<my-video-report #videoReportModal [video]="video"></my-video-report>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { MetaService } from '@ngx-meta/core'
|
||||||
import { NotificationsService } from 'angular2-notifications'
|
import { NotificationsService } from 'angular2-notifications'
|
||||||
|
|
||||||
import { AuthService, ConfirmService } from '../../core'
|
import { AuthService, ConfirmService } from '../../core'
|
||||||
import { VideoMagnetComponent } from './video-magnet.component'
|
import { VideoDownloadComponent } from './video-download.component'
|
||||||
import { VideoShareComponent } from './video-share.component'
|
import { VideoShareComponent } from './video-share.component'
|
||||||
import { VideoReportComponent } from './video-report.component'
|
import { VideoReportComponent } from './video-report.component'
|
||||||
import { Video, VideoService } from '../shared'
|
import { Video, VideoService } from '../shared'
|
||||||
|
@ -23,7 +23,7 @@ import { UserVideoRateType, VideoRateType } from '../../../../../shared'
|
||||||
styleUrls: [ './video-watch.component.scss' ]
|
styleUrls: [ './video-watch.component.scss' ]
|
||||||
})
|
})
|
||||||
export class VideoWatchComponent implements OnInit, OnDestroy {
|
export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
@ViewChild('videoMagnetModal') videoMagnetModal: VideoMagnetComponent
|
@ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
|
||||||
@ViewChild('videoShareModal') videoShareModal: VideoShareComponent
|
@ViewChild('videoShareModal') videoShareModal: VideoShareComponent
|
||||||
@ViewChild('videoReportModal') videoReportModal: VideoReportComponent
|
@ViewChild('videoReportModal') videoReportModal: VideoReportComponent
|
||||||
|
|
||||||
|
@ -160,9 +160,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
this.videoShareModal.show()
|
this.videoShareModal.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
showMagnetUriModal (event: Event) {
|
showDownloadModal (event: Event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
this.videoMagnetModal.show()
|
this.videoDownloadModal.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
isUserLoggedIn () {
|
isUserLoggedIn () {
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { SharedModule } from '../../shared'
|
||||||
import { VideoWatchComponent } from './video-watch.component'
|
import { VideoWatchComponent } from './video-watch.component'
|
||||||
import { VideoReportComponent } from './video-report.component'
|
import { VideoReportComponent } from './video-report.component'
|
||||||
import { VideoShareComponent } from './video-share.component'
|
import { VideoShareComponent } from './video-share.component'
|
||||||
import { VideoMagnetComponent } from './video-magnet.component'
|
import { VideoDownloadComponent } from './video-download.component'
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -18,7 +18,7 @@ import { VideoMagnetComponent } from './video-magnet.component'
|
||||||
declarations: [
|
declarations: [
|
||||||
VideoWatchComponent,
|
VideoWatchComponent,
|
||||||
|
|
||||||
VideoMagnetComponent,
|
VideoDownloadComponent,
|
||||||
VideoShareComponent,
|
VideoShareComponent,
|
||||||
VideoReportComponent
|
VideoReportComponent
|
||||||
],
|
],
|
||||||
|
|
|
@ -158,7 +158,12 @@ const peertubePlugin = function (options: PeertubePluginOptions) {
|
||||||
})
|
})
|
||||||
|
|
||||||
player.torrent.on('error', err => handleError(err))
|
player.torrent.on('error', err => handleError(err))
|
||||||
player.torrent.on('warning', err => handleError(err))
|
player.torrent.on('warning', err => {
|
||||||
|
// We don't support HTTP tracker but we don't care -> we use the web socket tracker
|
||||||
|
if (err.message.indexOf('Unsupported tracker protocol: http://') !== -1) return
|
||||||
|
|
||||||
|
return handleError(err)
|
||||||
|
})
|
||||||
|
|
||||||
player.trigger('videoFileUpdate')
|
player.trigger('videoFileUpdate')
|
||||||
|
|
||||||
|
|
44
server.ts
44
server.ts
|
@ -79,26 +79,6 @@ app.use(morgan('combined', {
|
||||||
app.use(bodyParser.json({ limit: '500kb' }))
|
app.use(bodyParser.json({ limit: '500kb' }))
|
||||||
app.use(bodyParser.urlencoded({ extended: false }))
|
app.use(bodyParser.urlencoded({ extended: false }))
|
||||||
|
|
||||||
// ----------- Views, routes and static files -----------
|
|
||||||
|
|
||||||
// API
|
|
||||||
const apiRoute = '/api/' + API_VERSION
|
|
||||||
app.use(apiRoute, apiRouter)
|
|
||||||
|
|
||||||
// Services (oembed...)
|
|
||||||
app.use('/services', servicesRouter)
|
|
||||||
|
|
||||||
// Client files
|
|
||||||
app.use('/', clientsRouter)
|
|
||||||
|
|
||||||
// Static files
|
|
||||||
app.use('/', staticRouter)
|
|
||||||
|
|
||||||
// Always serve index client page (the client is a single page application, let it handle routing)
|
|
||||||
app.use('/*', function (req, res, next) {
|
|
||||||
res.sendFile(path.join(__dirname, '../client/dist/index.html'))
|
|
||||||
})
|
|
||||||
|
|
||||||
// ----------- Tracker -----------
|
// ----------- Tracker -----------
|
||||||
|
|
||||||
const trackerServer = new TrackerServer({
|
const trackerServer = new TrackerServer({
|
||||||
|
@ -122,6 +102,30 @@ wss.on('connection', function (ws) {
|
||||||
trackerServer.onWebSocketConnection(ws)
|
trackerServer.onWebSocketConnection(ws)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const onHttpRequest = trackerServer.onHttpRequest.bind(trackerServer)
|
||||||
|
app.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' }))
|
||||||
|
app.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' }))
|
||||||
|
|
||||||
|
// ----------- Views, routes and static files -----------
|
||||||
|
|
||||||
|
// API
|
||||||
|
const apiRoute = '/api/' + API_VERSION
|
||||||
|
app.use(apiRoute, apiRouter)
|
||||||
|
|
||||||
|
// Services (oembed...)
|
||||||
|
app.use('/services', servicesRouter)
|
||||||
|
|
||||||
|
// Client files
|
||||||
|
app.use('/', clientsRouter)
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
app.use('/', staticRouter)
|
||||||
|
|
||||||
|
// Always serve index client page (the client is a single page application, let it handle routing)
|
||||||
|
app.use('/*', function (req, res) {
|
||||||
|
res.sendFile(path.join(__dirname, '../client/dist/index.html'))
|
||||||
|
})
|
||||||
|
|
||||||
// ----------- Errors -----------
|
// ----------- Errors -----------
|
||||||
|
|
||||||
// Catch 404 and forward to error handler
|
// Catch 404 and forward to error handler
|
||||||
|
|
|
@ -18,7 +18,6 @@ export namespace VideoMethods {
|
||||||
export type ToFormattedJSON = (this: VideoInstance) => FormattedVideo
|
export type ToFormattedJSON = (this: VideoInstance) => FormattedVideo
|
||||||
|
|
||||||
export type GetOriginalFile = (this: VideoInstance) => VideoFileInstance
|
export type GetOriginalFile = (this: VideoInstance) => VideoFileInstance
|
||||||
export type GenerateMagnetUri = (this: VideoInstance, videoFile: VideoFileInstance) => string
|
|
||||||
export type GetTorrentFileName = (this: VideoInstance, videoFile: VideoFileInstance) => string
|
export type GetTorrentFileName = (this: VideoInstance, videoFile: VideoFileInstance) => string
|
||||||
export type GetVideoFilename = (this: VideoInstance, videoFile: VideoFileInstance) => string
|
export type GetVideoFilename = (this: VideoInstance, videoFile: VideoFileInstance) => string
|
||||||
export type CreatePreview = (this: VideoInstance, videoFile: VideoFileInstance) => Promise<string>
|
export type CreatePreview = (this: VideoInstance, videoFile: VideoFileInstance) => Promise<string>
|
||||||
|
@ -108,7 +107,6 @@ export interface VideoInstance extends VideoClass, VideoAttributes, Sequelize.In
|
||||||
createThumbnail: VideoMethods.CreateThumbnail
|
createThumbnail: VideoMethods.CreateThumbnail
|
||||||
createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
|
createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
|
||||||
getOriginalFile: VideoMethods.GetOriginalFile
|
getOriginalFile: VideoMethods.GetOriginalFile
|
||||||
generateMagnetUri: VideoMethods.GenerateMagnetUri
|
|
||||||
getPreviewName: VideoMethods.GetPreviewName
|
getPreviewName: VideoMethods.GetPreviewName
|
||||||
getPreviewPath: VideoMethods.GetPreviewPath
|
getPreviewPath: VideoMethods.GetPreviewPath
|
||||||
getThumbnailName: VideoMethods.GetThumbnailName
|
getThumbnailName: VideoMethods.GetThumbnailName
|
||||||
|
|
|
@ -52,7 +52,6 @@ import { PREVIEWS_SIZE } from '../../initializers/constants'
|
||||||
|
|
||||||
let Video: Sequelize.Model<VideoInstance, VideoAttributes>
|
let Video: Sequelize.Model<VideoInstance, VideoAttributes>
|
||||||
let getOriginalFile: VideoMethods.GetOriginalFile
|
let getOriginalFile: VideoMethods.GetOriginalFile
|
||||||
let generateMagnetUri: VideoMethods.GenerateMagnetUri
|
|
||||||
let getVideoFilename: VideoMethods.GetVideoFilename
|
let getVideoFilename: VideoMethods.GetVideoFilename
|
||||||
let getThumbnailName: VideoMethods.GetThumbnailName
|
let getThumbnailName: VideoMethods.GetThumbnailName
|
||||||
let getThumbnailPath: VideoMethods.GetThumbnailPath
|
let getThumbnailPath: VideoMethods.GetThumbnailPath
|
||||||
|
@ -254,7 +253,6 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
|
||||||
createPreview,
|
createPreview,
|
||||||
createThumbnail,
|
createThumbnail,
|
||||||
createTorrentAndSetInfoHash,
|
createTorrentAndSetInfoHash,
|
||||||
generateMagnetUri,
|
|
||||||
getPreviewName,
|
getPreviewName,
|
||||||
getPreviewPath,
|
getPreviewPath,
|
||||||
getThumbnailName,
|
getThumbnailName,
|
||||||
|
@ -426,33 +424,6 @@ createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
|
|
||||||
let baseUrlHttp
|
|
||||||
let baseUrlWs
|
|
||||||
|
|
||||||
if (this.isOwned()) {
|
|
||||||
baseUrlHttp = CONFIG.WEBSERVER.URL
|
|
||||||
baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
|
|
||||||
} else {
|
|
||||||
baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
|
|
||||||
baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
|
|
||||||
}
|
|
||||||
|
|
||||||
const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
|
|
||||||
const announce = [ baseUrlWs + '/tracker/socket' ]
|
|
||||||
const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
|
|
||||||
|
|
||||||
const magnetHash = {
|
|
||||||
xs,
|
|
||||||
announce,
|
|
||||||
urlList,
|
|
||||||
infoHash: videoFile.infoHash,
|
|
||||||
name: this.name
|
|
||||||
}
|
|
||||||
|
|
||||||
return magnetUtil.encode(magnetHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
getEmbedPath = function (this: VideoInstance) {
|
getEmbedPath = function (this: VideoInstance) {
|
||||||
return '/videos/embed/' + this.uuid
|
return '/videos/embed/' + this.uuid
|
||||||
}
|
}
|
||||||
|
@ -516,6 +487,7 @@ toFormattedJSON = function (this: VideoInstance) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format and sort video files
|
// Format and sort video files
|
||||||
|
const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
|
||||||
json.files = this.VideoFiles
|
json.files = this.VideoFiles
|
||||||
.map(videoFile => {
|
.map(videoFile => {
|
||||||
let resolutionLabel = videoFile.resolution + 'p'
|
let resolutionLabel = videoFile.resolution + 'p'
|
||||||
|
@ -523,8 +495,10 @@ toFormattedJSON = function (this: VideoInstance) {
|
||||||
const videoFileJson = {
|
const videoFileJson = {
|
||||||
resolution: videoFile.resolution,
|
resolution: videoFile.resolution,
|
||||||
resolutionLabel,
|
resolutionLabel,
|
||||||
magnetUri: this.generateMagnetUri(videoFile),
|
magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
|
||||||
size: videoFile.size
|
size: videoFile.size,
|
||||||
|
torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
|
||||||
|
fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
|
||||||
}
|
}
|
||||||
|
|
||||||
return videoFileJson
|
return videoFileJson
|
||||||
|
@ -972,3 +946,42 @@ function createBaseVideosWhere () {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBaseUrls (video: VideoInstance) {
|
||||||
|
let baseUrlHttp
|
||||||
|
let baseUrlWs
|
||||||
|
|
||||||
|
if (video.isOwned()) {
|
||||||
|
baseUrlHttp = CONFIG.WEBSERVER.URL
|
||||||
|
baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
|
||||||
|
} else {
|
||||||
|
baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.Author.Pod.host
|
||||||
|
baseUrlWs = REMOTE_SCHEME.WS + '://' + video.Author.Pod.host
|
||||||
|
}
|
||||||
|
|
||||||
|
return { baseUrlHttp, baseUrlWs }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
|
||||||
|
return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
|
||||||
|
return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
|
||||||
|
const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
|
||||||
|
const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
|
||||||
|
const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
|
||||||
|
|
||||||
|
const magnetHash = {
|
||||||
|
xs,
|
||||||
|
announce,
|
||||||
|
urlList,
|
||||||
|
infoHash: videoFile.infoHash,
|
||||||
|
name: video.name
|
||||||
|
}
|
||||||
|
|
||||||
|
return magnetUtil.encode(magnetHash)
|
||||||
|
}
|
||||||
|
|
|
@ -106,6 +106,8 @@ describe('Test multiple pods', function () {
|
||||||
const file = video.files[0]
|
const file = video.files[0]
|
||||||
const magnetUri = file.magnetUri
|
const magnetUri = file.magnetUri
|
||||||
expect(file.magnetUri).to.have.lengthOf.above(2)
|
expect(file.magnetUri).to.have.lengthOf.above(2)
|
||||||
|
expect(file.torrentUrl).to.equal(`http://${video.podHost}/static/torrents/${video.uuid}-${file.resolution}.torrent`)
|
||||||
|
expect(file.fileUrl).to.equal(`http://${video.podHost}/static/webseed/${video.uuid}-${file.resolution}.webm`)
|
||||||
expect(file.resolution).to.equal(720)
|
expect(file.resolution).to.equal(720)
|
||||||
expect(file.resolutionLabel).to.equal('720p')
|
expect(file.resolutionLabel).to.equal('720p')
|
||||||
expect(file.size).to.equal(572456)
|
expect(file.size).to.equal(572456)
|
||||||
|
|
|
@ -127,6 +127,8 @@ describe('Test a single pod', function () {
|
||||||
const file = video.files[0]
|
const file = video.files[0]
|
||||||
const magnetUri = file.magnetUri
|
const magnetUri = file.magnetUri
|
||||||
expect(file.magnetUri).to.have.lengthOf.above(2)
|
expect(file.magnetUri).to.have.lengthOf.above(2)
|
||||||
|
expect(file.torrentUrl).to.equal(`${server.url}/static/torrents/${video.uuid}-${file.resolution}.torrent`)
|
||||||
|
expect(file.fileUrl).to.equal(`${server.url}/static/webseed/${video.uuid}-${file.resolution}.webm`)
|
||||||
expect(file.resolution).to.equal(720)
|
expect(file.resolution).to.equal(720)
|
||||||
expect(file.resolutionLabel).to.equal('720p')
|
expect(file.resolutionLabel).to.equal('720p')
|
||||||
expect(file.size).to.equal(218910)
|
expect(file.size).to.equal(218910)
|
||||||
|
|
|
@ -3,6 +3,8 @@ export interface VideoFile {
|
||||||
resolution: number
|
resolution: number
|
||||||
resolutionLabel: string
|
resolutionLabel: string
|
||||||
size: number // Bytes
|
size: number // Bytes
|
||||||
|
torrentUrl: string
|
||||||
|
fileUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Video {
|
export interface Video {
|
||||||
|
|
Loading…
Reference in New Issue