Skip to content
123 changes: 123 additions & 0 deletions API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
@@ -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

29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
94 changes: 69 additions & 25 deletions src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ export class AppModule implements NestModule {
);
}
}

Loading