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

View File

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

View File

@@ -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) 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) 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:\ 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`.\ `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.\ 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). 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), 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) 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. > 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. > Jellyfin API key is located under Administration > Dashboard > Advanced > API Keys in the Jellyfin Web UI.
# Contributing # Contributing
Unlike the most Servarr projects, this one is built with Svelte and NestJS. If you haven't used Svelte before, 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` 5. To start the backend: `npm run --prefix backend start:dev`
## Notes ## Notes
- 2.0 will primarily target TVs, so the UI must be optimized with TVs in mind. This means larger text, buttons, etc. - 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 [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. 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. css `gap` property. You can use the `space-x` and `space-y` classes from Tailwind CSS to achieve the same effect.
## Useful resources ## Useful resources
- https://developer.themoviedb.org/reference - https://developer.themoviedb.org/reference
- https://api.jellyfin.org/ - https://api.jellyfin.org/
- https://sonarr.tv/docs/api/ - 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 ## Structure
- Routes - Routes
- series -> shows - series -> shows
### Users ### Users
- PUT /users/{id} - PUT /users/{id}
- GET /users - GET /users
- GET /users/{id} - GET /users/{id}
@@ -70,30 +72,36 @@ $ npm run test:cov
- POST /users - POST /users
### Users > Library ### Users > Library
- GET /users/{userId}/library - GET /users/{userId}/library
- POST /users/{userId}/library/{tmdbId} - POST /users/{userId}/library/{tmdbId}
- DELETE /users/{userId}/library/{tmdbId} - DELETE /users/{userId}/library/{tmdbId}
### Users > PlayState ### Users > PlayState
- GET /users/{userId}/playstate/movies/{tmdbId} - GET /users/{userId}/playstate/movies/{tmdbId}
- GET /users/{userId}/playstate/series/{tmdbId}/{season}/{episode} - GET /users/{userId}/playstate/series/{tmdbId}/{season}/{episode}
- PUT /users/{userId}/playstate/movies/{tmdbId} - PUT /users/{userId}/playstate/movies/{tmdbId}
- PUT /users/{userId}/playstate/series/{tmdbId}/{season}/{episode} - PUT /users/{userId}/playstate/series/{tmdbId}/{season}/{episode}
### TMDB / Metadata ### TMDB / Metadata
- GET /movies/{tmdbId} - GET /movies/{tmdbId}
- GET /movies/{tmdbId}/similar - GET /movies/{tmdbId}/similar
- GET /movies/recommendations
- User-specific
- GET /series/{tmdbId} - GET /series/{tmdbId}
- (GET /series/{tmdbId}/season/{season}) - (GET /series/{tmdbId}/season/{season})
- GET /series/{tmdbId}/season/{season}/episode/{episode} - GET /series/{tmdbId}/season/{season}/episode/{episode}
- GET /series/{tmdbId}/similar - GET /series/{tmdbId}/similar
- GET /series/recommendations
- User-specific
- GET /people/{tmdbPersonId} - GET /people/{tmdbPersonId}
### TMDB / Discovery ### TMDB / Discovery
- GET /discover/movies
- User-specific
- GET /discover/series
- User-specific
- (GET /discover/popular) - (GET /discover/popular)
- GET /discover/popular/movies - GET /discover/popular/movies
- GET /discover/popular/series - GET /discover/popular/series
@@ -105,6 +113,7 @@ $ npm run test:cov
- GET /discover/genre/{genre}/series - GET /discover/genre/{genre}/series
### Sources ### Sources
- GET /sources - GET /sources
- (GET /sources/{sourceId}) - (GET /sources/{sourceId})
- GET /sources/{sourceId}/movies - GET /sources/{sourceId}/movies

View File

@@ -1,20 +1,27 @@
import { MigrationInterface, QueryRunner } from "typeorm"; import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProfilePicture1718397928862 implements MigrationInterface { export class AddProfilePicture1718397928862 implements MigrationInterface {
name = 'AddProfilePicture1718397928862' name = 'AddProfilePicture1718397928862';
public async up(queryRunner: QueryRunner): Promise<void> { 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(
await queryRunner.query(`INSERT INTO "temporary_user"("id", "name", "password", "isAdmin", "onboardingDone", "settings") SELECT "id", "name", "password", "isAdmin", "onboardingDone", "settings" FROM "user"`); `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(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); 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(
await queryRunner.query(`INSERT INTO "user"("id", "name", "password", "isAdmin", "onboardingDone", "settings") SELECT "id", "name", "password", "isAdmin", "onboardingDone", "settings" FROM "temporary_user"`); `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"`); await queryRunner.query(`DROP TABLE "temporary_user"`);
} }
} }

View File

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

View File

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

View File

@@ -79,5 +79,11 @@
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "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 { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path'; import { join } from 'path';
import { MediaModule } from './media/media.module'; import { MediaModule } from './media/media.module';
import { SourcesModule } from './sources/sources.module'; import { SourceModule } from './sources/sources.module';
@Module({ @Module({
imports: [ imports: [
@@ -18,7 +18,7 @@ import { SourcesModule } from './sources/sources.module';
rootPath: join(__dirname, '../dist'), rootPath: join(__dirname, '../dist'),
}), }),
MediaModule, MediaModule,
SourcesModule, SourceModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@@ -6,7 +6,7 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { JWT_SECRET } from '../consts'; import { ENV, JWT_SECRET } from '../consts';
import { AccessTokenPayload } from './auth.service'; import { AccessTokenPayload } from './auth.service';
import { User } from '../users/user.entity'; import { User } from '../users/user.entity';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
@@ -34,7 +34,11 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const token = extractTokenFromHeader(request); 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(); throw new UnauthorizedException();
} }
try { try {

View File

@@ -2,3 +2,4 @@ export const JWT_SECRET =
process.env.SECRET || Math.random().toString(36).substring(2, 15); process.env.SECRET || Math.random().toString(36).substring(2, 15);
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME; export const ADMIN_USERNAME = process.env.ADMIN_USERNAME;
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; 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 { ADMIN_PASSWORD, ADMIN_USERNAME } from './consts';
import { json, urlencoded } from 'express'; import { json, urlencoded } from 'express';
// import * as proxy from 'express-http-proxy'; // import * as proxy from 'express-http-proxy';
require('ts-node/register'); // For importing plugins
async function createAdminUser(userService: UsersService) { async function createAdminUser(userService: UsersService) {
if (!ADMIN_USERNAME || ADMIN_PASSWORD === undefined) return; if (!ADMIN_USERNAME || ADMIN_PASSWORD === undefined) return;
@@ -42,6 +43,7 @@ async function bootstrap() {
await createAdminUser(app.get(UsersService)); await createAdminUser(app.get(UsersService));
await app.listen(9494); await app.listen(9494);
console.log(`Application is running on: ${await app.getUrl()}`);
} }
bootstrap(); 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 { 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({}) @Module({
export class SourcesModule {} providers: [SourcePluginsService],
controllers: [SourcesController],
exports: [SourcePluginsService],
imports: [UsersModule],
})
export class SourceModule {}

View File

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

View File

@@ -3,16 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="./public/favicon.png" /> <link rel="icon" href="./public/favicon.png" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<title>Vite + Svelte + TS</title> <title>Vite + Svelte + TS</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -27,7 +21,7 @@
/> />
<script> <script>
document.documentElement.setAttribute('data-useragent', navigator.userAgent); document.documentElement.setAttribute('data-useragent', navigator.userAgent);
document.documentElement.setAttribute('data-platform', navigator.platform ); document.documentElement.setAttribute('data-platform', navigator.platform);
</script> </script>
</head> </head>
<body class="bg-secondary-900 min-h-screen text-white touch-manipulation relative -z-10"> <body class="bg-secondary-900 min-h-screen text-white touch-manipulation relative -z-10">

View File

@@ -71,5 +71,25 @@
}, },
"dependencies": { "dependencies": {
"gsap": "^3.12.5" "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,7 +1,6 @@
{ {
"name": "Reiverr", "name": "Reiverr",
"icons": [ "icons": [],
],
"start_url": "/", "start_url": "/",
"theme_color": "#000000", "theme_color": "#000000",
"background_color": "#000000", "background_color": "#000000",

View File

@@ -38,11 +38,11 @@ a {
/* @apply outline-offset-[2px]*/ /* @apply outline-offset-[2px]*/
/*}*/ /*}*/
html:not([data-useragent*="Tizen"]) .selectable { html:not([data-useragent*='Tizen']) .selectable {
@apply focus:border-primary-500 focus-visible:border-primary-500; @apply focus:border-primary-500 focus-visible:border-primary-500;
} }
html[data-useragent*="Tizen"] .selectable { html[data-useragent*='Tizen'] .selectable {
@apply focus:border-primary-500 focus-within:border-primary-500; @apply focus:border-primary-500 focus-within:border-primary-500;
} }
@@ -50,21 +50,20 @@ html[data-useragent*="Tizen"] .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 { .selectable:focus,
.selectable:focus-within {
border-width: 2px; border-width: 2px;
} }
.selectable-secondary { .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 { html:not([data-useragent*='Tizen']) .selectable-secondary {
@apply focus-visible:border-primary-500; @apply focus-visible:border-primary-500;
} }
html[data-useragent*="Tizen"] .selectable-secondary { html[data-useragent*='Tizen'] .selectable-secondary {
@apply focus-within:border-primary-500; @apply focus-within:border-primary-500;
} }

View File

@@ -13,9 +13,7 @@ import {
/** /**
* List of supported Ts audio codecs * List of supported Ts audio codecs
*/ */
export function getSupportedTsAudioCodecs( export function getSupportedTsAudioCodecs(videoTestElement: HTMLVideoElement): string[] {
videoTestElement: HTMLVideoElement
): string[] {
const codecs = []; const codecs = [];
if (hasAacSupport(videoTestElement)) { if (hasAacSupport(videoTestElement)) {

View File

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

View File

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

View File

@@ -199,22 +199,14 @@ export function isWebOS(): boolean {
* Determines if current platform is WebOS1 * Determines if current platform is WebOS1
*/ */
export function isWebOS1(): boolean { export function isWebOS1(): boolean {
return ( return isWebOS() && userAgentContains('AppleWebKit/537') && !userAgentContains('Chrome/');
isWebOS() &&
userAgentContains('AppleWebKit/537') &&
!userAgentContains('Chrome/')
);
} }
/** /**
* Determines if current platform is WebOS2 * Determines if current platform is WebOS2
*/ */
export function isWebOS2(): boolean { export function isWebOS2(): boolean {
return ( return isWebOS() && userAgentContains('AppleWebKit/538') && !userAgentContains('Chrome/');
isWebOS() &&
userAgentContains('AppleWebKit/538') &&
!userAgentContains('Chrome/')
);
} }
/** /**

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> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"> <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"/> <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"/> <link rel="stylesheet" type="text/css" href="css/style.css" />
<script src="js/main.js"></script> <script src="js/main.js"></script>
<meta http-equiv="refresh" content="0; url=dist/index.html" /> <meta http-equiv="refresh" content="0; url=dist/index.html" />
</head> </head>
<body> <body></body>
</body>
</html> </html>

View File

@@ -16,8 +16,8 @@
"isolatedModules": true, "isolatedModules": true,
"noUncheckedIndexedAccess": true "noUncheckedIndexedAccess": true
// "paths": { // "paths": {
// "$lib/*": ["src/lib/*"], // "$lib/*": ["src/lib/*"],
// } // }
}, },
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]