├── .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 | [](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 |
--------------------------------------------------------------------------------