diff --git a/backend/package-lock.json b/backend/package-lock.json index e9810d7..ab0c4bc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,8 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/serve-static": "^4.0.1", "@nestjs/swagger": "^7.3.0", + "@types/express-http-proxy": "^1.6.6", + "express-http-proxy": "^2.0.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "sqlite3": "^5.1.7", @@ -2251,7 +2253,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -2261,7 +2262,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -2302,7 +2302,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2310,11 +2309,18 @@ "@types/serve-static": "*" } }, + "node_modules/@types/express-http-proxy": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/express-http-proxy/-/express-http-proxy-1.6.6.tgz", + "integrity": "sha512-J8ZqHG76rq1UB716IZ3RCmUhg406pbWxsM3oFCFccl5xlWUPzoR4if6Og/cE4juK8emH0H9quZa5ltn6ZdmQJg==", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/express-serve-static-core": { "version": "4.17.43", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", - "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -2334,8 +2340,7 @@ "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -2394,8 +2399,7 @@ "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "node_modules/@types/node": { "version": "20.11.30", @@ -2408,14 +2412,12 @@ "node_modules/@types/qs": { "version": "6.9.14", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", - "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==", - "dev": true + "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "node_modules/@types/semver": { "version": "7.5.8", @@ -2427,7 +2429,6 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -2437,7 +2438,6 @@ "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", - "dev": true, "dependencies": { "@types/http-errors": "*", "@types/mime": "*", @@ -4527,6 +4527,11 @@ "integrity": "sha512-7nOqkomXZEaxUDJw21XZNtRk739QvrPSoZoRtbsEfcii00vdzZUh6zh1CQwHhrib8MdEtJfv5rJiGeb4KuV/vw==", "dev": true }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "node_modules/escalade": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", @@ -4925,6 +4930,27 @@ "node": ">= 0.10.0" } }, + "node_modules/express-http-proxy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/express-http-proxy/-/express-http-proxy-2.0.0.tgz", + "integrity": "sha512-TXxcPFTWVUMSEmyM6iX2sT/JtmqhqngTq29P+eXTVFdtxZrTmM8THUYK59rUXiln0FfPGvxEpGRnVrgvHksXDw==", + "dependencies": { + "debug": "^3.0.1", + "es6-promise": "^4.1.1", + "raw-body": "^2.3.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/express-http-proxy/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/backend/package.json b/backend/package.json index fa70c6e..599e88a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,8 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/serve-static": "^4.0.1", "@nestjs/swagger": "^7.3.0", + "@types/express-http-proxy": "^1.6.6", + "express-http-proxy": "^2.0.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "sqlite3": "^5.1.7", diff --git a/backend/src/main.ts b/backend/src/main.ts index 678b1da..c3768a8 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,11 +3,15 @@ import { AppModule } from './app.module'; import 'reflect-metadata'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import * as fs from 'fs'; +import * as proxy from 'express-http-proxy'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); app.enableCors(); + + // app.use('/api/proxy/jellyfin', proxy('http://192.168.0.129:8096')); + const config = new DocumentBuilder().build(); const document = SwaggerModule.createDocument(app, config, { diff --git a/src/lib/apis/jellyfin/jellyfin-api.ts b/src/lib/apis/jellyfin/jellyfin-api.ts index a99ca5e..2627c78 100644 --- a/src/lib/apis/jellyfin/jellyfin-api.ts +++ b/src/lib/apis/jellyfin/jellyfin-api.ts @@ -1,11 +1,9 @@ -import axios from 'axios'; import createClient from 'openapi-fetch'; import { get } from 'svelte/store'; import type { components, paths } from './jellyfin.generated'; -import { settings } from '../../stores/settings.store'; -import type { DeviceProfile } from './playback-profiles'; import type { Api } from '../api.interface'; import { appState } from '../../stores/app-state.store'; +import type { DeviceProfile } from './playback-profiles'; export type JellyfinItem = components['schemas']['BaseItemDto']; @@ -29,6 +27,10 @@ export class JellyfinApi implements Api { return get(appState).user?.settings.jellyfin.userId || ''; } + getApiKey() { + return get(appState).user?.settings.jellyfin.apiKey || ''; + } + async getContinueWatching(): Promise { return this.getClient() .GET('/Users/{userId}/Items/Resume', { @@ -45,24 +47,28 @@ export class JellyfinApi implements Api { .then((r) => r.data?.Items || []); } - async getLibraryItems() { - return ( - this.getClient() - .GET('/Users/{userId}/Items', { - params: { - path: { - userId: this.getUserId() - }, - query: { - hasTmdbId: true, - recursive: true, - includeItemTypes: ['Movie', 'Series'], - fields: ['ProviderIds', 'Genres', 'DateLastMediaAdded', 'DateCreated'] + jellyfinItemsCache: JellyfinItem[] = []; + async getLibraryItems(refreshCache = false) { + if (refreshCache || !this.jellyfinItemsCache.length) { + this.jellyfinItemsCache = + (await this.getClient() + .GET('/Users/{userId}/Items', { + params: { + path: { + userId: this.getUserId() + }, + query: { + hasTmdbId: true, + recursive: true, + includeItemTypes: ['Movie', 'Series'], + fields: ['ProviderIds', 'Genres', 'DateLastMediaAdded', 'DateCreated'] + } } - } - }) - .then((r) => r.data?.Items || []) || Promise.resolve([]) - ); + }) + .then((r) => r.data?.Items || [])) || Promise.resolve([]); + } + + return this.jellyfinItemsCache; } getPosterUrl(item: JellyfinItem, quality = 100, original = false) { @@ -74,6 +80,113 @@ export class JellyfinApi implements Api { }` : ''; } + + getLibraryItem(itemId: string, refreshCache = false) { + return this.getLibraryItems(refreshCache).then((items) => items.find((i) => i.Id === itemId)); + } + + getLibraryItemFromTmdbId(tmdbId: string, refreshCache = false) { + return this.getLibraryItems(refreshCache).then((items) => + items.find((i) => i.ProviderIds?.Tmdb === tmdbId) + ); + } + + // PLAYBACK + + getPlaybackInfo = async ( + itemId: string, + playbackProfile: DeviceProfile, + startTimeTicks = 0, + maxStreamingBitrate = 140000000 + ) => + this.getClient() + ?.POST('/Items/{itemId}/PlaybackInfo', { + params: { + path: { + itemId: itemId + }, + query: { + userId: this.getUserId(), + startTimeTicks, + autoOpenLiveStream: true, + maxStreamingBitrate + } + }, + body: { + DeviceProfile: playbackProfile + } + }) + .then((r) => ({ + playbackUri: + r.data?.MediaSources?.[0]?.TranscodingUrl || + `/Videos/${r.data?.MediaSources?.[0]?.Id}/stream.mp4?Static=true&mediaSourceId=${ + r.data?.MediaSources?.[0]?.Id + }&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${this.getApiKey()}&Tag=${ + r.data?.MediaSources?.[0]?.ETag + }`, + mediaSourceId: r.data?.MediaSources?.[0]?.Id, + playSessionId: r.data?.PlaySessionId, + directPlay: + !!r.data?.MediaSources?.[0]?.SupportsDirectPlay || + !!r.data?.MediaSources?.[0]?.SupportsDirectStream + })); + + reportPlaybackStarted( + itemId: string, + sessionId: string, + mediaSourceId: string, + audioStreamIndex?: number, + subtitleStreamIndex?: number + ) { + return this.getClient()?.POST('/Sessions/Playing', { + body: { + CanSeek: true, + ItemId: itemId, + PlaySessionId: sessionId, + MediaSourceId: mediaSourceId, + AudioStreamIndex: 1, + SubtitleStreamIndex: -1 + } + }); + } + reportPlaybackProgress( + itemId: string, + sessionId: string, + isPaused: boolean, + positionTicks: number + ) { + return this.getClient()?.POST('/Sessions/Playing/Progress', { + body: { + ItemId: itemId, + PlaySessionId: sessionId, + IsPaused: isPaused, + PositionTicks: Math.round(positionTicks), + CanSeek: true, + MediaSourceId: itemId + } + }); + } + + reportPlaybackStopped(itemId: string, sessionId: string, positionTicks: number) { + return this.getClient()?.POST('/Sessions/Playing/Stopped', { + body: { + ItemId: itemId, + PlaySessionId: sessionId, + PositionTicks: Math.round(positionTicks), + MediaSourceId: itemId + } + }); + } + deleteActiveEncoding(playSessionId: string) { + return this.getClient()?.DELETE('/Videos/ActiveEncodings', { + params: { + query: { + deviceId: JELLYFIN_DEVICE_ID, + playSessionId: playSessionId + } + } + }); + } } export const jellyfinApi = new JellyfinApi(); diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte new file mode 100644 index 0000000..409cd54 --- /dev/null +++ b/src/lib/components/Button.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/src/lib/components/Sidebar/Sidebar.svelte b/src/lib/components/Sidebar/Sidebar.svelte index 7158cd6..2b9c28f 100644 --- a/src/lib/components/Sidebar/Sidebar.svelte +++ b/src/lib/components/Sidebar/Sidebar.svelte @@ -40,7 +40,7 @@ - navigate('/movie/76600')}> + navigate('/movie/695721')}>
diff --git a/src/lib/components/VideoPlayer/VideoPlayer.svelte b/src/lib/components/VideoPlayer/VideoPlayer.svelte index 6a9bba7..d16447b 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -1,19 +1,9 @@ @@ -374,7 +385,7 @@
handleUserInteraction(false)} on:touchend|preventDefault={() => handleUserInteraction(true)} @@ -407,7 +418,7 @@ {#if uiVisible}
diff --git a/src/lib/pages/MoviePage.svelte b/src/lib/pages/MoviePage.svelte index 3bbbf47..890f94b 100644 --- a/src/lib/pages/MoviePage.svelte +++ b/src/lib/pages/MoviePage.svelte @@ -5,11 +5,16 @@ import { TMDB_IMAGES_ORIGINAL } from '../constants'; import classNames from 'classnames'; import { DotFilled } from 'radix-icons-svelte'; - import SidebarMargin from '../components/SidebarMargin.svelte'; + import Button from '../components/Button.svelte'; + import { jellyfinApi } from '../apis/jellyfin/jellyfin-api'; + import VideoPlayer from '../components/VideoPlayer/VideoPlayer.svelte'; export let id: string; const movieDataP = tmdbApi.getTmdbMovie(Number(id)); + const jellyfinItem = jellyfinApi.getLibraryItemFromTmdbId(id); + + let playbackId: string = ''; let heroIndex: number; @@ -60,8 +65,19 @@
{/if} {/await} + {#await jellyfinItem then item} + {#if item} +
+ +
+ {/if} + {/await}
- history.back()}>Go back +
+ {#if playbackId} + + {/if} +
diff --git a/src/lib/stores/data.store.ts b/src/lib/stores/data.store.ts index 56fa255..7095871 100644 --- a/src/lib/stores/data.store.ts +++ b/src/lib/stores/data.store.ts @@ -25,7 +25,7 @@ type AwaitableStoreValue = { loading: boolean; } & T; -function _createDataFetchStore(fn: () => Promise) { +export function _createDataFetchStore(fn: () => Promise) { const store = writable>({ loading: true, data: undefined diff --git a/src/lib/stores/localstorage.store.ts b/src/lib/stores/localstorage.store.ts index 5c2ea9f..2e95474 100644 --- a/src/lib/stores/localstorage.store.ts +++ b/src/lib/stores/localstorage.store.ts @@ -22,3 +22,10 @@ export function createLocalStorageStore(key: string, defaultValue: T) { } export const skippedVersion = createLocalStorageStore('skipped-version', null); +export const videoPlayerSettings = createLocalStorageStore<{ + muted: boolean; + volume: number; +}>('video-player-settings', { + muted: false, + volume: 1 +});