├── .dockerignore ├── .env.example ├── .eslintrc.js ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── docker-compose.yml ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── auth.middleware.ts ├── cleanup │ ├── cleanup.service.ts │ └── removalUtils.ts ├── constants.ts ├── jellyfin-auth.service.ts └── main.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .git 4 | .gitignore 5 | .dockerignore 6 | .vscode 7 | dist 8 | build 9 | *.md 10 | .env 11 | *.log -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # All these are optional - uncomment to use 2 | 3 | # JELLYFIN_URL=http://your-jellyfin-url 4 | # MAX_CONCURRENT_JOBS=1 5 | 6 | # File-removal 7 | # REMOVE_FILE_AFTER_RIGHT_DOWNLOAD=true 8 | # TIME_TO_KEEP_FILES=8 # Hours. Non-option when REMOVE_FILE_AFTER_RIGHT_DOWNLOAD=false 9 | 10 | # File management 11 | # MAX_CACHED_PER_USER=10 -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Streamyfin Optimized Version Server CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | name: Lint & Test Build 14 | if: github.event_name == 'pull_request' 15 | runs-on: ubuntu-22.04 16 | container: node:22-alpine 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Install dependencies 21 | run: npm install 22 | - name: Lint 23 | run: npm lint 24 | - name: Formatting 25 | run: npm format:check 26 | - name: Build 27 | run: npm build 28 | 29 | build_and_push: 30 | name: Build & Publish Docker Images 31 | if: github.ref == 'refs/heads/master' && !contains(github.event.head_commit.message, '[skip ci]') 32 | runs-on: ubuntu-22.04 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | - name: Set up QEMU 37 | uses: docker/setup-qemu-action@v3 38 | - name: Set up Docker Buildx 39 | uses: docker/setup-buildx-action@v3 40 | - name: Log in to Docker Hub 41 | uses: docker/login-action@v3 42 | with: 43 | username: ${{ secrets.DOCKER_USERNAME }} 44 | password: ${{ secrets.DOCKER_TOKEN }} 45 | - name: Set lower case owner name 46 | run: | 47 | echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV} 48 | env: 49 | OWNER: ${{ github.repository_owner }} 50 | - name: Build and push 51 | uses: docker/build-push-action@v5 52 | with: 53 | context: . 54 | file: ./Dockerfile 55 | platforms: linux/amd64,linux/arm64 56 | push: true 57 | tags: | 58 | fredrikburmester/streamyfin-optimized-versions-server:master 59 | discord: 60 | name: Send Discord Notification 61 | needs: build_and_push 62 | if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]') 63 | runs-on: ubuntu-22.04 64 | steps: 65 | - name: Get Build Job Status 66 | uses: technote-space/workflow-conclusion-action@v3 67 | - name: Combine Job Status 68 | id: status 69 | run: | 70 | failures=(neutral, skipped, timed_out, action_required) 71 | if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then 72 | echo "status=failure" >> $GITHUB_OUTPUT 73 | else 74 | echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT 75 | fi 76 | - name: Post Status to Discord 77 | uses: sarisia/actions-status-discord@v1 78 | with: 79 | webhook: ${{ secrets.DISCORD_WEBHOOK }} 80 | status: ${{ steps.status.outputs.status }} 81 | title: ${{ github.workflow }} 82 | nofail: true 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.mkv 2 | *.mp4 3 | /media 4 | /cache 5 | 6 | # compiled output 7 | /dist 8 | /node_modules 9 | /build 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | pnpm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | 20 | # OS 21 | .DS_Store 22 | 23 | # Tests 24 | /coverage 25 | /.nyc_output 26 | 27 | # IDEs and editors 28 | /.idea 29 | .project 30 | .classpath 31 | .c9/ 32 | *.launch 33 | .settings/ 34 | *.sublime-workspace 35 | 36 | # IDE - VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | 43 | # dotenv environment variable files 44 | .env 45 | .env.development.local 46 | .env.test.local 47 | .env.production.local 48 | .env.local 49 | 50 | # temp directory 51 | .temp 52 | .tmp 53 | 54 | # Runtime data 55 | pids 56 | *.pid 57 | *.seed 58 | *.pid.lock 59 | 60 | # Diagnostic reports (https://nodejs.org/api/report.html) 61 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 62 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the application 2 | FROM node:22-alpine AS builder 3 | 4 | # Install FFmpeg 5 | RUN apk add --no-cache ffmpeg 6 | 7 | # Set working directory 8 | WORKDIR /usr/src/app 9 | 10 | # Copy package.json and package-lock.json 11 | COPY package*.json ./ 12 | 13 | # Install dependencies (including dev dependencies) 14 | RUN npm ci 15 | 16 | # Copy the rest of the application code 17 | COPY . . 18 | 19 | # Build the application 20 | RUN npm run build 21 | 22 | # Stage 2: Create the production image 23 | FROM node:22-alpine 24 | 25 | # Install FFmpeg 26 | RUN apk add --no-cache ffmpeg 27 | 28 | # Set working directory 29 | WORKDIR /usr/src/app 30 | 31 | # Copy package.json and package-lock.json 32 | COPY package*.json ./ 33 | 34 | # Install only production dependencies 35 | RUN npm ci --only=production 36 | 37 | # Copy the build artifacts from the builder stage 38 | COPY --from=builder /usr/src/app/dist ./dist 39 | 40 | # Expose the application port 41 | EXPOSE 3000 42 | 43 | # Start the application 44 | CMD ["node", "dist/main.js"] 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Optimized versions 2 | > A streamyfin companion server for better downloads 3 | 4 | ## About 5 | 6 | Optimized versions is a transcoding server (henceforth refered to as _the server_). It acts as a middleman between the Jellyfin server and the client (Streamyfin app) when downloading content. The job of the server is to combine an HLS stream into a single video file which in turn enables better and more stable downloads in the app. Streamyfin can then also utilize background downloads, which means that the app does not need to be open for the content to download. 7 | 8 | The download in the app becomed a 2 step process. 9 | 10 | 1. Optimize 11 | 2. Download 12 | 13 | ## Usage 14 | 15 | Note: The server works best if it's on the same server as the Jellyfin server. 16 | 17 | ### Docker-compose 18 | 19 | #### Docker-compose example 20 | 21 | ```yaml 22 | services: 23 | app: 24 | image: fredrikburmester/streamyfin-optimized-versions-server:master 25 | ports: 26 | - '3000:3000' 27 | env_file: 28 | - .env 29 | environment: 30 | - NODE_ENV=development 31 | restart: unless-stopped 32 | 33 | # If you want to use a local volume for the cache, uncomment the following lines: 34 | # volumes: 35 | # - ./cache:/usr/src/app/cache 36 | ``` 37 | 38 | Create a .env file following the example below or by copying the .env.example file from this repository. 39 | 40 | #### .env example 41 | 42 | ```bash 43 | JELLYFIN_URL=http://your-jellyfin-url 44 | # MAX_CONCURRENT_JOBS=1 # OPTIONAL default is 1 45 | ``` 46 | 47 | ## How it works 48 | 49 | ### 1. Optimize 50 | 51 | A POST request is made to the server with the HLS stream URL. The server will then start a job, downloading the HLS stream to the server, and convert it to a single file. 52 | 53 | In the meantime, the app will poll the server for the progress of the optimize. 54 | 55 | ### 2. Download 56 | 57 | As soon as the server is finished with the conversion the app (if open) will start downloading the video file. If the app is not open the download will start as soon as the app is opened. After the download has started the app can be minimized. 58 | 59 | This means that the user needs to 1. initiate the download, and 2. open the app once before download. 60 | 61 | ## Other 62 | 63 | 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. -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: fredrikburmester/streamyfin-optimized-versions-server:master 4 | ports: 5 | - '3000:3000' 6 | env_file: 7 | - .env 8 | environment: 9 | - NODE_ENV=development 10 | restart: unless-stopped 11 | 12 | # If you want to use a local volume for the cache, uncomment the following lines: 13 | volumes: 14 | - ./cache:/usr/src/app/cache 15 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "optimize-versions-api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^10.0.0", 24 | "@nestjs/config": "^3.2.3", 25 | "@nestjs/core": "^10.0.0", 26 | "@nestjs/platform-express": "^10.0.0", 27 | "@nestjs/schedule": "^4.1.1", 28 | "axios": "^1.7.7", 29 | "dotenv": "^16.4.5", 30 | "reflect-metadata": "^0.2.0", 31 | "rxjs": "^7.8.1", 32 | "tree-kill": "^1.2.2", 33 | "uuid": "^10.0.0" 34 | }, 35 | "devDependencies": { 36 | "@nestjs/cli": "^10.0.0", 37 | "@nestjs/schematics": "^10.0.0", 38 | "@nestjs/testing": "^10.0.0", 39 | "@types/express": "^4.17.17", 40 | "@types/jest": "^29.5.2", 41 | "@types/node": "^20.3.1", 42 | "@types/supertest": "^6.0.0", 43 | "@types/uuid": "^10.0.0", 44 | "@typescript-eslint/eslint-plugin": "^8.0.0", 45 | "@typescript-eslint/parser": "^8.0.0", 46 | "eslint": "^8.42.0", 47 | "eslint-config-prettier": "^9.0.0", 48 | "eslint-plugin-prettier": "^5.0.0", 49 | "jest": "^29.5.0", 50 | "prettier": "^3.0.0", 51 | "source-map-support": "^0.5.21", 52 | "supertest": "^7.0.0", 53 | "ts-jest": "^29.1.0", 54 | "ts-loader": "^9.4.3", 55 | "ts-node": "^10.9.1", 56 | "tsconfig-paths": "^4.2.0", 57 | "typescript": "^5.1.3" 58 | }, 59 | "jest": { 60 | "moduleFileExtensions": [ 61 | "js", 62 | "json", 63 | "ts" 64 | ], 65 | "rootDir": "src", 66 | "testRegex": ".*\\.spec\\.ts$", 67 | "transform": { 68 | "^.+\\.(t|j)s$": "ts-jest" 69 | }, 70 | "collectCoverageFrom": [ 71 | "**/*.(t|j)s" 72 | ], 73 | "coverageDirectory": "../coverage", 74 | "testEnvironment": "node" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { Logger } from '@nestjs/common'; 5 | import { Response } from 'express'; 6 | import * as fs from 'fs'; 7 | 8 | jest.mock('fs'); 9 | 10 | describe('AppController', () => { 11 | let appController: AppController; 12 | let appService: AppService; 13 | let logger: Logger; 14 | 15 | beforeEach(async () => { 16 | const module: TestingModule = await Test.createTestingModule({ 17 | controllers: [AppController], 18 | providers: [ 19 | AppService, 20 | { 21 | provide: Logger, 22 | useValue: { 23 | log: jest.fn(), 24 | }, 25 | }, 26 | ], 27 | }).compile(); 28 | 29 | appController = module.get(AppController); 30 | appService = module.get(AppService); 31 | logger = module.get(Logger); 32 | }); 33 | 34 | describe('cancelJob', () => { 35 | it('should cancel job successfully', async () => { 36 | const id = 'abc123'; 37 | jest.spyOn(appService, 'cancelJob').mockReturnValue(true); 38 | 39 | const result = await appController.cancelJob(id); 40 | 41 | expect(result).toEqual({ message: 'Job cancelled successfully' }); 42 | expect(logger.log).toHaveBeenCalledWith( 43 | `Cancellation request for job: ${id}`, 44 | ); 45 | }); 46 | 47 | it('should return not found message if job does not exist', async () => { 48 | const id = 'abc123'; 49 | jest.spyOn(appService, 'cancelJob').mockReturnValue(false); 50 | 51 | const result = await appController.cancelJob(id); 52 | 53 | expect(result).toEqual({ message: 'Job not found or already completed' }); 54 | }); 55 | }); 56 | 57 | describe('downloadTranscodedFile', () => { 58 | it('should download file successfully', async () => { 59 | const id = 'abc123'; 60 | const filePath = '/path/to/file.mp4'; 61 | const mockResponse = { 62 | setHeader: jest.fn(), 63 | on: jest.fn().mockImplementation((event, callback) => { 64 | if (event === 'finish') callback(); 65 | }), 66 | } as unknown as Response; 67 | 68 | jest.spyOn(appService, 'getTranscodedFilePath').mockReturnValue(filePath); 69 | jest.spyOn(fs, 'statSync').mockReturnValue({ size: 1000 } as fs.Stats); 70 | jest.spyOn(fs, 'createReadStream').mockReturnValue({ 71 | pipe: jest.fn(), 72 | } as unknown as fs.ReadStream); 73 | jest.spyOn(appService, 'cleanupJob').mockImplementation(() => {}); 74 | 75 | await appController.downloadTranscodedFile(id, mockResponse); 76 | 77 | expect(mockResponse.setHeader).toHaveBeenCalledTimes(3); 78 | expect(appService.cleanupJob).toHaveBeenCalledWith(id); 79 | }); 80 | 81 | it('should throw NotFoundException if file not found', async () => { 82 | const id = 'abc123'; 83 | jest.spyOn(appService, 'getTranscodedFilePath').mockReturnValue(null); 84 | 85 | await expect( 86 | appController.downloadTranscodedFile(id, {} as Response), 87 | ).rejects.toThrow('File not found or job not completed'); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Logger, 7 | NotFoundException, 8 | Param, 9 | Post, 10 | Query, 11 | Res, 12 | HttpException, 13 | HttpStatus, 14 | } from '@nestjs/common'; 15 | import { Response } from 'express'; 16 | import * as fs from 'fs'; 17 | import { AppService, Job } from './app.service'; 18 | import { log } from 'console'; 19 | 20 | @Controller() 21 | export class AppController { 22 | constructor( 23 | private readonly appService: AppService, 24 | private logger: Logger, 25 | ) {} 26 | 27 | @Get('statistics') 28 | async getStatistics() { 29 | return this.appService.getStatistics(); 30 | } 31 | 32 | @Post('optimize-version') 33 | async downloadAndCombine( 34 | @Body('url') url: string, 35 | @Body('fileExtension') fileExtension: string, 36 | @Body('deviceId') deviceId: string, 37 | @Body('itemId') itemId: string, 38 | @Body('item') item: any, 39 | ): Promise<{ id: string }> { 40 | this.logger.log(`Optimize request for URL: ${url.slice(0, 50)}...`); 41 | 42 | let jellyfinUrl = process.env.JELLYFIN_URL; 43 | 44 | let finalUrl: string; 45 | 46 | if (jellyfinUrl) { 47 | jellyfinUrl = jellyfinUrl.replace(/\/$/, ''); 48 | // If JELLYFIN_URL is set, use it to replace the base of the incoming URL 49 | const parsedUrl = new URL(url); 50 | finalUrl = new URL( 51 | parsedUrl.pathname + parsedUrl.search, 52 | jellyfinUrl, 53 | ).toString(); 54 | } else { 55 | // If JELLYFIN_URL is not set, use the incoming URL as is 56 | finalUrl = url; 57 | } 58 | 59 | const id = await this.appService.downloadAndCombine( 60 | finalUrl, 61 | fileExtension, 62 | deviceId, 63 | itemId, 64 | item, 65 | ); 66 | return { id }; 67 | } 68 | 69 | @Get('job-status/:id') 70 | async getActiveJob(@Param('id') id: string): Promise { 71 | return this.appService.getJobStatus(id); 72 | } 73 | 74 | @Post('start-job/:id') 75 | async startJob(@Param('id') id: string): Promise<{ message: string }> { 76 | this.logger.log(`Manual start request for job: ${id}`); 77 | 78 | try { 79 | const result = await this.appService.manuallyStartJob(id); 80 | if (result) { 81 | return { message: 'Job started successfully' }; 82 | } else { 83 | throw new HttpException( 84 | 'Job not found or already started', 85 | HttpStatus.BAD_REQUEST, 86 | ); 87 | } 88 | } catch (error) { 89 | throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); 90 | } 91 | } 92 | 93 | @Delete('cancel-job/:id') 94 | async cancelJob(@Param('id') id: string) { 95 | this.logger.log(`Cancellation request for job: ${id}`); 96 | // this.appService.completeJob(id); 97 | const result = this.appService.cancelJob(id); 98 | if (result) { 99 | return { message: 'Job cancelled successfully' }; 100 | } else { 101 | return { message: 'Job not found or already completed' }; 102 | } 103 | } 104 | 105 | @Get('all-jobs') 106 | async getAllJobs(@Query('deviceId') deviceId?: string) { 107 | return this.appService.getAllJobs(deviceId); 108 | } 109 | 110 | @Get('download/:id') 111 | async downloadTranscodedFile( 112 | @Param('id') id: string, 113 | @Res({ passthrough: true }) res: Response, 114 | ) { 115 | const filePath = this.appService.getTranscodedFilePath(id); 116 | 117 | if (!filePath) { 118 | throw new NotFoundException('File not found or job not completed'); 119 | } 120 | 121 | const stat = fs.statSync(filePath); 122 | 123 | res.setHeader('Content-Length', stat.size); 124 | res.setHeader('Content-Type', 'video/mp4'); 125 | res.setHeader( 126 | 'Content-Disposition', 127 | `attachment; filename=transcoded_${id}.mp4`, 128 | ); 129 | 130 | const fileStream = fs.createReadStream(filePath); 131 | this.logger.log(`Download started for ${filePath}`) 132 | 133 | return new Promise((resolve, reject) => { 134 | fileStream.pipe(res); 135 | 136 | fileStream.on('end', () => { 137 | // File transfer completed 138 | this.logger.log(`File transfer ended for: ${filePath}`) 139 | 140 | resolve(null); 141 | }); 142 | 143 | fileStream.on('error', (err) => { 144 | // Handle errors during file streaming 145 | this.logger.error(`Error streaming file ${filePath}: ${err.message}`); 146 | reject(err); 147 | }); 148 | }); 149 | } 150 | 151 | 152 | @Delete('delete-cache') 153 | async deleteCache() { 154 | this.logger.log('Cache deletion request'); 155 | return this.appService.deleteCache(); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, NestModule, MiddlewareConsumer, Logger } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { AuthMiddleware } from './auth.middleware'; 5 | import { ConfigModule } from '@nestjs/config'; 6 | import { JellyfinAuthService } from './jellyfin-auth.service'; 7 | import { ScheduleModule } from '@nestjs/schedule'; 8 | import { CleanupService } from './cleanup/cleanup.service'; 9 | import { FileRemoval } from './cleanup/removalUtils'; 10 | 11 | 12 | @Module({ 13 | imports: [ScheduleModule.forRoot(), ConfigModule.forRoot({ isGlobal: true })], 14 | controllers: [AppController], 15 | providers: [AppService, Logger, JellyfinAuthService, CleanupService, FileRemoval], 16 | }) 17 | export class AppModule implements NestModule { 18 | configure(consumer: MiddlewareConsumer) { 19 | consumer 20 | .apply(AuthMiddleware) 21 | .forRoutes( 22 | 'optimize-version', 23 | 'download/:id', 24 | 'cancel-job/:id', 25 | 'statistics', 26 | 'job-status/:id', 27 | 'start-job/:id', 28 | 'all-jobs', 29 | 'delete-cache', 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | InternalServerErrorException, 4 | Logger, 5 | } from '@nestjs/common'; 6 | import { ChildProcess, spawn } from 'child_process'; 7 | import { v4 as uuidv4 } from 'uuid'; 8 | import * as path from 'path'; 9 | import { ConfigService } from '@nestjs/config'; 10 | import * as fs from 'fs'; 11 | import { promises as fsPromises } from 'fs'; 12 | import { CACHE_DIR } from './constants'; 13 | import { FileRemoval } from './cleanup/removalUtils'; 14 | import * as kill from 'tree-kill'; 15 | 16 | export interface Job { 17 | id: string; 18 | status: 'queued' | 'optimizing' | 'pending downloads limit' | 'completed' | 'failed' | 'cancelled' | 'ready-for-removal'; 19 | progress: number; 20 | outputPath: string; 21 | inputUrl: string; 22 | deviceId: string; 23 | itemId: string; 24 | timestamp: Date; 25 | size: number; 26 | item: any; 27 | speed?: number; 28 | } 29 | 30 | @Injectable() 31 | export class AppService { 32 | private activeJobs: Job[] = []; 33 | private optimizationHistory: Job[] = []; 34 | private ffmpegProcesses: Map = new Map(); 35 | private videoDurations: Map = new Map(); 36 | private jobQueue: string[] = []; 37 | private maxConcurrentJobs: number; 38 | private maxCachedPerUser: number; 39 | private cacheDir: string; 40 | private immediateRemoval: boolean; 41 | 42 | constructor( 43 | private logger: Logger, 44 | private configService: ConfigService, 45 | private readonly fileRemoval: FileRemoval 46 | 47 | ) { 48 | this.cacheDir = CACHE_DIR; 49 | this.maxConcurrentJobs = this.configService.get( 50 | 'MAX_CONCURRENT_JOBS', 51 | 1, 52 | ); 53 | this.maxCachedPerUser = this.configService.get( 54 | 'MAX_CACHED_PER_USER', 55 | 10, 56 | ); 57 | this.immediateRemoval = this.configService.get( 58 | 'REMOVE_FILE_AFTER_RIGHT_DOWNLOAD', 59 | true, 60 | ); 61 | } 62 | 63 | async downloadAndCombine( 64 | url: string, 65 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 66 | fileExtension: string, 67 | deviceId: string, 68 | itemId: string, 69 | item: any, 70 | ): Promise { 71 | const jobId = uuidv4(); 72 | const outputPath = path.join(this.cacheDir, `combined_${jobId}.mp4`); 73 | 74 | this.logger.log( 75 | `Queueing job ${jobId.padEnd(36)} | URL: ${(url.slice(0, 50) + '...').padEnd(53)} | Path: ${outputPath}`, 76 | ); 77 | 78 | this.activeJobs.push({ 79 | id: jobId, 80 | status: 'queued', 81 | progress: 0, 82 | outputPath, 83 | inputUrl: url, 84 | itemId, 85 | item, 86 | deviceId, 87 | timestamp: new Date(), 88 | size: 0, 89 | }); 90 | 91 | this.jobQueue.push(jobId); 92 | this.checkQueue(); // Check if we can start the job immediately 93 | 94 | return jobId; 95 | } 96 | 97 | getJobStatus(jobId: string): Job | null { 98 | const job = this.activeJobs.find((job) => job.id === jobId); 99 | return job || null; 100 | } 101 | 102 | getAllJobs(deviceId?: string | null): Job[] { 103 | if (!deviceId) { 104 | return this.activeJobs; 105 | } 106 | return this.activeJobs.filter((job) => job.deviceId === deviceId && job.status !== 'ready-for-removal'); 107 | } 108 | 109 | async deleteCache(): Promise<{ message: string }> { 110 | try { 111 | const files = await fsPromises.readdir(this.cacheDir); 112 | await Promise.all( 113 | files.map((file) => fsPromises.unlink(path.join(this.cacheDir, file))), 114 | ); 115 | return { 116 | message: 'Cache deleted successfully', 117 | }; 118 | } catch (error) { 119 | this.logger.error('Error deleting cache:', error); 120 | throw new InternalServerErrorException('Failed to delete cache'); 121 | } 122 | } 123 | 124 | removeJob(jobId: string): void { 125 | this.activeJobs = this.activeJobs.filter(job => job.id !== jobId); 126 | this.logger.log(`Job ${jobId} removed.`); 127 | } 128 | 129 | cancelJob(jobId: string): boolean { 130 | this.completeJob(jobId); 131 | const job = this.activeJobs.find(job => job.id === jobId); 132 | const process = this.ffmpegProcesses.get(jobId); 133 | 134 | const finalizeJobRemoval = () => { 135 | if (job) { 136 | this.jobQueue = this.jobQueue.filter(id => id !== jobId); 137 | if (this.immediateRemoval === true || job.progress < 100) { 138 | this.fileRemoval.cleanupReadyForRemovalJobs([job]); 139 | this.activeJobs = this.activeJobs.filter(activeJob => activeJob.id !== jobId); 140 | this.logger.log(`Job ${jobId} removed`); 141 | } 142 | else{ 143 | this.logger.log('Immediate removal is not allowed, cleanup service will take care in due time') 144 | } 145 | } 146 | this.activeJobs 147 | .filter((nextjob) => nextjob.deviceId === job.deviceId && nextjob.status === 'pending downloads limit') 148 | .forEach((job) => job.status = 'queued') 149 | this.checkQueue(); 150 | }; 151 | 152 | if (process) { 153 | try { 154 | this.logger.log(`Attempting to kill process tree for PID ${process.pid}`); 155 | new Promise((resolve, reject) => { 156 | kill(process.pid, 'SIGINT', (err) => { 157 | if (err) { 158 | this.logger.error(`Failed to kill process tree for PID ${process.pid}: ${err.message}`); 159 | reject(err); 160 | } else { 161 | this.logger.log(`Successfully killed process tree for PID ${process.pid}`); 162 | resolve(); 163 | finalizeJobRemoval() 164 | } 165 | }); 166 | }); 167 | } catch (err) { 168 | this.logger.error(`Error terminating process for job ${jobId}: ${err.message}`); 169 | } 170 | this.ffmpegProcesses.delete(jobId); 171 | return true; 172 | } else { 173 | finalizeJobRemoval(); 174 | return true; 175 | } 176 | } 177 | 178 | completeJob(jobId: string):void{ 179 | const job = this.activeJobs.find((job) => job.id === jobId); 180 | 181 | if (job) { 182 | job.status = 'ready-for-removal'; 183 | job.timestamp = new Date() 184 | this.logger.log(`Job ${jobId} marked as completed and ready for removal.`); 185 | } else { 186 | this.logger.warn(`Job ${jobId} not found. Cannot mark as completed.`); 187 | } 188 | } 189 | 190 | cleanupJob(jobId: string): void { 191 | const job = this.activeJobs.find((job) => job.id === jobId); 192 | this.activeJobs = this.activeJobs.filter((job) => job.id !== jobId); 193 | this.ffmpegProcesses.delete(jobId); 194 | this.videoDurations.delete(jobId); 195 | } 196 | 197 | getTranscodedFilePath(jobId: string): string | null { 198 | const job = this.activeJobs.find((job) => job.id === jobId); 199 | if (job && job.status === 'completed') { 200 | return job.outputPath; 201 | } 202 | return null; 203 | } 204 | 205 | getMaxConcurrentJobs(): number { 206 | return this.maxConcurrentJobs; 207 | } 208 | 209 | async getStatistics() { 210 | const cacheSize = await this.getCacheSize(); 211 | const totalTranscodes = this.getTotalTranscodes(); 212 | const activeJobs = this.getActiveJobs(); 213 | const completedJobs = this.getCompletedJobs(); 214 | const uniqueDevices = this.getUniqueDevices(); 215 | 216 | return { 217 | cacheSize, 218 | totalTranscodes, 219 | activeJobs, 220 | completedJobs, 221 | uniqueDevices, 222 | }; 223 | } 224 | 225 | async manuallyStartJob(jobId: string): Promise { 226 | const job = this.activeJobs.find((job) => job.id === jobId); 227 | 228 | if (!job || job.status !== 'queued') { 229 | return false; 230 | } 231 | 232 | this.startJob(jobId); 233 | return true; 234 | } 235 | 236 | private async getCacheSize(): Promise { 237 | const cacheSize = await this.getDirectorySize(this.cacheDir); 238 | return this.formatSize(cacheSize); 239 | } 240 | 241 | private async getDirectorySize(directory: string): Promise { 242 | const files = await fs.promises.readdir(directory); 243 | const stats = await Promise.all( 244 | files.map((file) => fs.promises.stat(path.join(directory, file))), 245 | ); 246 | 247 | return stats.reduce((accumulator, { size }) => accumulator + size, 0); 248 | } 249 | 250 | private formatSize(bytes: number): string { 251 | const units = ['B', 'KB', 'MB', 'GB', 'TB']; 252 | let size = bytes; 253 | let unitIndex = 0; 254 | 255 | while (size >= 1024 && unitIndex < units.length - 1) { 256 | size /= 1024; 257 | unitIndex++; 258 | } 259 | 260 | return `${size.toFixed(2)} ${units[unitIndex]}`; 261 | } 262 | 263 | private getTotalTranscodes(): number { 264 | return this.activeJobs.length; 265 | } 266 | 267 | private getActiveJobs(): number { 268 | return this.activeJobs.filter((job) => job.status === 'optimizing').length; 269 | } 270 | 271 | private getCompletedJobs(): number { 272 | return this.activeJobs.filter((job) => job.status === 'ready-for-removal').length; 273 | } 274 | 275 | private isDeviceIdInOptimizeHistory(job:Job){ 276 | const uniqueDeviceIds: string[] = [...new Set(this.optimizationHistory.map((job: Job) => job.deviceId))]; 277 | const result = uniqueDeviceIds.includes(job.deviceId); // Check if job.deviceId is in uniqueDeviceIds 278 | this.logger.log(`Device ID ${job.deviceId} is ${result ? 'in' : 'not in'} the finished jobs. Optimizing ${result ? 'Allowed' : 'not Allowed'}`); 279 | return result 280 | } 281 | 282 | private getActiveJobDeviceIds(): string[]{ 283 | const uniqueDeviceIds: string[] = [ 284 | ...new Set( 285 | this.activeJobs 286 | .filter((job: Job) => job.status === 'queued') // Filter jobs with status 'queued' 287 | .map((job: Job) => job.deviceId) // Extract deviceId 288 | ) 289 | ]; 290 | return uniqueDeviceIds 291 | } 292 | 293 | private handleOptimizationHistory(job: Job): void{ 294 | // create a finished jobs list to make sure every device gets equal optimizing time 295 | this.optimizationHistory.push(job) // push the newest job to the finished jobs list 296 | const amountOfActiveDeviceIds = this.getActiveJobDeviceIds().length // get the amount of active queued job device ids 297 | while(amountOfActiveDeviceIds <= this.optimizationHistory.length && this.optimizationHistory.length > 0){ // the finished jobs should always be lower than the amount of active jobs. This is to push out the last deviceid: FIFO 298 | this.optimizationHistory.shift() // shift away the oldest job. 299 | } 300 | this.logger.log(`${this.optimizationHistory.length} deviceIDs have recently finished a job`) 301 | } 302 | 303 | private getUniqueDevices(): number { 304 | const devices = new Set(this.activeJobs.map((job) => job.deviceId)); 305 | return devices.size; 306 | } 307 | 308 | private checkQueue() { 309 | let runningJobs = this.activeJobs.filter((job) => job.status === 'optimizing').length; 310 | 311 | this.logger.log( 312 | `${runningJobs} active jobs running and ${this.jobQueue.length} items in the queue`, 313 | ); 314 | 315 | for (const index in this.jobQueue) { 316 | if (runningJobs >= this.maxConcurrentJobs) { 317 | break; // Stop if max concurrent jobs are reached 318 | } 319 | const nextJobId = this.jobQueue[index]; // Access job ID by index 320 | let nextJob: Job = this.activeJobs.find((job) => job.id === nextJobId); 321 | 322 | if (!this.userTooManyCachedItems(nextJobId) ) { 323 | nextJob.status = 'pending downloads limit' 324 | // Skip this job if user cache limits are reached 325 | continue; 326 | } 327 | if(this.isDeviceIdInOptimizeHistory(nextJob)){ 328 | // Skip this job if deviceID is in the recently finished jobs 329 | continue 330 | } 331 | // Start the job and remove it from the queue 332 | this.startJob(nextJobId); 333 | this.jobQueue.splice(Number(index), 1); // Remove the started job from the queue 334 | runningJobs++; // Increment running jobs 335 | } 336 | } 337 | 338 | private userTooManyCachedItems(jobid): boolean{ 339 | if(this.maxCachedPerUser == 0){ 340 | return false 341 | } 342 | const theNewJob: Job = this.activeJobs.find((job) => job.id === jobid) 343 | let completedUserJobs = this.activeJobs.filter((job) => (job.status === "completed" || job.status === 'optimizing') && job.deviceId === theNewJob.deviceId) 344 | if((completedUserJobs.length >= this.maxCachedPerUser)){ 345 | this.logger.log(`Waiting for items to be downloaded - device ${theNewJob.deviceId} has ${completedUserJobs.length} downloads waiting `); 346 | return false 347 | } 348 | else{ 349 | this.logger.log(`Optimizing - device ${theNewJob.deviceId} has ${completedUserJobs.length} downloads waiting`); 350 | return true 351 | } 352 | } 353 | 354 | private startJob(jobId: string) { 355 | const job = this.activeJobs.find((job) => job.id === jobId); 356 | if (job) { 357 | job.status = 'optimizing'; 358 | this.handleOptimizationHistory(job) 359 | const ffmpegArgs = this.getFfmpegArgs(job.inputUrl, job.outputPath); 360 | this.startFFmpegProcess(jobId, ffmpegArgs) 361 | .finally(() => { 362 | // This runs after the returned Promise resolves or rejects. 363 | this.checkQueue(); 364 | }); 365 | this.logger.log(`Started job ${jobId}`); 366 | } 367 | } 368 | 369 | private getFfmpegArgs(inputUrl: string, outputPath: string): string[] { 370 | return [ 371 | '-i', 372 | inputUrl, 373 | '-c', 374 | 'copy', // Copy both video and audio without re-encoding 375 | '-movflags', 376 | '+faststart', // Optimize for web streaming 377 | '-f', 378 | 'mp4', // Force MP4 container 379 | outputPath, 380 | ]; 381 | } 382 | 383 | 384 | private async startFFmpegProcess( 385 | jobId: string, 386 | ffmpegArgs: string[], 387 | ): Promise { 388 | try { 389 | await this.getVideoDuration(ffmpegArgs[1], jobId); 390 | 391 | return new Promise((resolve, reject) => { 392 | const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, { stdio: ['pipe', 'pipe', 'pipe']}); 393 | this.ffmpegProcesses.set(jobId, ffmpegProcess); 394 | 395 | ffmpegProcess.stderr.on('data', (data) => { 396 | this.updateProgress(jobId, data.toString()); 397 | }); 398 | 399 | ffmpegProcess.on('close', async (code) => { 400 | this.ffmpegProcesses.delete(jobId); 401 | this.videoDurations.delete(jobId); 402 | 403 | const job = this.activeJobs.find((job) => job.id === jobId); 404 | if (!job) { 405 | resolve(); 406 | return; 407 | } 408 | 409 | if (code === 0) { 410 | 411 | job.status = 'completed'; 412 | job.progress = 100; 413 | // Update the file size 414 | try { 415 | const stats = await fsPromises.stat(job.outputPath); 416 | job.size = stats.size; 417 | } catch (error) { 418 | this.logger.error( 419 | `Error getting file size for job ${jobId}: ${error.message}`, 420 | ); 421 | } 422 | this.logger.log( 423 | `Job ${jobId} completed successfully. Output: ${job.outputPath}, Size: ${this.formatSize(job.size || 0)}`, 424 | ); 425 | resolve(); 426 | } else { 427 | job.status = 'failed'; 428 | job.progress = 0; 429 | this.logger.error( 430 | `Job ${jobId} failed with exit code ${code}. Input URL: ${job.inputUrl}`, 431 | ); 432 | // reject(new Error(`FFmpeg process failed with exit code ${code}`)); 433 | } 434 | }); 435 | 436 | ffmpegProcess.on('error', (error) => { 437 | this.logger.error( 438 | `FFmpeg process error for job ${jobId}: ${error.message}`, 439 | ); 440 | // reject(error); 441 | }); 442 | }); 443 | } catch (error) { 444 | this.logger.error(`Error processing job ${jobId}: ${error.message}`); 445 | const job = this.activeJobs.find((job) => job.id === jobId); 446 | if (job) { 447 | job.status = 'failed'; 448 | } 449 | } 450 | } 451 | 452 | 453 | private async getVideoDuration( 454 | inputUrl: string, 455 | jobId: string, 456 | ): Promise { 457 | return new Promise((resolve, reject) => { 458 | const ffprobe = spawn('ffprobe', [ 459 | '-v', 460 | 'error', 461 | '-show_entries', 462 | 'format=duration', 463 | '-of', 464 | 'default=noprint_wrappers=1:nokey=1', 465 | inputUrl, 466 | ]); 467 | 468 | let output = ''; 469 | 470 | ffprobe.stdout.on('data', (data) => { 471 | output += data.toString(); 472 | }); 473 | 474 | ffprobe.on('close', (code) => { 475 | if (code === 0) { 476 | const duration = parseFloat(output.trim()); 477 | this.videoDurations.set(jobId, duration); 478 | resolve(); 479 | } else { 480 | reject(new Error(`ffprobe process exited with code ${code}`)); 481 | } 482 | }); 483 | }); 484 | } 485 | 486 | private updateProgress(jobId: string, ffmpegOutput: string): void { 487 | const progressMatch = ffmpegOutput.match( 488 | /time=(\d{2}):(\d{2}):(\d{2})\.\d{2}/, 489 | ); 490 | const speedMatch = ffmpegOutput.match(/speed=(\d+\.?\d*)x/); 491 | 492 | if (progressMatch) { 493 | const [, hours, minutes, seconds] = progressMatch; 494 | const currentTime = 495 | parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); 496 | 497 | const totalDuration = this.videoDurations.get(jobId); 498 | if (totalDuration) { 499 | const progress = Math.min((currentTime / totalDuration) * 100, 99.9); 500 | const job = this.activeJobs.find((job) => job.id === jobId); 501 | if (job) { 502 | job.progress = Math.max(progress, 0); 503 | 504 | // Update speed if available 505 | if (speedMatch) { 506 | const speed = parseFloat(speedMatch[1]); 507 | job.speed = Math.max(speed, 0); 508 | } 509 | } 510 | } 511 | } 512 | } 513 | } 514 | -------------------------------------------------------------------------------- /src/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestMiddleware, 4 | UnauthorizedException, 5 | } from '@nestjs/common'; 6 | import { Request, Response, NextFunction } from 'express'; 7 | import { JellyfinAuthService } from './jellyfin-auth.service'; // You'll need to create this service 8 | 9 | @Injectable() 10 | export class AuthMiddleware implements NestMiddleware { 11 | constructor(private jellyfinAuthService: JellyfinAuthService) {} 12 | 13 | async use(req: Request, res: Response, next: NextFunction) { 14 | const authHeader = req.headers['authorization']; 15 | 16 | if (!authHeader) return res.sendStatus(401); 17 | 18 | try { 19 | const isValid = 20 | await this.jellyfinAuthService.validateCredentials(authHeader); 21 | if (!isValid) { 22 | throw new UnauthorizedException('Invalid credentials'); 23 | } 24 | next(); 25 | } catch (error) { 26 | console.log(error); 27 | throw new UnauthorizedException('Authentication failed'); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/cleanup/cleanup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { Cron, CronExpression } from '@nestjs/schedule'; 3 | import { AppService } from '../app.service'; 4 | import { promises as fsPromises } from 'fs'; 5 | import * as path from 'path'; 6 | import { CACHE_DIR } from '../constants'; 7 | import { FileRemoval } from './removalUtils'; 8 | import { ConfigService } from '@nestjs/config'; 9 | import { Job } from '../app.service'; 10 | 11 | @Injectable() 12 | export class CleanupService { 13 | private readonly logger = new Logger(CleanupService.name); 14 | private readonly cacheDir: string; 15 | private readonly removalDelayMs: number; 16 | 17 | constructor( 18 | private readonly appService: AppService, 19 | private readonly fileRemoval: FileRemoval, 20 | private readonly configService: ConfigService, 21 | ) { 22 | this.cacheDir = CACHE_DIR; 23 | 24 | const removalDelayHours = this.configService.get( 25 | 'TIME_TO_KEEP_FILES', 26 | 8, // default to 8 hours 27 | ); 28 | this.removalDelayMs = removalDelayHours * 60 * 60 * 1000; // Convert hours to milliseconds 29 | } 30 | 31 | @Cron(CronExpression.EVERY_HOUR) 32 | async handleCleanup(): Promise { 33 | const jobs = this.appService.getAllJobs(); 34 | const outputPaths = new Set(jobs.map((job) => job.outputPath)); 35 | 36 | if (outputPaths.size === 0) { 37 | this.logger.log('No files to clean up, skipping cleanup job.'); 38 | return; 39 | } 40 | 41 | this.logger.log(`Running cleanup job on ${outputPaths.size} files...`); 42 | this.logger.debug(`Output paths: ${[...outputPaths].join(', ')}`); 43 | 44 | const now = Date.now(); 45 | 46 | try { 47 | const files = await fsPromises.readdir(this.cacheDir); 48 | this.logger.log(`Found ${files.length} files in cache directory: ${this.cacheDir}`); 49 | 50 | // Filter files not associated with any active job 51 | const filesToRemove = files.filter((file) => { 52 | const filePath = path.join(this.cacheDir, file); 53 | return !outputPaths.has(filePath); 54 | }); 55 | 56 | if (filesToRemove.length > 0) { 57 | this.logger.log(`Removing ${filesToRemove.length} unassociated files...`); 58 | 59 | // Remove files concurrently 60 | await Promise.all( 61 | filesToRemove.map(async (file) => { 62 | const filePath = path.join(this.cacheDir, file); 63 | this.logger.log(`Removing ${filePath}, no associated jobs`); 64 | try { 65 | await this.fileRemoval.removeFile(filePath); 66 | this.logger.log(`Successfully removed file: ${filePath}`); 67 | } catch (error) { 68 | this.logger.error(`Failed to remove file ${filePath}: ${error.message}`); 69 | } 70 | }), 71 | ); 72 | } else { 73 | this.logger.log('No unassociated files found for removal.'); 74 | } 75 | 76 | const jobsToCleanup = jobs.filter( 77 | (job) => 78 | this.isOlderThanDelay(job.timestamp, now) && job.status === 'ready-for-removal', 79 | ); 80 | 81 | if (jobsToCleanup.length > 0) { 82 | this.logger.log(`Cleaning up ${jobsToCleanup.length} jobs...`); 83 | 84 | await Promise.all( 85 | jobsToCleanup.map(async (job) => { 86 | this.logger.log(`Cleaning up job ${job.id} (timestamp: ${job.timestamp})`); 87 | try { 88 | await this.fileRemoval.cleanupReadyForRemovalJobs([job]); 89 | this.appService.removeJob(job.id); 90 | this.logger.log(`Successfully cleaned up job ${job.id}`); 91 | } catch (error) { 92 | this.logger.error(`Failed to clean up job ${job.id}: ${error.message}`); 93 | } 94 | }), 95 | ); 96 | } else { 97 | this.logger.log('No jobs eligible for cleanup at this time.'); 98 | } 99 | } catch (error) { 100 | this.logger.error(`Error during cleanup process: ${error.message}`); 101 | } 102 | } 103 | 104 | /** 105 | * Determines if the given timestamp is older than the configured delay. 106 | * @param timestamp The timestamp to compare. 107 | * @param now The current time in milliseconds. 108 | * @returns True if the timestamp is older than the delay, else false. 109 | */ 110 | private isOlderThanDelay(timestamp: Date, now: number): boolean { 111 | const timestampMs = timestamp.getTime(); 112 | return now - timestampMs > this.removalDelayMs; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/cleanup/removalUtils.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import * as fs from 'fs/promises'; 3 | 4 | @Injectable() 5 | export class FileRemoval { 6 | private readonly logger = new Logger(FileRemoval.name); 7 | 8 | public async cleanupReadyForRemovalJobs(jobs: any[]): Promise { 9 | for (const job of jobs.filter(job => job.status === 'ready-for-removal')) { 10 | await this.removeFile(job.outputPath); 11 | } 12 | } 13 | 14 | public async removeFile(filePath: string): Promise { 15 | let retries = 3; 16 | 17 | while (retries > 0) { 18 | try { 19 | // Check if file exists 20 | const exists = await this.fileExists(filePath); 21 | if (!exists) { 22 | this.logger.log(`File does not exist: ${filePath} - no action needed`); 23 | return; 24 | } 25 | 26 | // Check if file is deletable 27 | const isDeletable = await Promise.race([ 28 | this.isFileDeletable(filePath), 29 | this.delay(5000), // Timeout after 5000ms 30 | ]); 31 | 32 | if (!isDeletable) { 33 | throw new Error(`File is busy or check timed out: ${filePath}`); 34 | } 35 | 36 | await fs.unlink(filePath); // Attempt to delete the file 37 | this.logger.log(`Removed file: ${filePath}`); 38 | return; // Exit if successful 39 | } catch (error) { 40 | retries--; 41 | if (retries > 0 && (error.code === 'EBUSY' || error.message.includes('timed out'))) { 42 | this.logger.warn(`Retrying file removal (${retries} retries left): ${filePath}`); 43 | await this.delay(5000); // Wait before retrying 44 | } else { 45 | this.logger.error(`Error removing file ${filePath}: ${error.message}`); 46 | break; // Exit loop on final failure 47 | } 48 | } 49 | } 50 | } 51 | 52 | private async fileExists(filePath: string): Promise { 53 | try { 54 | await fs.access(filePath); 55 | return true; 56 | } catch { 57 | return false; 58 | } 59 | } 60 | 61 | private async isFileDeletable(filePath: string): Promise { 62 | try { 63 | const fileHandle = await fs.open(filePath, 'r+'); 64 | await fileHandle.close(); 65 | return true; 66 | } catch { 67 | return false; 68 | } 69 | } 70 | 71 | private delay(ms: number): Promise { 72 | return new Promise(resolve => setTimeout(() => resolve(false), ms)); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | 4 | export const CACHE_DIR = path.join(process.cwd(), 'cache'); 5 | 6 | // Ensure the cache directory exists 7 | if (!fs.existsSync(CACHE_DIR)) { 8 | fs.mkdirSync(CACHE_DIR, { recursive: true }); 9 | } -------------------------------------------------------------------------------- /src/jellyfin-auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import axios from 'axios'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | @Injectable() 6 | export class JellyfinAuthService { 7 | constructor(private configService: ConfigService) {} 8 | 9 | async validateCredentials(authHeader: string): Promise { 10 | const jellyfinUrl = this.configService.get('JELLYFIN_URL'); 11 | try { 12 | const response = await axios.get(`${jellyfinUrl}/Users/Me`, { 13 | headers: { 'X-EMBY-TOKEN': authHeader }, 14 | }); 15 | return response.status === 200; 16 | } catch { 17 | return false; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import * as dotenv from 'dotenv'; 4 | import axios from 'axios'; 5 | import { Logger } from '@nestjs/common'; 6 | 7 | async function bootstrap() { 8 | dotenv.config(); 9 | const app = await NestFactory.create(AppModule); 10 | const logger = new Logger('Bootstrap'); 11 | 12 | // Check Jellyfin server connection 13 | const jellyfinUrl = process.env.JELLYFIN_URL; 14 | try { 15 | await axios.get(`${jellyfinUrl}/System/Info/Public`); 16 | logger.log('Successfully connected to Jellyfin server: ' + jellyfinUrl); 17 | } catch (error) { 18 | logger.error( 19 | `Failed to connect to Jellyfin server at ${jellyfinUrl}: ${error.message}`, 20 | ); 21 | // Optionally, you can choose to exit the process if the Jellyfin server is unreachable 22 | // process.exit(1); 23 | } 24 | 25 | await app.listen(3000); 26 | logger.log(`Application is running on: ${await app.getUrl()}`); 27 | } 28 | bootstrap(); 29 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "cache"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | }, 21 | "exclude": ["node_modules", "dist", "cache"] 22 | } 23 | --------------------------------------------------------------------------------