mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-17 21:53:12 +02:00
Merge branch 'feat/Trailerv2' into dev
This commit is contained in:
10
backend/migrations/1739757875787-clear-metadata-cache.ts
Normal file
10
backend/migrations/1739757875787-clear-metadata-cache.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ClearMetadataCache1739757875787 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop all rows in SERIES table
|
||||
await queryRunner.query('DELETE FROM "series"');
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {}
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
"typeorm": "ts-node ./node_modules/typeorm/cli",
|
||||
"typeorm:run-migrations": "npm run typeorm migration:run -- -d ./dist/data-source.js",
|
||||
"typeorm:generate-migration": "ts-node ./node_modules/typeorm/cli -d ./dist/data-source.js migration:generate",
|
||||
"typeorm:create-migration": "ts-node ./node_modules/typeorm/cli migration:create ./migrations/$npm_config_name",
|
||||
"typeorm:create-migration": "ts-node ./node_modules/typeorm/cli migration:create",
|
||||
"typeorm:revert-migration": "ts-node ./node_modules/typeorm/cli -d ./dist/data-source.js migration:revert"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -24,7 +24,14 @@ async function createAdminUser(userService: UsersService) {
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: [
|
||||
'error',
|
||||
'warn',
|
||||
'log',
|
||||
...(ENV === 'development' ? (['debug'] as const) : []),
|
||||
],
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors();
|
||||
app.use(json({ limit: '50mb' }));
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from 'src/database/database.module';
|
||||
import { TMDB_CACHE_TTL } from 'src/consts';
|
||||
import { UsersModule } from 'src/users/users.module';
|
||||
import { metadataProviders } from './metadata.providers';
|
||||
import { MetadataService } from './metadata.service';
|
||||
import { TmdbModule } from './tmdb/tmdb.module';
|
||||
import { TmdbController } from './tmdb/tmdb.controller';
|
||||
import { tmdbProviders } from './tmdb/tmdb.providers';
|
||||
import { TmdbService } from './tmdb/tmdb.service';
|
||||
|
||||
@Module({
|
||||
imports: [TmdbModule],
|
||||
providers: [...metadataProviders, MetadataService],
|
||||
imports: [
|
||||
UsersModule,
|
||||
CacheModule.register({ ttl: TMDB_CACHE_TTL, max: 10_000 }),
|
||||
],
|
||||
providers: [
|
||||
...metadataProviders,
|
||||
MetadataService,
|
||||
...tmdbProviders,
|
||||
TmdbService,
|
||||
],
|
||||
controllers: [TmdbController],
|
||||
exports: [MetadataService],
|
||||
})
|
||||
export class MetadataModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Movie, Series } from './metadata.entity';
|
||||
import { MOVIE_REPOSITORY, SERIES_REPOSITORY } from './metadata.providers';
|
||||
@@ -9,6 +9,8 @@ import { TmdbService } from './tmdb/tmdb.service';
|
||||
|
||||
@Injectable()
|
||||
export class MetadataService {
|
||||
private logger = new Logger(MetadataService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(TMDB_API)
|
||||
private tmdbApi: TmdbApi,
|
||||
@@ -39,11 +41,7 @@ export class MetadataService {
|
||||
!movie.updatedAt ||
|
||||
new Date().getTime() - movie.updatedAt.getTime() > TMDB_CACHE_TTL
|
||||
) {
|
||||
const tmdbMovie = await this.tmdbApi.v3
|
||||
.movieDetails(Number(tmdbId), {
|
||||
append_to_response: 'videos,credits,external_ids,images',
|
||||
})
|
||||
.then((r) => r.data as TmdbMovieFull);
|
||||
const tmdbMovie = await this.tmdbService.getFullMovie(Number(tmdbId));
|
||||
movie.tmdbMovie = tmdbMovie;
|
||||
}
|
||||
|
||||
@@ -65,7 +63,7 @@ export class MetadataService {
|
||||
}
|
||||
|
||||
if (series.isStale()) {
|
||||
console.log('getting metadata for series', tmdbId);
|
||||
this.logger.debug(`Caching series ${tmdbId}`);
|
||||
const tmdbSeries = await this.tmdbService.getFullSeries(Number(tmdbId));
|
||||
if (tmdbSeries) series.tmdbSeries = tmdbSeries;
|
||||
}
|
||||
|
||||
@@ -1,55 +1,28 @@
|
||||
import {
|
||||
Cache,
|
||||
CACHE_MANAGER
|
||||
} from '@nestjs/cache-manager';
|
||||
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import {
|
||||
All,
|
||||
Controller,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { GetAuthUser, UserAccessControl } from 'src/auth/auth.guard';
|
||||
import { TMDB_API_KEY, TMDB_CACHE_TTL } from 'src/consts';
|
||||
import { User } from 'src/users/user.entity';
|
||||
import { MetadataService } from '../metadata.service';
|
||||
|
||||
@UseGuards(UserAccessControl)
|
||||
@Controller('tmdb')
|
||||
export class TmdbController {
|
||||
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||
// constructor(private metadataService: MetadataService) {}
|
||||
|
||||
// @Get('v3/proxy/3/movie/:tmdbId')
|
||||
// async getMovieDetails(
|
||||
// @Param('tmdbId') tmdbId: string,
|
||||
// @Next() next: NextFunction,
|
||||
// @GetAuthUser() user: User,
|
||||
// // @Res({ passthrough: true }) res: Response, // Passthrough required
|
||||
// ) {
|
||||
// if (!parseInt(tmdbId)) {
|
||||
// console.log('Invalid TMDB ID', tmdbId);
|
||||
// next();
|
||||
// return;
|
||||
// }
|
||||
|
||||
// console.log('getting cached movie', tmdbId);
|
||||
|
||||
// const movie = await this.metadataService
|
||||
// .getMovieByTmdbId(tmdbId)
|
||||
// .catch((e) => {
|
||||
// console.error('Error getting movie by TMDB ID', tmdbId, e);
|
||||
// return null;
|
||||
// });
|
||||
|
||||
// if (!movie?.tmdbMovie) {
|
||||
// console.error('No movie found for TMDB ID', tmdbId);
|
||||
// }
|
||||
// console.log('returning cached movie', tmdbId);
|
||||
// return movie?.tmdbMovie;
|
||||
// }
|
||||
private logger = new Logger(TmdbController.name);
|
||||
constructor(
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
private metadataService: MetadataService,
|
||||
) {}
|
||||
|
||||
// @UseInterceptors(CacheInterceptor)
|
||||
// @CacheTTL(METADATA_CACHE_TTL)
|
||||
@@ -62,7 +35,7 @@ export class TmdbController {
|
||||
) {
|
||||
const uri = params[0] + '?' + req.url.split('?')[1];
|
||||
const cached = await this.cacheManager.get(uri).catch((e) => {
|
||||
console.error('Error getting cache', e);
|
||||
this.logger.error('Error getting cache', e);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -72,44 +45,34 @@ export class TmdbController {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// console.log('TMDB PROXY', req.url);
|
||||
// 3/tv/87739?append_to_response=videos%2Caggregate_credits%2Cexternal_ids%2Cimages&include_image_language=en%2Cen%2Cnull
|
||||
const first = uri.split('?')?.[0];
|
||||
if (req.method === 'GET' && first.match(/3\/tv\/\d+$/)) {
|
||||
const tmdbId = first.split('/').pop();
|
||||
this.logger.debug(`Getting series from cache: ${tmdbId}`);
|
||||
const metadata = await this.metadataService.getSeriesByTmdbId(tmdbId);
|
||||
res.json(metadata.tmdbSeries);
|
||||
return metadata;
|
||||
} else if (req.method === 'GET' && first.match(/3\/movie\/\d+$/)) {
|
||||
const tmdbId = first.split('/').pop();
|
||||
this.logger.debug(`Getting movie from cache: ${tmdbId}`);
|
||||
const metadata = await this.metadataService.getMovieByTmdbId(tmdbId);
|
||||
res.json(metadata.tmdbMovie);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
// if (params[0].match(/^3\/movie\/\d+\/?$/)) {
|
||||
// // console.log('req.params', req.params);
|
||||
|
||||
// const movie = await this.metadataService.getMovieByTmdbId(
|
||||
// req.params[0].split('/')[2],
|
||||
// );
|
||||
|
||||
// // console.log('movie', movie);
|
||||
// if (movie?.tmdbMovie) {
|
||||
// // console.log('returning cached movie');
|
||||
// res.json(movie.tmdbMovie);
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
this.logger.debug(`TMDB proxy cache miss: ${req.method} ${uri}`);
|
||||
|
||||
const proxyRes = await fetch(`https://api.themoviedb.org/${uri}`, {
|
||||
method: req.method || 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TMDB_API_KEY}`,
|
||||
// ...headers,
|
||||
// Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${settings.apiKey}"`,
|
||||
},
|
||||
})
|
||||
// .then((r) => {
|
||||
// // r.text().then((text) =>
|
||||
// // console.log('TMDB Proxy response', uri, r.status, text),
|
||||
// // );
|
||||
// return r;
|
||||
// })
|
||||
.catch((e) => {
|
||||
console.error('TMDB Proxy error', e);
|
||||
// res.status(500).send('Proxy error');
|
||||
throw e;
|
||||
});
|
||||
}).catch((e) => {
|
||||
this.logger.error('TMDB Proxy error', e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
// Readable.from(proxyRes.body).pipe(res);
|
||||
const json = await proxyRes.json();
|
||||
res.status(proxyRes.status);
|
||||
res.json(json);
|
||||
|
||||
@@ -12,11 +12,22 @@ export type MovieExternalIds = Awaited<
|
||||
export type MovieImages = Awaited<
|
||||
ReturnType<TmdbApi['v3']['movieImages']>
|
||||
>['data'];
|
||||
|
||||
export type TmdbMovie = Awaited<
|
||||
ReturnType<TmdbApi['v3']['movieDetails']>
|
||||
>['data'];
|
||||
|
||||
export type SeriesVideos = Awaited<
|
||||
ReturnType<TmdbApi['v3']['tvSeriesVideos']>
|
||||
>['data'];
|
||||
export type SeriesCredits = Awaited<
|
||||
ReturnType<TmdbApi['v3']['tvSeriesAggregateCredits']>
|
||||
>['data'];
|
||||
export type SeriesExternalIds = Awaited<
|
||||
ReturnType<TmdbApi['v3']['tvSeriesExternalIds']>
|
||||
>['data'];
|
||||
export type SeriesImages = Awaited<
|
||||
ReturnType<TmdbApi['v3']['tvSeriesImages']>
|
||||
>['data'];
|
||||
export type TmdbSeries = Awaited<
|
||||
ReturnType<TmdbApi['v3']['tvSeriesDetails']>
|
||||
>['data'];
|
||||
@@ -28,4 +39,9 @@ export type TmdbMovieFull = TmdbMovie & {
|
||||
images: MovieImages;
|
||||
};
|
||||
|
||||
export type TmdbSeriesFull = TmdbSeries;
|
||||
export type TmdbSeriesFull = TmdbSeries & {
|
||||
videos: SeriesVideos;
|
||||
aggregate_credits: SeriesCredits;
|
||||
external_ids: SeriesExternalIds;
|
||||
images: SeriesImages;
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TMDB_CACHE_TTL } from 'src/consts';
|
||||
import { UsersModule } from 'src/users/users.module';
|
||||
import { TmdbController } from './tmdb.controller';
|
||||
import { tmdbProviders } from './tmdb.providers';
|
||||
import { TmdbService } from './tmdb.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
CacheModule.register({ ttl: TMDB_CACHE_TTL, max: 10_000 }),
|
||||
],
|
||||
providers: [...tmdbProviders, TmdbService],
|
||||
exports: [...tmdbProviders, TmdbService],
|
||||
controllers: [TmdbController],
|
||||
})
|
||||
export class TmdbModule {}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TmdbSeriesFull } from './tmdb.dto';
|
||||
import { TmdbMovieFull, TmdbSeriesFull } from './tmdb.dto';
|
||||
import { TMDB_API, TmdbApi } from './tmdb.providers';
|
||||
|
||||
@Injectable()
|
||||
@@ -11,8 +11,10 @@ export class TmdbService {
|
||||
|
||||
async getFullSeries(tmdbId: number): Promise<TmdbSeriesFull> {
|
||||
const tmdbSeries = await this.tmdbApi.v3
|
||||
.tvSeriesDetails(Number(tmdbId))
|
||||
.then((r) => r.data);
|
||||
.tvSeriesDetails(Number(tmdbId), {
|
||||
append_to_response: 'videos,aggregate_credits,external_ids,images',
|
||||
})
|
||||
.then((r) => r.data as TmdbSeriesFull);
|
||||
// .catch((e) => {
|
||||
// console.error('could not get metadata for series', tmdbId, e);
|
||||
// return e;
|
||||
@@ -20,4 +22,12 @@ export class TmdbService {
|
||||
|
||||
return tmdbSeries;
|
||||
}
|
||||
|
||||
async getFullMovie(tmdbId: number) {
|
||||
return this.tmdbApi.v3
|
||||
.movieDetails(Number(tmdbId), {
|
||||
append_to_response: 'videos,credits,external_ids,images',
|
||||
})
|
||||
.then((r) => r.data as TmdbMovieFull);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,35 @@ export class TmdbApi implements Api<paths> {
|
||||
getPersonBackdrops = async (person_id: number) =>
|
||||
this.getPersonTaggedImages(person_id).then((r) => r.filter((i) => (i.aspect_ratio || 0) > 1.5));
|
||||
|
||||
getMovieVideos = async (tmdbId: number) => {
|
||||
return this.getClient()
|
||||
.GET('/3/movie/{movie_id}/videos', {
|
||||
params: {
|
||||
path: {
|
||||
movie_id: tmdbId
|
||||
},
|
||||
query: {
|
||||
language: get(settings)?.language || 'en',
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.data?.results || []);
|
||||
};
|
||||
getSeriesVideos = async (tmdbId: number) => {
|
||||
return this.getClient()
|
||||
?.GET('/3/tv/{series_id}/videos', {
|
||||
params: {
|
||||
path: {
|
||||
series_id: tmdbId
|
||||
},
|
||||
query: {
|
||||
language: get(settings)?.language || 'en',
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.data?.results || []);
|
||||
};
|
||||
|
||||
// OTHER
|
||||
|
||||
// USER
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
import classNames from 'classnames';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { isFirefox } from '../../utils/browser-detection';
|
||||
import YouTubeVideo from '../YoutubeVideo.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { localSettings } from '$lib/stores/localstorage.store';
|
||||
|
||||
export let urls: Promise<string[]>;
|
||||
export let items: Promise<{ backdropUrl: string; videoUrl?: string }[]>;
|
||||
export let index: number;
|
||||
export let hasFocus = true;
|
||||
export let heroHasFocus = false;
|
||||
export let hideInterface = false;
|
||||
let visibleIndex = -2;
|
||||
let visibleIndexTimeout: ReturnType<typeof setTimeout>;
|
||||
@@ -36,17 +40,21 @@
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0" style="-webkit-transform: translate3d(0,0,0);">
|
||||
{#if !isFirefox()}
|
||||
{#await urls then urls}
|
||||
{#each urls as url, i}
|
||||
{#if true}
|
||||
{#await items then items}
|
||||
{#each items as { videoUrl, backdropUrl }, i}
|
||||
<div
|
||||
class={classNames('absolute inset-0 bg-center bg-cover', {
|
||||
'opacity-100': visibleIndex === i,
|
||||
'opacity-0': visibleIndex !== i,
|
||||
'scale-110': !hasFocus
|
||||
})}
|
||||
style={`background-image: url('${url}'); transition: opacity 500ms, transform 500ms;`}
|
||||
/>
|
||||
style={`background-image: url('${backdropUrl}'); transition: opacity 500ms, transform 500ms;`}
|
||||
>
|
||||
{#if videoUrl && i === visibleIndex && $localSettings.enableTrailers && $localSettings.autoplayTrailers}
|
||||
<YouTubeVideo videoId={videoUrl} play={heroHasFocus} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/await}
|
||||
{:else}
|
||||
@@ -56,8 +64,8 @@
|
||||
})}
|
||||
style="perspective: 1px; -webkit-perspective: 1px;"
|
||||
>
|
||||
{#await urls then urls}
|
||||
{#each urls as url, i}
|
||||
{#await items then items}
|
||||
{#each items as { backdropUrl, videoUrl }, i}
|
||||
<div
|
||||
class="w-full h-full flex-shrink-0 basis-auto relative"
|
||||
style="transform-style: preserve-3d; -webkit-transform-style: preserve-3d; overflow: hidden;"
|
||||
@@ -65,11 +73,14 @@
|
||||
>
|
||||
<div
|
||||
class="w-full h-full flex-shrink-0 basis-auto bg-center bg-cover absolute inset-0"
|
||||
style={`background-image: url('${url}'); ${
|
||||
style={`background-image: url('${backdropUrl}'); ${
|
||||
!PLATFORM_TV &&
|
||||
'transform: translateZ(-5px) scale(6); -webkit-transform: translateZ(-5px) scale(6);'
|
||||
}`}
|
||||
/>
|
||||
<!-- {#if videoUrl && mountVideo}
|
||||
<YouTubeBackground videoId={videoUrl} backgroundUrl={backdropUrl} />
|
||||
{/if} -->
|
||||
</div>
|
||||
{/each}
|
||||
{/await}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Container from '../Container.svelte';
|
||||
import HeroShowcaseBackground from './HeroBackground.svelte';
|
||||
import HeroBackground from './HeroBackground.svelte';
|
||||
import IconButton from '../FloatingIconButton.svelte';
|
||||
import { ChevronRight } from 'radix-icons-svelte';
|
||||
import PageDots from '../HeroShowcase/PageDots.svelte';
|
||||
@@ -10,13 +10,13 @@
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let urls: Promise<string[]>;
|
||||
export let items: Promise<{ backdropUrl: string; videoUrl?: string }[]>;
|
||||
export let index = 0;
|
||||
export let hideInterface = false;
|
||||
|
||||
let length = 0;
|
||||
|
||||
$: urls.then((urls) => (length = urls.length));
|
||||
$: items.then((urls) => (length = urls.length));
|
||||
|
||||
function onNext() {
|
||||
if (index === length - 1) {
|
||||
@@ -42,9 +42,9 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
let hasFocusWithin: Readable<boolean>;
|
||||
let heroHasFocusWithin: Readable<boolean>;
|
||||
let focusIndex: Writable<number>;
|
||||
$: backgroundHasFocus = $hasFocusWithin && $focusIndex === 0;
|
||||
$: backgroundHasFocus = $heroHasFocusWithin && $focusIndex === 0;
|
||||
</script>
|
||||
|
||||
<Container
|
||||
@@ -68,10 +68,10 @@
|
||||
dispatch('navigate', detail);
|
||||
}
|
||||
}}
|
||||
bind:hasFocusWithin
|
||||
bind:hasFocusWithin={heroHasFocusWithin}
|
||||
bind:focusIndex
|
||||
>
|
||||
<HeroShowcaseBackground {urls} {index} hasFocus={backgroundHasFocus} {hideInterface} />
|
||||
<HeroBackground {items} {index} hasFocus={backgroundHasFocus} heroHasFocus={$heroHasFocusWithin} {hideInterface} />
|
||||
<div
|
||||
class={classNames('flex flex-1 z-10 transition-opacity', {
|
||||
'opacity-0': hideInterface
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
type: 'movie' | 'tv';
|
||||
posterUri: string;
|
||||
backdropUri: string;
|
||||
trailerUrl?: string;
|
||||
videoUrl?: string;
|
||||
title: string;
|
||||
overview: string;
|
||||
infoProperties: { label: string; href?: string }[];
|
||||
@@ -35,7 +35,12 @@
|
||||
</script>
|
||||
|
||||
<HeroCarousel
|
||||
urls={items.then((items) => items.map((i) => `${TMDB_IMAGES_ORIGINAL}${i.backdropUri}`))}
|
||||
items={items.then((items) =>
|
||||
items.map((i) => ({
|
||||
backdropUrl: `${TMDB_IMAGES_ORIGINAL}${i.backdropUri}`,
|
||||
videoUrl: i.videoUrl
|
||||
}))
|
||||
)}
|
||||
bind:index={showcaseIndex}
|
||||
on:enter
|
||||
on:navigate={({ detail }) => {
|
||||
|
||||
@@ -3,35 +3,47 @@
|
||||
import { formatMinutesToTime, formatThousands } from '$lib/utils';
|
||||
import { navigate } from '../StackRouter/StackRouter';
|
||||
import HeroShowcase from './HeroShowcase.svelte';
|
||||
import { tmdbApi } from '$lib/apis/tmdb/tmdb-api';
|
||||
|
||||
export let movies: Promise<TmdbMovie2[]>;
|
||||
$: items = movies.then((movies) =>
|
||||
movies.map((movie) => ({
|
||||
id: movie.id ?? 0,
|
||||
type: 'movie' as const,
|
||||
posterUri: movie.poster_path ?? '',
|
||||
backdropUri: movie.backdrop_path ?? '',
|
||||
title: `${movie.title}`,
|
||||
overview: movie.overview ?? '',
|
||||
infoProperties: [
|
||||
...(movie.release_date
|
||||
? [{ label: new Date(movie.release_date).getFullYear().toString() }]
|
||||
: []),
|
||||
...(movie.runtime ? [{ label: formatMinutesToTime(movie.runtime) }] : []),
|
||||
...(movie.vote_average
|
||||
? [
|
||||
{
|
||||
label: `${movie.vote_average.toFixed(1)} TMDB (${formatThousands(
|
||||
movie.vote_count ?? 0
|
||||
)})`,
|
||||
href: `https://www.themoviedb.org/movie/${movie.id}`
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(movie.genres ? [{ label: movie.genres.map((genre) => genre.name).join(', ') }] : [])
|
||||
]
|
||||
}))
|
||||
);
|
||||
|
||||
$: items = movies
|
||||
.then(async (movies) =>
|
||||
movies.map(async (movie) => {
|
||||
const movieFull = await tmdbApi.getTmdbMovie(movie.id ?? 0);
|
||||
const videoUrl = movieFull?.videos?.results?.find(
|
||||
(video) => video.type === 'Trailer' && video.site === 'YouTube'
|
||||
)?.key;
|
||||
|
||||
return {
|
||||
id: movie.id ?? 0,
|
||||
type: 'movie' as const,
|
||||
posterUri: movie.poster_path ?? '',
|
||||
backdropUri: movie.backdrop_path ?? '',
|
||||
title: movie.title ?? '',
|
||||
overview: movie.overview ?? '',
|
||||
videoUrl,
|
||||
infoProperties: [
|
||||
...(movie.release_date
|
||||
? [{ label: new Date(movie.release_date).getFullYear().toString() }]
|
||||
: []),
|
||||
...(movie.runtime ? [{ label: formatMinutesToTime(movie.runtime) }] : []),
|
||||
...(movie.vote_average
|
||||
? [
|
||||
{
|
||||
label: `${movie.vote_average.toFixed(1)} TMDB (${formatThousands(
|
||||
movie.vote_count ?? 0
|
||||
)})`,
|
||||
href: `https://www.themoviedb.org/movie/${movie.id}`
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(movie.genres ? [{ label: movie.genres.map((genre) => genre.name).join(', ') }] : [])
|
||||
]
|
||||
};
|
||||
})
|
||||
)
|
||||
.then((i) => Promise.all(i));
|
||||
</script>
|
||||
|
||||
<HeroShowcase on:select={({ detail }) => navigate(`/movie/${detail?.id}`)} {items} />
|
||||
|
||||
@@ -1,38 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { TmdbMovie2, TmdbSeries2 } from '$lib/apis/tmdb/tmdb-api';
|
||||
import { formatMinutesToTime, formatThousands } from '$lib/utils';
|
||||
import type { TmdbSeries2 } from '$lib/apis/tmdb/tmdb-api';
|
||||
import { formatThousands } from '$lib/utils';
|
||||
import { navigate } from '../StackRouter/StackRouter';
|
||||
import HeroShowcase from './HeroShowcase.svelte';
|
||||
import { tmdbApi } from '$lib/apis/tmdb/tmdb-api';
|
||||
|
||||
export let series: Promise<TmdbSeries2[]>;
|
||||
$: items = series.then((series) =>
|
||||
series.map((series) => ({
|
||||
id: series.id ?? 0,
|
||||
type: 'movie' as const,
|
||||
posterUri: series.poster_path ?? '',
|
||||
backdropUri: series.backdrop_path ?? '',
|
||||
title: `${series.name}`,
|
||||
overview: series.overview ?? '',
|
||||
infoProperties: [
|
||||
...(series.status !== 'Ended'
|
||||
? [{ label: `Since ${new Date(series.first_air_date ?? 0).getFullYear()}` }]
|
||||
: series.last_air_date
|
||||
? [{ label: `Ended ${new Date(series.last_air_date).getFullYear()}` }]
|
||||
: []),
|
||||
...(series.vote_average
|
||||
? [
|
||||
{
|
||||
label: `${series.vote_average.toFixed(1)} TMDB (${formatThousands(
|
||||
series.vote_count ?? 0
|
||||
)})`,
|
||||
href: `https://www.themoviedb.org/tv/${series.id}`
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(series.genres ? [{ label: series.genres.map((genre) => genre.name).join(', ') }] : [])
|
||||
]
|
||||
}))
|
||||
);
|
||||
|
||||
$: items = series.then(async (series) => {
|
||||
return Promise.all(
|
||||
series.map(async (series) => {
|
||||
const seriesFull = await tmdbApi.getTmdbSeries(series.id ?? 0);
|
||||
const videoUrl = seriesFull?.videos?.results?.find(
|
||||
(video) => video.type === 'Trailer' && video.site === 'YouTube'
|
||||
)?.key;
|
||||
|
||||
return {
|
||||
id: series.id ?? 0,
|
||||
type: 'tv' as const,
|
||||
posterUri: series.poster_path ?? '',
|
||||
backdropUri: series.backdrop_path ?? '',
|
||||
title: series.name ?? '',
|
||||
overview: series.overview ?? '',
|
||||
videoUrl,
|
||||
infoProperties: [
|
||||
...(series.status !== 'Ended'
|
||||
? [{ label: `Since ${new Date(series.first_air_date ?? 0).getFullYear()}` }]
|
||||
: series.last_air_date
|
||||
? [{ label: `Ended ${new Date(series.last_air_date).getFullYear()}` }]
|
||||
: []),
|
||||
...(series.vote_average
|
||||
? [
|
||||
{
|
||||
label: `${series.vote_average.toFixed(1)} TMDB (${formatThousands(
|
||||
series.vote_count ?? 0
|
||||
)})`,
|
||||
href: `https://www.themoviedb.org/tv/${series.id}`
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(series.genres ? [{ label: series.genres.map((genre) => genre.name).join(', ') }] : [])
|
||||
]
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<HeroShowcase on:select={({ detail }) => navigate(`/series/${detail?.id}`)} {items} />
|
||||
|
||||
222
src/lib/components/YoutubeVideo.svelte
Normal file
222
src/lib/components/YoutubeVideo.svelte
Normal file
@@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import { PLATFORM_TV } from '$lib/constants';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const STOP_WHEN_REMAINING = 12;
|
||||
|
||||
export let videoId: string | null = null;
|
||||
export let play = false;
|
||||
export let autoplay = true;
|
||||
export let autoplayDelay = 2000;
|
||||
export let loadTime = PLATFORM_TV ? 2500 : 1000;
|
||||
|
||||
const playerId = `youtube-player-${videoId}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
let didMount = false;
|
||||
let isInitialized = false;
|
||||
let player: YT['Player'];
|
||||
let isPlayerReady = false;
|
||||
let checkStopInterval: ReturnType<typeof setInterval>;
|
||||
let autoplayTimeout: ReturnType<typeof setTimeout>;
|
||||
let loadTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
$: if (isInitialized && player?.playVideo && play) {
|
||||
player.playVideo();
|
||||
} else if (isInitialized && player?.pauseVideo && !play) {
|
||||
player.pauseVideo();
|
||||
}
|
||||
|
||||
$: if (didMount && !isInitialized && play) loadYouTubeAPI();
|
||||
function loadYouTubeAPI() {
|
||||
isInitialized = true;
|
||||
console.log('Loading YouTube API for ' + videoId);
|
||||
if (!window.YT) {
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://www.youtube.com/iframe_api';
|
||||
document.head.appendChild(tag);
|
||||
(window as any).onYouTubeIframeAPIReady = () => {
|
||||
setupPlayer();
|
||||
};
|
||||
} else {
|
||||
setupPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
function destroyPlayer() {
|
||||
console.log('Destroying player');
|
||||
|
||||
clearInterval(checkStopInterval);
|
||||
clearTimeout(autoplayTimeout);
|
||||
clearTimeout(loadTimeout);
|
||||
isPlayerReady = false;
|
||||
|
||||
if (!player) return;
|
||||
|
||||
try {
|
||||
player.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying player.', e);
|
||||
}
|
||||
}
|
||||
|
||||
function setupPlayer() {
|
||||
if (!window.YT || !videoId) return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (!window.YT) return;
|
||||
|
||||
player = new window.YT.Player(playerId, {
|
||||
videoId: videoId,
|
||||
playerVars: {
|
||||
autoplay: 0,
|
||||
controls: 0,
|
||||
modestbranding: 1,
|
||||
rel: 0,
|
||||
iv_load_policy: 3,
|
||||
start: 3,
|
||||
fs: 0,
|
||||
disablekb: 1,
|
||||
cc_load_policy: 0,
|
||||
mute: 1
|
||||
},
|
||||
events: {
|
||||
onReady: () => {
|
||||
player?.playVideo();
|
||||
play = true;
|
||||
if (loadTime) {
|
||||
loadTimeout = setTimeout(() => {
|
||||
isPlayerReady = true;
|
||||
console.log('Playing video');
|
||||
}, loadTime);
|
||||
} else {
|
||||
isPlayerReady = true;
|
||||
console.log('Playing video');
|
||||
}
|
||||
},
|
||||
onStateChange: handlePlayerStateChange,
|
||||
onError: handlePlayerError
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handlePlayerError(event: any) {
|
||||
const errorMessages: Record<number, string> = {
|
||||
2: 'Invalid video ID.',
|
||||
5: 'Playback error.',
|
||||
100: 'Video not found.',
|
||||
101: 'Embedding restricted by the owner.',
|
||||
150: 'Embedding restricted by the owner.'
|
||||
};
|
||||
|
||||
console.error('YouTube Player Error:', errorMessages[event.data] || 'Unknown error.');
|
||||
destroyPlayer();
|
||||
}
|
||||
|
||||
function handlePlayerStateChange(event: any) {
|
||||
if (!isPlayerReady) return;
|
||||
|
||||
if (event.data === window.YT.PlayerState.PLAYING) {
|
||||
// setTimeout(() => (showBackgroundImage = false), 1000);
|
||||
|
||||
clearInterval(checkStopInterval);
|
||||
|
||||
checkStopInterval = setInterval(() => {
|
||||
if (
|
||||
!player
|
||||
// showBackgroundImageError ||
|
||||
) {
|
||||
clearInterval(checkStopInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingTime = player.getDuration() - player.getCurrentTime();
|
||||
|
||||
if (remainingTime <= STOP_WHEN_REMAINING) {
|
||||
try {
|
||||
player.pauseVideo();
|
||||
player.seekTo(0);
|
||||
player.playVideo();
|
||||
} catch (e) {
|
||||
console.warn('Error looping video.', e);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
} else if (event.data === window.YT.PlayerState.ENDED) {
|
||||
try {
|
||||
player.seekTo(0);
|
||||
player.playVideo();
|
||||
} catch (e) {
|
||||
console.warn('Error restarting video.', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (autoplay) {
|
||||
autoplayTimeout = setTimeout(() => {
|
||||
play = true;
|
||||
didMount = true;
|
||||
}, autoplayDelay);
|
||||
} else {
|
||||
didMount = true;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroyPlayer();
|
||||
});
|
||||
$: {
|
||||
const el = document.getElementById(playerId);
|
||||
if (el) el.style.opacity = isPlayerReady && play ? '1' : '0';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div out:fade={{ delay: isPlayerReady && play ? 2000 : 0 }}>
|
||||
<div id={playerId} class="video-background" style="opacity: 0;" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.video-background {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
transform: translate(-50%, -50%) scale(1.6);
|
||||
transition: transform 0.5s ease-in-out, opacity 1s ease-in-out;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.video-background {
|
||||
transform: translate(-50%, -50%) scale(2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.video-background {
|
||||
transform: translate(-50%, -50%) scale(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.video-background {
|
||||
transform: translate(-50%, -50%) scale(3);
|
||||
}
|
||||
}
|
||||
|
||||
.background-image {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -332,6 +332,22 @@
|
||||
localSettings.update((p) => ({ ...p, checkForUpdates: detail }))}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-lg font-medium text-secondary-100 py-2">
|
||||
<label class="mr-2">Enable Trailers</label>
|
||||
<Toggle
|
||||
checked={$localSettings.enableTrailers}
|
||||
on:change={({ detail }) =>
|
||||
localSettings.update((p) => ({ ...p, enableTrailers: detail }))}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-lg font-medium text-secondary-100 py-2">
|
||||
<label class="mr-2">Autoplay Trailers</label>
|
||||
<Toggle
|
||||
checked={$localSettings.autoplayTrailers}
|
||||
on:change={({ detail }) =>
|
||||
localSettings.update((p) => ({ ...p, autoplayTrailers: detail }))}
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab {...tab} direction="vertical" tab={Tabs.About}>
|
||||
|
||||
@@ -37,6 +37,23 @@
|
||||
|
||||
$: recommendations = tmdbApi.getMovieRecommendations(tmdbId);
|
||||
|
||||
$: images = $tmdbMovie.then((movie) => {
|
||||
const trailer = movie?.videos?.results?.find(
|
||||
(video) => video.type === 'Trailer' && video.site === 'YouTube'
|
||||
)?.key;
|
||||
|
||||
return (
|
||||
movie?.images.backdrops
|
||||
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
|
||||
?.map((bd, i) => ({
|
||||
backdropUrl: TMDB_IMAGES_ORIGINAL + bd.file_path || '',
|
||||
|
||||
videoUrl: trailer && i === 0 ? trailer : undefined
|
||||
}))
|
||||
.slice(0, 5) || []
|
||||
);
|
||||
});
|
||||
|
||||
let titleProperties: { href?: string; label: string }[] = [];
|
||||
$tmdbMovie.then((movie) => {
|
||||
if (movie?.runtime) {
|
||||
@@ -71,15 +88,7 @@
|
||||
class="h-[calc(100vh-4rem)] flex flex-col py-16 px-32"
|
||||
on:enter={scrollIntoView({ top: 999 })}
|
||||
>
|
||||
<HeroCarousel
|
||||
urls={$tmdbMovie.then(
|
||||
(movie) =>
|
||||
movie?.images.backdrops
|
||||
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
|
||||
?.map((bd) => TMDB_IMAGES_ORIGINAL + bd.file_path || '')
|
||||
.slice(0, 5) || []
|
||||
)}
|
||||
>
|
||||
<HeroCarousel items={images}>
|
||||
<Container />
|
||||
<div class="h-full flex-1 flex flex-col justify-end">
|
||||
{#await $tmdbMovie then movie}
|
||||
|
||||
@@ -40,6 +40,22 @@
|
||||
const episodeCards = useRegistrar();
|
||||
let scrollTop: number;
|
||||
|
||||
$: images = $tmdbSeries.then((series) => {
|
||||
const trailer = series?.videos?.results?.find(
|
||||
(video) => video.type === 'Trailer' && video.site === 'YouTube'
|
||||
)?.key;
|
||||
|
||||
return (
|
||||
series?.images.backdrops
|
||||
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
|
||||
?.map((bd, i) => ({
|
||||
backdropUrl: TMDB_IMAGES_ORIGINAL + bd.file_path || '',
|
||||
videoUrl: trailer && i === 0 ? trailer : undefined
|
||||
}))
|
||||
.slice(0, 5) || []
|
||||
);
|
||||
});
|
||||
|
||||
let titleProperties: { href?: string; label: string }[] = [];
|
||||
$tmdbSeries.then((series) => {
|
||||
if (series && series.status !== 'Ended') {
|
||||
@@ -86,15 +102,7 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HeroCarousel
|
||||
urls={$tmdbSeries.then(
|
||||
(series) =>
|
||||
series?.images.backdrops
|
||||
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
|
||||
?.map((i) => TMDB_IMAGES_ORIGINAL + i.file_path)
|
||||
.slice(0, 5) || []
|
||||
)}
|
||||
>
|
||||
<HeroCarousel items={images}>
|
||||
<Container />
|
||||
<div class="h-full flex-1 flex flex-col justify-end">
|
||||
{#await $tmdbSeries then series}
|
||||
|
||||
@@ -44,11 +44,15 @@ export const localSettings = createLocalStorageStore<{
|
||||
useCssTransitions: boolean;
|
||||
checkForUpdates: boolean;
|
||||
skippedVersion: string;
|
||||
enableTrailers: boolean;
|
||||
autoplayTrailers: boolean;
|
||||
}>('settings', {
|
||||
animateScrolling: true,
|
||||
useCssTransitions: true,
|
||||
checkForUpdates: true,
|
||||
skippedVersion: ''
|
||||
skippedVersion: '',
|
||||
enableTrailers: true,
|
||||
autoplayTrailers: true
|
||||
});
|
||||
|
||||
export type LibraryViewSettings = {
|
||||
|
||||
@@ -9,4 +9,59 @@ export type MediaType = 'Movie' | 'Series';
|
||||
|
||||
declare global {
|
||||
const REIVERR_VERSION: string;
|
||||
|
||||
type YTPlayerOptions = {
|
||||
videoId: string;
|
||||
playerVars: Record<string, string | number | boolean>;
|
||||
// playerVars: {
|
||||
// autoplay: 0 | 1;
|
||||
// controls: 0 | 1;
|
||||
// disablekb: 0 | 1;
|
||||
// enablejsapi: 0 | 1;
|
||||
// iv_load_policy: 1 | 3;
|
||||
// loop: 0 | 1;
|
||||
// modestbranding: 0 | 1;
|
||||
// playsinline: 0 | 1;
|
||||
// rel: 0 | 1;
|
||||
// showinfo: 0 | 1;
|
||||
// start: number;
|
||||
// fs: 0 | 1;
|
||||
// cc_load_policy: 0 | 1;
|
||||
// mute: 0 | 1;
|
||||
// };
|
||||
events: {
|
||||
onReady: (event: any) => void;
|
||||
onStateChange: (event: any) => void;
|
||||
onError: (event: any) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type YTPlayer = {
|
||||
new (id: string, options: YTPlayerOptions): YTPlayer;
|
||||
destroy(): void;
|
||||
getDuration(): number;
|
||||
getCurrentTime(): number;
|
||||
pauseVideo(): void;
|
||||
playVideo(): void;
|
||||
stopVideo(): void;
|
||||
seekTo(seconds: number, allowSeekAhead?: boolean): void;
|
||||
};
|
||||
|
||||
// Youtube API
|
||||
interface YT {
|
||||
Player: YTPlayer;
|
||||
PlayerState: {
|
||||
ENDED: number;
|
||||
PLAYING: number;
|
||||
PAUSED: number;
|
||||
BUFFERING: number;
|
||||
CUED: number;
|
||||
};
|
||||
}
|
||||
|
||||
const YT: YT;
|
||||
|
||||
interface Window {
|
||||
YT: YT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@
|
||||
<name>Reiverr</name>
|
||||
<tizen:profile name="tv-samsung"/>
|
||||
<tizen:setting screen-orientation="landscape" context-menu="enable" background-support="disable" encryption="disable" install-location="auto" hwkey-event="enable"/>
|
||||
<tizen:allow-navigation>*</tizen:allow-navigation>
|
||||
<access origin="http://youtube.com" subdomains="true"/>
|
||||
</widget>
|
||||
|
||||
Reference in New Issue
Block a user