From f2688d02f7e5bf666b0517c9325e52ad6757fda9 Mon Sep 17 00:00:00 2001 From: Ziv Harary Date: Tue, 8 Apr 2025 18:39:41 +0300 Subject: [PATCH 01/11] asdasd --- src/app.controller.ts | 102 +++++++++++++++----- src/app.module.ts | 5 +- src/app.service.ts | 78 +++++++++++++++ src/download.service.ts | 209 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 26 deletions(-) create mode 100644 src/download.service.ts diff --git a/src/app.controller.ts b/src/app.controller.ts index f17981e..4f8f081 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -11,12 +11,19 @@ import { Res, HttpException, HttpStatus, + Headers, } from '@nestjs/common'; import { Response } from 'express'; import * as fs from 'fs'; import { AppService, Job } from './app.service'; import { log } from 'console'; +interface RangeRequest { + start: number; + end: number; + total: number; +} + @Controller() export class AppController { constructor( @@ -110,6 +117,7 @@ export class AppController { @Get('download/:id') async downloadTranscodedFile( @Param('id') id: string, + @Headers('range') rangeHeader: string, @Res({ passthrough: true }) res: Response, ) { const filePath = this.appService.getTranscodedFilePath(id); @@ -119,39 +127,85 @@ export class AppController { } const stat = fs.statSync(filePath); + const range = this.appService.parseRangeHeader(rangeHeader, stat.size); - res.setHeader('Content-Length', stat.size); - res.setHeader('Content-Type', 'video/mp4'); - res.setHeader( - 'Content-Disposition', - `attachment; filename=transcoded_${id}.mp4`, - ); - - const fileStream = fs.createReadStream(filePath); - this.logger.log(`Download started for ${filePath}`) - - return new Promise((resolve, reject) => { - fileStream.pipe(res); + // Validate file integrity before sending + const { isValid, checksum } = await this.appService.validateFileIntegrity(filePath, stat.size); + if (!isValid) { + throw new HttpException('File integrity check failed', HttpStatus.INTERNAL_SERVER_ERROR); + } - fileStream.on('end', () => { - // File transfer completed - this.logger.log(`File transfer ended for: ${filePath}`) - - resolve(null); + // Add checksum to response headers + res.setHeader('X-File-Checksum', checksum); + res.setHeader('X-File-Size', stat.size); + + if (range) { + // Handle partial content request + res.status(206); + res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${range.total}`); + res.setHeader('Content-Length', range.end - range.start + 1); + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Content-Type', 'video/mp4'); + + const fileStream = fs.createReadStream(filePath, { + start: range.start, + end: range.end }); - - fileStream.on('error', (err) => { - // Handle errors during file streaming - this.logger.error(`Error streaming file ${filePath}: ${err.message}`); - reject(err); + + return new Promise((resolve, reject) => { + fileStream.pipe(res); + fileStream.on('end', () => { + this.logger.log(`Partial download completed for ${filePath}`); + resolve(null); + }); + fileStream.on('error', (err) => { + this.logger.error(`Error streaming partial file ${filePath}: ${err.message}`); + reject(err); + }); }); - }); + } else { + // Handle full file download + res.setHeader('Content-Length', stat.size); + res.setHeader('Content-Type', 'video/mp4'); + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Content-Disposition', `attachment; filename=transcoded_${id}.mp4`); + + const fileStream = fs.createReadStream(filePath); + return new Promise((resolve, reject) => { + fileStream.pipe(res); + fileStream.on('end', () => { + this.logger.log(`Full download completed for ${filePath}`); + resolve(null); + }); + fileStream.on('error', (err) => { + this.logger.error(`Error streaming file ${filePath}: ${err.message}`); + reject(err); + }); + }); + } } - @Delete('delete-cache') async deleteCache() { this.logger.log('Cache deletion request'); return this.appService.deleteCache(); } + + @Get('file-info/:id') + async getFileInfo(@Param('id') id: string) { + const filePath = this.appService.getTranscodedFilePath(id); + if (!filePath) { + throw new NotFoundException('File not found or job not completed'); + } + + const stat = fs.statSync(filePath); + const { checksum } = await this.appService.validateFileIntegrity(filePath, stat.size); + + return { + size: stat.size, + contentType: 'video/mp4', + acceptsRanges: true, + checksum + }; + } } diff --git a/src/app.module.ts b/src/app.module.ts index 5b0f3e1..4e2132e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,12 +7,12 @@ import { JellyfinAuthService } from './jellyfin-auth.service'; import { ScheduleModule } from '@nestjs/schedule'; import { CleanupService } from './cleanup/cleanup.service'; import { FileRemoval } from './cleanup/removalUtils'; - +import { DownloadService } from './download.service'; @Module({ imports: [ScheduleModule.forRoot(), ConfigModule.forRoot({ isGlobal: true })], controllers: [AppController], - providers: [AppService, Logger, JellyfinAuthService, CleanupService, FileRemoval], + providers: [AppService, Logger, JellyfinAuthService, CleanupService, FileRemoval, DownloadService], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { @@ -21,6 +21,7 @@ export class AppModule implements NestModule { .forRoutes( 'optimize-version', 'download/:id', + 'file-info/:id', 'cancel-job/:id', 'statistics', 'job-status/:id', diff --git a/src/app.service.ts b/src/app.service.ts index 06b402b..3f1edf5 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -2,6 +2,7 @@ import { Injectable, InternalServerErrorException, Logger, + NotFoundException, } from '@nestjs/common'; import { ChildProcess, spawn } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; @@ -12,6 +13,9 @@ import { promises as fsPromises } from 'fs'; import { CACHE_DIR } from './constants'; import { FileRemoval } from './cleanup/removalUtils'; import * as kill from 'tree-kill'; +import { Get } from '@nestjs/common'; +import { Param } from '@nestjs/common'; +import * as crypto from 'crypto'; export interface Job { id: string; @@ -27,6 +31,12 @@ export interface Job { speed?: number; } +export interface RangeRequest { + start: number; + end: number; + total: number; +} + @Injectable() export class AppService { private activeJobs: Job[] = []; @@ -510,4 +520,72 @@ export class AppService { } } } + + private parseRangeHeader(rangeHeader: string, fileSize: number): RangeRequest | null { + if (!rangeHeader) return null; + + const matches = rangeHeader.match(/bytes=(\d+)-(\d+)?/); + if (!matches) return null; + + const start = parseInt(matches[1], 10); + const end = matches[2] ? parseInt(matches[2], 10) : fileSize - 1; + + return { + start, + end: Math.min(end, fileSize - 1), + total: fileSize + }; + } + + @Get('file-info/:id') + async getFileInfo(@Param('id') id: string) { + const filePath = this.getTranscodedFilePath(id); + if (!filePath) { + throw new NotFoundException('File not found or job not completed'); + } + + const stat = fs.statSync(filePath); + return { + size: stat.size, + contentType: 'video/mp4', + acceptsRanges: true + }; + } + + /** + * Calculate SHA-256 checksum for a file + */ + public async calculateFileChecksum(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + + stream.on('error', err => reject(err)); + stream.on('data', chunk => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + }); + } + + /** + * Validate file integrity + */ + public async validateFileIntegrity(filePath: string, expectedSize: number): Promise<{ isValid: boolean; checksum: string }> { + try { + const stats = await fsPromises.stat(filePath); + + // Check file size + if (stats.size !== expectedSize) { + this.logger.error(`File size mismatch for ${filePath}. Expected: ${expectedSize}, Got: ${stats.size}`); + return { isValid: false, checksum: '' }; + } + + // Calculate checksum + const checksum = await this.calculateFileChecksum(filePath); + + return { isValid: true, checksum }; + } catch (error) { + this.logger.error(`Error validating file integrity for ${filePath}: ${error.message}`); + return { isValid: false, checksum: '' }; + } + } } diff --git a/src/download.service.ts b/src/download.service.ts new file mode 100644 index 0000000..9aca710 --- /dev/null +++ b/src/download.service.ts @@ -0,0 +1,209 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import axios from 'axios'; + +export interface FileMetadata { + size: number; + contentType: string; + acceptsRanges: boolean; + checksum: string; +} + +export interface DownloadProgress { + bytesDownloaded: number; + totalBytes: number; + percentage: number; + speed: number; + isComplete: boolean; +} + +@Injectable() +export class DownloadService { + private readonly logger = new Logger(DownloadService.name); + + /** + * Get file metadata from the server + */ + async getFileMetadata(fileId: string, serverUrl: string): Promise { + try { + const response = await axios.get(`${serverUrl}/file-info/${fileId}`); + return response.data; + } catch (error) { + this.logger.error(`Error getting file metadata: ${error.message}`); + throw error; + } + } + + /** + * Calculate SHA-256 checksum for a file + */ + async calculateFileChecksum(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + + stream.on('error', err => reject(err)); + stream.on('data', chunk => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + }); + } + + /** + * Download a file with progress tracking and validation + */ + async downloadFile( + fileId: string, + serverUrl: string, + outputPath: string, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + // Get file metadata first + const metadata = await this.getFileMetadata(fileId, serverUrl); + + // Create write stream + const writeStream = fs.createWriteStream(outputPath); + + // Download file with progress tracking + const response = await axios({ + method: 'get', + url: `${serverUrl}/download/${fileId}`, + responseType: 'stream', + onDownloadProgress: (progressEvent) => { + if (onProgress) { + const progress: DownloadProgress = { + bytesDownloaded: progressEvent.loaded, + totalBytes: metadata.size, + percentage: (progressEvent.loaded / metadata.size) * 100, + speed: progressEvent.rate || 0, + isComplete: false + }; + onProgress(progress); + } + } + }); + + // Pipe the response to the write stream + response.data.pipe(writeStream); + + // Return a promise that resolves when the download is complete + return new Promise((resolve, reject) => { + writeStream.on('finish', async () => { + try { + // Verify file size + const stats = await fs.promises.stat(outputPath); + if (stats.size !== metadata.size) { + throw new Error(`File size mismatch. Expected: ${metadata.size}, Got: ${stats.size}`); + } + + // Verify checksum + const downloadedChecksum = await this.calculateFileChecksum(outputPath); + if (downloadedChecksum !== metadata.checksum) { + throw new Error('Checksum verification failed'); + } + + if (onProgress) { + onProgress({ + bytesDownloaded: metadata.size, + totalBytes: metadata.size, + percentage: 100, + speed: 0, + isComplete: true + }); + } + + resolve(); + } catch (error) { + reject(error); + } + }); + + writeStream.on('error', (error) => { + reject(error); + }); + }); + } + + /** + * Resume a partial download + */ + async resumeDownload( + fileId: string, + serverUrl: string, + outputPath: string, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + // Get file metadata + const metadata = await this.getFileMetadata(fileId, serverUrl); + + // Get the size of the partially downloaded file + const stats = await fs.promises.stat(outputPath); + const startByte = stats.size; + + // Create write stream in append mode + const writeStream = fs.createWriteStream(outputPath, { flags: 'a' }); + + // Download remaining bytes with progress tracking + const response = await axios({ + method: 'get', + url: `${serverUrl}/download/${fileId}`, + headers: { + Range: `bytes=${startByte}-${metadata.size - 1}` + }, + responseType: 'stream', + onDownloadProgress: (progressEvent) => { + if (onProgress) { + const progress: DownloadProgress = { + bytesDownloaded: startByte + progressEvent.loaded, + totalBytes: metadata.size, + percentage: ((startByte + progressEvent.loaded) / metadata.size) * 100, + speed: progressEvent.rate || 0, + isComplete: false + }; + onProgress(progress); + } + } + }); + + // Pipe the response to the write stream + response.data.pipe(writeStream); + + // Return a promise that resolves when the download is complete + return new Promise((resolve, reject) => { + writeStream.on('finish', async () => { + try { + // Verify file size + const finalStats = await fs.promises.stat(outputPath); + if (finalStats.size !== metadata.size) { + throw new Error(`File size mismatch. Expected: ${metadata.size}, Got: ${finalStats.size}`); + } + + // Verify checksum + const downloadedChecksum = await this.calculateFileChecksum(outputPath); + if (downloadedChecksum !== metadata.checksum) { + throw new Error('Checksum verification failed'); + } + + if (onProgress) { + onProgress({ + bytesDownloaded: metadata.size, + totalBytes: metadata.size, + percentage: 100, + speed: 0, + isComplete: true + }); + } + + resolve(); + } catch (error) { + reject(error); + } + }); + + writeStream.on('error', (error) => { + reject(error); + }); + }); + } +} \ No newline at end of file From f83b88862ddd2360f586c2e2ec0b982b4d6b5fef Mon Sep 17 00:00:00 2001 From: Ziv Harary Date: Tue, 8 Apr 2025 18:56:24 +0300 Subject: [PATCH 02/11] asd asd --- src/app.module.ts | 4 +- src/download.service.ts | 209 ---------------------------------------- 2 files changed, 2 insertions(+), 211 deletions(-) delete mode 100644 src/download.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 4e2132e..d2af273 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,12 +7,12 @@ import { JellyfinAuthService } from './jellyfin-auth.service'; import { ScheduleModule } from '@nestjs/schedule'; import { CleanupService } from './cleanup/cleanup.service'; import { FileRemoval } from './cleanup/removalUtils'; -import { DownloadService } from './download.service'; + @Module({ imports: [ScheduleModule.forRoot(), ConfigModule.forRoot({ isGlobal: true })], controllers: [AppController], - providers: [AppService, Logger, JellyfinAuthService, CleanupService, FileRemoval, DownloadService], + providers: [AppService, Logger, JellyfinAuthService, CleanupService, FileRemoval], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { diff --git a/src/download.service.ts b/src/download.service.ts deleted file mode 100644 index 9aca710..0000000 --- a/src/download.service.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as crypto from 'crypto'; -import axios from 'axios'; - -export interface FileMetadata { - size: number; - contentType: string; - acceptsRanges: boolean; - checksum: string; -} - -export interface DownloadProgress { - bytesDownloaded: number; - totalBytes: number; - percentage: number; - speed: number; - isComplete: boolean; -} - -@Injectable() -export class DownloadService { - private readonly logger = new Logger(DownloadService.name); - - /** - * Get file metadata from the server - */ - async getFileMetadata(fileId: string, serverUrl: string): Promise { - try { - const response = await axios.get(`${serverUrl}/file-info/${fileId}`); - return response.data; - } catch (error) { - this.logger.error(`Error getting file metadata: ${error.message}`); - throw error; - } - } - - /** - * Calculate SHA-256 checksum for a file - */ - async calculateFileChecksum(filePath: string): Promise { - return new Promise((resolve, reject) => { - const hash = crypto.createHash('sha256'); - const stream = fs.createReadStream(filePath); - - stream.on('error', err => reject(err)); - stream.on('data', chunk => hash.update(chunk)); - stream.on('end', () => resolve(hash.digest('hex'))); - }); - } - - /** - * Download a file with progress tracking and validation - */ - async downloadFile( - fileId: string, - serverUrl: string, - outputPath: string, - onProgress?: (progress: DownloadProgress) => void - ): Promise { - // Get file metadata first - const metadata = await this.getFileMetadata(fileId, serverUrl); - - // Create write stream - const writeStream = fs.createWriteStream(outputPath); - - // Download file with progress tracking - const response = await axios({ - method: 'get', - url: `${serverUrl}/download/${fileId}`, - responseType: 'stream', - onDownloadProgress: (progressEvent) => { - if (onProgress) { - const progress: DownloadProgress = { - bytesDownloaded: progressEvent.loaded, - totalBytes: metadata.size, - percentage: (progressEvent.loaded / metadata.size) * 100, - speed: progressEvent.rate || 0, - isComplete: false - }; - onProgress(progress); - } - } - }); - - // Pipe the response to the write stream - response.data.pipe(writeStream); - - // Return a promise that resolves when the download is complete - return new Promise((resolve, reject) => { - writeStream.on('finish', async () => { - try { - // Verify file size - const stats = await fs.promises.stat(outputPath); - if (stats.size !== metadata.size) { - throw new Error(`File size mismatch. Expected: ${metadata.size}, Got: ${stats.size}`); - } - - // Verify checksum - const downloadedChecksum = await this.calculateFileChecksum(outputPath); - if (downloadedChecksum !== metadata.checksum) { - throw new Error('Checksum verification failed'); - } - - if (onProgress) { - onProgress({ - bytesDownloaded: metadata.size, - totalBytes: metadata.size, - percentage: 100, - speed: 0, - isComplete: true - }); - } - - resolve(); - } catch (error) { - reject(error); - } - }); - - writeStream.on('error', (error) => { - reject(error); - }); - }); - } - - /** - * Resume a partial download - */ - async resumeDownload( - fileId: string, - serverUrl: string, - outputPath: string, - onProgress?: (progress: DownloadProgress) => void - ): Promise { - // Get file metadata - const metadata = await this.getFileMetadata(fileId, serverUrl); - - // Get the size of the partially downloaded file - const stats = await fs.promises.stat(outputPath); - const startByte = stats.size; - - // Create write stream in append mode - const writeStream = fs.createWriteStream(outputPath, { flags: 'a' }); - - // Download remaining bytes with progress tracking - const response = await axios({ - method: 'get', - url: `${serverUrl}/download/${fileId}`, - headers: { - Range: `bytes=${startByte}-${metadata.size - 1}` - }, - responseType: 'stream', - onDownloadProgress: (progressEvent) => { - if (onProgress) { - const progress: DownloadProgress = { - bytesDownloaded: startByte + progressEvent.loaded, - totalBytes: metadata.size, - percentage: ((startByte + progressEvent.loaded) / metadata.size) * 100, - speed: progressEvent.rate || 0, - isComplete: false - }; - onProgress(progress); - } - } - }); - - // Pipe the response to the write stream - response.data.pipe(writeStream); - - // Return a promise that resolves when the download is complete - return new Promise((resolve, reject) => { - writeStream.on('finish', async () => { - try { - // Verify file size - const finalStats = await fs.promises.stat(outputPath); - if (finalStats.size !== metadata.size) { - throw new Error(`File size mismatch. Expected: ${metadata.size}, Got: ${finalStats.size}`); - } - - // Verify checksum - const downloadedChecksum = await this.calculateFileChecksum(outputPath); - if (downloadedChecksum !== metadata.checksum) { - throw new Error('Checksum verification failed'); - } - - if (onProgress) { - onProgress({ - bytesDownloaded: metadata.size, - totalBytes: metadata.size, - percentage: 100, - speed: 0, - isComplete: true - }); - } - - resolve(); - } catch (error) { - reject(error); - } - }); - - writeStream.on('error', (error) => { - reject(error); - }); - }); - } -} \ No newline at end of file From a9a523050f381ec67343e1e819c4dda4bdd65873 Mon Sep 17 00:00:00 2001 From: Ziv Harary Date: Tue, 8 Apr 2025 19:01:13 +0300 Subject: [PATCH 03/11] aaaa aaaa --- README.md | 27 ++++++ src/download.service.ts | 209 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/download.service.ts diff --git a/README.md b/README.md index 645fc48..6e78716 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ The download in the app becomed a 2 step process. 1. Optimize 2. Download +## Features + +- **File Transfer Validation**: Ensures downloads are complete and uncorrupted using checksums +- **Resumable Downloads**: Support for resuming interrupted downloads +- **Progress Tracking**: Real-time download progress monitoring +- **Range Requests**: Efficient partial file downloads +- **File Metadata**: Pre-download file information including size and checksum + ## Usage Note: The server works best if it's on the same server as the Jellyfin server. @@ -58,6 +66,25 @@ As soon as the server is finished with the conversion the app (if open) will sta This means that the user needs to 1. initiate the download, and 2. open the app once before download. +### 3. File Transfer Validation + +The server implements several validation mechanisms to ensure reliable downloads: + +- **Checksums**: SHA-256 checksums verify file integrity +- **Size Validation**: Ensures complete file transfers +- **Range Requests**: Supports partial downloads and resuming +- **Progress Tracking**: Real-time download status updates + +## API Endpoints + +- `POST /optimize-version`: Start a new optimization job +- `GET /download/:id`: Download a transcoded file +- `GET /file-info/:id`: Get file metadata including checksum +- `GET /job-status/:id`: Check job status +- `DELETE /cancel-job/:id`: Cancel a job +- `GET /statistics`: Get server statistics +- `DELETE /delete-cache`: Clear the cache + ## Other This server can work with other clients and is not limited to only using the Streamyfin client. Though support needs to be added to the clients by the maintainer. \ No newline at end of file diff --git a/src/download.service.ts b/src/download.service.ts new file mode 100644 index 0000000..9aca710 --- /dev/null +++ b/src/download.service.ts @@ -0,0 +1,209 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import axios from 'axios'; + +export interface FileMetadata { + size: number; + contentType: string; + acceptsRanges: boolean; + checksum: string; +} + +export interface DownloadProgress { + bytesDownloaded: number; + totalBytes: number; + percentage: number; + speed: number; + isComplete: boolean; +} + +@Injectable() +export class DownloadService { + private readonly logger = new Logger(DownloadService.name); + + /** + * Get file metadata from the server + */ + async getFileMetadata(fileId: string, serverUrl: string): Promise { + try { + const response = await axios.get(`${serverUrl}/file-info/${fileId}`); + return response.data; + } catch (error) { + this.logger.error(`Error getting file metadata: ${error.message}`); + throw error; + } + } + + /** + * Calculate SHA-256 checksum for a file + */ + async calculateFileChecksum(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + + stream.on('error', err => reject(err)); + stream.on('data', chunk => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + }); + } + + /** + * Download a file with progress tracking and validation + */ + async downloadFile( + fileId: string, + serverUrl: string, + outputPath: string, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + // Get file metadata first + const metadata = await this.getFileMetadata(fileId, serverUrl); + + // Create write stream + const writeStream = fs.createWriteStream(outputPath); + + // Download file with progress tracking + const response = await axios({ + method: 'get', + url: `${serverUrl}/download/${fileId}`, + responseType: 'stream', + onDownloadProgress: (progressEvent) => { + if (onProgress) { + const progress: DownloadProgress = { + bytesDownloaded: progressEvent.loaded, + totalBytes: metadata.size, + percentage: (progressEvent.loaded / metadata.size) * 100, + speed: progressEvent.rate || 0, + isComplete: false + }; + onProgress(progress); + } + } + }); + + // Pipe the response to the write stream + response.data.pipe(writeStream); + + // Return a promise that resolves when the download is complete + return new Promise((resolve, reject) => { + writeStream.on('finish', async () => { + try { + // Verify file size + const stats = await fs.promises.stat(outputPath); + if (stats.size !== metadata.size) { + throw new Error(`File size mismatch. Expected: ${metadata.size}, Got: ${stats.size}`); + } + + // Verify checksum + const downloadedChecksum = await this.calculateFileChecksum(outputPath); + if (downloadedChecksum !== metadata.checksum) { + throw new Error('Checksum verification failed'); + } + + if (onProgress) { + onProgress({ + bytesDownloaded: metadata.size, + totalBytes: metadata.size, + percentage: 100, + speed: 0, + isComplete: true + }); + } + + resolve(); + } catch (error) { + reject(error); + } + }); + + writeStream.on('error', (error) => { + reject(error); + }); + }); + } + + /** + * Resume a partial download + */ + async resumeDownload( + fileId: string, + serverUrl: string, + outputPath: string, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + // Get file metadata + const metadata = await this.getFileMetadata(fileId, serverUrl); + + // Get the size of the partially downloaded file + const stats = await fs.promises.stat(outputPath); + const startByte = stats.size; + + // Create write stream in append mode + const writeStream = fs.createWriteStream(outputPath, { flags: 'a' }); + + // Download remaining bytes with progress tracking + const response = await axios({ + method: 'get', + url: `${serverUrl}/download/${fileId}`, + headers: { + Range: `bytes=${startByte}-${metadata.size - 1}` + }, + responseType: 'stream', + onDownloadProgress: (progressEvent) => { + if (onProgress) { + const progress: DownloadProgress = { + bytesDownloaded: startByte + progressEvent.loaded, + totalBytes: metadata.size, + percentage: ((startByte + progressEvent.loaded) / metadata.size) * 100, + speed: progressEvent.rate || 0, + isComplete: false + }; + onProgress(progress); + } + } + }); + + // Pipe the response to the write stream + response.data.pipe(writeStream); + + // Return a promise that resolves when the download is complete + return new Promise((resolve, reject) => { + writeStream.on('finish', async () => { + try { + // Verify file size + const finalStats = await fs.promises.stat(outputPath); + if (finalStats.size !== metadata.size) { + throw new Error(`File size mismatch. Expected: ${metadata.size}, Got: ${finalStats.size}`); + } + + // Verify checksum + const downloadedChecksum = await this.calculateFileChecksum(outputPath); + if (downloadedChecksum !== metadata.checksum) { + throw new Error('Checksum verification failed'); + } + + if (onProgress) { + onProgress({ + bytesDownloaded: metadata.size, + totalBytes: metadata.size, + percentage: 100, + speed: 0, + isComplete: true + }); + } + + resolve(); + } catch (error) { + reject(error); + } + }); + + writeStream.on('error', (error) => { + reject(error); + }); + }); + } +} \ No newline at end of file From 7b46c8ee7d059465a86a4fefad9f8e3221dfdf42 Mon Sep 17 00:00:00 2001 From: Ziv Harary Date: Tue, 8 Apr 2025 19:14:00 +0300 Subject: [PATCH 04/11] change change --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 2e71396..a2d63ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,5 +10,5 @@ services: restart: unless-stopped # If you want to use a local volume for the cache, uncomment the following lines: - volumes: - - ./cache:/usr/src/app/cache + # volumes: + # - ./cache:/usr/src/app/cache From 56c6e50a64ed6eac3f530c782b3e096334f6deb4 Mon Sep 17 00:00:00 2001 From: Ziv2004 <63927939+Ziv2004@users.noreply.github.com> Date: Sat, 12 Apr 2025 16:21:46 +0300 Subject: [PATCH 05/11] On branch master modified: src/app.service.ts modified: src/app.controller.ts modified: src/app.service.ts these are improvments to the server, now it can serve range requests and it will complete the job when the download is finished --- src/app.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app.service.ts b/src/app.service.ts index 3f1edf5..4c473fb 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -447,7 +447,6 @@ export class AppService { this.logger.error( `FFmpeg process error for job ${jobId}: ${error.message}`, ); - // reject(error); }); }); } catch (error) { From e5297c824775b1de4e8cfe922687eb4d8cc5c41f Mon Sep 17 00:00:00 2001 From: Ziv2004 <63927939+Ziv2004@users.noreply.github.com> Date: Sat, 12 Apr 2025 17:09:13 +0300 Subject: [PATCH 06/11] Add Partial Download Support And Update Documentation --- API_DOCUMENTATION.md | 123 +++++++++++++++++++++++++++++++++++ README.md | 21 +++--- src/app.controller.ts | 34 ++++------ src/app.service.ts | 36 +++++----- src/jellyfin-auth.service.ts | 22 ++++++- 5 files changed, 179 insertions(+), 57 deletions(-) create mode 100644 API_DOCUMENTATION.md diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..cb3769b --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,123 @@ +# Optimized Versions Server API Documentation + +## Overview +This server provides endpoints for optimizing and downloading video files. It supports partial downloads and file integrity checks. + +## Authentication +All endpoints require Jellyfin authentication token in the `Authorization` header. + +## Error Responses +All endpoints may return the following error responses: + +- `400 Bad Request`: Invalid input parameters +- `401 Unauthorized`: Missing or invalid authentication +- `404 Not Found`: Resource not found +- `500 Internal Server Error`: Server error + +## Notes +- Partial downloads are supported using the `Range` header +- File integrity is verified using SHA-256 checksums +- Jobs are automatically cleaned up after completion +- Concurrent job processing is limited by configuration + +## Endpoints + +### 1. Optimize Video +- **Endpoint**: `POST /optimize-version` +- **Description**: Queues a video for optimization +- **Request Body**: + ```json + { + "url": "string", // URL of the video to optimize + "fileExtension": "string", // Desired output format + "deviceId": "string", // Client device ID + "itemId": "string", // Media item ID + "item": "object" // Media item metadata + } + ``` +- **Response**: + ```json + { + "id": "string" // Job ID for tracking + } + ``` + +### 2. Download Optimized Video +- **Endpoint**: `GET /download/:id` +- **Description**: Downloads the optimized video file +- **Headers**: + - `Range`: Optional. Format: `bytes=start-` or `bytes=start-end` for partial downloads +- **Response**: Video file stream +- **Response Headers**: + - `X-File-Checksum`: SHA-256 checksum of the file + - `X-File-Size`: Total size of the file in bytes + - `Content-Range`: For partial downloads, format: `bytes start-end/total` + - `Content-Length`: Size of the current response + - `Content-Type`: `video/mp4` + - `Accept-Ranges`: `bytes` + +### 3. Get Job Status +- **Endpoint**: `GET /job-status/:id` +- **Description**: Returns Job object +- **Response**: + ```json + { + "id": "string", + "status": "string", // One of: queued, optimizing, pending downloads limit, completed, failed, cancelled, ready-for-removal + "progress": number, // Progress percentage + "outputPath": "string", + "inputUrl": "string", + "deviceId": "string", + "itemId": "string", + "timestamp": "string", // ISO date string + "size": number, + "item": object, + "speed": number // Optional: Processing speed + "checksum": string // Optional: Once the job is done, provides a checksum + } + ``` + +### 4. Cancel Job +- **Endpoint**: `DELETE /cancel-job/:id` +- **Description**: Cancels a optimization job +- **Response**: + ```json + { + "message": "string" // Success or error message + } + ``` + +### 5. Start Job Manually +- **Endpoint**: `POST /start-job/:id` +- **Description**: Manually starts a queued optimization job +- **Response**: + ```json + { + "message": "string" // Success or error message + } + ``` + +### 6. Get All Jobs +- **Endpoint**: `GET /all-jobs` +- **Description**: Get information about all jobs +- **Response**: Array of job objects (like get job-status but for all jobs) + +### 7. Get Statistics +- **Endpoint**: `GET /statistics` +- **Description**: Get server statistics +- **Response**: + ```json + { + "cacheSize": "string", // Total size of cached files + "totalTranscodes": number, // Total number of transcoded files + "activeJobs": number, // Number of currently active jobs + "completedJobs": number, // Number of successfully completed jobs + "uniqueDevices": number // Number of unique devices that have used the service + } + ``` + +### 8. Delete Cache +- **Endpoint**: `DELETE /delete-cache` +- **Description**: Cleans up all cached files +- **Response**: Success message + diff --git a/README.md b/README.md index 6e78716..4105c20 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,19 @@ The download in the app becomed a 2 step process. ## Features -- **File Transfer Validation**: Ensures downloads are complete and uncorrupted using checksums -- **Resumable Downloads**: Support for resuming interrupted downloads -- **Progress Tracking**: Real-time download progress monitoring -- **Range Requests**: Efficient partial file downloads -- **File Metadata**: Pre-download file information including size and checksum +- **Video Optimization**: Transcode videos to optimal formats and bitrates +- **Partial Downloads**: Support for HTTP Range requests for efficient streaming +- **File Integrity**: SHA-256 checksum verification for downloaded files +- **Job Management**: Queue and track video optimization jobs +- **Cache Management**: Efficient caching system with automatic cleanup +- **Statistics**: Monitor server performance and usage metrics -## Usage Note: The server works best if it's on the same server as the Jellyfin server. ### Docker-compose -#### Docker-compose example +## Installation using Docker-compose (example) ```yaml services: @@ -68,12 +68,7 @@ This means that the user needs to 1. initiate the download, and 2. open the app ### 3. File Transfer Validation -The server implements several validation mechanisms to ensure reliable downloads: - -- **Checksums**: SHA-256 checksums verify file integrity -- **Size Validation**: Ensures complete file transfers -- **Range Requests**: Supports partial downloads and resuming -- **Progress Tracking**: Real-time download status updates +The server implements several validation mechanisms to ensure reliable downloads, it run the checks and then hashes the item using SHA256. ## API Endpoints diff --git a/src/app.controller.ts b/src/app.controller.ts index 4f8f081..1ae29e1 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -29,7 +29,9 @@ export class AppController { constructor( private readonly appService: AppService, private logger: Logger, - ) {} + ) { + this.logger = new Logger('ApiRequest'); + } @Get('statistics') async getStatistics() { @@ -152,10 +154,16 @@ export class AppController { end: range.end }); + this.logger.log(`Partial download started for ${filePath}`); + return new Promise((resolve, reject) => { fileStream.pipe(res); fileStream.on('end', () => { this.logger.log(`Partial download completed for ${filePath}`); + if (range.end === stat.size - 1) { + this.logger.log(`Download completed for ${filePath}`); + this.appService.completeJob(id); + } resolve(null); }); fileStream.on('error', (err) => { @@ -164,17 +172,19 @@ export class AppController { }); }); } else { - // Handle full file download + // Handle full file request res.setHeader('Content-Length', stat.size); res.setHeader('Content-Type', 'video/mp4'); res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Content-Disposition', `attachment; filename=transcoded_${id}.mp4`); + this.logger.log(`Full download started for ${filePath}`); + const fileStream = fs.createReadStream(filePath); return new Promise((resolve, reject) => { fileStream.pipe(res); fileStream.on('end', () => { this.logger.log(`Full download completed for ${filePath}`); + this.appService.cancelJob(id); resolve(null); }); fileStream.on('error', (err) => { @@ -190,22 +200,4 @@ export class AppController { this.logger.log('Cache deletion request'); return this.appService.deleteCache(); } - - @Get('file-info/:id') - async getFileInfo(@Param('id') id: string) { - const filePath = this.appService.getTranscodedFilePath(id); - if (!filePath) { - throw new NotFoundException('File not found or job not completed'); - } - - const stat = fs.statSync(filePath); - const { checksum } = await this.appService.validateFileIntegrity(filePath, stat.size); - - return { - size: stat.size, - contentType: 'video/mp4', - acceptsRanges: true, - checksum - }; - } } diff --git a/src/app.service.ts b/src/app.service.ts index 4c473fb..41bfeda 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -29,6 +29,7 @@ export interface Job { size: number; item: any; speed?: number; + checksum?: string; } export interface RangeRequest { @@ -55,6 +56,7 @@ export class AppService { private readonly fileRemoval: FileRemoval ) { + this.logger = new Logger('Job'); this.cacheDir = CACHE_DIR; this.maxConcurrentJobs = this.configService.get( 'MAX_CONCURRENT_JOBS', @@ -106,7 +108,7 @@ export class AppService { getJobStatus(jobId: string): Job | null { const job = this.activeJobs.find((job) => job.id === jobId); - return job || null; + return job; } getAllJobs(deviceId?: string | null): Job[] { @@ -191,7 +193,7 @@ export class AppService { if (job) { job.status = 'ready-for-removal'; job.timestamp = new Date() - this.logger.log(`Job ${jobId} marked as completed and ready for removal.`); + this.logger.log(`Job ${jobId} was cancelled or marked as completed and is ready for removal.`); } else { this.logger.warn(`Job ${jobId} not found. Cannot mark as completed.`); } @@ -417,16 +419,19 @@ export class AppService { } if (code === 0) { - job.status = 'completed'; job.progress = 100; - // Update the file size + // Update the file size and calculate checksum try { const stats = await fsPromises.stat(job.outputPath); job.size = stats.size; + const { isValid, checksum } = await this.validateFileIntegrity(job.outputPath, stats.size); + if (isValid) { + job.checksum = checksum; + } } catch (error) { this.logger.error( - `Error getting file size for job ${jobId}: ${error.message}`, + `Error getting file size and checksum for job ${jobId}: ${error.message}`, ); } this.logger.log( @@ -447,6 +452,7 @@ export class AppService { this.logger.error( `FFmpeg process error for job ${jobId}: ${error.message}`, ); + // reject(error); }); }); } catch (error) { @@ -520,7 +526,10 @@ export class AppService { } } - private parseRangeHeader(rangeHeader: string, fileSize: number): RangeRequest | null { + /** + * Parse HTTP Range header + */ + public parseRangeHeader(rangeHeader: string, fileSize: number): RangeRequest | null { if (!rangeHeader) return null; const matches = rangeHeader.match(/bytes=(\d+)-(\d+)?/); @@ -536,21 +545,6 @@ export class AppService { }; } - @Get('file-info/:id') - async getFileInfo(@Param('id') id: string) { - const filePath = this.getTranscodedFilePath(id); - if (!filePath) { - throw new NotFoundException('File not found or job not completed'); - } - - const stat = fs.statSync(filePath); - return { - size: stat.size, - contentType: 'video/mp4', - acceptsRanges: true - }; - } - /** * Calculate SHA-256 checksum for a file */ diff --git a/src/jellyfin-auth.service.ts b/src/jellyfin-auth.service.ts index 2730b65..350d616 100644 --- a/src/jellyfin-auth.service.ts +++ b/src/jellyfin-auth.service.ts @@ -9,11 +9,29 @@ export class JellyfinAuthService { async validateCredentials(authHeader: string): Promise { const jellyfinUrl = this.configService.get('JELLYFIN_URL'); try { + // Handle both formats: + // 1. Just the token: "00d5ba4119a84be99adcf041b94474fd" + // 2. With prefix: "MediaBrowser Token=00d5ba4119a84be99adcf041b94474fd" + const token = authHeader.includes('MediaBrowser Token=') + ? authHeader.split('=')[1].trim() + : authHeader.trim(); + const response = await axios.get(`${jellyfinUrl}/Users/Me`, { - headers: { 'X-EMBY-TOKEN': authHeader }, + headers: { + 'X-Emby-Token': token, + 'X-Emby-Client': 'OptimizedVersionsServer', + 'X-Emby-Client-Version': '1.0.0', + 'X-Emby-Device-Name': 'OptimizedVersionsServer', + 'X-Emby-Device-Id': 'OptimizedVersionsServer' + }, }); return response.status === 200; - } catch { + } catch (error) { + console.error('Jellyfin authentication error:', error.message); + if (error.response) { + console.error('Response status:', error.response.status); + console.error('Response data:', error.response.data); + } return false; } } From 9891498d87190e572baf29e965be1f9229b4e5e7 Mon Sep 17 00:00:00 2001 From: Ziv2004 <63927939+Ziv2004@users.noreply.github.com> Date: Sat, 12 Apr 2025 17:27:49 +0300 Subject: [PATCH 07/11] small changes --- src/app.controller.ts | 4 +--- src/app.service.ts | 2 +- src/jellyfin-auth.service.ts | 10 +++------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/app.controller.ts b/src/app.controller.ts index 1ae29e1..a2db0b1 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -29,9 +29,7 @@ export class AppController { constructor( private readonly appService: AppService, private logger: Logger, - ) { - this.logger = new Logger('ApiRequest'); - } + ) { this.logger = new Logger('ApiRequest'); } @Get('statistics') async getStatistics() { diff --git a/src/app.service.ts b/src/app.service.ts index 41bfeda..82f5350 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -108,7 +108,7 @@ export class AppService { getJobStatus(jobId: string): Job | null { const job = this.activeJobs.find((job) => job.id === jobId); - return job; + return job || null; } getAllJobs(deviceId?: string | null): Job[] { diff --git a/src/jellyfin-auth.service.ts b/src/jellyfin-auth.service.ts index 350d616..3ed8a1f 100644 --- a/src/jellyfin-auth.service.ts +++ b/src/jellyfin-auth.service.ts @@ -10,8 +10,8 @@ export class JellyfinAuthService { const jellyfinUrl = this.configService.get('JELLYFIN_URL'); try { // Handle both formats: - // 1. Just the token: "00d5ba4119a84be99adcf041b94474fd" - // 2. With prefix: "MediaBrowser Token=00d5ba4119a84be99adcf041b94474fd" + // 1. Just the token: "******" + // 2. With prefix: "MediaBrowser Token=*****" const token = authHeader.includes('MediaBrowser Token=') ? authHeader.split('=')[1].trim() : authHeader.trim(); @@ -19,11 +19,7 @@ export class JellyfinAuthService { const response = await axios.get(`${jellyfinUrl}/Users/Me`, { headers: { 'X-Emby-Token': token, - 'X-Emby-Client': 'OptimizedVersionsServer', - 'X-Emby-Client-Version': '1.0.0', - 'X-Emby-Device-Name': 'OptimizedVersionsServer', - 'X-Emby-Device-Id': 'OptimizedVersionsServer' - }, + }, }); return response.status === 200; } catch (error) { From 630ac9bb0b1daf62655fd6802d886a0454cba426 Mon Sep 17 00:00:00 2001 From: Ziv2004 <63927939+Ziv2004@users.noreply.github.com> Date: Sat, 12 Apr 2025 17:50:07 +0300 Subject: [PATCH 08/11] aaaaaaaaa --- README.md | 5 ++++- src/app.controller.ts | 4 +--- src/app.module.ts | 2 +- src/app.service.ts | 2 +- src/jellyfin-auth.service.ts | 10 +++------- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4105c20..8e4e771 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,16 @@ The server implements several validation mechanisms to ensure reliable downloads ## API Endpoints - `POST /optimize-version`: Start a new optimization job +- `POST /start-job/:id`: Manually start a queued optimization job - `GET /download/:id`: Download a transcoded file -- `GET /file-info/:id`: Get file metadata including checksum - `GET /job-status/:id`: Check job status +- `GET /all-jobs `: Check all jobs status - `DELETE /cancel-job/:id`: Cancel a job - `GET /statistics`: Get server statistics - `DELETE /delete-cache`: Clear the cache +For detailed API documentation, see [API Documentation](API_DOCUMENTATION.md). + ## Other This server can work with other clients and is not limited to only using the Streamyfin client. Though support needs to be added to the clients by the maintainer. \ No newline at end of file diff --git a/src/app.controller.ts b/src/app.controller.ts index 1ae29e1..a2db0b1 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -29,9 +29,7 @@ export class AppController { constructor( private readonly appService: AppService, private logger: Logger, - ) { - this.logger = new Logger('ApiRequest'); - } + ) { this.logger = new Logger('ApiRequest'); } @Get('statistics') async getStatistics() { diff --git a/src/app.module.ts b/src/app.module.ts index d2af273..8472c36 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,7 +21,6 @@ export class AppModule implements NestModule { .forRoutes( 'optimize-version', 'download/:id', - 'file-info/:id', 'cancel-job/:id', 'statistics', 'job-status/:id', @@ -31,3 +30,4 @@ export class AppModule implements NestModule { ); } } + \ No newline at end of file diff --git a/src/app.service.ts b/src/app.service.ts index 41bfeda..82f5350 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -108,7 +108,7 @@ export class AppService { getJobStatus(jobId: string): Job | null { const job = this.activeJobs.find((job) => job.id === jobId); - return job; + return job || null; } getAllJobs(deviceId?: string | null): Job[] { diff --git a/src/jellyfin-auth.service.ts b/src/jellyfin-auth.service.ts index 350d616..3ed8a1f 100644 --- a/src/jellyfin-auth.service.ts +++ b/src/jellyfin-auth.service.ts @@ -10,8 +10,8 @@ export class JellyfinAuthService { const jellyfinUrl = this.configService.get('JELLYFIN_URL'); try { // Handle both formats: - // 1. Just the token: "00d5ba4119a84be99adcf041b94474fd" - // 2. With prefix: "MediaBrowser Token=00d5ba4119a84be99adcf041b94474fd" + // 1. Just the token: "******" + // 2. With prefix: "MediaBrowser Token=*****" const token = authHeader.includes('MediaBrowser Token=') ? authHeader.split('=')[1].trim() : authHeader.trim(); @@ -19,11 +19,7 @@ export class JellyfinAuthService { const response = await axios.get(`${jellyfinUrl}/Users/Me`, { headers: { 'X-Emby-Token': token, - 'X-Emby-Client': 'OptimizedVersionsServer', - 'X-Emby-Client-Version': '1.0.0', - 'X-Emby-Device-Name': 'OptimizedVersionsServer', - 'X-Emby-Device-Id': 'OptimizedVersionsServer' - }, + }, }); return response.status === 200; } catch (error) { From 75d1742f7a8af1c1df9d9f4a28a67a6643c1f519 Mon Sep 17 00:00:00 2001 From: Ziv2004 <63927939+Ziv2004@users.noreply.github.com> Date: Sat, 12 Apr 2025 17:55:21 +0300 Subject: [PATCH 09/11] a --- API_DOCUMENTATION.md | 12 ++++++------ README.md | 5 ++++- src/app.controller.ts | 4 +--- src/app.module.ts | 2 +- src/app.service.ts | 2 +- src/jellyfin-auth.service.ts | 10 +++------- 6 files changed, 16 insertions(+), 19 deletions(-) diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index cb3769b..c6f7dbe 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -49,12 +49,12 @@ All endpoints may return the following error responses: - `Range`: Optional. Format: `bytes=start-` or `bytes=start-end` for partial downloads - **Response**: Video file stream - **Response Headers**: - - `X-File-Checksum`: SHA-256 checksum of the file - - `X-File-Size`: Total size of the file in bytes - - `Content-Range`: For partial downloads, format: `bytes start-end/total` - - `Content-Length`: Size of the current response - - `Content-Type`: `video/mp4` - - `Accept-Ranges`: `bytes` + - `X-File-Checksum`: String //SHA-256 checksum of the file + - `X-File-Size`: Number //Total size of the file in bytes + - `Content-Range`: String //For partial downloads, format: `bytes start-end/total` + - `Content-Length`: Number //Size of the current response + - `Content-Type`: String //`video/mp4` + - `Accept-Ranges`: String //`bytes` ### 3. Get Job Status - **Endpoint**: `GET /job-status/:id` diff --git a/README.md b/README.md index 4105c20..8e4e771 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,16 @@ The server implements several validation mechanisms to ensure reliable downloads ## API Endpoints - `POST /optimize-version`: Start a new optimization job +- `POST /start-job/:id`: Manually start a queued optimization job - `GET /download/:id`: Download a transcoded file -- `GET /file-info/:id`: Get file metadata including checksum - `GET /job-status/:id`: Check job status +- `GET /all-jobs `: Check all jobs status - `DELETE /cancel-job/:id`: Cancel a job - `GET /statistics`: Get server statistics - `DELETE /delete-cache`: Clear the cache +For detailed API documentation, see [API Documentation](API_DOCUMENTATION.md). + ## Other This server can work with other clients and is not limited to only using the Streamyfin client. Though support needs to be added to the clients by the maintainer. \ No newline at end of file diff --git a/src/app.controller.ts b/src/app.controller.ts index 1ae29e1..a2db0b1 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -29,9 +29,7 @@ export class AppController { constructor( private readonly appService: AppService, private logger: Logger, - ) { - this.logger = new Logger('ApiRequest'); - } + ) { this.logger = new Logger('ApiRequest'); } @Get('statistics') async getStatistics() { diff --git a/src/app.module.ts b/src/app.module.ts index d2af273..8472c36 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,7 +21,6 @@ export class AppModule implements NestModule { .forRoutes( 'optimize-version', 'download/:id', - 'file-info/:id', 'cancel-job/:id', 'statistics', 'job-status/:id', @@ -31,3 +30,4 @@ export class AppModule implements NestModule { ); } } + \ No newline at end of file diff --git a/src/app.service.ts b/src/app.service.ts index 41bfeda..82f5350 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -108,7 +108,7 @@ export class AppService { getJobStatus(jobId: string): Job | null { const job = this.activeJobs.find((job) => job.id === jobId); - return job; + return job || null; } getAllJobs(deviceId?: string | null): Job[] { diff --git a/src/jellyfin-auth.service.ts b/src/jellyfin-auth.service.ts index 350d616..3ed8a1f 100644 --- a/src/jellyfin-auth.service.ts +++ b/src/jellyfin-auth.service.ts @@ -10,8 +10,8 @@ export class JellyfinAuthService { const jellyfinUrl = this.configService.get('JELLYFIN_URL'); try { // Handle both formats: - // 1. Just the token: "00d5ba4119a84be99adcf041b94474fd" - // 2. With prefix: "MediaBrowser Token=00d5ba4119a84be99adcf041b94474fd" + // 1. Just the token: "******" + // 2. With prefix: "MediaBrowser Token=*****" const token = authHeader.includes('MediaBrowser Token=') ? authHeader.split('=')[1].trim() : authHeader.trim(); @@ -19,11 +19,7 @@ export class JellyfinAuthService { const response = await axios.get(`${jellyfinUrl}/Users/Me`, { headers: { 'X-Emby-Token': token, - 'X-Emby-Client': 'OptimizedVersionsServer', - 'X-Emby-Client-Version': '1.0.0', - 'X-Emby-Device-Name': 'OptimizedVersionsServer', - 'X-Emby-Device-Id': 'OptimizedVersionsServer' - }, + }, }); return response.status === 200; } catch (error) { From de17824e3424025f13d677f910ce6b7ddf7d2aae Mon Sep 17 00:00:00 2001 From: Ziv2004 <63927939+Ziv2004@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:07:47 +0300 Subject: [PATCH 10/11] asd --- API_DOCUMENTATION.md | 12 ++++++------ README.md | 5 ++++- src/app.controller.ts | 4 +--- src/app.module.ts | 2 +- src/app.service.ts | 2 +- src/jellyfin-auth.service.ts | 10 +++------- 6 files changed, 16 insertions(+), 19 deletions(-) diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index cb3769b..c6f7dbe 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -49,12 +49,12 @@ All endpoints may return the following error responses: - `Range`: Optional. Format: `bytes=start-` or `bytes=start-end` for partial downloads - **Response**: Video file stream - **Response Headers**: - - `X-File-Checksum`: SHA-256 checksum of the file - - `X-File-Size`: Total size of the file in bytes - - `Content-Range`: For partial downloads, format: `bytes start-end/total` - - `Content-Length`: Size of the current response - - `Content-Type`: `video/mp4` - - `Accept-Ranges`: `bytes` + - `X-File-Checksum`: String //SHA-256 checksum of the file + - `X-File-Size`: Number //Total size of the file in bytes + - `Content-Range`: String //For partial downloads, format: `bytes start-end/total` + - `Content-Length`: Number //Size of the current response + - `Content-Type`: String //`video/mp4` + - `Accept-Ranges`: String //`bytes` ### 3. Get Job Status - **Endpoint**: `GET /job-status/:id` diff --git a/README.md b/README.md index 4105c20..8e4e771 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,16 @@ The server implements several validation mechanisms to ensure reliable downloads ## API Endpoints - `POST /optimize-version`: Start a new optimization job +- `POST /start-job/:id`: Manually start a queued optimization job - `GET /download/:id`: Download a transcoded file -- `GET /file-info/:id`: Get file metadata including checksum - `GET /job-status/:id`: Check job status +- `GET /all-jobs `: Check all jobs status - `DELETE /cancel-job/:id`: Cancel a job - `GET /statistics`: Get server statistics - `DELETE /delete-cache`: Clear the cache +For detailed API documentation, see [API Documentation](API_DOCUMENTATION.md). + ## Other This server can work with other clients and is not limited to only using the Streamyfin client. Though support needs to be added to the clients by the maintainer. \ No newline at end of file diff --git a/src/app.controller.ts b/src/app.controller.ts index 1ae29e1..a2db0b1 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -29,9 +29,7 @@ export class AppController { constructor( private readonly appService: AppService, private logger: Logger, - ) { - this.logger = new Logger('ApiRequest'); - } + ) { this.logger = new Logger('ApiRequest'); } @Get('statistics') async getStatistics() { diff --git a/src/app.module.ts b/src/app.module.ts index d2af273..8472c36 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,7 +21,6 @@ export class AppModule implements NestModule { .forRoutes( 'optimize-version', 'download/:id', - 'file-info/:id', 'cancel-job/:id', 'statistics', 'job-status/:id', @@ -31,3 +30,4 @@ export class AppModule implements NestModule { ); } } + \ No newline at end of file diff --git a/src/app.service.ts b/src/app.service.ts index 41bfeda..82f5350 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -108,7 +108,7 @@ export class AppService { getJobStatus(jobId: string): Job | null { const job = this.activeJobs.find((job) => job.id === jobId); - return job; + return job || null; } getAllJobs(deviceId?: string | null): Job[] { diff --git a/src/jellyfin-auth.service.ts b/src/jellyfin-auth.service.ts index 350d616..3ed8a1f 100644 --- a/src/jellyfin-auth.service.ts +++ b/src/jellyfin-auth.service.ts @@ -10,8 +10,8 @@ export class JellyfinAuthService { const jellyfinUrl = this.configService.get('JELLYFIN_URL'); try { // Handle both formats: - // 1. Just the token: "00d5ba4119a84be99adcf041b94474fd" - // 2. With prefix: "MediaBrowser Token=00d5ba4119a84be99adcf041b94474fd" + // 1. Just the token: "******" + // 2. With prefix: "MediaBrowser Token=*****" const token = authHeader.includes('MediaBrowser Token=') ? authHeader.split('=')[1].trim() : authHeader.trim(); @@ -19,11 +19,7 @@ export class JellyfinAuthService { const response = await axios.get(`${jellyfinUrl}/Users/Me`, { headers: { 'X-Emby-Token': token, - 'X-Emby-Client': 'OptimizedVersionsServer', - 'X-Emby-Client-Version': '1.0.0', - 'X-Emby-Device-Name': 'OptimizedVersionsServer', - 'X-Emby-Device-Id': 'OptimizedVersionsServer' - }, + }, }); return response.status === 200; } catch (error) { From 5e5b11c8fbc88ec5fb71fc1f95b8ac051a1c5f08 Mon Sep 17 00:00:00 2001 From: Ziv2004 <63927939+Ziv2004@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:08:50 +0300 Subject: [PATCH 11/11] Delete src/download.service.ts not relevent to the server --- src/download.service.ts | 209 ---------------------------------------- 1 file changed, 209 deletions(-) delete mode 100644 src/download.service.ts diff --git a/src/download.service.ts b/src/download.service.ts deleted file mode 100644 index 9aca710..0000000 --- a/src/download.service.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as crypto from 'crypto'; -import axios from 'axios'; - -export interface FileMetadata { - size: number; - contentType: string; - acceptsRanges: boolean; - checksum: string; -} - -export interface DownloadProgress { - bytesDownloaded: number; - totalBytes: number; - percentage: number; - speed: number; - isComplete: boolean; -} - -@Injectable() -export class DownloadService { - private readonly logger = new Logger(DownloadService.name); - - /** - * Get file metadata from the server - */ - async getFileMetadata(fileId: string, serverUrl: string): Promise { - try { - const response = await axios.get(`${serverUrl}/file-info/${fileId}`); - return response.data; - } catch (error) { - this.logger.error(`Error getting file metadata: ${error.message}`); - throw error; - } - } - - /** - * Calculate SHA-256 checksum for a file - */ - async calculateFileChecksum(filePath: string): Promise { - return new Promise((resolve, reject) => { - const hash = crypto.createHash('sha256'); - const stream = fs.createReadStream(filePath); - - stream.on('error', err => reject(err)); - stream.on('data', chunk => hash.update(chunk)); - stream.on('end', () => resolve(hash.digest('hex'))); - }); - } - - /** - * Download a file with progress tracking and validation - */ - async downloadFile( - fileId: string, - serverUrl: string, - outputPath: string, - onProgress?: (progress: DownloadProgress) => void - ): Promise { - // Get file metadata first - const metadata = await this.getFileMetadata(fileId, serverUrl); - - // Create write stream - const writeStream = fs.createWriteStream(outputPath); - - // Download file with progress tracking - const response = await axios({ - method: 'get', - url: `${serverUrl}/download/${fileId}`, - responseType: 'stream', - onDownloadProgress: (progressEvent) => { - if (onProgress) { - const progress: DownloadProgress = { - bytesDownloaded: progressEvent.loaded, - totalBytes: metadata.size, - percentage: (progressEvent.loaded / metadata.size) * 100, - speed: progressEvent.rate || 0, - isComplete: false - }; - onProgress(progress); - } - } - }); - - // Pipe the response to the write stream - response.data.pipe(writeStream); - - // Return a promise that resolves when the download is complete - return new Promise((resolve, reject) => { - writeStream.on('finish', async () => { - try { - // Verify file size - const stats = await fs.promises.stat(outputPath); - if (stats.size !== metadata.size) { - throw new Error(`File size mismatch. Expected: ${metadata.size}, Got: ${stats.size}`); - } - - // Verify checksum - const downloadedChecksum = await this.calculateFileChecksum(outputPath); - if (downloadedChecksum !== metadata.checksum) { - throw new Error('Checksum verification failed'); - } - - if (onProgress) { - onProgress({ - bytesDownloaded: metadata.size, - totalBytes: metadata.size, - percentage: 100, - speed: 0, - isComplete: true - }); - } - - resolve(); - } catch (error) { - reject(error); - } - }); - - writeStream.on('error', (error) => { - reject(error); - }); - }); - } - - /** - * Resume a partial download - */ - async resumeDownload( - fileId: string, - serverUrl: string, - outputPath: string, - onProgress?: (progress: DownloadProgress) => void - ): Promise { - // Get file metadata - const metadata = await this.getFileMetadata(fileId, serverUrl); - - // Get the size of the partially downloaded file - const stats = await fs.promises.stat(outputPath); - const startByte = stats.size; - - // Create write stream in append mode - const writeStream = fs.createWriteStream(outputPath, { flags: 'a' }); - - // Download remaining bytes with progress tracking - const response = await axios({ - method: 'get', - url: `${serverUrl}/download/${fileId}`, - headers: { - Range: `bytes=${startByte}-${metadata.size - 1}` - }, - responseType: 'stream', - onDownloadProgress: (progressEvent) => { - if (onProgress) { - const progress: DownloadProgress = { - bytesDownloaded: startByte + progressEvent.loaded, - totalBytes: metadata.size, - percentage: ((startByte + progressEvent.loaded) / metadata.size) * 100, - speed: progressEvent.rate || 0, - isComplete: false - }; - onProgress(progress); - } - } - }); - - // Pipe the response to the write stream - response.data.pipe(writeStream); - - // Return a promise that resolves when the download is complete - return new Promise((resolve, reject) => { - writeStream.on('finish', async () => { - try { - // Verify file size - const finalStats = await fs.promises.stat(outputPath); - if (finalStats.size !== metadata.size) { - throw new Error(`File size mismatch. Expected: ${metadata.size}, Got: ${finalStats.size}`); - } - - // Verify checksum - const downloadedChecksum = await this.calculateFileChecksum(outputPath); - if (downloadedChecksum !== metadata.checksum) { - throw new Error('Checksum verification failed'); - } - - if (onProgress) { - onProgress({ - bytesDownloaded: metadata.size, - totalBytes: metadata.size, - percentage: 100, - speed: 0, - isComplete: true - }); - } - - resolve(); - } catch (error) { - reject(error); - } - }); - - writeStream.on('error', (error) => { - reject(error); - }); - }); - } -} \ No newline at end of file