mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-17 21:53:12 +02:00
feat: tmdb cache, plugin support changes, series page, episode page, movie page streaming updated
This commit is contained in:
3705
backend/package-lock.json
generated
3705
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
2673
backend/plugins/jellyfin.plugin/package-lock.json
generated
2673
backend/plugins/jellyfin.plugin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -165,12 +165,6 @@ export default class JellyfinPlugin implements SourcePlugin {
|
||||
},
|
||||
});
|
||||
|
||||
getEpisodeStream: (
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
) => Promise<any>;
|
||||
|
||||
// handleProxy({ uri, headers }, settings: JellyfinSettings) {
|
||||
// return {
|
||||
// url: `https://tmstr2.luminousstreamhaven.com/${uri}`,
|
||||
@@ -185,9 +179,13 @@ export default class JellyfinPlugin implements SourcePlugin {
|
||||
return context.api.items
|
||||
.getItems({
|
||||
userId: context.settings.userId,
|
||||
hasTmdbId: true,
|
||||
// hasTmdbId: true,
|
||||
recursive: true,
|
||||
includeItemTypes: [BaseItemKind.Movie, BaseItemKind.Series],
|
||||
includeItemTypes: [
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.Series,
|
||||
BaseItemKind.Episode,
|
||||
],
|
||||
fields: [
|
||||
ItemFields.ProviderIds,
|
||||
ItemFields.Genres,
|
||||
@@ -199,8 +197,27 @@ export default class JellyfinPlugin implements SourcePlugin {
|
||||
.then((res) => res.data.Items ?? []);
|
||||
}
|
||||
|
||||
// private async getLibraryEpisodes(context: PluginContext) {
|
||||
// return context.api.items
|
||||
// .getItems({
|
||||
// userId: context.settings.userId,
|
||||
// // hasTmdbId: true,
|
||||
// recursive: true,
|
||||
// includeItemTypes: [BaseItemKind.Episode],
|
||||
// fields: [
|
||||
// ItemFields.ProviderIds,
|
||||
// ItemFields.Genres,
|
||||
// ItemFields.DateLastMediaAdded,
|
||||
// ItemFields.DateCreated,
|
||||
// ItemFields.MediaSources,
|
||||
// ],
|
||||
// })
|
||||
// .then((res) => res.data.Items ?? []);
|
||||
// }
|
||||
|
||||
async getMovieStreams(
|
||||
tmdbId: string,
|
||||
metadata,
|
||||
userContext: JellyfinUserContext,
|
||||
config: PlaybackConfig = {
|
||||
audioStreamIndex: undefined,
|
||||
@@ -210,7 +227,7 @@ export default class JellyfinPlugin implements SourcePlugin {
|
||||
deviceProfile: undefined,
|
||||
},
|
||||
): Promise<VideoStreamCandidate[]> {
|
||||
return this.getMovieStream(tmdbId, '', userContext, config)
|
||||
return this.getMovieStream(tmdbId, metadata, '', userContext, config)
|
||||
.then((stream) => [stream])
|
||||
.catch((e) => {
|
||||
if (e === SourcePluginError.StreamNotFound) {
|
||||
@@ -219,16 +236,32 @@ export default class JellyfinPlugin implements SourcePlugin {
|
||||
});
|
||||
}
|
||||
|
||||
getEpisodeStreams: (
|
||||
async getEpisodeStreams(
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
context: UserContext,
|
||||
userContext: JellyfinUserContext,
|
||||
config?: PlaybackConfig,
|
||||
) => Promise<VideoStreamCandidate[]>;
|
||||
): Promise<VideoStreamCandidate[]> {
|
||||
return this.getEpisodeStream(
|
||||
tmdbId,
|
||||
season,
|
||||
episode,
|
||||
'',
|
||||
userContext,
|
||||
config,
|
||||
)
|
||||
.then((stream) => [stream])
|
||||
.catch((e) => {
|
||||
if (e === SourcePluginError.StreamNotFound) {
|
||||
return [];
|
||||
} else throw e;
|
||||
});
|
||||
}
|
||||
|
||||
async getMovieStream(
|
||||
tmdbId: string,
|
||||
metadata,
|
||||
key: string,
|
||||
userContext: JellyfinUserContext,
|
||||
config: PlaybackConfig = {
|
||||
@@ -377,6 +410,171 @@ export default class JellyfinPlugin implements SourcePlugin {
|
||||
};
|
||||
}
|
||||
|
||||
async getEpisodeStream(
|
||||
tmdbId: string,
|
||||
seasonNumber: number,
|
||||
episodeNumber: number,
|
||||
key: string,
|
||||
userContext: JellyfinUserContext,
|
||||
config: PlaybackConfig = {
|
||||
audioStreamIndex: undefined,
|
||||
bitrate: undefined,
|
||||
progress: undefined,
|
||||
defaultLanguage: undefined,
|
||||
deviceProfile: undefined,
|
||||
},
|
||||
): Promise<VideoStream> {
|
||||
const context = new PluginContext(userContext.settings, userContext.token);
|
||||
const items = await this.getLibraryItems(context);
|
||||
const proxyUrl = this.getProxyUrl();
|
||||
|
||||
const show = items.find(
|
||||
(item) => item.ProviderIds?.Tmdb === tmdbId,
|
||||
// && item.ParentIndexNumber === seasonNumber &&
|
||||
// item.IndexNumber === episodeNumber,
|
||||
);
|
||||
|
||||
const episode = items.find(
|
||||
(item) =>
|
||||
item.SeriesId === show?.Id &&
|
||||
item.IndexNumber === episodeNumber &&
|
||||
item.ParentIndexNumber === seasonNumber,
|
||||
);
|
||||
|
||||
if (
|
||||
!episode ||
|
||||
!episode.MediaSources ||
|
||||
episode.MediaSources.length === 0
|
||||
) {
|
||||
throw SourcePluginError.StreamNotFound;
|
||||
}
|
||||
|
||||
/*
|
||||
await jellyfinApi.getPlaybackInfo(
|
||||
id,
|
||||
getDeviceProfile(),
|
||||
options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0,
|
||||
options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate,
|
||||
audioStreamIndex
|
||||
);
|
||||
*/
|
||||
|
||||
const startTimeTicks = episode.RunTimeTicks
|
||||
? Math.floor(episode.RunTimeTicks * (config?.progress ?? 0))
|
||||
: undefined;
|
||||
const maxStreamingBitrate = config?.bitrate || 0; //|| movie.MediaSources?.[0]?.Bitrate || 10000000
|
||||
|
||||
const playbackInfo = await context.api.items.getPostedPlaybackInfo(
|
||||
episode.Id,
|
||||
{
|
||||
DeviceProfile: config?.deviceProfile,
|
||||
},
|
||||
{
|
||||
userId: context.settings.userId,
|
||||
startTimeTicks: startTimeTicks || 0,
|
||||
...(maxStreamingBitrate ? { maxStreamingBitrate } : {}),
|
||||
autoOpenLiveStream: true,
|
||||
...(config?.audioStreamIndex
|
||||
? { audioStreamIndex: config?.audioStreamIndex }
|
||||
: {}),
|
||||
mediaSourceId: episode.Id,
|
||||
|
||||
// deviceId: JELLYFIN_DEVICE_ID,
|
||||
// mediaSourceId: movie.MediaSources[0].Id,
|
||||
// maxBitrate: 8000000,
|
||||
},
|
||||
);
|
||||
|
||||
const mediasSource = playbackInfo.data?.MediaSources?.[0];
|
||||
|
||||
const playbackUri =
|
||||
proxyUrl +
|
||||
(mediasSource?.TranscodingUrl ||
|
||||
`/Videos/${mediasSource?.Id}/stream.mp4?Static=true&mediaSourceId=${mediasSource?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${context.settings.apiKey}&Tag=${mediasSource?.ETag}`) +
|
||||
`&reiverr_token=${userContext.token}`;
|
||||
|
||||
const audioStreams: VideoStream['audioStreams'] =
|
||||
mediasSource?.MediaStreams.filter((s) => s.Type === 'Audio').map((s) => ({
|
||||
bitrate: s.BitRate,
|
||||
label: s.Language,
|
||||
codec: s.Codec,
|
||||
index: s.Index,
|
||||
})) ?? [];
|
||||
|
||||
const qualities: VideoStream['qualities'] = [
|
||||
...bitrateQualities,
|
||||
{
|
||||
bitrate: mediasSource.Bitrate,
|
||||
label: 'Original',
|
||||
codec: undefined,
|
||||
original: true,
|
||||
},
|
||||
].map((q, i) => ({
|
||||
...q,
|
||||
index: i,
|
||||
}));
|
||||
|
||||
const bitrate = Math.min(maxStreamingBitrate, mediasSource.Bitrate);
|
||||
|
||||
const subtitles: Subtitles[] = mediasSource.MediaStreams.filter(
|
||||
(s) => s.Type === 'Subtitle' && s.DeliveryUrl,
|
||||
).map((s, i) => ({
|
||||
index: i,
|
||||
uri: proxyUrl + s.DeliveryUrl + `reiverr_token=${userContext.token}`,
|
||||
label: s.DisplayTitle,
|
||||
codec: s.Codec,
|
||||
}));
|
||||
|
||||
return {
|
||||
key: '0',
|
||||
title: episode.Name,
|
||||
properties: [
|
||||
{
|
||||
label: 'Video',
|
||||
value: mediasSource.Bitrate || 0,
|
||||
formatted:
|
||||
mediasSource.MediaStreams.find((s) => s.Type === 'Video')
|
||||
?.DisplayTitle || 'Unknown',
|
||||
},
|
||||
{
|
||||
label: 'Size',
|
||||
value: mediasSource.Size,
|
||||
formatted: formatSize(mediasSource.Size),
|
||||
},
|
||||
{
|
||||
label: 'Filename',
|
||||
value: mediasSource.Name,
|
||||
formatted: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Runtime',
|
||||
value: mediasSource.RunTimeTicks,
|
||||
formatted: formatTicksToTime(mediasSource.RunTimeTicks),
|
||||
},
|
||||
],
|
||||
audioStreamIndex:
|
||||
config.audioStreamIndex ??
|
||||
mediasSource?.DefaultAudioStreamIndex ??
|
||||
audioStreams[0].index,
|
||||
audioStreams,
|
||||
duration: mediasSource.RunTimeTicks
|
||||
? mediasSource.RunTimeTicks / 10_000_000
|
||||
: 0,
|
||||
progress: config.progress ?? 0,
|
||||
qualities,
|
||||
qualityIndex: getClosestBitrate(qualities, bitrate).index,
|
||||
subtitles,
|
||||
uri: playbackUri,
|
||||
// uri:
|
||||
// proxyUrl +
|
||||
// '/stream_new2/H4sIAAAAAAAAAw3OWXKDIAAA0Cvhggn9TBqSuJARBcU_CloiYp2Ojcvpm3eCB2EXASWjIAwRUkd4AF7XdYdQAY0kVPIjDTghrElZT0EJqGlv5I_64V5UOk58vOSO7F8bcjKYnvmusRg0zLe5Lv2YaWsSUpFMuTXOAAS5O66s_H5RBpbWrmftnV4JuIdZ8LNrf1laHs_FTqkMmro4z7CsSS7sRNpx2liFotJ5TPY45Q6tms3R45NSdYWGWZ6yvTm14.lXAV7r67IyOy85n5JHjQeFzV0z0guHo2YcrCzQQoEumgIZxrlQgQir2m4suLyPK22t6eX7nmG.Sn8SxRNdH7dBNKMxxGucvgyj8Lind4D.AeRg7d1BAQAA/master.m3u8' +
|
||||
// `?reiverr_token=${userContext.token}`,
|
||||
directPlay:
|
||||
!!mediasSource?.SupportsDirectPlay ||
|
||||
!!mediasSource?.SupportsDirectStream,
|
||||
};
|
||||
}
|
||||
|
||||
proxyHandler?: (
|
||||
req: any,
|
||||
res: any,
|
||||
|
||||
@@ -109,6 +109,15 @@ export class PaginationParams {
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
title?: string;
|
||||
year?: number;
|
||||
tmdbId?: string;
|
||||
imdbId?: string;
|
||||
}
|
||||
|
||||
export interface MovieMetadata extends Metadata {}
|
||||
|
||||
export interface SourcePlugin {
|
||||
name: string;
|
||||
|
||||
@@ -127,6 +136,7 @@ export interface SourcePlugin {
|
||||
|
||||
getMovieStream: (
|
||||
tmdbId: string,
|
||||
metadata: MovieMetadata,
|
||||
key: string,
|
||||
context: UserContext,
|
||||
config?: PlaybackConfig,
|
||||
@@ -134,6 +144,7 @@ export interface SourcePlugin {
|
||||
|
||||
getMovieStreams: (
|
||||
tmdbId: string,
|
||||
metadata: MovieMetadata,
|
||||
context: UserContext,
|
||||
config?: PlaybackConfig,
|
||||
) => Promise<VideoStreamCandidate[]>;
|
||||
|
||||
@@ -24,4 +24,3 @@ import { SourcePluginsModule } from './source-plugins/source-plugins.module';
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -30,7 +30,7 @@ function extractTokenFromRequest(request: Request): string | undefined {
|
||||
const [type, token] =
|
||||
(request.headers as any).authorization?.split(' ') ?? [];
|
||||
|
||||
let v = type === 'Bearer' ? token : undefined;
|
||||
const v = type === 'Bearer' ? token : undefined;
|
||||
|
||||
if (v) return v;
|
||||
|
||||
|
||||
@@ -44,3 +44,8 @@ export class SuccessResponseDto {
|
||||
@ApiProperty()
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export enum MediaType {
|
||||
Movie = 'Movie',
|
||||
Series = 'Series',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
export const JWT_SECRET =
|
||||
process.env.SECRET || Math.random().toString(36).substring(2, 15);
|
||||
process.env.SECRET ||
|
||||
(NODE_ENV === 'development'
|
||||
? 'secret'
|
||||
: Math.random().toString(36).substring(2, 15));
|
||||
export const TMDB_API_KEY =
|
||||
process.env.TMDB_API_KEY ||
|
||||
'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0YTZiMDIxZTE5Y2YxOTljMTM1NGFhMGRiMDZiOTkzMiIsInN1YiI6IjY0ODYzYWRmMDI4ZjE0MDExZTU1MDkwMiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.yyMkZlhGOGBHtw1yvpBVUUHhu7IKVYho49MvNNKt_wY';
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'reflect-metadata';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import * as fs from 'fs';
|
||||
import { UsersService } from './users/users.service';
|
||||
import { ADMIN_PASSWORD, ADMIN_USERNAME, ENV } from './consts';
|
||||
import { ADMIN_PASSWORD, ADMIN_USERNAME, ENV, NODE_ENV } from './consts';
|
||||
import { json, urlencoded } from 'express';
|
||||
// import * as proxy from 'express-http-proxy';
|
||||
require('ts-node/register'); // For importing plugins
|
||||
@@ -48,7 +48,7 @@ async function bootstrap() {
|
||||
await createAdminUser(app.get(UsersService));
|
||||
|
||||
await app.listen(9494);
|
||||
console.log(`Application is running on: ${await app.getUrl()}`);
|
||||
console.log(`Application is running on: ${await app.getUrl()} in ${NODE_ENV} mode`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
5
backend/src/metadata/dto/movie.dto.ts
Normal file
5
backend/src/metadata/dto/movie.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Movie } from '../metadata.entity';
|
||||
|
||||
// export class MovieDto extends Movie {
|
||||
// static FromEntity(entity: Movie): MovieDto {}
|
||||
// }
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { TmdbMovieFull } from './tmdb/tmdb.dto';
|
||||
|
||||
@Entity()
|
||||
export class Movie {
|
||||
@@ -17,7 +18,7 @@ export class Movie {
|
||||
tmdbId: string;
|
||||
|
||||
@Column('json')
|
||||
tmdbMovie: any
|
||||
tmdbMovie: TmdbMovieFull;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataSource } from "typeorm";
|
||||
import { Movie } from "./metadata.entity";
|
||||
import { DATA_SOURCE } from "src/database/database.providers";
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Movie } from './metadata.entity';
|
||||
import { DATA_SOURCE } from 'src/database/database.providers';
|
||||
|
||||
export const MOVIE_REPOSITORY = 'MOVIE_REPOSITORY';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Movie } from './metadata.entity';
|
||||
import { MOVIE_REPOSITORY } from './metadata.providers';
|
||||
import { TMDB_CACHE_TTL } from 'src/consts';
|
||||
import { TMDB_API, TmdbApi } from './tmdb/tmdb.providers';
|
||||
import { TmdbMovieFull } from './tmdb/tmdb.dto';
|
||||
|
||||
@Injectable()
|
||||
export class MetadataService {
|
||||
@@ -15,7 +16,7 @@ export class MetadataService {
|
||||
private movieRepository: Repository<Movie>,
|
||||
) {}
|
||||
|
||||
async getMovieByTmdbId(tmdbId: string): Promise<any> {
|
||||
async getMovieByTmdbId(tmdbId: string): Promise<Movie | undefined> {
|
||||
let movie = await this.movieRepository.findOne({ where: { tmdbId } });
|
||||
|
||||
if (!movie) {
|
||||
@@ -31,7 +32,7 @@ export class MetadataService {
|
||||
.movieDetails(Number(tmdbId), {
|
||||
append_to_response: 'videos,credits,external_ids,images',
|
||||
})
|
||||
.then((r) => r.data);
|
||||
.then((r) => r.data as TmdbMovieFull);
|
||||
movie.tmdbMovie = tmdbMovie;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
PipeTransform,
|
||||
Post,
|
||||
Query,
|
||||
@@ -18,7 +19,11 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { SourcePlugin, SourcePluginError } from 'plugins/plugin-types';
|
||||
import {
|
||||
MovieMetadata,
|
||||
SourcePlugin,
|
||||
SourcePluginError,
|
||||
} from 'plugins/plugin-types';
|
||||
import {
|
||||
UserAccessControl,
|
||||
GetAuthToken,
|
||||
@@ -46,6 +51,7 @@ import {
|
||||
VideoStreamListDto,
|
||||
} from './source-plugins.dto';
|
||||
import { SourcePluginsService } from './source-plugins.service';
|
||||
import { MetadataService } from 'src/metadata/metadata.service';
|
||||
|
||||
export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
|
||||
|
||||
@@ -71,8 +77,21 @@ export class SourcesController {
|
||||
constructor(
|
||||
private sourcesService: SourcePluginsService,
|
||||
private userSourcesService: UserSourcesService,
|
||||
private metadataService: MetadataService,
|
||||
) {}
|
||||
|
||||
async getMovieMetadata(tmdbId: string): Promise<MovieMetadata> {
|
||||
const metadata = await this.metadataService.getMovieByTmdbId(tmdbId);
|
||||
|
||||
return {
|
||||
title: metadata.tmdbMovie?.title,
|
||||
...(metadata.tmdbMovie.release_date && {
|
||||
year: new Date(metadata.tmdbMovie.release_date).getFullYear(),
|
||||
}),
|
||||
tmdbId,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('sources')
|
||||
@ApiOkResponse({
|
||||
description: 'All source plugins found',
|
||||
@@ -185,7 +204,7 @@ export class SourcesController {
|
||||
type: VideoStreamListDto,
|
||||
})
|
||||
async getMovieStreams(
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
@Param('tmdbId') tmdbId: string, // TODO: Reorder params
|
||||
@Param('sourceId') sourceId: string,
|
||||
@GetAuthUser() user: User,
|
||||
@GetAuthToken() token: string,
|
||||
@@ -202,7 +221,9 @@ export class SourcesController {
|
||||
throw new BadRequestException('Source configuration not found');
|
||||
}
|
||||
|
||||
const streams = await plugin.getMovieStreams(tmdbId, {
|
||||
const metadata = await this.getMovieMetadata(tmdbId);
|
||||
|
||||
const streams = await plugin.getMovieStreams(tmdbId, metadata, {
|
||||
settings,
|
||||
token,
|
||||
});
|
||||
@@ -241,6 +262,43 @@ export class SourcesController {
|
||||
// };
|
||||
}
|
||||
|
||||
@Get(
|
||||
'sources/:sourceId/shows/tmdb/:tmdbId/season/:season/episode/:episode/streams',
|
||||
)
|
||||
@ApiOkResponse({
|
||||
description: 'Movie sources',
|
||||
type: VideoStreamListDto,
|
||||
})
|
||||
async getEpisodeStreams(
|
||||
@Param('sourceId') sourceId: string,
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
@Param('season', ParseIntPipe) season: number,
|
||||
@Param('episode', ParseIntPipe) episode: number,
|
||||
@GetAuthUser() user: User,
|
||||
@GetAuthToken() token: string,
|
||||
): Promise<VideoStreamListDto> {
|
||||
const plugin = this.sourcesService.getPlugin(sourceId);
|
||||
|
||||
if (!plugin) {
|
||||
throw new NotFoundException('Plugin not found');
|
||||
}
|
||||
|
||||
const settings = this.userSourcesService.getSourceSettings(user, sourceId);
|
||||
|
||||
if (!settings) {
|
||||
throw new BadRequestException('Source configuration not found');
|
||||
}
|
||||
|
||||
const streams = await plugin.getEpisodeStreams(tmdbId, season, episode, {
|
||||
settings,
|
||||
token,
|
||||
});
|
||||
|
||||
return {
|
||||
streams,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('sources/:sourceId/movies/tmdb/:tmdbId/streams/:key')
|
||||
@ApiOkResponse({
|
||||
description: 'Movie stream',
|
||||
@@ -267,9 +325,63 @@ export class SourcesController {
|
||||
throw new BadRequestException('Source configuration not found');
|
||||
}
|
||||
|
||||
const metadata = await this.getMovieMetadata(tmdbId);
|
||||
|
||||
return plugin
|
||||
.getMovieStream(
|
||||
tmdbId,
|
||||
metadata,
|
||||
key || '',
|
||||
{
|
||||
settings,
|
||||
token,
|
||||
},
|
||||
config,
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e === SourcePluginError.StreamNotFound) {
|
||||
throw new NotFoundException('Stream not found');
|
||||
} else {
|
||||
console.error(e);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Post(
|
||||
'sources/:sourceId/shows/tmdb/:tmdbId/season/:season/episode/:episode/streams/:key',
|
||||
)
|
||||
@ApiOkResponse({
|
||||
description: 'Show stream',
|
||||
type: VideoStreamDto,
|
||||
})
|
||||
async getEpisodeStream(
|
||||
@Param('sourceId') sourceId: string,
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
@Param('season', ParseIntPipe) season: number,
|
||||
@Param('episode', ParseIntPipe) episode: number,
|
||||
@Param('key') key: string,
|
||||
@GetAuthUser() user: User,
|
||||
@GetAuthToken() token: string,
|
||||
@Body() config: PlaybackConfigDto,
|
||||
): Promise<VideoStreamDto> {
|
||||
const plugin = this.sourcesService.getPlugin(sourceId);
|
||||
|
||||
if (!plugin) {
|
||||
throw new NotFoundException('Plugin not found');
|
||||
}
|
||||
|
||||
const settings = this.userSourcesService.getSourceSettings(user, sourceId);
|
||||
|
||||
if (!settings) {
|
||||
throw new BadRequestException('Source configuration not found');
|
||||
}
|
||||
|
||||
return plugin
|
||||
.getEpisodeStream(
|
||||
tmdbId,
|
||||
season,
|
||||
episode,
|
||||
key || '',
|
||||
{
|
||||
settings,
|
||||
@@ -316,7 +428,7 @@ export class SourcesController {
|
||||
|
||||
await plugin.proxyHandler?.(req, res, {
|
||||
context: { settings, token },
|
||||
uri: `/${params[0]}?${req.url.split('?')[1] || ''}`,
|
||||
uri: `/${params[0]}?${req.url.split('?').slice(1).join('?') || ''}`,
|
||||
targetUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { DynamicModule } from '@nestjs/common';
|
||||
import { SourcePluginsService } from './source-plugins.service';
|
||||
import { SourcesController } from './source-plugins.controller';
|
||||
import { UsersModule } from 'src/users/users.module';
|
||||
import { MetadataModule } from 'src/metadata/metadata.module';
|
||||
|
||||
@Module({
|
||||
providers: [SourcePluginsService],
|
||||
controllers: [SourcesController],
|
||||
exports: [SourcePluginsService],
|
||||
imports: [UsersModule],
|
||||
imports: [UsersModule, MetadataModule],
|
||||
})
|
||||
export class SourcePluginsModule {}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Controller, Delete, Get, Param, Put, UseGuards } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseEnumPipe,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { UserAccessControl } from 'src/auth/auth.guard';
|
||||
import { LibraryService } from './library.service';
|
||||
import {
|
||||
MediaType,
|
||||
PaginatedResponseDto,
|
||||
PaginationParamsDto,
|
||||
SuccessResponseDto,
|
||||
@@ -44,6 +54,7 @@ export class LibraryController {
|
||||
}
|
||||
|
||||
@Put('tmdb/:tmdbId')
|
||||
@ApiQuery({ name: 'mediaType', enum: MediaType })
|
||||
@ApiOkResponse({
|
||||
description: 'Library item added',
|
||||
type: SuccessResponseDto,
|
||||
@@ -51,8 +62,13 @@ export class LibraryController {
|
||||
async addLibraryItem(
|
||||
@Param('userId') userId: string,
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
@Query('mediaType', new ParseEnumPipe(MediaType)) mediaType: MediaType,
|
||||
): Promise<SuccessResponseDto> {
|
||||
const item = await this.libraryService.findOrCreateByTmdbId(userId, tmdbId);
|
||||
const item = await this.libraryService.findOrCreateByTmdbId(
|
||||
userId,
|
||||
tmdbId,
|
||||
mediaType,
|
||||
);
|
||||
|
||||
return {
|
||||
success: !!item,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { MovieDto } from 'src/metadata/metadata.dto';
|
||||
import { PlayStateDto } from '../play-state/play-state.dto';
|
||||
import { MediaType } from 'src/common/common.dto';
|
||||
|
||||
export class LibraryItemDto {
|
||||
@ApiProperty()
|
||||
tmdbId: string;
|
||||
|
||||
@ApiProperty({ enum: MediaType })
|
||||
mediaType: MediaType;
|
||||
|
||||
@ApiProperty({ type: [PlayStateDto], required: false })
|
||||
playStates?: PlayStateDto[];
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'typeorm';
|
||||
import { User } from '../user.entity';
|
||||
import { PlayState } from '../play-state/play-state.entity';
|
||||
import { MediaType } from 'src/common/common.dto';
|
||||
|
||||
@Entity()
|
||||
@Unique(['tmdbId', 'userId'])
|
||||
@@ -24,6 +25,10 @@ export class LibraryItem {
|
||||
@Column({ unique: true })
|
||||
tmdbId: string;
|
||||
|
||||
@ApiProperty({ required: true, enum: MediaType })
|
||||
@Column()
|
||||
mediaType: MediaType;
|
||||
|
||||
@ApiProperty({ required: true, type: 'string' })
|
||||
@PrimaryColumn()
|
||||
userId: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { USER_LIBRARY_REPOSITORY } from '../user.providers';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LibraryItem } from './library.entity';
|
||||
import { PaginationParamsDto } from 'src/common/common.dto';
|
||||
import { MediaType, PaginationParamsDto } from 'src/common/common.dto';
|
||||
|
||||
@Injectable()
|
||||
export class LibraryService {
|
||||
@@ -35,11 +35,16 @@ export class LibraryService {
|
||||
async findOrCreateByTmdbId(
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
mediaType: MediaType,
|
||||
): Promise<LibraryItem> {
|
||||
let libraryItem = await this.findByTmdbId(userId, tmdbId);
|
||||
|
||||
if (!libraryItem) {
|
||||
libraryItem = this.libraryRepository.create({ userId, tmdbId });
|
||||
libraryItem = this.libraryRepository.create({
|
||||
userId,
|
||||
tmdbId,
|
||||
mediaType,
|
||||
});
|
||||
await this.libraryRepository.save(libraryItem);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
ParseEnumPipe,
|
||||
ParseIntPipe,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PlayStateService } from './play-state.service';
|
||||
import { UserAccessControl } from 'src/auth/auth.guard';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { UpdatePlayStateDto } from './play-state.dto';
|
||||
import { MediaType } from 'src/common/common.dto';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users/:userId/play-state')
|
||||
@@ -18,15 +22,19 @@ export class PlayStateController {
|
||||
constructor(private playStateService: PlayStateService) {}
|
||||
|
||||
@Put('movie/tmdb/:tmdbId')
|
||||
@ApiQuery({ name: 'mediaType', enum: MediaType, required: false })
|
||||
async updateMoviePlayStateByTmdbId(
|
||||
@Param('userId') userId: string,
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
@Body() playState: UpdatePlayStateDto,
|
||||
@Query('mediaType', new ParseEnumPipe(MediaType, { optional: true }))
|
||||
mediaType?: MediaType,
|
||||
) {
|
||||
return this.playStateService.updateOrCreateMoviePlayState(
|
||||
userId,
|
||||
tmdbId,
|
||||
playState,
|
||||
mediaType,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,4 +45,36 @@ export class PlayStateController {
|
||||
) {
|
||||
return this.playStateService.deleteMoviePlayState(userId, tmdbId);
|
||||
}
|
||||
|
||||
@Put('show/tmdb/:tmdbId/season/:season/episode/:episode')
|
||||
async updateEpisodePlayStateByTmdbId(
|
||||
@Param('userId') userId: string,
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
@Param('season', ParseIntPipe) season: number,
|
||||
@Param('episode', ParseIntPipe) episode: number,
|
||||
@Body() playState: UpdatePlayStateDto,
|
||||
) {
|
||||
return this.playStateService.updateOrCreateEpisodePlayState(
|
||||
userId,
|
||||
tmdbId,
|
||||
season,
|
||||
episode,
|
||||
playState,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('show/tmdb/:tmdbId/season/:season/episode/:episode')
|
||||
async deleteEpisodePlayStateByTmdbId(
|
||||
@Param('userId') userId: string,
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
@Param('season', ParseIntPipe) season: number,
|
||||
@Param('episode', ParseIntPipe) episode: number,
|
||||
) {
|
||||
return this.playStateService.deleteEpisodePlayState(
|
||||
userId,
|
||||
tmdbId,
|
||||
season,
|
||||
episode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,24 @@ import { PlayState } from './play-state.entity';
|
||||
|
||||
export class PlayStateDto extends PlayState {}
|
||||
|
||||
// export class PlayStateDto extends IntersectionType(
|
||||
// OmitType(PlayState, ['id']),
|
||||
// PartialType(PickType(PlayState, ['id'])),
|
||||
// ) {
|
||||
// constructor(
|
||||
// tmdbId: string,
|
||||
// userId: string,
|
||||
// season?: number,
|
||||
// episode?: number,
|
||||
// ) {
|
||||
// super();
|
||||
// this.tmdbId = tmdbId;
|
||||
// this.userId = userId;
|
||||
// if (season !== undefined) this.season = season;
|
||||
// if (episode !== undefined) this.episode = episode;
|
||||
// }
|
||||
// }
|
||||
|
||||
export class UpdatePlayStateDto extends OmitType(PlayState, [
|
||||
'id',
|
||||
'tmdbId',
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from 'typeorm';
|
||||
import { User } from '../user.entity';
|
||||
import { LibraryItem } from '../library/library.entity';
|
||||
import { UserDto } from '../user.dto';
|
||||
import { MediaType } from 'src/common/common.dto';
|
||||
|
||||
@Entity()
|
||||
@Unique(['tmdbId', 'userId', 'season', 'episode'])
|
||||
@@ -23,14 +25,18 @@ export class PlayState {
|
||||
@Column({ unique: true })
|
||||
tmdbId: string;
|
||||
|
||||
@ApiProperty({ required: false, enum: MediaType })
|
||||
@Column({ nullable: true })
|
||||
mediaType: MediaType;
|
||||
|
||||
@ApiProperty({ required: true, type: 'string' })
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ required: true, type: 'string' })
|
||||
// @ApiProperty({ required: false, type: UserDto })
|
||||
@ManyToOne(() => User, (user) => user.playStates, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
user?: User;
|
||||
|
||||
@ApiProperty({ required: false, type: 'number' })
|
||||
@PrimaryColumn({ default: 0 })
|
||||
@@ -61,6 +67,7 @@ export class PlayState {
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
description: 'Last time the user played this media',
|
||||
required: false,
|
||||
})
|
||||
@UpdateDateColumn()
|
||||
lastPlayedAt: Date;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UpdatePlayStateDto } from './play-state.dto';
|
||||
import { USER_PLAY_STATE_REPOSITORY } from '../user.providers';
|
||||
import { PlayState } from './play-state.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { MediaType } from 'src/common/common.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PlayStateService {
|
||||
@@ -17,10 +18,38 @@ export class PlayStateService {
|
||||
});
|
||||
}
|
||||
|
||||
async findShowPlayState(
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
): Promise<PlayState | undefined> {
|
||||
const playStates =
|
||||
(await this.playStateRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
tmdbId,
|
||||
...(season ? { season } : {}),
|
||||
...(episode ? { episode } : {}),
|
||||
},
|
||||
})) ?? [];
|
||||
|
||||
playStates.sort((a, b) => {
|
||||
if (a.season !== b.season) {
|
||||
return a.season - b.season;
|
||||
}
|
||||
|
||||
return a.episode - b.episode;
|
||||
});
|
||||
|
||||
return playStates[0];
|
||||
}
|
||||
|
||||
async updateOrCreateMoviePlayState(
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
playState: UpdatePlayStateDto,
|
||||
mediaType?: MediaType,
|
||||
) {
|
||||
let state = await this.findMoviePlayState(userId, tmdbId);
|
||||
|
||||
@@ -28,6 +57,32 @@ export class PlayStateService {
|
||||
state = this.playStateRepository.create();
|
||||
state.userId = userId;
|
||||
state.tmdbId = tmdbId;
|
||||
if (mediaType) {
|
||||
state.mediaType = mediaType;
|
||||
}
|
||||
}
|
||||
|
||||
state.progress = playState.progress;
|
||||
state.watched = playState.watched;
|
||||
|
||||
return this.playStateRepository.save(state);
|
||||
}
|
||||
|
||||
async updateOrCreateEpisodePlayState(
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
playState: UpdatePlayStateDto,
|
||||
) {
|
||||
let state = await this.findShowPlayState(userId, tmdbId, season, episode);
|
||||
|
||||
if (!state) {
|
||||
state = this.playStateRepository.create();
|
||||
state.userId = userId;
|
||||
state.tmdbId = tmdbId;
|
||||
state.season = season;
|
||||
state.episode = episode;
|
||||
}
|
||||
|
||||
state.progress = playState.progress;
|
||||
@@ -40,4 +95,14 @@ export class PlayStateService {
|
||||
const state = await this.findMoviePlayState(userId, tmdbId);
|
||||
return await this.playStateRepository.remove(state);
|
||||
}
|
||||
|
||||
async deleteEpisodePlayState(
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
) {
|
||||
const state = await this.findShowPlayState(userId, tmdbId, season, episode);
|
||||
return await this.playStateRepository.remove(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@ export class UpdateUserDto extends PartialType(
|
||||
|
||||
export class SignInDto extends PickType(User, ['name', 'password'] as const) {}
|
||||
|
||||
export class MovieUserDataDto {
|
||||
export class MediaUserDataDto {
|
||||
@ApiProperty()
|
||||
tmdbId: string;
|
||||
|
||||
@ApiProperty()
|
||||
inLibrary: boolean;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Put,
|
||||
UnauthorizedException,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
CreateUserDto,
|
||||
MovieUserDataDto,
|
||||
MediaUserDataDto,
|
||||
UpdateUserDto,
|
||||
UserDto,
|
||||
} from './user.dto';
|
||||
@@ -29,6 +30,8 @@ import { User } from './user.entity';
|
||||
import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator';
|
||||
import { LibraryService } from './library/library.service';
|
||||
import { PlayStateService } from './play-state/play-state.service';
|
||||
import { PlayState } from './play-state/play-state.entity';
|
||||
import { PlayStateDto } from './play-state/play-state.dto';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
@@ -164,12 +167,12 @@ export class UsersController {
|
||||
@Get(':userId/user-data/movie/tmdb/:tmdbId')
|
||||
@ApiOkResponse({
|
||||
description: 'User movie data found',
|
||||
type: MovieUserDataDto,
|
||||
type: MediaUserDataDto,
|
||||
})
|
||||
async getUserMovieData(
|
||||
@Param('userId') userId: string,
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
): Promise<MovieUserDataDto> {
|
||||
): Promise<MediaUserDataDto> {
|
||||
const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId);
|
||||
const playState = await this.playStateService.findMoviePlayState(
|
||||
userId,
|
||||
@@ -177,8 +180,59 @@ export class UsersController {
|
||||
);
|
||||
|
||||
return {
|
||||
tmdbId,
|
||||
inLibrary: !!libraryItem,
|
||||
playState: playState || undefined,
|
||||
playState: playState,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(UserAccessControl)
|
||||
@Get(':userId/user-data/show/tmdb/:tmdbId')
|
||||
@ApiOkResponse({
|
||||
description: 'User show data found',
|
||||
type: MediaUserDataDto,
|
||||
})
|
||||
async getShowUserData(
|
||||
@Param('userId') userId: string,
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
): Promise<MediaUserDataDto> {
|
||||
const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId);
|
||||
const playState = await this.playStateService.findShowPlayState(
|
||||
userId,
|
||||
tmdbId,
|
||||
);
|
||||
|
||||
return {
|
||||
tmdbId,
|
||||
inLibrary: !!libraryItem,
|
||||
playState: playState,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(UserAccessControl)
|
||||
@Get(':userId/user-data/show/tmdb/:tmdbId/season/:season/episode/:episode')
|
||||
@ApiOkResponse({
|
||||
description: 'User show data found',
|
||||
type: MediaUserDataDto,
|
||||
})
|
||||
async getEpisodeUserData(
|
||||
@Param('userId') userId: string,
|
||||
@Param('tmdbId') tmdbId: string,
|
||||
@Param('season', ParseIntPipe) season: number,
|
||||
@Param('episode', ParseIntPipe) episode: number,
|
||||
): Promise<MediaUserDataDto> {
|
||||
const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId);
|
||||
const playState = await this.playStateService.findShowPlayState(
|
||||
userId,
|
||||
tmdbId,
|
||||
season,
|
||||
episode,
|
||||
);
|
||||
|
||||
return {
|
||||
tmdbId,
|
||||
inLibrary: !!libraryItem,
|
||||
playState: playState,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
// "strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
|
||||
@@ -59,8 +59,8 @@ export interface MediaSource {
|
||||
export interface PlayState {
|
||||
id?: string;
|
||||
tmdbId: number;
|
||||
mediaType?: 'Movie' | 'Series';
|
||||
userId: string;
|
||||
user: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
/**
|
||||
@@ -75,12 +75,13 @@ export interface PlayState {
|
||||
*/
|
||||
progress?: number;
|
||||
/** Last time the user played this media */
|
||||
lastPlayedAt: string;
|
||||
lastPlayedAt?: string;
|
||||
}
|
||||
|
||||
export interface LibraryItem {
|
||||
id?: string;
|
||||
tmdbId: number;
|
||||
mediaType: 'Movie' | 'Series';
|
||||
userId: string;
|
||||
user?: string;
|
||||
}
|
||||
@@ -117,8 +118,8 @@ export interface UpdateUserDto {
|
||||
export interface PlayStateDto {
|
||||
id?: string;
|
||||
tmdbId: number;
|
||||
mediaType?: 'Movie' | 'Series';
|
||||
userId: string;
|
||||
user: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
/**
|
||||
@@ -133,10 +134,11 @@ export interface PlayStateDto {
|
||||
*/
|
||||
progress?: number;
|
||||
/** Last time the user played this media */
|
||||
lastPlayedAt: string;
|
||||
lastPlayedAt?: string;
|
||||
}
|
||||
|
||||
export interface MovieUserDataDto {
|
||||
export interface MediaUserDataDto {
|
||||
tmdbId: string;
|
||||
inLibrary: boolean;
|
||||
playState?: PlayStateDto;
|
||||
}
|
||||
@@ -162,6 +164,7 @@ export interface MovieDto {
|
||||
|
||||
export interface LibraryItemDto {
|
||||
tmdbId: string;
|
||||
mediaType: 'Movie' | 'Series';
|
||||
playStates?: PlayStateDto[];
|
||||
metadata?: MovieDto;
|
||||
}
|
||||
@@ -171,6 +174,7 @@ export interface SuccessResponseDto {
|
||||
}
|
||||
|
||||
export interface UpdatePlayStateDto {
|
||||
mediaType?: 'Movie' | 'Series';
|
||||
/**
|
||||
* Whether the user has watched this media
|
||||
* @default false
|
||||
@@ -782,13 +786,49 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
* @request GET:/api/users/{userId}/user-data/movie/tmdb/{tmdbId}
|
||||
*/
|
||||
getUserMovieData: (userId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
this.request<MovieUserDataDto, any>({
|
||||
this.request<MediaUserDataDto, any>({
|
||||
path: `/api/users/${userId}/user-data/movie/tmdb/${tmdbId}`,
|
||||
method: 'GET',
|
||||
format: 'json',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags users
|
||||
* @name GetShowUserData
|
||||
* @request GET:/api/users/{userId}/user-data/show/tmdb/{tmdbId}
|
||||
*/
|
||||
getShowUserData: (userId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
this.request<MediaUserDataDto, any>({
|
||||
path: `/api/users/${userId}/user-data/show/tmdb/${tmdbId}`,
|
||||
method: 'GET',
|
||||
format: 'json',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags users
|
||||
* @name GetEpisodeUserData
|
||||
* @request GET:/api/users/{userId}/user-data/show/tmdb/{tmdbId}/season/{season}/episode/{episode}
|
||||
*/
|
||||
getEpisodeUserData: (
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
params: RequestParams = {}
|
||||
) =>
|
||||
this.request<MediaUserDataDto, any>({
|
||||
path: `/api/users/${userId}/user-data/show/tmdb/${tmdbId}/season/${season}/episode/${episode}`,
|
||||
method: 'GET',
|
||||
format: 'json',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
@@ -853,10 +893,18 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
* @name AddLibraryItem
|
||||
* @request PUT:/api/users/{userId}/library/tmdb/{tmdbId}
|
||||
*/
|
||||
addLibraryItem: (userId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
addLibraryItem: (
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
query: {
|
||||
mediaType: 'Movie' | 'Series';
|
||||
},
|
||||
params: RequestParams = {}
|
||||
) =>
|
||||
this.request<SuccessResponseDto, any>({
|
||||
path: `/api/users/${userId}/library/tmdb/${tmdbId}`,
|
||||
method: 'PUT',
|
||||
query: query,
|
||||
format: 'json',
|
||||
...params
|
||||
}),
|
||||
@@ -887,11 +935,15 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
data: UpdatePlayStateDto,
|
||||
query?: {
|
||||
mediaType?: 'Movie' | 'Series';
|
||||
},
|
||||
params: RequestParams = {}
|
||||
) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/users/${userId}/play-state/movie/tmdb/${tmdbId}`,
|
||||
method: 'PUT',
|
||||
query: query,
|
||||
body: data,
|
||||
type: ContentType.Json,
|
||||
...params
|
||||
@@ -909,6 +961,49 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
path: `/api/users/${userId}/play-state/movie/tmdb/${tmdbId}`,
|
||||
method: 'DELETE',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags users
|
||||
* @name UpdateEpisodePlayStateByTmdbId
|
||||
* @request PUT:/api/users/{userId}/play-state/show/tmdb/{tmdbId}/season/{season}/episode/{episode}
|
||||
*/
|
||||
updateEpisodePlayStateByTmdbId: (
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
data: UpdatePlayStateDto,
|
||||
params: RequestParams = {}
|
||||
) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/users/${userId}/play-state/show/tmdb/${tmdbId}/season/${season}/episode/${episode}`,
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
type: ContentType.Json,
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags users
|
||||
* @name DeleteEpisodePlayStateByTmdbId
|
||||
* @request DELETE:/api/users/{userId}/play-state/show/tmdb/{tmdbId}/season/{season}/episode/{episode}
|
||||
*/
|
||||
deleteEpisodePlayStateByTmdbId: (
|
||||
userId: string,
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
params: RequestParams = {}
|
||||
) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/users/${userId}/play-state/show/tmdb/${tmdbId}/season/${season}/episode/${episode}`,
|
||||
method: 'DELETE',
|
||||
...params
|
||||
})
|
||||
};
|
||||
api = {
|
||||
@@ -1157,6 +1252,27 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name GetEpisodeStreams
|
||||
* @request GET:/api/sources/{sourceId}/shows/tmdb/{tmdbId}/season/{season}/episode/{episode}/streams
|
||||
*/
|
||||
getEpisodeStreams: (
|
||||
sourceId: string,
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
params: RequestParams = {}
|
||||
) =>
|
||||
this.request<VideoStreamListDto, any>({
|
||||
path: `/api/sources/${sourceId}/shows/tmdb/${tmdbId}/season/${season}/episode/${episode}/streams`,
|
||||
method: 'GET',
|
||||
format: 'json',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
@@ -1180,14 +1296,153 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name GetEpisodeStream
|
||||
* @request POST:/api/sources/{sourceId}/shows/tmdb/{tmdbId}/season/{season}/episode/{episode}/streams/{key}
|
||||
*/
|
||||
getEpisodeStream: (
|
||||
sourceId: string,
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
key: string,
|
||||
data: PlaybackConfigDto,
|
||||
params: RequestParams = {}
|
||||
) =>
|
||||
this.request<VideoStreamDto, any>({
|
||||
path: `/api/sources/${sourceId}/shows/tmdb/${tmdbId}/season/${season}/episode/${episode}/streams/${key}`,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
type: ContentType.Json,
|
||||
format: 'json',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyGet
|
||||
* @request GET:/api/sources/{sourceId}/proxy/*
|
||||
* @request GET:/api/sources/{sourceId}/proxy
|
||||
*/
|
||||
movieStreamProxyGet: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy`,
|
||||
method: 'GET',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyPost
|
||||
* @request POST:/api/sources/{sourceId}/proxy
|
||||
*/
|
||||
movieStreamProxyPost: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy`,
|
||||
method: 'POST',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyPut
|
||||
* @request PUT:/api/sources/{sourceId}/proxy
|
||||
*/
|
||||
movieStreamProxyPut: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy`,
|
||||
method: 'PUT',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyDelete
|
||||
* @request DELETE:/api/sources/{sourceId}/proxy
|
||||
*/
|
||||
movieStreamProxyDelete: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy`,
|
||||
method: 'DELETE',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyPatch
|
||||
* @request PATCH:/api/sources/{sourceId}/proxy
|
||||
*/
|
||||
movieStreamProxyPatch: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy`,
|
||||
method: 'PATCH',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyOptions
|
||||
* @request OPTIONS:/api/sources/{sourceId}/proxy
|
||||
*/
|
||||
movieStreamProxyOptions: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy`,
|
||||
method: 'OPTIONS',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyHead
|
||||
* @request HEAD:/api/sources/{sourceId}/proxy
|
||||
*/
|
||||
movieStreamProxyHead: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy`,
|
||||
method: 'HEAD',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxySearch
|
||||
* @request SEARCH:/api/sources/{sourceId}/proxy
|
||||
*/
|
||||
movieStreamProxySearch: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy`,
|
||||
method: 'SEARCH',
|
||||
...params
|
||||
}),
|
||||
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyGet2
|
||||
* @request GET:/api/sources/{sourceId}/proxy/*
|
||||
* @originalName movieStreamProxyGet
|
||||
* @duplicate
|
||||
*/
|
||||
movieStreamProxyGet2: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy/*`,
|
||||
method: 'GET',
|
||||
@@ -1198,10 +1453,12 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyPost
|
||||
* @name MovieStreamProxyPost2
|
||||
* @request POST:/api/sources/{sourceId}/proxy/*
|
||||
* @originalName movieStreamProxyPost
|
||||
* @duplicate
|
||||
*/
|
||||
movieStreamProxyPost: (sourceId: string, params: RequestParams = {}) =>
|
||||
movieStreamProxyPost2: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy/*`,
|
||||
method: 'POST',
|
||||
@@ -1212,10 +1469,12 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyPut
|
||||
* @name MovieStreamProxyPut2
|
||||
* @request PUT:/api/sources/{sourceId}/proxy/*
|
||||
* @originalName movieStreamProxyPut
|
||||
* @duplicate
|
||||
*/
|
||||
movieStreamProxyPut: (sourceId: string, params: RequestParams = {}) =>
|
||||
movieStreamProxyPut2: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy/*`,
|
||||
method: 'PUT',
|
||||
@@ -1226,10 +1485,12 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyDelete
|
||||
* @name MovieStreamProxyDelete2
|
||||
* @request DELETE:/api/sources/{sourceId}/proxy/*
|
||||
* @originalName movieStreamProxyDelete
|
||||
* @duplicate
|
||||
*/
|
||||
movieStreamProxyDelete: (sourceId: string, params: RequestParams = {}) =>
|
||||
movieStreamProxyDelete2: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy/*`,
|
||||
method: 'DELETE',
|
||||
@@ -1240,10 +1501,12 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyPatch
|
||||
* @name MovieStreamProxyPatch2
|
||||
* @request PATCH:/api/sources/{sourceId}/proxy/*
|
||||
* @originalName movieStreamProxyPatch
|
||||
* @duplicate
|
||||
*/
|
||||
movieStreamProxyPatch: (sourceId: string, params: RequestParams = {}) =>
|
||||
movieStreamProxyPatch2: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy/*`,
|
||||
method: 'PATCH',
|
||||
@@ -1254,10 +1517,12 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyOptions
|
||||
* @name MovieStreamProxyOptions2
|
||||
* @request OPTIONS:/api/sources/{sourceId}/proxy/*
|
||||
* @originalName movieStreamProxyOptions
|
||||
* @duplicate
|
||||
*/
|
||||
movieStreamProxyOptions: (sourceId: string, params: RequestParams = {}) =>
|
||||
movieStreamProxyOptions2: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy/*`,
|
||||
method: 'OPTIONS',
|
||||
@@ -1268,10 +1533,12 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxyHead
|
||||
* @name MovieStreamProxyHead2
|
||||
* @request HEAD:/api/sources/{sourceId}/proxy/*
|
||||
* @originalName movieStreamProxyHead
|
||||
* @duplicate
|
||||
*/
|
||||
movieStreamProxyHead: (sourceId: string, params: RequestParams = {}) =>
|
||||
movieStreamProxyHead2: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy/*`,
|
||||
method: 'HEAD',
|
||||
@@ -1282,10 +1549,12 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @name MovieStreamProxySearch
|
||||
* @name MovieStreamProxySearch2
|
||||
* @request SEARCH:/api/sources/{sourceId}/proxy/*
|
||||
* @originalName movieStreamProxySearch
|
||||
* @duplicate
|
||||
*/
|
||||
movieStreamProxySearch: (sourceId: string, params: RequestParams = {}) =>
|
||||
movieStreamProxySearch2: (sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/proxy/*`,
|
||||
method: 'SEARCH',
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
export let rating: number | undefined = undefined;
|
||||
export let progress = 0;
|
||||
|
||||
$: console.log('progress', progress);
|
||||
|
||||
export let disabled = false;
|
||||
export let shadow = false;
|
||||
export let size: 'dynamic' | 'md' | 'lg' | 'sm' = 'md';
|
||||
|
||||
@@ -6,7 +6,16 @@
|
||||
import { tmdbApi } from '../../apis/tmdb/tmdb-api';
|
||||
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants';
|
||||
import classNames from 'classnames';
|
||||
import { Cross1, DotFilled, ExternalLink, Play, Plus, Trash } from 'radix-icons-svelte';
|
||||
import {
|
||||
Bookmark,
|
||||
Cross1,
|
||||
DotFilled,
|
||||
ExternalLink,
|
||||
Minus,
|
||||
Play,
|
||||
Plus,
|
||||
Trash
|
||||
} from 'radix-icons-svelte';
|
||||
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
|
||||
import {
|
||||
type EpisodeDownload,
|
||||
@@ -16,7 +25,7 @@
|
||||
import Button from '../Button.svelte';
|
||||
import { playerState } from '../VideoPlayer/VideoPlayer';
|
||||
import { createModal, modalStack } from '../Modal/modal.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { scrollIntoView, useRegistrar } from '../../selectable';
|
||||
import ScrollHelper from '../ScrollHelper.svelte';
|
||||
import Carousel from '../Carousel/Carousel.svelte';
|
||||
@@ -29,15 +38,34 @@
|
||||
import MMAddToSonarrDialog from '../MediaManagerModal/MMAddToSonarrDialog.svelte';
|
||||
import ConfirmDialog from '../Dialog/ConfirmDialog.svelte';
|
||||
import DownloadDetailsDialog from './DownloadDetailsDialog.svelte';
|
||||
import { reiverrApiNew, sources, user } from '../../stores/user.store';
|
||||
import type { VideoStreamCandidateDto } from '../../apis/reiverr/reiverr.openapi';
|
||||
import type { MediaSource } from '../../apis/reiverr/reiverr.openapi';
|
||||
import SelectDialog from '../Dialog/SelectDialog.svelte';
|
||||
import { useUserData } from '../../stores/library.store';
|
||||
|
||||
export let id: string;
|
||||
const tmdbId = Number(id);
|
||||
|
||||
const { promise: tmdbSeries, data: tmdbSeriesData } = useRequest(
|
||||
tmdbApi.getTmdbSeries,
|
||||
Number(id)
|
||||
const showUserData = reiverrApiNew.users
|
||||
.getShowUserData($user?.id as string, id)
|
||||
.then((r) => r.data);
|
||||
const streams = getStreams();
|
||||
|
||||
const availableForStreaming = writable(false);
|
||||
const { inLibrary, progress, handleAddToLibrary, handleRemoveFromLibrary } = useUserData(
|
||||
'Series',
|
||||
id,
|
||||
showUserData
|
||||
);
|
||||
let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
|
||||
const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, Number(id));
|
||||
|
||||
streams.forEach((p) =>
|
||||
p.streams.then((s) => availableForStreaming.update((p) => p || s.length > 0))
|
||||
);
|
||||
|
||||
const { promise: tmdbSeries, data: tmdbSeriesData } = useRequest(tmdbApi.getTmdbSeries, tmdbId);
|
||||
let sonarrItem = sonarrApi.getSeriesByTmdbId(tmdbId);
|
||||
const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, tmdbId);
|
||||
|
||||
$: sonarrDownloads = getDownloads(sonarrItem);
|
||||
$: sonarrFiles = getFiles(sonarrItem);
|
||||
@@ -71,6 +99,25 @@
|
||||
// hideInterface = !!modal;
|
||||
// });
|
||||
|
||||
function getStreams() {
|
||||
const out: { source: MediaSource; streams: Promise<VideoStreamCandidateDto[]> }[] = [];
|
||||
|
||||
for (const source of get(sources)) {
|
||||
out.push({
|
||||
source: source.source,
|
||||
streams: showUserData.then((userData) => {
|
||||
const { season, episode } = userData.playState ?? {};
|
||||
|
||||
return reiverrApiNew.sources
|
||||
.getEpisodeStreams(source.source.id, id, season ?? 1, episode ?? 1)
|
||||
.then((r) => r.data?.streams ?? []);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function getJellyfinSeries(id: string) {
|
||||
return jellyfinApi.getLibraryItemFromTmdbId(id);
|
||||
}
|
||||
@@ -78,7 +125,7 @@
|
||||
const onGrabRelease = () => setTimeout(() => (sonarrDownloads = getDownloads(sonarrItem)), 8000);
|
||||
|
||||
function handleAddedToSonarr() {
|
||||
sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
|
||||
sonarrItem = sonarrApi.getSeriesByTmdbId(tmdbId);
|
||||
sonarrItem.then(
|
||||
(sonarrItem) =>
|
||||
sonarrItem &&
|
||||
@@ -141,6 +188,49 @@
|
||||
.then(() => (sonarrDownloads = getDownloads(sonarrItem)))
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePlay() {
|
||||
const awaitedStreams = await Promise.all(
|
||||
streams.map(async (p) => ({ ...p, streams: await p.streams }))
|
||||
).then((d) => d.filter((p) => p.streams.length > 0));
|
||||
|
||||
if (awaitedStreams.length > 1) {
|
||||
modalStack.create(SelectDialog, {
|
||||
title: 'Select Media Source',
|
||||
subtitle: 'Select the media source you want to use',
|
||||
options: awaitedStreams.map((p) => p.source.id),
|
||||
handleSelectOption: (sourceId) => {
|
||||
const s = awaitedStreams.find((p) => p.source.id === sourceId);
|
||||
const key = s?.streams[0]?.key;
|
||||
showUserData.then((userData) =>
|
||||
playerState.streamEpisode(
|
||||
id,
|
||||
userData.playState?.season ?? 1,
|
||||
userData.playState?.episode ?? 1,
|
||||
userData,
|
||||
sourceId,
|
||||
key
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if (awaitedStreams.length === 1) {
|
||||
const asd = awaitedStreams.find((p) => p.streams.length > 0);
|
||||
const sourceId = asd?.source.id;
|
||||
const key = asd?.streams[0]?.key;
|
||||
|
||||
showUserData.then((userData) =>
|
||||
playerState.streamEpisode(
|
||||
id,
|
||||
userData.playState?.season ?? 1,
|
||||
userData.playState?.episode ?? 1,
|
||||
userData,
|
||||
sourceId,
|
||||
key
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DetachedPage let:handleGoBack let:registrar>
|
||||
@@ -214,7 +304,20 @@
|
||||
on:back={handleGoBack}
|
||||
on:mount={registrar}
|
||||
>
|
||||
{#if nextJellyfinEpisode}
|
||||
<Button class="mr-4" action={handlePlay} disabled={!$availableForStreaming}>
|
||||
Play
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
{#if !$inLibrary}
|
||||
<Button class="mr-4" action={handleAddToLibrary} icon={Bookmark}>
|
||||
Add to Library
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="mr-4" action={handleRemoveFromLibrary} icon={Minus}>
|
||||
Remove from Library
|
||||
</Button>
|
||||
{/if}
|
||||
<!-- {#if nextJellyfinEpisode}
|
||||
<Button
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() =>
|
||||
@@ -223,13 +326,13 @@
|
||||
Play Season {nextJellyfinEpisode?.ParentIndexNumber} Episode
|
||||
{nextJellyfinEpisode?.IndexNumber}
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="mr-4" action={() => handleRequestSeason(1)}>
|
||||
Request
|
||||
<Plus size={19} slot="icon" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Button> -->
|
||||
<!-- {:else} -->
|
||||
<Button class="mr-4" action={() => handleRequestSeason(1)}>
|
||||
Request
|
||||
<Plus size={19} slot="icon" />
|
||||
</Button>
|
||||
<!-- {/if} -->
|
||||
|
||||
{#if PLATFORM_WEB}
|
||||
<Button class="mr-4">
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import { modalStackTop } from '../Modal/modal.store';
|
||||
|
||||
export let tmdbId: string;
|
||||
export let season: number | undefined = undefined;
|
||||
export let episode: number | undefined = undefined;
|
||||
export let sourceId: string;
|
||||
export let key: string = '';
|
||||
export let progress: number = 0;
|
||||
@@ -46,27 +48,50 @@
|
||||
|
||||
let videoStreamP: Promise<VideoStreamDto>;
|
||||
|
||||
const movieP = tmdbApi.getTmdbMovie(Number(tmdbId)).then((r) => {
|
||||
title = r?.title || '';
|
||||
subtitle = '';
|
||||
});
|
||||
// const movieP = tmdbApi.getTmdbMovie(Number(tmdbId)).then((r) => {
|
||||
// title = r?.title || '';
|
||||
// subtitle = '';
|
||||
// });
|
||||
|
||||
function reportProgress() {
|
||||
const userId = get(user)?.id;
|
||||
|
||||
if (!userId) {
|
||||
console.error('Update progress failed: User not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
if (video?.readyState === 4 && video?.currentTime > 0 && video?.duration > 0)
|
||||
reiverrApiNew.users.updateMoviePlayStateByTmdbId($user?.id as string, tmdbId, {
|
||||
progress: video.currentTime / video?.duration,
|
||||
watched: progressTime > 0.9
|
||||
});
|
||||
if (season !== undefined && episode !== undefined) {
|
||||
reiverrApiNew.users.updateEpisodePlayStateByTmdbId(userId, tmdbId, season, episode, {
|
||||
progress: video.currentTime / video?.duration,
|
||||
watched: progressTime > 0.9
|
||||
});
|
||||
} else {
|
||||
reiverrApiNew.users.updateMoviePlayStateByTmdbId(userId, tmdbId, {
|
||||
progress: video.currentTime / video?.duration,
|
||||
watched: progressTime > 0.9
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const refreshVideoStream = async (audioStreamIndex = 0) => {
|
||||
videoStreamP = reiverrApiNew.sources
|
||||
.getMovieStream(tmdbId, sourceId, key, {
|
||||
// bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000,
|
||||
progress,
|
||||
audioStreamIndex,
|
||||
deviceProfile: getDeviceProfile() as any
|
||||
})
|
||||
console.log('refreshVideoStream', season, episode);
|
||||
videoStreamP = (
|
||||
season !== undefined && episode !== undefined
|
||||
? reiverrApiNew.sources.getEpisodeStream(sourceId, tmdbId, season, episode, key, {
|
||||
// bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000,
|
||||
progress,
|
||||
audioStreamIndex,
|
||||
deviceProfile: getDeviceProfile() as any
|
||||
})
|
||||
: reiverrApiNew.sources.getMovieStream(tmdbId, sourceId, key, {
|
||||
// bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000,
|
||||
progress,
|
||||
audioStreamIndex,
|
||||
deviceProfile: getDeviceProfile() as any
|
||||
})
|
||||
)
|
||||
.then((r) => r.data)
|
||||
.then((d) => ({
|
||||
...d,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { reiverrApiNew, sources } from '../../stores/user.store';
|
||||
import { createErrorNotification } from '../Notifications/notification.store';
|
||||
import VideoPlayerModal from './VideoPlayerModal.svelte';
|
||||
import MovieVideoPlayerModal from './MovieVideoPlayerModal.svelte';
|
||||
import type { MovieUserDataDto } from '../../apis/reiverr/reiverr.openapi';
|
||||
import type { MediaUserDataDto } from '../../apis/reiverr/reiverr.openapi';
|
||||
|
||||
export type SubtitleInfo = {
|
||||
subtitles?: Subtitles;
|
||||
@@ -51,7 +51,7 @@ function usePlayerState() {
|
||||
|
||||
async function streamTmdbMovie(
|
||||
tmdbId: string,
|
||||
userData: MovieUserDataDto,
|
||||
userData: MediaUserDataDto,
|
||||
sourceId: string = '',
|
||||
key: string = ''
|
||||
) {
|
||||
@@ -81,9 +81,47 @@ function usePlayerState() {
|
||||
});
|
||||
}
|
||||
|
||||
async function streamTmdbEpisode(
|
||||
tmdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
userData: MediaUserDataDto,
|
||||
sourceId: string = '',
|
||||
key: string = ''
|
||||
) {
|
||||
if (!sourceId) {
|
||||
const streams = await Promise.all(
|
||||
get(sources).map((s) =>
|
||||
reiverrApiNew.sources
|
||||
.getEpisodeStreams(s.source.id, tmdbId, season, episode)
|
||||
.then((r) => ({ source: s.source, streams: r.data.streams }))
|
||||
)
|
||||
);
|
||||
sourceId = streams?.[0]?.source.id || '';
|
||||
key = streams?.[0]?.streams?.[0]?.key || '';
|
||||
}
|
||||
|
||||
if (!sourceId) {
|
||||
createErrorNotification('Could not find a suitable source');
|
||||
return;
|
||||
}
|
||||
|
||||
store.set({ visible: true, jellyfinId: tmdbId, sourceId });
|
||||
console.log('sourceId', season, episode);
|
||||
modalStack.create(MovieVideoPlayerModal, {
|
||||
tmdbId,
|
||||
episode,
|
||||
season,
|
||||
sourceId,
|
||||
key,
|
||||
progress: userData.playState?.progress || 0
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...store,
|
||||
streamMovie: streamTmdbMovie,
|
||||
streamEpisode: streamTmdbEpisode,
|
||||
streamJellyfinId: (id: string) => {
|
||||
store.set({ visible: true, jellyfinId: id, sourceId: '' });
|
||||
modalStack.create(JellyfinVideoPlayerModal, { id });
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api';
|
||||
import { playerState } from '../components/VideoPlayer/VideoPlayer';
|
||||
import { formatSize, retry, timeout } from '../utils';
|
||||
import { createModal } from '../components/Modal/modal.store';
|
||||
import { createModal, modalStack } from '../components/Modal/modal.store';
|
||||
import ButtonGhost from '../components/Ghosts/ButtonGhost.svelte';
|
||||
import {
|
||||
type EpisodeFileResource,
|
||||
@@ -22,11 +22,28 @@
|
||||
import SonarrMediaManagerModal from '../components/MediaManagerModal/SonarrMediaManagerModal.svelte';
|
||||
import ConfirmDialog from '../components/Dialog/ConfirmDialog.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { useUserData } from '../stores/library.store';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { reiverrApiNew, sources, user } from '../stores/user.store';
|
||||
import type { MediaSource, VideoStreamCandidateDto } from '../apis/reiverr/reiverr.openapi';
|
||||
import SelectDialog from '../components/Dialog/SelectDialog.svelte';
|
||||
|
||||
export let id: string; // Series ID
|
||||
export let season: string;
|
||||
export let episode: string;
|
||||
|
||||
const episodeUserData = reiverrApiNew.users
|
||||
.getEpisodeUserData($user?.id as string, id, Number(season), Number(episode))
|
||||
.then((r) => r.data);
|
||||
const streams = getStreams();
|
||||
|
||||
const availableForStreaming = writable(false);
|
||||
const { progress } = useUserData('Series', id, episodeUserData);
|
||||
|
||||
streams.forEach((p) =>
|
||||
p.streams.then((s) => availableForStreaming.update((p) => p || s.length > 0))
|
||||
);
|
||||
|
||||
let isWatched = false;
|
||||
|
||||
const tmdbEpisode = tmdbApi.getEpisode(Number(id), Number(season), Number(episode));
|
||||
@@ -56,6 +73,28 @@
|
||||
isWatched = e?.UserData?.Played || false;
|
||||
});
|
||||
|
||||
function getStreams() {
|
||||
const out: { source: MediaSource; streams: Promise<VideoStreamCandidateDto[]> }[] = [];
|
||||
|
||||
for (const source of get(sources)) {
|
||||
out.push({
|
||||
source: source.source,
|
||||
streams: episodeUserData.then((userData) => {
|
||||
const { season: s, episode: e } = userData.playState ?? {
|
||||
season: Number(season),
|
||||
episode: Number(episode)
|
||||
};
|
||||
|
||||
return reiverrApiNew.sources
|
||||
.getEpisodeStreams(source.source.id, id, s ?? 1, e ?? 1)
|
||||
.then((r) => r.data?.streams ?? []);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function getSonarrEpisode(sonarrItem: Promise<SonarrSeries | undefined>) {
|
||||
return sonarrItem.then((sonarrItem) => {
|
||||
if (!sonarrItem?.id) return;
|
||||
@@ -119,6 +158,49 @@
|
||||
.then((files) => files.filter((f) => sonarrEpisode?.episodeFileId === f.id));
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePlay() {
|
||||
const awaitedStreams = await Promise.all(
|
||||
streams.map(async (p) => ({ ...p, streams: await p.streams }))
|
||||
).then((d) => d.filter((p) => p.streams.length > 0));
|
||||
|
||||
if (awaitedStreams.length > 1) {
|
||||
modalStack.create(SelectDialog, {
|
||||
title: 'Select Media Source',
|
||||
subtitle: 'Select the media source you want to use',
|
||||
options: awaitedStreams.map((p) => p.source.id),
|
||||
handleSelectOption: (sourceId) => {
|
||||
const s = awaitedStreams.find((p) => p.source.id === sourceId);
|
||||
const key = s?.streams[0]?.key;
|
||||
episodeUserData.then((userData) =>
|
||||
playerState.streamEpisode(
|
||||
id,
|
||||
userData.playState?.season ?? Number(season) ?? 1,
|
||||
userData.playState?.episode ?? Number(episode) ?? 1,
|
||||
userData,
|
||||
sourceId,
|
||||
key
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if (awaitedStreams.length === 1) {
|
||||
const asd = awaitedStreams.find((p) => p.streams.length > 0);
|
||||
const sourceId = asd?.source.id;
|
||||
const key = asd?.streams[0]?.key;
|
||||
|
||||
episodeUserData.then((userData) =>
|
||||
playerState.streamEpisode(
|
||||
id,
|
||||
userData.playState?.season ?? 1,
|
||||
userData.playState?.episode ?? 1,
|
||||
userData,
|
||||
sourceId,
|
||||
key
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DetachedPage let:handleGoBack let:registrar>
|
||||
@@ -180,19 +262,23 @@
|
||||
{tmdbEpisode?.overview}
|
||||
</div>
|
||||
<Container direction="horizontal" class="flex mt-8 space-x-4">
|
||||
<Button class="mr-4" action={handlePlay} disabled={!$availableForStreaming}>
|
||||
Play
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
{#await Promise.all([jellyfinEpisode, sonarrEpisode])}
|
||||
<ButtonGhost>Play</ButtonGhost>
|
||||
<ButtonGhost>Manage Media</ButtonGhost>
|
||||
<ButtonGhost>Delete Files</ButtonGhost>
|
||||
{:then [jellyfinEpisode]}
|
||||
{#if jellyfinEpisode?.MediaSources?.length}
|
||||
<Button
|
||||
<!-- <Button
|
||||
on:clickOrSelect={() =>
|
||||
jellyfinEpisode?.Id && playerState.streamJellyfinId(jellyfinEpisode.Id)}
|
||||
>
|
||||
Play
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
</Button> -->
|
||||
<Button disabled={$markAsLoading} on:clickOrSelect={toggleMarkAs}>
|
||||
{#if isWatched}
|
||||
Mark as Unwatched
|
||||
|
||||
@@ -23,11 +23,15 @@
|
||||
.then((r) =>
|
||||
Promise.all(
|
||||
r.data.items.map((i) =>
|
||||
tmdbApi
|
||||
.getTmdbMovie(Number(i.tmdbId))
|
||||
.then((movie) => ({ tmdbMovie: movie as TmdbMovieFull2, playStates: i.playStates }))
|
||||
i.mediaType === 'Movie'
|
||||
? tmdbApi
|
||||
.getTmdbMovie(Number(i.tmdbId))
|
||||
.then((movie) => ({ tmdbMovie: movie as TmdbMovieFull2, playStates: i.playStates }))
|
||||
: tmdbApi
|
||||
.getTmdbSeries(Number(i.tmdbId))
|
||||
.then((series) => ({ tmdbSeries: series as TmdbSeries2, playStates: i.playStates }))
|
||||
)
|
||||
).then((i) => i.filter((i) => !!i.tmdbMovie))
|
||||
).then((i) => i.filter((i) => ('tmdbMovie' in i ? !!i.tmdbMovie : !!i.tmdbSeries)))
|
||||
);
|
||||
|
||||
$: console.log('libraryItems', libraryItems);
|
||||
@@ -156,7 +160,7 @@
|
||||
{:then items} -->
|
||||
{#each items as item}
|
||||
<TmdbCard
|
||||
item={item.tmdbMovie}
|
||||
item={'tmdbMovie' in item ? item.tmdbMovie : item.tmdbSeries}
|
||||
progress={item.playStates?.[0]?.progress || 0}
|
||||
on:enter={scrollIntoView({ all: 64 })}
|
||||
size="dynamic"
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import MovieStreams from './MovieStreams.MoviePage.svelte';
|
||||
import StreamDetailsDialog from './StreamDetailsDialog.MoviePage.svelte';
|
||||
import SelectDialog from '../../components/Dialog/SelectDialog.svelte';
|
||||
import { useUserData } from '../../stores/library.store';
|
||||
|
||||
export let id: string;
|
||||
const tmdbId = Number(id);
|
||||
@@ -41,13 +42,13 @@
|
||||
const streams = getStreams();
|
||||
|
||||
const availableForStreaming = writable(false);
|
||||
const inLibrary = writable<boolean>(undefined);
|
||||
const progress = writable(0);
|
||||
|
||||
movieUserData.then((d) => {
|
||||
inLibrary.set(d?.inLibrary ?? false);
|
||||
progress.set(d?.playState?.progress ?? 0);
|
||||
});
|
||||
const { inLibrary, progress, handleAddToLibrary, handleRemoveFromLibrary } = useUserData(
|
||||
'Movie',
|
||||
id,
|
||||
movieUserData
|
||||
);
|
||||
|
||||
streams.forEach((p) =>
|
||||
p.streams.then((s) => availableForStreaming.update((p) => p || s.length > 0))
|
||||
);
|
||||
@@ -158,20 +159,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAddToLibrary() {
|
||||
const success = await reiverrApiNew.users
|
||||
.addLibraryItem($user?.id as string, id)
|
||||
.then((r) => r.data.success);
|
||||
if (success) inLibrary.set(true);
|
||||
}
|
||||
|
||||
async function handleRemoveFromLibrary() {
|
||||
const success = await reiverrApiNew.users
|
||||
.removeLibraryItem($user?.id as string, id)
|
||||
.then((r) => r.data.success);
|
||||
if (success) inLibrary.set(false);
|
||||
}
|
||||
|
||||
async function handlePlay() {
|
||||
const awaited = await Promise.all(
|
||||
streams.map(async (p) => ({ ...p, streams: await p.streams }))
|
||||
|
||||
53
src/lib/stores/library.store.ts
Normal file
53
src/lib/stores/library.store.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { reiverrApiNew, user } from './user.store';
|
||||
import type { MediaUserDataDto } from '../apis/reiverr/reiverr.openapi';
|
||||
import type { MediaType } from '../types';
|
||||
|
||||
export function useUserData(
|
||||
mediaType: MediaType,
|
||||
tmdbId: string,
|
||||
userDataP: Promise<MediaUserDataDto>
|
||||
) {
|
||||
const inLibrary = writable<boolean>(undefined);
|
||||
const progress = writable(0);
|
||||
|
||||
userDataP.then((d) => {
|
||||
inLibrary.set(d?.inLibrary ?? false);
|
||||
progress.set(d?.playState?.progress ?? 0);
|
||||
});
|
||||
|
||||
async function handleAddToLibrary() {
|
||||
const userId = get(user)?.id;
|
||||
|
||||
if (!userId) {
|
||||
console.error('Add to library: No user ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await reiverrApiNew.users
|
||||
.addLibraryItem(userId, tmdbId, { mediaType })
|
||||
.then((r) => r.data.success);
|
||||
if (success) inLibrary.set(true);
|
||||
}
|
||||
|
||||
async function handleRemoveFromLibrary() {
|
||||
const userId = get(user)?.id;
|
||||
|
||||
if (!userId) {
|
||||
console.error('Remove from library: No user ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await reiverrApiNew.users
|
||||
.removeLibraryItem(userId, tmdbId)
|
||||
.then((r) => r.data.success);
|
||||
if (success) inLibrary.set(false);
|
||||
}
|
||||
|
||||
return {
|
||||
inLibrary,
|
||||
progress,
|
||||
handleAddToLibrary,
|
||||
handleRemoveFromLibrary
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ function useUser() {
|
||||
activeSession.subscribe(async (activeSession) => {
|
||||
initializedStores.set({ user: false, sources: false });
|
||||
await refreshUser(activeSession);
|
||||
});
|
||||
});
|
||||
|
||||
userStore.subscribe(async (user) => {
|
||||
if (!user) {
|
||||
@@ -111,4 +111,3 @@ function useUser() {
|
||||
|
||||
export const { user, sources, isAppInitialized } = useUser();
|
||||
// isAppInitialized.subscribe((i) => console.log('isAppInitialized', i));
|
||||
sources.subscribe((s) => console.log('sources', s, s.length));
|
||||
|
||||
@@ -5,6 +5,8 @@ export type TitleId = {
|
||||
type: TitleType;
|
||||
};
|
||||
|
||||
export type MediaType = 'Movie' | 'Series';
|
||||
|
||||
declare global {
|
||||
const REIVERR_VERSION: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user