feat: Loading source plugins

This commit is contained in:
Aleksi Lassila
2024-12-03 19:03:00 +02:00
parent ec3b19288f
commit ffc4197832
38 changed files with 30028 additions and 288 deletions

View File

@@ -23,7 +23,7 @@ module.exports = {
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
parser: '@typescript-eslint/parser'
}
}
],
@@ -31,6 +31,6 @@ module.exports = {
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'prefer-const': 'warn',
'prefer-const': 'warn'
}
};

View File

@@ -60,8 +60,8 @@ jobs:
- name: Add tag
run: docker tag ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:latest ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:${{env.TAG}}
# - name: Add tag
# run: docker tag ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:latest ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:${{github.sha}}
# - name: Add tag
# run: docker tag ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:latest ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:${{github.sha}}
# - name: Tag with Git SHA
# run: docker tag ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:latest ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:${{github.sha}}

View File

@@ -37,7 +37,7 @@ Local Library & Playback
- Create requests for movies & TV shows in Radarr & Sonarr
- Manage local library files
- ~~View Radarr & Sonarr stats (disk space, items, etc.)~~
For a list of planned features & known bugs, see [Reiverr Taskboard](https://github.com/users/aleksilassila/projects/5).
# Installation
@@ -104,12 +104,12 @@ the app using Tizen Studio or the CLI, following roughly these steps:
2. Download either Tizen Studio or the CLI tools from the [official website](https://developer.tizen.org/development/tizen-studio/download)
3. [Connect Tizen Studio to your TV](https://developer.samsung.com/smarttv/develop/getting-started/using-sdk/tv-device.html)
4. Use the following command to build and install the app on your TV:\
\
`npm run build:tizen;C:\tizen-studio\tools\ide\bin\tizen.bat build-web -- tizen;C:\tizen-studio\tools\ide\bin\tizen.bat package -t wgt -o .\tizen -- .\tizen\.buildResult\;C:\tizen-studio\tools\ide\bin\tizen.bat install -n .\tizen\Reiverr.wgt -t QE55Q64TAUXXC`.\
\
You may need to replace the paths for Tizen Studio tools according to your installation location, as well as the device identifier, which was in my case the tv model number.\
\
Alternatively, you can open the project in Tizen Studio and install the project on a device from there. For more instructions on run a project on a device, see [here](https://docs.tizen.org/application/web/get-started/tv/first-samsung-tv-app/#run-on-a-target-device).
\
`npm run build:tizen;C:\tizen-studio\tools\ide\bin\tizen.bat build-web -- tizen;C:\tizen-studio\tools\ide\bin\tizen.bat package -t wgt -o .\tizen -- .\tizen\.buildResult\;C:\tizen-studio\tools\ide\bin\tizen.bat install -n .\tizen\Reiverr.wgt -t QE55Q64TAUXXC`.\
\
You may need to replace the paths for Tizen Studio tools according to your installation location, as well as the device identifier, which was in my case the tv model number.\
\
Alternatively, you can open the project in Tizen Studio and install the project on a device from there. For more instructions on run a project on a device, see [here](https://docs.tizen.org/application/web/get-started/tv/first-samsung-tv-app/#run-on-a-target-device).
If you have any questions or run into issues or bugs, you can start a [discussion](https://github.com/aleksilassila/reiverr/discussions),
open an [issue](https://github.com/aleksilassila/reiverr/issues)
@@ -134,7 +134,6 @@ To get most out of Reiverr, it is recommended to connect to TMDB, Jellyfin, Rada
> Hint: Radarr & Sonarr API keys can be found under Settings > General in their respective web UIs.
> Jellyfin API key is located under Administration > Dashboard > Advanced > API Keys in the Jellyfin Web UI.
# Contributing
Unlike the most Servarr projects, this one is built with Svelte and NestJS. If you haven't used Svelte before,
@@ -175,6 +174,7 @@ To get started with development:
5. To start the backend: `npm run --prefix backend start:dev`
## Notes
- 2.0 will primarily target TVs, so the UI must be optimized with TVs in mind. This means larger text, buttons, etc.
[Design Guide for Android TV](https://developer.android.com/design/ui/tv/guides/styles/layouts) is a good resource
for how to design for TVs.
@@ -185,6 +185,7 @@ To get started with development:
css `gap` property. You can use the `space-x` and `space-y` classes from Tailwind CSS to achieve the same effect.
## Useful resources
- https://developer.themoviedb.org/reference
- https://api.jellyfin.org/
- https://sonarr.tv/docs/api/

0
backend/.eslintrc.js Normal file → Executable file
View File

View File

@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -59,10 +59,12 @@ $ npm run test:cov
```
## Structure
- Routes
- series -> shows
### Users
- PUT /users/{id}
- GET /users
- GET /users/{id}
@@ -70,30 +72,36 @@ $ npm run test:cov
- POST /users
### Users > Library
- GET /users/{userId}/library
- POST /users/{userId}/library/{tmdbId}
- DELETE /users/{userId}/library/{tmdbId}
### Users > PlayState
- GET /users/{userId}/playstate/movies/{tmdbId}
- GET /users/{userId}/playstate/series/{tmdbId}/{season}/{episode}
- PUT /users/{userId}/playstate/movies/{tmdbId}
- PUT /users/{userId}/playstate/series/{tmdbId}/{season}/{episode}
### TMDB / Metadata
- GET /movies/{tmdbId}
- GET /movies/{tmdbId}/similar
- GET /movies/recommendations
- User-specific
- GET /series/{tmdbId}
- (GET /series/{tmdbId}/season/{season})
- GET /series/{tmdbId}/season/{season}/episode/{episode}
- GET /series/{tmdbId}/similar
- GET /series/recommendations
- User-specific
- GET /people/{tmdbPersonId}
### TMDB / Discovery
- GET /discover/movies
- User-specific
- GET /discover/series
- User-specific
- (GET /discover/popular)
- GET /discover/popular/movies
- GET /discover/popular/series
@@ -105,10 +113,11 @@ $ npm run test:cov
- GET /discover/genre/{genre}/series
### Sources
- GET /sources
- (GET /sources/{sourceId})
- GET /sources/{sourceId}/movies
- if indexable
- if indexable
- GET /sources/{sourceId}/movies/{tmdbId}
- GET /sources/{sourceId}/movies/{tmdbId}/stream
- GET /sources/{sourceId}/series

View File

@@ -1,20 +1,27 @@
import { MigrationInterface, QueryRunner } from "typeorm";
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProfilePicture1718397928862 implements MigrationInterface {
name = 'AddProfilePicture1718397928862'
name = 'AddProfilePicture1718397928862';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "password" varchar NOT NULL, "isAdmin" boolean NOT NULL DEFAULT (0), "onboardingDone" boolean NOT NULL DEFAULT (0), "settings" json NOT NULL DEFAULT ('{"autoplayTrailers":true,"language":"en","animationDuration":300,"sonarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":"","languageProfileId":0},"radarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":""},"jellyfin":{"apiKey":"","baseUrl":"","userId":""},"tmdb":{"sessionId":"","userId":""}}'), "profilePicture" blob, CONSTRAINT "UQ_065d4d8f3b5adb4a08841eae3c8" UNIQUE ("name"))`);
await queryRunner.query(`INSERT INTO "temporary_user"("id", "name", "password", "isAdmin", "onboardingDone", "settings") SELECT "id", "name", "password", "isAdmin", "onboardingDone", "settings" FROM "user"`);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(`CREATE TABLE "user" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "password" varchar NOT NULL, "isAdmin" boolean NOT NULL DEFAULT (0), "onboardingDone" boolean NOT NULL DEFAULT (0), "settings" json NOT NULL DEFAULT ('{"autoplayTrailers":true,"language":"en","animationDuration":300,"sonarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":"","languageProfileId":0},"radarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":""},"jellyfin":{"apiKey":"","baseUrl":"","userId":""},"tmdb":{"sessionId":"","userId":""}}'), CONSTRAINT "UQ_065d4d8f3b5adb4a08841eae3c8" UNIQUE ("name"))`);
await queryRunner.query(`INSERT INTO "user"("id", "name", "password", "isAdmin", "onboardingDone", "settings") SELECT "id", "name", "password", "isAdmin", "onboardingDone", "settings" FROM "temporary_user"`);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "password" varchar NOT NULL, "isAdmin" boolean NOT NULL DEFAULT (0), "onboardingDone" boolean NOT NULL DEFAULT (0), "settings" json NOT NULL DEFAULT ('{"autoplayTrailers":true,"language":"en","animationDuration":300,"sonarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":"","languageProfileId":0},"radarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":""},"jellyfin":{"apiKey":"","baseUrl":"","userId":""},"tmdb":{"sessionId":"","userId":""}}'), "profilePicture" blob, CONSTRAINT "UQ_065d4d8f3b5adb4a08841eae3c8" UNIQUE ("name"))`,
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "name", "password", "isAdmin", "onboardingDone", "settings") SELECT "id", "name", "password", "isAdmin", "onboardingDone", "settings" FROM "user"`,
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "user" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "password" varchar NOT NULL, "isAdmin" boolean NOT NULL DEFAULT (0), "onboardingDone" boolean NOT NULL DEFAULT (0), "settings" json NOT NULL DEFAULT ('{"autoplayTrailers":true,"language":"en","animationDuration":300,"sonarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":"","languageProfileId":0},"radarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":""},"jellyfin":{"apiKey":"","baseUrl":"","userId":""},"tmdb":{"sessionId":"","userId":""}}'), CONSTRAINT "UQ_065d4d8f3b5adb4a08841eae3c8" UNIQUE ("name"))`,
);
await queryRunner.query(
`INSERT INTO "user"("id", "name", "password", "isAdmin", "onboardingDone", "settings") SELECT "id", "name", "password", "isAdmin", "onboardingDone", "settings" FROM "temporary_user"`,
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}
}

View File

@@ -3,6 +3,13 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
{
"include": "../plugins/**",
"outDir": "dist/plugins",
"watchAssets": true
}
]
}
}

View File

@@ -8236,9 +8236,9 @@
}
},
"node_modules/prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
@@ -10173,9 +10173,9 @@
}
},
"node_modules/typescript": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"devOptional": true,
"bin": {
"tsc": "bin/tsc",

View File

@@ -79,5 +79,11 @@
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prettier": {
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import { generateApi, generateTemplates } from 'swagger-typescript-api';
import {
BaseItemKind,
ItemFields,
Api as JellyfinApi,
} from './api/jellyfin.openapi';
export interface SourcePlugin {
handleProxy(request: { uri: string; headers: any }): any;
name: string;
indexable: boolean;
getMovieStream: (tmdbId: string) => Promise<string>;
}
const config = {
apiKey: '',
baseUrl: 'http://192.168.0.129:8096',
userId: '',
};
export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
@Injectable()
export default class JellyfinPlugin implements SourcePlugin {
name: string = 'jellyfin';
indexable: boolean = true;
api: JellyfinApi<{}>;
constructor() {
generateApi({
name: 'jellyfin.openapi.ts',
url: 'https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json',
output:
'/Users/aleksilassila/Workspace/Documents/node/reiverr/backend/plugins/jellyfin.plugin/api',
// generateClient: true,
// generateRouteTypes: false,
// sortTypes: true,
httpClientType: 'axios',
});
this.api = new JellyfinApi({
baseURL: config.baseUrl,
headers: {
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${config.apiKey}"`,
},
paramsSerializer: {
indexes: null,
},
});
}
handleProxy({ uri, headers }) {
return {
url: `${config.baseUrl}/${uri}`,
headers: {
...headers,
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${config.apiKey}"`,
},
};
}
private async getLibraryItems() {
return this.api.items
.getItems({
userId: config.userId,
hasTmdbId: true,
recursive: true,
includeItemTypes: [BaseItemKind.Movie, BaseItemKind.Series],
fields: [
ItemFields.ProviderIds,
ItemFields.Genres,
ItemFields.DateLastMediaAdded,
ItemFields.DateCreated,
ItemFields.MediaSources,
],
})
.then((res) => {
console.log(res.request.path);
return res;
})
.then((res) => res.data.Items ?? []);
}
async getMovieStream(tmdbId: string): Promise<string> {
const items = await this.getLibraryItems();
const movie = items.find((item) => item.ProviderIds?.Tmdb === tmdbId);
// console.log(items.map((item) => item))
if (!movie || !movie.MediaSources || movie.MediaSources.length === 0) {
throw new Error('Movie stream not found');
}
/*
await jellyfinApi.getPlaybackInfo(
id,
getDeviceProfile(),
options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0,
options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate,
audioStreamIndex
);
*/
const playbackInfo = await this.api.items.getPlaybackInfo(movie.Id, {
userId: config.userId,
// deviceId: JELLYFIN_DEVICE_ID,
// mediaSourceId: movie.MediaSources[0].Id,
// maxBitrate: 8000000,
});
const playbackUri =
playbackInfo.data?.MediaSources?.[0]?.TranscodingUrl ||
`/Videos/${playbackInfo.data?.MediaSources?.[0]?.Id}/stream.mp4?Static=true&mediaSourceId=${playbackInfo.data?.MediaSources?.[0]?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${config.apiKey}&Tag=${playbackInfo.data?.MediaSources?.[0]?.ETag}`;
return playbackUri;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "jellyfin",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"@types/axios": "^0.14.4",
"axios": "^1.7.8",
"express-http-proxy": "^2.1.1",
"swagger-typescript-api": "^13.0.23"
}
}

View File

@@ -7,7 +7,7 @@ import { AuthModule } from './auth/auth.module';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { MediaModule } from './media/media.module';
import { SourcesModule } from './sources/sources.module';
import { SourceModule } from './sources/sources.module';
@Module({
imports: [
@@ -18,7 +18,7 @@ import { SourcesModule } from './sources/sources.module';
rootPath: join(__dirname, '../dist'),
}),
MediaModule,
SourcesModule,
SourceModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -6,7 +6,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JWT_SECRET } from '../consts';
import { ENV, JWT_SECRET } from '../consts';
import { AccessTokenPayload } from './auth.service';
import { User } from '../users/user.entity';
import { UsersService } from '../users/users.service';
@@ -34,7 +34,11 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = extractTokenFromHeader(request);
if (!token) {
if (ENV === 'development' && !token) {
request['user'] = await this.userService.findOneByName('test');
return true;
} else if (!token) {
throw new UnauthorizedException();
}
try {

View File

@@ -2,3 +2,4 @@ export const JWT_SECRET =
process.env.SECRET || Math.random().toString(36).substring(2, 15);
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME;
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
export const ENV = process.env.NODE_ENV || 'development';

View File

@@ -7,6 +7,7 @@ import { UsersService } from './users/users.service';
import { ADMIN_PASSWORD, ADMIN_USERNAME } from './consts';
import { json, urlencoded } from 'express';
// import * as proxy from 'express-http-proxy';
require('ts-node/register'); // For importing plugins
async function createAdminUser(userService: UsersService) {
if (!ADMIN_USERNAME || ADMIN_PASSWORD === undefined) return;
@@ -42,6 +43,7 @@ async function bootstrap() {
await createAdminUser(app.get(UsersService));
await app.listen(9494);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();

View File

@@ -0,0 +1,27 @@
import { Inject, Injectable } from '@nestjs/common';
import { promises as fs } from 'fs';
import axios from 'axios';
import * as vm from 'vm';
@Injectable()
export class PluginLoaderService {
constructor(@Inject('PLUGIN_URL') private readonly pluginUrl: string) {}
async loadPlugin(): Promise<any> {
const response = await axios.get(this.pluginUrl);
const sandbox = {
module: { exports: {} },
console: console,
};
vm.createContext(sandbox);
const script = new vm.Script(response.data, {
filename: 'plugin-module.js',
});
script.runInContext(sandbox, { displayErrors: true });
return sandbox.module.exports;
}
}

View File

@@ -0,0 +1,59 @@
import { Inject, Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
export interface SourcePlugin {
handleProxy(request: { uri: string; headers: any }): any;
name: string;
indexable: boolean;
getMovieStream: (tmdbId: string) => Promise<string>;
}
@Injectable()
export class SourcePluginsService {
private plugins: Record<string, SourcePlugin>;
constructor() {
console.log('Loading source plugins...');
this.plugins = this.loadPlugins(
path.join(require.main.path, '..', 'plugins'),
);
console.log(
`Loaded source plugins: ${Object.keys(this.plugins).join(', ')}`,
);
}
async getLoadedPlugins(): Promise<Record<string, SourcePlugin>> {
return this.plugins;
}
private loadPlugins(rootDirectory: string): Record<string, SourcePlugin> {
const pluginDirectories = fs.readdirSync(rootDirectory);
const pluginPaths = [];
for (const directoryName of pluginDirectories) {
const directoryPath = path.join(rootDirectory, directoryName);
const directoryStat = fs.statSync(directoryPath);
if (directoryStat.isDirectory()) {
pluginPaths.push(directoryPath);
}
}
const plugins: Record<string, SourcePlugin> = {};
for (const pluginPath of pluginPaths) {
const pluginModule = require(pluginPath);
const plugin = new pluginModule.default();
plugins[plugin.name] = plugin;
}
return plugins;
}
getPlugin(pluginName: string): SourcePlugin | undefined {
return this.plugins[pluginName];
}
}

View File

@@ -0,0 +1,68 @@
import {
All,
Controller,
Get,
Next,
Param,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SourcePluginsService } from './source-plugins.service';
import { AuthGuard } from 'src/auth/auth.guard';
import { NextFunction, Request, Response } from 'express';
import { Readable } from 'stream';
const config = {
apiKey: '',
baseUrl: 'http://192.168.0.129:8096',
userId: '',
};
export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
@ApiTags('sources')
@Controller('sources')
@UseGuards(AuthGuard)
export class SourcesController {
constructor(private sourcesService: SourcePluginsService) {}
@Get()
async getSources() {
this.sourcesService.getLoadedPlugins();
}
@Get(':sourceId/movies/:tmdbId/stream')
async getMovieStream(
@Param('sourceId') sourceId: string,
@Param('tmdbId') tmdbId: string,
) {
return this.sourcesService.getPlugin(sourceId)?.getMovieStream(tmdbId);
}
@All(':sourceId/movies/:tmdbId/stream/*')
async getMovieStreamProxy(
@Param() params: any,
@Req() req: Request,
@Res() res: Response,
@Next() next: NextFunction,
) {
const { url, headers } = this.sourcesService
.getPlugin(params.sourceId)
?.handleProxy({
uri: params[0] + '?' + req.url.split('?')[1],
headers: req.headers,
});
const proxyRes = await fetch(url, {
method: req.method || 'GET',
headers: {
...headers,
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${config.apiKey}"`,
},
});
Readable.from(proxyRes.body).pipe(res);
res.status(proxyRes.status);
}
}

View File

@@ -1,4 +1,14 @@
import { Module } from '@nestjs/common';
import { DynamicModule } from '@nestjs/common';
import { PluginLoaderService } from './plguin-loader.service';
import { SourcePluginsService } from './source-plugins.service';
import { SourcesController } from './sources.controller';
import { UsersModule } from 'src/users/users.module';
@Module({})
export class SourcesModule {}
@Module({
providers: [SourcePluginsService],
controllers: [SourcesController],
exports: [SourcePluginsService],
imports: [UsersModule],
})
export class SourceModule {}

View File

@@ -18,5 +18,5 @@
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"lib": ["es6"]
},
}
}

View File

@@ -1,37 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./public/favicon.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<title>Vite + Svelte + TS</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Nunito+Sans:wght@200;300;400;500;600;700;800;900;1000&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Montserrat:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<script>
document.documentElement.setAttribute('data-useragent', navigator.userAgent);
document.documentElement.setAttribute('data-platform', navigator.platform );
</script>
</head>
<body class="bg-secondary-900 min-h-screen text-white touch-manipulation relative -z-10">
<div id="app" class="h-screen w-screen overflow-hidden relative"></div>
<script type="module" src="/src/main.ts"></script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./public/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<title>Vite + Svelte + TS</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Nunito+Sans:wght@200;300;400;500;600;700;800;900;1000&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Montserrat:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<script>
document.documentElement.setAttribute('data-useragent', navigator.userAgent);
document.documentElement.setAttribute('data-platform', navigator.platform);
</script>
</head>
<body class="bg-secondary-900 min-h-screen text-white touch-manipulation relative -z-10">
<div id="app" class="h-screen w-screen overflow-hidden relative"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -71,5 +71,25 @@
},
"dependencies": {
"gsap": "^3.12.5"
},
"prettier": {
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"pluginSearchDirs": [
"."
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
}

0
postcss.config.js Normal file → Executable file
View File

View File

@@ -1,9 +1,8 @@
{
"name": "Reiverr",
"icons": [
],
"start_url": "/",
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}
"name": "Reiverr",
"icons": [],
"start_url": "/",
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}

View File

@@ -3,19 +3,19 @@
@tailwind utilities;
a {
@apply hover:text-amber-200;
@apply hover:text-amber-200;
}
.placeholder {
@apply bg-zinc-700 bg-opacity-40 animate-pulse;
@apply bg-zinc-700 bg-opacity-40 animate-pulse;
}
.placeholder-image {
@apply bg-zinc-700 bg-opacity-75;
@apply bg-zinc-700 bg-opacity-75;
}
.placeholder-text {
@apply bg-zinc-700 bg-opacity-40 animate-pulse text-transparent rounded-lg select-none;
@apply bg-zinc-700 bg-opacity-40 animate-pulse text-transparent rounded-lg select-none;
}
/*html:not([data-useragent*="Tizen"]) .selectable, html:not([data-useragent*="Tizen"]) .selectable-offset {*/
@@ -38,74 +38,73 @@ a {
/* @apply outline-offset-[2px]*/
/*}*/
html:not([data-useragent*="Tizen"]) .selectable {
@apply focus:border-primary-500 focus-visible:border-primary-500;
html:not([data-useragent*='Tizen']) .selectable {
@apply focus:border-primary-500 focus-visible:border-primary-500;
}
html[data-useragent*="Tizen"] .selectable {
@apply focus:border-primary-500 focus-within:border-primary-500;
html[data-useragent*='Tizen'] .selectable {
@apply focus:border-primary-500 focus-within:border-primary-500;
}
.selectable {
@apply outline-none outline-0 border-2 border-[#00000000] transition-colors hover:border-primary-500;
@apply outline-none outline-0 border-2 border-[#00000000] transition-colors hover:border-primary-500;
}
.selectable:focus, .selectable:focus-within {
border-width: 2px;
.selectable:focus,
.selectable:focus-within {
border-width: 2px;
}
.selectable-secondary {
@apply outline-none outline-0 border-2 transition-colors hover:border-primary-500;
@apply outline-none outline-0 border-2 transition-colors hover:border-primary-500;
}
html:not([data-useragent*="Tizen"]) .selectable-secondary {
@apply focus-visible:border-primary-500;
html:not([data-useragent*='Tizen']) .selectable-secondary {
@apply focus-visible:border-primary-500;
}
html[data-useragent*="Tizen"] .selectable-secondary {
@apply focus-within:border-primary-500;
html[data-useragent*='Tizen'] .selectable-secondary {
@apply focus-within:border-primary-500;
}
.selected {
@apply outline-none outline-0 border-2 border-primary-500;
@apply outline-none outline-0 border-2 border-primary-500;
}
.unselected {
@apply outline-none outline-0 border-2 border-transparent;
@apply outline-none outline-0 border-2 border-transparent;
}
.peer-selectable {
@apply peer-focus-visible:outline outline-2 outline-primary-500 outline-offset-2;
@apply peer-focus-visible:outline outline-2 outline-primary-500 outline-offset-2;
}
.selectable-explicit {
@apply focus-within:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
@apply focus-within:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
}
.header1 {
@apply font-semibold text-xl text-secondary-100;
@apply font-semibold text-xl text-secondary-100;
}
.header2 {
@apply font-semibold text-2xl text-secondary-100;
@apply font-semibold text-2xl text-secondary-100;
}
.header3 {
@apply font-semibold text-3xl text-secondary-100;
@apply font-semibold text-3xl text-secondary-100;
}
.header4 {
@apply font-semibold text-4xl text-secondary-100 tracking-wider;
@apply font-semibold text-4xl text-secondary-100 tracking-wider;
}
.body {
@apply font-medium text-lg text-secondary-300;
@apply font-medium text-lg text-secondary-300;
}
@media tv {
html {
font-size: 24px;
}
html {
font-size: 24px;
}
}

View File

@@ -3,36 +3,34 @@
*/
import {
hasAacSupport,
hasAc3InHlsSupport,
hasAc3Support,
hasEac3Support,
hasMp3AudioSupport
hasAacSupport,
hasAc3InHlsSupport,
hasAc3Support,
hasEac3Support,
hasMp3AudioSupport
} from './mp4-audio-formats';
/**
* List of supported Ts audio codecs
*/
export function getSupportedTsAudioCodecs(
videoTestElement: HTMLVideoElement
): string[] {
const codecs = [];
export function getSupportedTsAudioCodecs(videoTestElement: HTMLVideoElement): string[] {
const codecs = [];
if (hasAacSupport(videoTestElement)) {
codecs.push('aac');
}
if (hasAacSupport(videoTestElement)) {
codecs.push('aac');
}
if (hasMp3AudioSupport(videoTestElement)) {
codecs.push('mp3');
}
if (hasMp3AudioSupport(videoTestElement)) {
codecs.push('mp3');
}
if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) {
codecs.push('ac3');
if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) {
codecs.push('ac3');
if (hasEac3Support(videoTestElement)) {
codecs.push('eac3');
}
}
if (hasEac3Support(videoTestElement)) {
codecs.push('eac3');
}
}
return codecs;
return codecs;
}

View File

@@ -7,14 +7,12 @@ import { hasH264Support } from './mp4-video-formats';
/**
* List of supported ts video codecs
*/
export function getSupportedTsVideoCodecs(
videoTestElement: HTMLVideoElement
): string[] {
const codecs = [];
export function getSupportedTsVideoCodecs(videoTestElement: HTMLVideoElement): string[] {
const codecs = [];
if (hasH264Support(videoTestElement)) {
codecs.push('h264');
}
if (hasH264Support(videoTestElement)) {
codecs.push('h264');
}
return codecs;
return codecs;
}

View File

@@ -2,31 +2,25 @@
* @deprecated - Check @/utils/playback-profiles/index
*/
import {
hasAv1Support,
hasVp8Support,
hasVp9Support
} from './mp4-video-formats';
import { hasAv1Support, hasVp8Support, hasVp9Support } from './mp4-video-formats';
/**
* Get an array of supported codecs WebM video codecs
*/
export function getSupportedWebMVideoCodecs(
videoTestElement: HTMLVideoElement
): string[] {
const codecs = [];
export function getSupportedWebMVideoCodecs(videoTestElement: HTMLVideoElement): string[] {
const codecs = [];
if (hasVp8Support(videoTestElement)) {
codecs.push('vp8');
}
if (hasVp8Support(videoTestElement)) {
codecs.push('vp8');
}
if (hasVp9Support(videoTestElement)) {
codecs.push('vp9');
}
if (hasVp9Support(videoTestElement)) {
codecs.push('vp9');
}
if (hasAv1Support(videoTestElement)) {
codecs.push('av1');
}
if (hasAv1Support(videoTestElement)) {
codecs.push('av1');
}
return codecs;
return codecs;
}

View File

@@ -17,9 +17,9 @@
* than ideal.
*/
export function supportsMediaSource(): boolean {
// Browsers that lack a media source implementation will have no reference
// to |window.MediaSource|.
return !!window.MediaSource;
// Browsers that lack a media source implementation will have no reference
// to |window.MediaSource|.
return !!window.MediaSource;
}
/**
@@ -31,9 +31,9 @@ export function supportsMediaSource(): boolean {
* @returns Determines if user agent of navigator contains a key
*/
function userAgentContains(key: string): boolean {
const userAgent = navigator.userAgent || '';
const userAgent = navigator.userAgent || '';
return userAgent.includes(key);
return userAgent.includes(key);
}
/* Desktop Browsers */
@@ -44,7 +44,7 @@ function userAgentContains(key: string): boolean {
* @returns Determines if browser is Mozilla Firefox
*/
export function isFirefox(): boolean {
return userAgentContains('Firefox/');
return userAgentContains('Firefox/');
}
/**
@@ -54,7 +54,7 @@ export function isFirefox(): boolean {
* @returns Determines if browser is Microsoft Edge
*/
export function isEdge(): boolean {
return userAgentContains('Edg/') || userAgentContains('Edge/');
return userAgentContains('Edg/') || userAgentContains('Edge/');
}
/**
@@ -63,7 +63,7 @@ export function isEdge(): boolean {
* @returns Determines if browser is Chromium based
*/
export function isChromiumBased(): boolean {
return userAgentContains('Chrome');
return userAgentContains('Chrome');
}
/**
@@ -72,9 +72,9 @@ export function isChromiumBased(): boolean {
* @returns Determines if browser is Google Chrome
*/
export function isChrome(): boolean {
// The Edge user agent will also contain the "Chrome" keyword, so we need
// to make sure this is not Edge.
return userAgentContains('Chrome') && !isEdge() && !isWebOS();
// The Edge user agent will also contain the "Chrome" keyword, so we need
// to make sure this is not Edge.
return userAgentContains('Chrome') && !isEdge() && !isWebOS();
}
/**
@@ -88,7 +88,7 @@ export function isChrome(): boolean {
* @returns Determines if current platform is from Apple
*/
export function isApple(): boolean {
return navigator?.vendor.includes('Apple') && !isTizen();
return navigator?.vendor.includes('Apple') && !isTizen();
}
/**
@@ -97,32 +97,32 @@ export function isApple(): boolean {
* @returns The major version number for Safari
*/
export function safariVersion(): number | undefined {
// All iOS browsers and desktop Safari will return true for isApple().
if (!isApple()) {
return;
}
// All iOS browsers and desktop Safari will return true for isApple().
if (!isApple()) {
return;
}
let userAgent = '';
let userAgent = '';
if (navigator.userAgent) {
userAgent = navigator.userAgent;
}
if (navigator.userAgent) {
userAgent = navigator.userAgent;
}
// This works for iOS Safari and desktop Safari, which contain something
// like "Version/13.0" indicating the major Safari or iOS version.
let match = userAgent.match(/Version\/(\d+)/);
// This works for iOS Safari and desktop Safari, which contain something
// like "Version/13.0" indicating the major Safari or iOS version.
let match = userAgent.match(/Version\/(\d+)/);
if (match) {
return Number.parseInt(match[1], /* base= */ 10);
}
if (match) {
return Number.parseInt(match[1], /* base= */ 10);
}
// This works for all other browsers on iOS, which contain something like
// "OS 13_3" indicating the major & minor iOS version.
match = userAgent.match(/OS (\d+)(?:_\d+)?/);
// This works for all other browsers on iOS, which contain something like
// "OS 13_3" indicating the major & minor iOS version.
match = userAgent.match(/OS (\d+)(?:_\d+)?/);
if (match) {
return Number.parseInt(match[1], /* base= */ 10);
}
if (match) {
return Number.parseInt(match[1], /* base= */ 10);
}
}
/* TV Platforms */
@@ -133,7 +133,7 @@ export function safariVersion(): number | undefined {
* @returns Determines if current platform is Tizen
*/
export function isTizen(): boolean {
return userAgentContains('Tizen');
return userAgentContains('Tizen');
}
/**
@@ -142,7 +142,7 @@ export function isTizen(): boolean {
* @returns Determines if current platform is Tizen 2
*/
export function isTizen2(): boolean {
return userAgentContains('Tizen 2');
return userAgentContains('Tizen 2');
}
/**
@@ -152,7 +152,7 @@ export function isTizen2(): boolean {
* @memberof BrowserDetector
*/
export function isTizen3(): boolean {
return userAgentContains('Tizen 3');
return userAgentContains('Tizen 3');
}
/**
@@ -162,7 +162,7 @@ export function isTizen3(): boolean {
* @memberof BrowserDetector
*/
export function isTizen4(): boolean {
return userAgentContains('Tizen 4');
return userAgentContains('Tizen 4');
}
/**
@@ -172,7 +172,7 @@ export function isTizen4(): boolean {
* @memberof BrowserDetector
*/
export function isTizen5(): boolean {
return userAgentContains('Tizen 5');
return userAgentContains('Tizen 5');
}
/**
@@ -182,7 +182,7 @@ export function isTizen5(): boolean {
* @memberof BrowserDetector
*/
export function isTizen55(): boolean {
return userAgentContains('Tizen 5.5');
return userAgentContains('Tizen 5.5');
}
/**
@@ -192,50 +192,42 @@ export function isTizen55(): boolean {
* @memberof BrowserDetector
*/
export function isWebOS(): boolean {
return userAgentContains('Web0S');
return userAgentContains('Web0S');
}
/**
* Determines if current platform is WebOS1
*/
export function isWebOS1(): boolean {
return (
isWebOS() &&
userAgentContains('AppleWebKit/537') &&
!userAgentContains('Chrome/')
);
return isWebOS() && userAgentContains('AppleWebKit/537') && !userAgentContains('Chrome/');
}
/**
* Determines if current platform is WebOS2
*/
export function isWebOS2(): boolean {
return (
isWebOS() &&
userAgentContains('AppleWebKit/538') &&
!userAgentContains('Chrome/')
);
return isWebOS() && userAgentContains('AppleWebKit/538') && !userAgentContains('Chrome/');
}
/**
* Determines if current platform is WebOS3
*/
export function isWebOS3(): boolean {
return isWebOS() && userAgentContains('Chrome/38');
return isWebOS() && userAgentContains('Chrome/38');
}
/**
* Determines if current platform is WebOS4
*/
export function isWebOS4(): boolean {
return isWebOS() && userAgentContains('Chrome/53');
return isWebOS() && userAgentContains('Chrome/53');
}
/**
* Determines if current platform is WebOS5
*/
export function isWebOS5(): boolean {
return isWebOS() && userAgentContains('Chrome/68');
return isWebOS() && userAgentContains('Chrome/68');
}
/* Platform Utilities */
@@ -244,7 +236,7 @@ export function isWebOS5(): boolean {
* Determines if current platform is Android
*/
export function isAndroid(): boolean {
return userAgentContains('Android');
return userAgentContains('Android');
}
/**
@@ -253,27 +245,27 @@ export function isAndroid(): boolean {
* @returns Determines if current platform is mobile (Guess)
*/
export function isMobile(): boolean {
let userAgent = '';
let userAgent = '';
if (navigator.userAgent) {
userAgent = navigator.userAgent;
}
if (navigator.userAgent) {
userAgent = navigator.userAgent;
}
if (/iPhone|iPad|iPod|Android/.test(userAgent)) {
// This is Android, iOS, or iPad < 13.
return true;
}
if (/iPhone|iPad|iPod|Android/.test(userAgent)) {
// This is Android, iOS, or iPad < 13.
return true;
}
// Starting with iOS 13 on iPad, the user agent string no longer has the
// word "iPad" in it. It looks very similar to desktop Safari. This seems
// to be intentional on Apple's part.
// See: https://forums.developer.apple.com/thread/119186
//
// So if it's an Apple device with multi-touch support, assume it's a mobile
// device. If some future iOS version starts masking their user agent on
// both iPhone & iPad, this clause should still work. If a future
// multi-touch desktop Mac is released, this will need some adjustment.
return isApple() && navigator.maxTouchPoints > 1;
// Starting with iOS 13 on iPad, the user agent string no longer has the
// word "iPad" in it. It looks very similar to desktop Safari. This seems
// to be intentional on Apple's part.
// See: https://forums.developer.apple.com/thread/119186
//
// So if it's an Apple device with multi-touch support, assume it's a mobile
// device. If some future iOS version starts masking their user agent on
// both iPhone & iPad, this clause should still work. If a future
// multi-touch desktop Mac is released, this will need some adjustment.
return isApple() && navigator.maxTouchPoints > 1;
}
/**
@@ -282,7 +274,7 @@ export function isMobile(): boolean {
* @returns Determines if platform is a Smart TV
*/
export function isTv(): boolean {
return isTizen() || isWebOS();
return isTizen() || isWebOS();
}
/**
@@ -291,7 +283,7 @@ export function isTv(): boolean {
* @returns Determines if the device is a PS4
*/
export function isPs4(): boolean {
return userAgentContains('playstation 4');
return userAgentContains('playstation 4');
}
/**
@@ -300,5 +292,5 @@ export function isPs4(): boolean {
* @returns Determines if the device is a Xbox
*/
export function isXbox(): boolean {
return userAgentContains('xbox');
return userAgentContains('xbox');
}

0
svelte.config.js Normal file → Executable file
View File

File diff suppressed because one or more lines are too long

0
tailwind.config.js Normal file → Executable file
View File

View File

@@ -1,18 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta name="description" content="Tizen basic template generated by Tizen Web IDE"/>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<meta name="description" content="Tizen basic template generated by Tizen Web IDE" />
<title>Tizen Web IDE - Tizen - Samsung Tizen TV basic Application</title>
<title>Tizen Web IDE - Tizen - Samsung Tizen TV basic Application</title>
<link rel="stylesheet" type="text/css" href="css/style.css"/>
<script src="js/main.js"></script>
<meta http-equiv="refresh" content="0; url=dist/index.html" />
</head>
<link rel="stylesheet" type="text/css" href="css/style.css" />
<script src="js/main.js"></script>
<meta http-equiv="refresh" content="0; url=dist/index.html" />
</head>
<body>
</body>
</html>
<body></body>
</html>

View File

@@ -1,24 +1,24 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true
// "paths": {
// "$lib/*": ["src/lib/*"],
// }
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true
// "paths": {
// "$lib/*": ["src/lib/*"],
// }
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,9 +1,9 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}