diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..c6f7dbe --- /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`: 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` +- **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 645fc48..8e4e771 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,21 @@ The download in the app becomed a 2 step process. 1. Optimize 2. Download -## Usage +## Features + +- **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 + 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: @@ -58,6 +66,23 @@ 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, it run the checks and then hashes the item using SHA256. + +## 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 /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/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 diff --git a/src/app.controller.ts b/src/app.controller.ts index f17981e..a2db0b1 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -11,18 +11,25 @@ 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( private readonly appService: AppService, private logger: Logger, - ) {} + ) { this.logger = new Logger('ApiRequest'); } @Get('statistics') async getStatistics() { @@ -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,36 +127,72 @@ 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); + + 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) => { + this.logger.error(`Error streaming partial file ${filePath}: ${err.message}`); + reject(err); + }); + }); + } else { + // Handle full file request + res.setHeader('Content-Length', stat.size); + res.setHeader('Content-Type', 'video/mp4'); + res.setHeader('Accept-Ranges', 'bytes'); + + 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) => { + this.logger.error(`Error streaming file ${filePath}: ${err.message}`); + reject(err); + }); }); - }); + } } - @Delete('delete-cache') async deleteCache() { this.logger.log('Cache deletion request'); diff --git a/src/app.module.ts b/src/app.module.ts index 5b0f3e1..8472c36 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,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 06b402b..82f5350 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; @@ -25,6 +29,13 @@ export interface Job { size: number; item: any; speed?: number; + checksum?: string; +} + +export interface RangeRequest { + start: number; + end: number; + total: number; } @Injectable() @@ -45,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', @@ -181,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.`); } @@ -407,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( @@ -510,4 +525,60 @@ export class AppService { } } } + + /** + * Parse HTTP Range header + */ + public 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 + }; + } + + /** + * 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/jellyfin-auth.service.ts b/src/jellyfin-auth.service.ts index 2730b65..3ed8a1f 100644 --- a/src/jellyfin-auth.service.ts +++ b/src/jellyfin-auth.service.ts @@ -9,11 +9,25 @@ export class JellyfinAuthService { async validateCredentials(authHeader: string): Promise { const jellyfinUrl = this.configService.get('JELLYFIN_URL'); try { + // Handle both formats: + // 1. Just the token: "******" + // 2. With prefix: "MediaBrowser Token=*****" + 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, + }, }); 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; } }