├── .dockerignore ├── src ├── index.ts ├── utils │ └── index.ts ├── jellyfin │ ├── index.ts │ ├── proxy │ │ ├── proxy.ts │ │ └── proxyManager.ts │ └── client.ts └── webserver │ └── index.ts ├── .gitignore ├── .env.example ├── client ├── types.ts ├── index.html ├── client.css └── client.ts ├── Dockerfile ├── DOCKER_DEPLOYMENT.md ├── vite.config.ts ├── encodingSettings.js ├── tsconfig.json ├── package.json ├── docker-compose.yml ├── .github └── workflows │ └── docker-publish.yml └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | dist 3 | node_modules 4 | .env 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | config(); 3 | 4 | import "./jellyfin" 5 | import "./webserver" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | vrchat-jellyfin.code-workspace 5 | project_structure.txt 6 | generateProjectStructure.js 7 | .gitignore 8 | .cursor/* 9 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export default class Utils { 2 | 3 | public static kFormat(num: number): string { 4 | return num > 999 ? (num/1000).toFixed(0) + 'k' : num.toString(); 5 | } 6 | 7 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | JELLYFIN_HOST=https://your-jellyfin-server.com 2 | JELLYFIN_USERNAME=your-jellyfin-username 3 | JELLYFIN_PASSWORD=your-jellyfin-password 4 | 5 | # Optional (have defaults) 6 | AUDIO_BITRATE=192000 7 | VIDEO_BITRATE=5000000 8 | MAX_AUDIO_CHANNELS=2 9 | MAX_HEIGHT=1080 10 | MAX_WIDTH=1920 11 | -------------------------------------------------------------------------------- /client/types.ts: -------------------------------------------------------------------------------- 1 | export type Nested = SubFolder | SubItem 2 | 3 | export interface SubFolder { 4 | itemId: string; 5 | name: string; 6 | subItems: Nested[]; 7 | } 8 | 9 | export interface SubItem { 10 | itemId: string; 11 | name: string; 12 | playable: boolean; 13 | episode?: number; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/jellyfin/index.ts: -------------------------------------------------------------------------------- 1 | import JellyfinClient from "./client"; 2 | 3 | export const client = new JellyfinClient(process.env.JELLYFIN_HOST!, process.env.JELLYFIN_USERNAME!, process.env.JELLYFIN_PASSWORD!); 4 | 5 | client.authenticate().then(async (success) => { 6 | 7 | if (!success) { 8 | console.error("Failed to authenticate with Jellyfin server"); 9 | process.exit(1); 10 | } 11 | 12 | }); 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine3.19 2 | 3 | LABEL maintainer="gurrrrrrett3 " 4 | LABEL version="1.0" 5 | LABEL description="a Jellyfin client for VRChat." 6 | 7 | WORKDIR /app 8 | 9 | COPY package.json /app/package.json 10 | COPY package-lock.json /app/package-lock.json 11 | COPY . /app 12 | 13 | RUN npm install 14 | RUN npm run build 15 | 16 | CMD ["npm", "run", "start:docker"] 17 | 18 | # docker build -t vrchat-jellyfin . -------------------------------------------------------------------------------- /DOCKER_DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Docker Deployment 2 | 3 | This project includes a `docker-compose.yml` file for easy deployment. 4 | 5 | ## Environment Variables 6 | 7 | Required: 8 | - `JELLYFIN_HOST` 9 | - `JELLYFIN_USERNAME` 10 | - `JELLYFIN_PASSWORD` 11 | 12 | Optional (see `.env.example` for defaults): 13 | - `AUDIO_BITRATE`, `VIDEO_BITRATE`, `MAX_AUDIO_CHANNELS`, `MAX_HEIGHT`, `MAX_WIDTH` 14 | 15 | ## Deployment 16 | 17 | 1. Set environment variables 18 | 2. Deploy with Docker Compose 19 | 20 | Service runs on port 4000. 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | 4 | export default defineConfig({ 5 | root: "client", 6 | base: "/assets", 7 | build: { 8 | target: "esnext", 9 | outDir: "../dist/client", 10 | emptyOutDir: true, 11 | sourcemap: true, 12 | rollupOptions: { 13 | output: { 14 | entryFileNames: "[name].js", 15 | chunkFileNames: "[name].js", 16 | assetFileNames: "[name].[ext]", 17 | sourcemapFileNames: "[name].js.map", 18 | }, 19 | } 20 | } 21 | }); -------------------------------------------------------------------------------- /encodingSettings.js: -------------------------------------------------------------------------------- 1 | const encodingSettings = { 2 | audioBitrate: process.env.AUDIO_BITRATE || "128000", // 128kbps 3 | videoBitrate: process.env.VIDEO_BITRATE || "3000000", // 3mbps 4 | maxAudioChannels: process.env.MAX_AUDIO_CHANNELS || "2", // stereo 5 | maxHeight: process.env.MAX_HEIGHT || "720", // 720p 6 | maxWidth: process.env.MAX_WIDTH || "1280", 7 | 8 | // caution changing these values 9 | container: "mp4", // Default container format 10 | videoCodec: "h264", // Default video codec 11 | audioCodec: "aac", // Default audio codec 12 | // Subtitle defaults 13 | SubtitleMethod: "Encode", 14 | SubtitleCodec: "srt" 15 | }; 16 | 17 | module.exports.encodingSettings = encodingSettings; 18 | -------------------------------------------------------------------------------- /src/jellyfin/proxy/proxy.ts: -------------------------------------------------------------------------------- 1 | // src/jellyfin/proxy/proxy.ts 2 | 3 | export default class Proxy { 4 | public readonly id: string = Math.random().toString(36).substring(2, 15); 5 | public readonly createdAt: Date = new Date(); 6 | 7 | constructor(public itemId: string, public options?: ProxyOptions) { } // Modified to accept options 8 | } 9 | 10 | export interface ProxyOptions { 11 | audioBitrate?: number; 12 | videoBitrate?: number; 13 | height?: number; 14 | width?: number; 15 | audioChannels?: number; 16 | videoStreamIndex?: number; 17 | audioStreamIndex?: number; 18 | subtitleStreamIndex?: number; 19 | subtitleMethod?: SubtitleMethod; 20 | } 21 | 22 | export enum SubtitleMethod { 23 | Encode = "Encode", 24 | Embed = "Embed", 25 | External = "External", 26 | Hls = "Hls", 27 | Drop = "Drop", 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // gart's default tsconfig.json 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "./dist/.tsbuildinfo", 5 | "incremental": true, 6 | "target": "ES2020", 7 | "module": "commonjs", 8 | "rootDir": "./src", 9 | "moduleResolution": "Node", 10 | "resolveJsonModule": true, 11 | "allowJs": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "sourceMap": true, 15 | "outDir": "./dist", 16 | "removeComments": true, 17 | "esModuleInterop": true, 18 | "preserveSymlinks": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "strict": true, 21 | "alwaysStrict": true, 22 | "skipLibCheck": true, 23 | "experimentalDecorators": true, 24 | "emitDecoratorMetadata": true 25 | }, 26 | "include": ["src"], 27 | "exclude": ["node_modules", "./dist/**/*", "../dist"], 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vrchat-jellyfin", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "npx tsc && npx vite build", 9 | "start": "node dist/index.js", 10 | "start:docker": "nodemon dist/index.js" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@jellyfin/sdk": "^0.7.0", 17 | "cloneable-readable": "^3.0.0", 18 | "dotenv": "^16.3.1", 19 | "express": "^4.18.2", 20 | "ffmpeg-static": "^5.2.0", 21 | "ffprobe-static": "^3.1.0", 22 | "fluent-ffmpeg": "^2.1.2", 23 | "http-proxy": "^1.18.1", 24 | "node-fetch": "^2.7.0" 25 | }, 26 | "devDependencies": { 27 | "@types/cloneable-readable": "^2.0.3", 28 | "@types/express": "^4.17.21", 29 | "@types/ffprobe-static": "^2.0.3", 30 | "@types/fluent-ffmpeg": "^2.1.24", 31 | "@types/http-proxy": "^1.17.14", 32 | "@types/node-fetch": "^2.6.9", 33 | "nodemon": "^3.1.3", 34 | "typescript": "^5.6.3", 35 | "vite": "^5.3.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/jellyfin/proxy/proxyManager.ts: -------------------------------------------------------------------------------- 1 | // src/jellyfin/proxy/proxyManager.ts 2 | 3 | import Proxy from "./proxy"; 4 | import { ProxyOptions } from "./proxy"; 5 | 6 | export default class ProxyManager { 7 | 8 | public static readonly PROXY_TIMEOUT = 1000 * 60 * 60 * 24; // 24 hours 9 | public static proxies: Map = new Map(); 10 | 11 | public static init() { 12 | setInterval(ProxyManager.cleanProxies, 1000 * 60 * 60); // 1 hour 13 | } 14 | 15 | // Modified createProxy method to accept options 16 | public static createProxy(itemId: string, options?: ProxyOptions) { 17 | const proxy = new Proxy(itemId, options); 18 | ProxyManager.proxies.set(proxy.id, proxy); 19 | return proxy; 20 | } 21 | 22 | public static getProxy(id: string) { 23 | return ProxyManager.proxies.get(id); 24 | } 25 | 26 | public static deleteProxy(id: string) { 27 | return ProxyManager.proxies.delete(id); 28 | } 29 | 30 | public static getProxies() { 31 | return Array.from(ProxyManager.proxies.values()); 32 | } 33 | 34 | public static cleanProxies() { 35 | const now = new Date(); 36 | ProxyManager.proxies.forEach((proxy) => { 37 | if (now.getTime() - proxy.createdAt.getTime() > ProxyManager.PROXY_TIMEOUT) { 38 | ProxyManager.deleteProxy(proxy.id); 39 | } 40 | }); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | vrchat-jellyfin: 5 | image: ghcr.io/gurrrrrrett3/vrchat-jellyfin:master 6 | container_name: vrchat-jellyfin 7 | restart: unless-stopped 8 | ports: 9 | - "4000:4000" 10 | environment: 11 | # Required Jellyfin connection settings 12 | - JELLYFIN_HOST=${JELLYFIN_HOST:?} 13 | - JELLYFIN_USERNAME=${JELLYFIN_USERNAME:?} 14 | - JELLYFIN_PASSWORD=${JELLYFIN_PASSWORD:?} 15 | 16 | # Optional audio/video quality settings with defaults 17 | - AUDIO_BITRATE=${AUDIO_BITRATE:-192000} 18 | - VIDEO_BITRATE=${VIDEO_BITRATE:-5000000} 19 | - MAX_AUDIO_CHANNELS=${MAX_AUDIO_CHANNELS:-2} 20 | - MAX_HEIGHT=${MAX_HEIGHT:-1080} 21 | - MAX_WIDTH=${MAX_WIDTH:-1920} 22 | healthcheck: 23 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4000/"] 24 | interval: 30s 25 | timeout: 10s 26 | retries: 3 27 | start_period: 40s 28 | labels: 29 | # Note: For Traefik deployments, add these labels to protect the interface but not video streams: 30 | # - "traefik.http.routers.vrchat-jellyfin.rule=Host(`your-domain.com`) && !PathPrefix(`/v/`)" 31 | # - "traefik.http.routers.vrchat-jellyfin.middlewares=auth@file,secureHeaders@file" 32 | # - "traefik.http.routers.vrchat-jellyfin-video.rule=Host(`your-domain.com`) && PathPrefix(`/v/`)" 33 | # - "traefik.http.routers.vrchat-jellyfin-video.middlewares=secureHeaders@file" 34 | -------------------------------------------------------------------------------- /client/client.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Roboto", sans-serif; 3 | background-color: #050505; 4 | color: #fff; 5 | } 6 | 7 | .folder { 8 | border: 1px solid #fff; 9 | border-radius: 5px; 10 | padding: 10px; 11 | margin: 10px; 12 | background-color: #1a1a1a; 13 | } 14 | 15 | .subfolder { 16 | border: 1px solid #fff; 17 | border-radius: 5px; 18 | padding: 10px; 19 | margin: 10px; 20 | background-color: #2a2a2a; 21 | } 22 | 23 | .subitem { 24 | border: 1px solid #fff; 25 | border-radius: 5px; 26 | padding: 10px; 27 | margin: 10px; 28 | background-color: #3a3a3a; 29 | } 30 | 31 | .title-container { 32 | display: flex; 33 | justify-content: space-between; 34 | border-radius: 5px; 35 | } 36 | 37 | .title-container:hover { 38 | background-color: #4a4a4a; 39 | } 40 | 41 | .modal { 42 | position: fixed; 43 | top: 50%; 44 | left: 50%; 45 | transform: translate(-50%, -50%); 46 | background-color: #fff; 47 | padding: 20px; 48 | border: 1px solid #ccc; 49 | z-index: 1001; 50 | box-shadow: 0 5px 15px rgba(0,0,0,0.3); 51 | border-radius: 8px; 52 | max-width: 90%; 53 | max-height: 90%; 54 | overflow-y: auto; 55 | } 56 | 57 | .overlay { 58 | position: fixed; 59 | top: 0; 60 | left: 0; 61 | width: 100%; 62 | height: 100%; 63 | background-color: rgba(0,0,0,0.5); 64 | z-index: 1000; 65 | } 66 | 67 | /* Optional Enhancements */ 68 | .modal h3 { 69 | margin-top: 0; 70 | } 71 | 72 | .modal ul { 73 | list-style: none; 74 | padding: 0; 75 | } 76 | 77 | .modal li { 78 | margin-bottom: 10px; 79 | } 80 | 81 | .modal button { 82 | padding: 8px 12px; 83 | border: none; 84 | border-radius: 4px; 85 | background-color: #007BFF; 86 | color: #fff; 87 | cursor: pointer; 88 | transition: background-color 0.3s; 89 | } 90 | 91 | .modal button:hover { 92 | background-color: #0056b3; 93 | } -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | # schedule: 5 | # - cron: '45 4 * * *' 6 | push: 7 | branches: [ "master" ] 8 | # Publish semver tags as releases. 9 | tags: [ 'v*.*.*' ] 10 | pull_request: 11 | branches: [ "master" ] 12 | 13 | env: 14 | # Use docker.io for Docker Hub if empty 15 | REGISTRY: ghcr.io 16 | # github.repository as / 17 | IMAGE_NAME: ${{ github.repository }} 18 | 19 | 20 | jobs: 21 | build: 22 | 23 | runs-on: ubuntu-latest 24 | permissions: 25 | contents: read 26 | packages: write 27 | # This is used to complete the identity challenge 28 | # with sigstore/fulcio when running outside of PRs. 29 | id-token: write 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | 35 | # Install the cosign tool except on PR 36 | # https://github.com/sigstore/cosign-installer 37 | - name: Install cosign 38 | if: github.event_name != 'pull_request' 39 | uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 40 | with: 41 | cosign-release: 'v2.2.4' 42 | 43 | # Set up BuildKit Docker container builder to be able to build 44 | # multi-platform images and export cache 45 | # https://github.com/docker/setup-buildx-action 46 | - name: Set up Docker Buildx 47 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 48 | 49 | # Login against a Docker registry except on PR 50 | # https://github.com/docker/login-action 51 | - name: Log into registry ${{ env.REGISTRY }} 52 | if: github.event_name != 'pull_request' 53 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 54 | with: 55 | registry: ${{ env.REGISTRY }} 56 | username: ${{ github.actor }} 57 | password: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | # Extract metadata (tags, labels) for Docker 60 | # https://github.com/docker/metadata-action 61 | - name: Extract Docker metadata 62 | id: meta 63 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 64 | with: 65 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 66 | 67 | # Build and push Docker image with Buildx (don't push on PR) 68 | # https://github.com/docker/build-push-action 69 | - name: Build and push Docker image 70 | id: build-and-push 71 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 72 | with: 73 | context: . 74 | push: ${{ github.event_name != 'pull_request' }} 75 | tags: ${{ steps.meta.outputs.tags }} 76 | labels: ${{ steps.meta.outputs.labels }} 77 | cache-from: type=gha 78 | cache-to: type=gha,mode=max 79 | 80 | # Sign the resulting Docker image digest except on PRs. 81 | # This will only write to the public Rekor transparency log when the Docker 82 | # repository is public to avoid leaking data. If you would like to publish 83 | # transparency data even for private images, pass --force to cosign below. 84 | # https://github.com/sigstore/cosign 85 | - name: Sign the published Docker image 86 | if: ${{ github.event_name != 'pull_request' }} 87 | env: 88 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 89 | TAGS: ${{ steps.meta.outputs.tags }} 90 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 91 | # This step uses the identity token to provision an ephemeral certificate 92 | # against the sigstore community Fulcio instance. 93 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 94 | -------------------------------------------------------------------------------- /src/webserver/index.ts: -------------------------------------------------------------------------------- 1 | // src/webserver/index.ts 2 | 3 | import express from "express"; 4 | import http from "http"; 5 | import ProxyManager from "../jellyfin/proxy/proxyManager"; 6 | import { client } from "../jellyfin"; 7 | import { ProxyOptions, SubtitleMethod } from "../jellyfin/proxy/proxy"; 8 | 9 | const app = express(); 10 | 11 | app.use(express.json()); 12 | app.use(express.urlencoded({ extended: true })); 13 | 14 | app.use("/assets", express.static("dist/client")); 15 | 16 | // Serve the index.html file from the correct directory 17 | app.get("/", (req, res) => { 18 | res.sendFile("index.html", { root: "dist/client" }); 19 | }); 20 | 21 | // Endpoint to fetch playable media 22 | app.get("/i", async (req, res) => { 23 | const items = await client.getPlayableMedia(); 24 | res.json(items); 25 | }); 26 | 27 | // Endpoint to create a proxy with subtitle options 28 | app.post("/i/:id", async (req, res) => { 29 | const itemId = req.params.id; 30 | const { subtitleStreamIndex } = req.body; 31 | 32 | const proxyOptions: ProxyOptions = {}; 33 | 34 | if (subtitleStreamIndex != null) { 35 | proxyOptions.subtitleStreamIndex = subtitleStreamIndex; 36 | proxyOptions.subtitleMethod = SubtitleMethod.Encode; 37 | } 38 | 39 | const proxy = ProxyManager.createProxy(itemId, proxyOptions); 40 | res.json({ 41 | id: proxy.id, 42 | }); 43 | }); 44 | 45 | // Endpoint to fetch subtitle streams 46 | app.get("/subtitles/:itemId", async (req, res) => { 47 | const itemId = req.params.itemId; 48 | try { 49 | const subtitleStreams = await client.getSubtitleStreams(itemId); 50 | res.json({ subtitleStreams }); 51 | } catch (error) { 52 | console.error('Error fetching subtitle streams:', error); 53 | res.status(500).json({ error: 'Failed to fetch subtitle streams.' }); 54 | } 55 | }); 56 | 57 | // Endpoint to stream video with subtitle options 58 | app.get("/v/:id", async (req, res) => { 59 | const proxy = ProxyManager.getProxy(req.params.id); 60 | 61 | if (!proxy) { 62 | res.status(404).send("Proxy not found, is your url valid?"); 63 | return; 64 | } 65 | 66 | const itemId = proxy.itemId; 67 | const options = proxy.options; 68 | 69 | try { 70 | const response = await client.getVideoStream(itemId!, options); 71 | if (!response.ok || !response.body) { 72 | const errorText = await response.text(); 73 | console.error(`Jellyfin stream fetch failed:`, { 74 | status: response.status, 75 | statusText: response.statusText, 76 | headers: Object.fromEntries(response.headers.entries()), 77 | bodySnippet: errorText.slice(0, 200) 78 | }); 79 | res.status(502).send("Failed to fetch video stream from Jellyfin."); 80 | return; 81 | } 82 | // Set headers from Jellyfin response 83 | for (const [key, value] of response.headers.entries()) { 84 | if (key.toLowerCase() === 'transfer-encoding') continue; // skip problematic headers 85 | res.setHeader(key, value); 86 | } 87 | response.body.pipe(res); 88 | console.log(`Piping stream to client with options:`, options); 89 | } catch (err) { 90 | console.error('Error in /v/:id route:', err); 91 | res.status(500).send('Internal server error while proxying video stream.'); 92 | } 93 | }); 94 | 95 | // Start the server after Jellyfin client authentication 96 | client.authenticate().then((success) => { 97 | if (!success) { 98 | console.error("Failed to authenticate with Jellyfin server"); 99 | process.exit(1); 100 | } 101 | 102 | const server = http.createServer(app); 103 | const port = parseInt(process.env.WEBSERVER_PORT || "4000"); 104 | 105 | server.listen(port, () => { 106 | console.log(`Webserver listening on port ${port}`); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vrchat-jellyfin 2 | 3 | [![Docker](https://github.com/gurrrrrrett3/vrchat-jellyfin/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/gurrrrrrett3/vrchat-jellyfin/actions/workflows/docker-publish.yml) 4 | 5 | a Jellyfin client designed for VRChat 6 | 7 | > [!IMPORTANT] 8 | > 9 | > ### Incomplete Project 10 | > 11 | > While this project is functional, it is not yet complete. The end goal is to have a link that can be pasted into the vrchat client, then use the jellyfin cast feature to control the player like a chromecast. This is not yet implemented, and the current implementation is a workaround. 12 | 13 | handles requesting media from jellyfin in a format that can be played in vrchat, as well as proxying urls to bypass the risk of sharing a jellyfin api key 14 | 15 | ## Tips 16 | - If the player has a switch between **Video** and **Stream**, use **Stream** 17 | - I've personally had better luck with the Unity video player over the AVPro one, but both should work 18 | - If you change the video encoding settings, 720p (the default) works best for most devices. I wouldn't go above 1080p unless you have a smaller group or high upload bandwitdh. 19 | 20 | ## Supported Platforms 21 | 22 | - [x] VRChat 23 | - [x] Chillout VR 24 | - [x] Resonite 25 | 26 | *These are just platforms that have been tested, feel free to PR with other platforms if you've tested on them* 27 | 28 | ## Docker 29 | 30 | A docker image is provided for easy deployment: 31 | 32 | ### Running 33 | 34 | Docker Compose (recommended): 35 | 36 | ```yaml 37 | version: '3' 38 | services: 39 | vrchat-jellyfin: 40 | image: ghcr.io/gurrrrrrett3/vrchat-jellyfin:master 41 | container_name: vrchat-jellyfin 42 | restart: unless-stopped 43 | ports: 44 | - 4000:4000 45 | environment: 46 | JELLYFIN_HOST: 47 | JELLYFIN_USERNAME: 48 | JELLYFIN_PASSWORD: 49 | AUDIO_BITRATE: 128000 50 | VIDEO_BITRATE: 3000000 51 | MAX_AUDIO_CHANNELS: 2 52 | MAX_HEIGHT: 720 53 | MAX_WIDTH: 1280 54 | ``` 55 | 56 | Docker CLI: 57 | 58 | ```bash 59 | docker run -d \ 60 | --name vrchat-jellyfin \ 61 | --restart unless-stopped \ 62 | -p 4000:4000 \ 63 | -e JELLYFIN_HOST= \ 64 | -e JELLYFIN_USERNAME= \ 65 | -e JELLYFIN_PASSWORD= \ 66 | -e AUDIO_BITRATE: 128000 \ 67 | -e VIDEO_BITRATE: 3000000 \ 68 | -e MAX_HEIGHT: 720 \ 69 | -e MAX_WIDTH: 1280 \ 70 | ghcr.io/gurrrrrrett3/vrchat-jellyfin:master 71 | ``` 72 | 73 | ## Installation (No Docker) 74 | 75 | Install Node.js and npm 76 | 77 | ```bash 78 | npm install 79 | npm run build 80 | ``` 81 | 82 | Rename the `.env.example` file to `.env` and fill in the required fields. 83 | 84 | It's reccommended to use a process manager like pm2 to keep it running: 85 | 86 | ```bash 87 | pm2 start dist/index.js --name vrc-jellyfin 88 | pm2 save 89 | ``` 90 | Make sure to do `pm2 startup` if you haven't already so it autostarts 91 | 92 | ## Sample Caddy config 93 | 94 | Caddy can be used to allow streaming to the web over HTTPS. 95 | 96 | ``` 97 | media.example.com { 98 | # VRChat (AVPro) doesn't like tls1.3 99 | tls { 100 | # Due to a lack of DHE support, you -must- use an ECDSA cert to support IE 11 on Windows 7 101 | ciphers TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 102 | } 103 | 104 | header Strict-Transport-Security "max-age=63072000" 105 | header X-Content-Type-Options nosniff 106 | 107 | header Content-Security-Policy " 108 | default-src 'self'; 109 | style-src 'self'; 110 | script-src 'self'; 111 | font-src 'self'; 112 | img-src data: 'self'; 113 | form-action 'self'; 114 | connect-src 'self'; 115 | frame-ancestors 'none'; 116 | " 117 | 118 | # Videos don't require sign-in, just the interface 119 | @auth { 120 | not path /v/* 121 | } 122 | 123 | basicauth @auth { 124 | vrcuser MY_PASSWORD_HASH # Password can be obtained with `caddy hash-password` 125 | } 126 | 127 | reverse_proxy 1.2.3.4:4000 { 128 | } 129 | } 130 | ``` 131 | 132 | ## Usage 133 | 134 | Go to the web interface (default port is 4000), select media, and copy the link. Paste the link into the vrchat client to play the media. 135 | 136 | ## Progress 137 | 138 | - [x] Jellyfin proxy 139 | - [x] Transcoding 140 | - [ ] Subtitle Baking 141 | - [ ] Audio track selection 142 | - [x] Subtitle track selection 143 | - [x] Temp Web interface 144 | - [ ] Support for the jellyfin cast api 145 | - [ ] Video Stream generation (splash screen with instructions, etc) 146 | - [x] Docker 147 | - [ ] Seeking (not sure if this is possible) 148 | -------------------------------------------------------------------------------- /src/jellyfin/client.ts: -------------------------------------------------------------------------------- 1 | // src/jellyfin/client.ts 2 | 3 | import { Api, Jellyfin } from "@jellyfin/sdk"; 4 | import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api/user-views-api"; 5 | import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api"; 6 | import fetch from "node-fetch"; 7 | import { resolve } from "path"; 8 | import { ProxyOptions, SubtitleMethod } from "./proxy/proxy"; // Added import 9 | 10 | const encodingSettings: Record = require(resolve("./encodingSettings.js")).encodingSettings; 11 | 12 | export default class JellyfinClient { 13 | public static readonly APP_NAME = "Jellyfin VRChat Proxy (jellyfin-vrchat)"; 14 | 15 | private _sdk: Jellyfin; 16 | private _api: Api; 17 | 18 | public userId?: string; 19 | 20 | constructor(public serverUrl: string, private username: string, private password: string) { 21 | // Ensure the serverUrl does not end with a slash 22 | this.serverUrl = serverUrl.replace(/\/+$/, ""); 23 | 24 | this._sdk = new Jellyfin({ 25 | clientInfo: { 26 | name: JellyfinClient.APP_NAME, 27 | version: process.env.npm_package_version || "0.0.0", 28 | }, 29 | deviceInfo: { 30 | name: `${JellyfinClient.APP_NAME} ${process.env.npm_package_version || "0.0.0"} | ${process.platform} | ${process.arch}`, 31 | id: "jellyfin-vrchat", 32 | }, 33 | }); 34 | 35 | this._api = this._sdk.createApi(this.serverUrl); 36 | } 37 | 38 | public get apiKey() { 39 | return this._api.accessToken; 40 | } 41 | 42 | public async authenticate() { 43 | const auth = await this._api.authenticateUserByName(this.username, this.password).catch((e) => { 44 | console.error("Failed to authenticate with Jellyfin, check your username and password", e); 45 | process.exit(1); 46 | }); 47 | 48 | this.userId = auth.data.User?.Id; 49 | return auth.status == 200; 50 | } 51 | 52 | public async getPlayableMedia() { 53 | const viewsResponse = await getUserViewsApi(this._api).getUserViews({ 54 | userId: this.userId!, 55 | }); 56 | 57 | const views = viewsResponse.data.Items || []; 58 | const items = await Promise.all( 59 | views.map(async (view) => { 60 | const itemsResponse = await this.getSubItemsRecursive(view.Id!); 61 | 62 | return { 63 | itemId: view.Id, 64 | name: view.Name, 65 | subItems: itemsResponse, 66 | }; 67 | }) 68 | ).catch((e) => { 69 | console.error("Failed to get playable media", e); 70 | }); 71 | return items; 72 | } 73 | 74 | public async getSubItems(parent: string) { 75 | const itemsResponse = await getItemsApi(this._api).getItems({ 76 | userId: this.userId!, 77 | parentId: parent, 78 | }); 79 | 80 | return itemsResponse.data.Items; 81 | } 82 | 83 | public async getSubItemsRecursive(parent: string): Promise { 84 | const items = await this.getSubItems(parent); 85 | 86 | if (!items || items.length == 0) { 87 | return []; 88 | } 89 | 90 | const subItems = await Promise.all( 91 | items.map(async (item) => { 92 | if (!item.IsFolder) { 93 | return { 94 | itemId: item.Id!, 95 | name: item.Name || undefined, 96 | playable: item.MediaType == "Video", 97 | episode: item.IndexNumber || undefined, 98 | }; 99 | } 100 | 101 | return { 102 | itemId: item.Id!, 103 | name: item.Name || undefined, 104 | subItems: await this.getSubItemsRecursive(item.Id!), 105 | }; 106 | }) 107 | ); 108 | 109 | return subItems; 110 | } 111 | 112 | public async getVideoStream(itemId: string, options?: ProxyOptions) { 113 | const url = new URL(`${this.serverUrl}/Videos/${itemId}/stream`); 114 | url.searchParams.set("api_key", this.apiKey); 115 | 116 | // Default encoding settings 117 | url.searchParams.set("container", "mp4"); 118 | url.searchParams.set("audioCodec", "aac"); 119 | url.searchParams.set("videoCodec", "h264"); 120 | 121 | // Override encoding settings if provided 122 | for (const [k, v] of Object.entries(encodingSettings)) { 123 | url.searchParams.set(k, v); 124 | } 125 | 126 | // Include subtitle parameters if provided 127 | if (options?.subtitleStreamIndex !== undefined) { 128 | url.searchParams.set("SubtitleMethod", options.subtitleMethod || SubtitleMethod.Encode); 129 | url.searchParams.set("SubtitleCodec", "srt"); // Adjust the codec if necessary 130 | url.searchParams.set("SubtitleStreamIndex", options.subtitleStreamIndex.toString()); 131 | } 132 | 133 | console.log(`Requesting video stream from ${url.toString()}`); 134 | 135 | const response = await fetch(url.toString(), { 136 | headers: { 137 | "User-Agent": JellyfinClient.APP_NAME, 138 | }, 139 | }); 140 | 141 | return response; 142 | } 143 | 144 | // New method to fetch available subtitle streams 145 | public async getSubtitleStreams(itemId: string) { 146 | const url = `${this.serverUrl}/Items/${itemId}?Fields=MediaStreams&api_key=${this.apiKey}`; 147 | const response = await fetch(url, { 148 | headers: { 149 | "User-Agent": JellyfinClient.APP_NAME, 150 | }, 151 | }); 152 | const data = await response.json(); 153 | const subtitleStreams = data.MediaStreams.filter((stream: any) => stream.Type === "Subtitle"); 154 | return subtitleStreams; 155 | } 156 | 157 | public getRandomItem(items: NestedItem[]): NestedItem | undefined { 158 | if (items.length == 0) { 159 | return undefined; 160 | } 161 | 162 | const item = items[Math.floor(Math.random() * items.length)]; 163 | if (item.subItems && item.subItems.length > 0) { 164 | return this.getRandomItem(item.subItems); 165 | } 166 | 167 | return item; 168 | } 169 | } 170 | 171 | interface NestedItem { 172 | itemId: string; 173 | name?: string; 174 | subItems?: NestedItem[]; 175 | playable?: boolean; 176 | episode?: number; 177 | } 178 | -------------------------------------------------------------------------------- /client/client.ts: -------------------------------------------------------------------------------- 1 | // client/client.ts 2 | 3 | import { Nested, SubFolder, SubItem } from "./types"; 4 | 5 | (async () => { 6 | 7 | class Ui { 8 | public static renderItems(views: SubFolder[]) { 9 | const container = document.getElementById("container")!; 10 | 11 | views.forEach(item => { 12 | const view = document.createElement("div"); 13 | view.classList.add("folder"); 14 | 15 | const titleContainer = document.createElement("div"); 16 | titleContainer.classList.add("title-container"); 17 | 18 | const title = document.createElement("h1"); 19 | title.textContent = item.name; 20 | 21 | titleContainer.appendChild(title); 22 | view.appendChild(titleContainer); 23 | 24 | item.subItems.forEach(subItem => { 25 | Ui.renderSubItem(subItem, view); 26 | }); 27 | 28 | titleContainer.addEventListener("click", () => { 29 | view.classList.toggle("open"); 30 | view.childNodes.forEach(node => { 31 | if (node instanceof HTMLElement) { 32 | if (node.classList.contains("subfolder") || node.classList.contains("subitem")) { 33 | node.hidden = !view.classList.contains("open"); 34 | } 35 | } 36 | }); 37 | }); 38 | 39 | container.appendChild(view); 40 | }); 41 | } 42 | 43 | public static renderSubItem(items: Nested, parent: HTMLElement) { 44 | if ("subItems" in items) { 45 | const container = document.createElement("div"); 46 | container.classList.add("subfolder"); 47 | container.hidden = true; 48 | 49 | const titleContainer = document.createElement("div"); 50 | titleContainer.classList.add("title-container"); 51 | 52 | const title = document.createElement("h2"); 53 | title.textContent = items.name; 54 | 55 | titleContainer.appendChild(title); 56 | container.appendChild(titleContainer); 57 | 58 | titleContainer.addEventListener("click", () => { 59 | container.classList.toggle("open"); 60 | container.childNodes.forEach(node => { 61 | if (node instanceof HTMLElement) { 62 | if (node.classList.contains("subfolder") || node.classList.contains("subitem")) { 63 | node.hidden = !container.classList.contains("open"); 64 | } 65 | } 66 | }); 67 | }); 68 | 69 | items.subItems.forEach(subItem => { 70 | this.renderSubItem(subItem, container); 71 | }); 72 | 73 | parent.appendChild(container); 74 | } else { 75 | const subItem = items as SubItem; 76 | const item = document.createElement("div"); 77 | item.classList.add("subitem"); 78 | item.hidden = true; 79 | 80 | const titleContainer = document.createElement("div"); 81 | titleContainer.classList.add("title-container"); 82 | 83 | const title = document.createElement("h2"); 84 | title.textContent = `${subItem.episode ? `Episode ${subItem.episode}: ` : ""} ${subItem.name}`; 85 | 86 | titleContainer.appendChild(title); 87 | item.appendChild(titleContainer); 88 | 89 | titleContainer.addEventListener("click", async () => { 90 | console.log("Title container clicked."); 91 | 92 | if (title.hasAttribute("data-url")) { 93 | console.log("Data URL already set. Exiting click handler."); 94 | return; 95 | } 96 | 97 | try { 98 | // Fetch available subtitle streams 99 | console.log("Fetching subtitle streams..."); 100 | const subtitleStreams = await fetchSubtitleStreams(subItem.itemId); 101 | console.log("Subtitle streams fetched:", subtitleStreams); 102 | 103 | let selectedSubtitleIndex: number | null = null; 104 | 105 | if (subtitleStreams && subtitleStreams.length > 0) { 106 | // Present subtitle options to the user 107 | console.log("Showing subtitle selection dialog..."); 108 | selectedSubtitleIndex = await showSubtitleSelectionDialog(subtitleStreams); 109 | console.log("User selected subtitle index:", selectedSubtitleIndex); 110 | } else { 111 | console.log("No subtitles available or subtitleStreams is empty."); 112 | } 113 | 114 | // Request the proxy with subtitle options 115 | console.log("Requesting proxy with subtitle options..."); 116 | const res = await fetch(`/i/${subItem.itemId}`, { 117 | method: 'POST', 118 | headers: { 119 | 'Content-Type': 'application/json', 120 | }, 121 | body: JSON.stringify({ 122 | subtitleStreamIndex: selectedSubtitleIndex, 123 | }), 124 | }); 125 | if (!res.ok) { 126 | throw new Error(`Failed to create proxy: ${res.status} ${res.statusText}`); 127 | } 128 | const data = await res.json(); 129 | const proxyId = data.id; 130 | 131 | const path = `/v/${proxyId}`; 132 | const fullUrl = `${window.location.origin}${path}`; 133 | 134 | // Copy to clipboard or display link 135 | if (navigator.clipboard) { 136 | await navigator.clipboard.writeText(fullUrl); 137 | console.log("Copied URL to clipboard:", fullUrl); 138 | 139 | title.textContent = `${title.textContent} - Copied!`; 140 | setTimeout(() => { 141 | title.textContent = `${title.textContent!.replace(" - Copied!", "")}`; 142 | }, 3000); 143 | } else { 144 | title.innerHTML = `${title.textContent} - Open`; 145 | } 146 | 147 | title.setAttribute("data-url", fullUrl); 148 | console.log("Proxy URL set and copied to clipboard:", fullUrl); 149 | } catch (error) { 150 | console.error("Error in click handler:", error); 151 | alert('An error occurred while processing your request. Please try again.'); 152 | } 153 | }); 154 | 155 | parent.appendChild(item); 156 | } 157 | } 158 | } 159 | 160 | // Function to fetch subtitle streams from the server 161 | async function fetchSubtitleStreams(itemId: string): Promise { 162 | console.log(`Fetching subtitle streams for itemId: ${itemId}`); 163 | try { 164 | const res = await fetch(`/subtitles/${itemId}`); 165 | if (!res.ok) { 166 | throw new Error(`Failed to fetch subtitle streams: ${res.status} ${res.statusText}`); 167 | } 168 | const data = await res.json(); 169 | console.log('Subtitle streams data:', data); 170 | return data.subtitleStreams; 171 | } catch (error) { 172 | console.error('Error fetching subtitle streams:', error); 173 | return []; 174 | } 175 | } 176 | 177 | // Function to show subtitle selection dialog 178 | async function showSubtitleSelectionDialog(subtitleStreams: any[]): Promise { 179 | console.log('showSubtitleSelectionDialog called with:', subtitleStreams); 180 | return new Promise((resolve) => { 181 | // Create a modal dialog 182 | const modal = document.createElement('div'); 183 | modal.classList.add('modal'); 184 | 185 | const overlay = document.createElement('div'); 186 | overlay.classList.add('overlay'); 187 | 188 | const title = document.createElement('h3'); 189 | title.textContent = 'Select Subtitle Track'; 190 | 191 | const list = document.createElement('ul'); 192 | 193 | subtitleStreams.forEach((stream) => { 194 | const listItem = document.createElement('li'); 195 | const button = document.createElement('button'); 196 | button.textContent = stream.Language || `Subtitle ${stream.Index}`; 197 | button.addEventListener('click', () => { 198 | document.body.removeChild(overlay); 199 | document.body.removeChild(modal); 200 | resolve(stream.Index); 201 | }); 202 | listItem.appendChild(button); 203 | list.appendChild(listItem); 204 | }); 205 | 206 | const noSubtitlesButton = document.createElement('button'); 207 | noSubtitlesButton.textContent = 'No Subtitles'; 208 | noSubtitlesButton.addEventListener('click', () => { 209 | document.body.removeChild(overlay); 210 | document.body.removeChild(modal); 211 | resolve(null); 212 | }); 213 | 214 | modal.appendChild(title); 215 | modal.appendChild(list); 216 | modal.appendChild(noSubtitlesButton); 217 | 218 | document.body.appendChild(overlay); 219 | document.body.appendChild(modal); 220 | 221 | console.log('Modal and overlay added to DOM'); 222 | }); 223 | } 224 | 225 | // Fetch initial items and render UI 226 | console.log("Fetching initial items..."); 227 | try { 228 | const res = await fetch("/i"); 229 | if (!res.ok) { 230 | throw new Error(`Failed to fetch initial items: ${res.status} ${res.statusText}`); 231 | } 232 | const items = await res.json(); 233 | console.log("Initial items fetched:", items); 234 | Ui.renderItems(items); 235 | } catch (error) { 236 | console.error('Error fetching initial items:', error); 237 | alert('Failed to load media items. Please try again later.'); 238 | } 239 | 240 | })().catch(error => console.error('Error in client script:', error)); 241 | --------------------------------------------------------------------------------