feat: tmdb cache, plugin support changes, series page, episode page, movie page streaming updated

This commit is contained in:
Aleksi Lassila
2025-01-31 18:54:04 +02:00
parent dc295ed203
commit cf289872f7
37 changed files with 3373 additions and 4380 deletions

3705
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -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[]>;

View File

@@ -24,4 +24,3 @@ import { SourcePluginsModule } from './source-plugins/source-plugins.module';
providers: [AppService],
})
export class AppModule {}

View File

@@ -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;

View File

@@ -44,3 +44,8 @@ export class SuccessResponseDto {
@ApiProperty()
success: boolean;
}
export enum MediaType {
Movie = 'Movie',
Series = 'Series',
}

View File

@@ -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';

View File

@@ -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();

View File

@@ -0,0 +1,5 @@
import { Movie } from '../metadata.entity';
// export class MovieDto extends Movie {
// static FromEntity(entity: Movie): MovieDto {}
// }

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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,
});
}

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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,
);
}
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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,
};
}
}

View File

@@ -13,6 +13,7 @@
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
// "strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,

View File

@@ -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',

View File

@@ -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';

View File

@@ -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">

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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

View File

@@ -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"

View File

@@ -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 }))

View 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
};
}

View File

@@ -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));

View File

@@ -5,6 +5,8 @@ export type TitleId = {
type: TitleType;
};
export type MediaType = 'Movie' | 'Series';
declare global {
const REIVERR_VERSION: string;
}